diff --git a/Package.swift b/Package.swift
index 280bfa8a..bb1f5118 100644
--- a/Package.swift
+++ b/Package.swift
@@ -49,6 +49,10 @@ let package = Package(
.product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])),
]
),
+ .target(
+ name: "TestRunner",
+ dependencies: ["AST", "IR", "Rego"]
+ ),
// Internal module tests
.testTarget(
name: "ASTTests",
@@ -68,6 +72,11 @@ let package = Package(
dependencies: ["Rego"],
resources: [.copy("TestData")]
),
+ .testTarget(
+ name: "TestRunnerTests",
+ dependencies: ["TestRunner", "AST", "IR", "Rego"],
+ resources: [.copy("Fixtures")]
+ ),
// Public API surface tests
.testTarget(
name: "SwiftOPATests",
@@ -77,6 +86,7 @@ let package = Package(
name: "CLI",
dependencies: [
"Rego",
+ "TestRunner",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
diff --git a/Sources/CLI/CLI.swift b/Sources/CLI/CLI.swift
index 0aaffc32..3858026a 100644
--- a/Sources/CLI/CLI.swift
+++ b/Sources/CLI/CLI.swift
@@ -5,6 +5,6 @@ struct CLIRootCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "swift-opa-cli",
abstract: "An example command line showing swift-opa in action.",
- subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self]
+ subcommands: [EvalCommand.self, BenchCommand.self, CapabilitiesCommand.self, TestCommand.self]
)
}
diff --git a/Sources/CLI/TestCommand.swift b/Sources/CLI/TestCommand.swift
new file mode 100644
index 00000000..301a355f
--- /dev/null
+++ b/Sources/CLI/TestCommand.swift
@@ -0,0 +1,146 @@
+import ArgumentParser
+import Foundation
+import TestRunner
+
+/// `swift-opa-cli test` — runs `test_*` rules found in compiled IR plan bundles.
+///
+/// Behaves like `opa test`, with the important caveat that swift-opa runs
+/// *compiled* IR plans rather than `.rego` source. Test rules must therefore be
+/// present in the plan bundle as funcs. OPA includes a test rule when it is
+/// reachable from the build's entrypoints. This happens when the test
+/// references an entrypoint's rules/data, or when the test's package/rules are
+/// entrypoints themselves. Making the test package an entrypoint
+/// (`opa build -b
-t plan -e `) reliably includes all of its
+/// tests, but is not the only way they can end up in the plan.
+struct TestCommand: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "test",
+ abstract: "Execute Rego test cases from compiled IR plan bundles.",
+ discussion: """
+ Searches the given plan bundle paths for rules named test_* (run) and \
+ todo_test_* (skipped), generates a plan for each, and evaluates it.
+
+ Note: swift-opa runs compiled IR plans, not .rego source. Test rules \
+ must be present in the plan as funcs. They are included when reachable \
+ from the build's entrypoints; making the test package an entrypoint \
+ (`opa build -b -t plan -e `) reliably includes all \
+ of its tests.
+ """
+ )
+
+ @Argument(help: "Bundle paths (directories) to search for tests.")
+ var paths: [String] = []
+
+ // MARK: CLI Options
+
+ @Flag(name: [.customShort("b"), .customLong("bundle")], help: "Load paths as bundles (always on).")
+ var bundle: Bool = false
+
+ @Option(name: [.customShort("r"), .customLong("run")], help: "Run only tests matching this regular expression.")
+ var run: String?
+
+ @Flag(name: [.short, .long], help: "Verbose output (list every test).")
+ var verbose: Bool = false
+
+ @Option(name: [.long], help: "Number of times to repeat each test.")
+ var count: Int = 1
+
+ @Flag(
+ name: [.customShort("z"), .customLong("exit-zero-on-skipped")],
+ help: "Exit with status 0 even when tests are skipped.")
+ var exitZeroOnSkipped: Bool = false
+
+ // MARK: Unimplemented Option Stubs
+
+ @Flag(name: [.long], help: .hidden) var bench: Bool = false
+ @Flag(name: [.long], help: .hidden) var benchmem: Bool = false
+ @Option(name: [.long], help: .hidden) var capabilities: String?
+ @Flag(name: [.customShort("c"), .customLong("coverage")], help: .hidden) var coverage: Bool = false
+ @Option(name: [.long], help: .hidden) var explain: String?
+ @Option(name: [.long], help: .hidden) var format: String?
+ @Option(name: [.long], help: .hidden) var ignore: [String] = []
+ @Option(name: [.long], help: .hidden) var maxErrors: Int?
+ @Option(name: [.short, .long], help: .hidden) var parallel: Int?
+ @Option(name: [.long], help: .hidden) var schema: String?
+ @Flag(name: [.long], help: .hidden) var sort: Bool = false
+ @Option(name: [.customShort("t"), .customLong("target")], help: .hidden) var target: String?
+ @Option(name: [.long], help: .hidden) var threshold: Double?
+ @Option(name: [.long], help: .hidden) var timeout: String?
+ @Flag(name: [.long], help: .hidden) var v0Compatible: Bool = false
+ @Option(name: [.long], help: .hidden) var varValues: String?
+ @Flag(name: [.long], help: .hidden) var watch: Bool = false
+
+ mutating func run() async throws {
+ warnUnimplemented()
+
+ guard !paths.isEmpty else {
+ throw ValidationError("no bundle paths provided")
+ }
+
+ // Resolve symlinks so the loaded paths match the enumerator's resolved
+ // child paths (e.g. /tmp -> /private/tmp on macOS). `URL(fileURLWithPath:)`
+ // already resolves relative paths against the current working directory.
+ let urls = paths.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath() }
+
+ let results = try await TestRunner.run(paths: urls, runFilter: run, count: count)
+
+ if results.isEmpty {
+ FileHandle.standardError.write(Data("warning: no tests found under the given paths\n".utf8))
+ }
+
+ let report = TestReporter(verbose: verbose).render(results)
+ print(report)
+
+ throw exitCode(for: results)
+ }
+
+ /// Determines the process exit code, mirroring `opa test`: failures/errors
+ /// yield status 2, as do skipped tests, unless `--exit-zero-on-skipped` is set.
+ private func exitCode(for results: [TestResult]) -> ExitCode {
+ var hasFailure = false
+ var hasSkip = false
+ for result in results {
+ switch result.outcome {
+ case .failed, .errored:
+ hasFailure = true
+ case .skipped:
+ hasSkip = true
+ case .passed:
+ break
+ }
+ }
+ if hasFailure {
+ return ExitCode(2)
+ }
+ if hasSkip && !exitZeroOnSkipped {
+ return ExitCode(2)
+ }
+ return ExitCode.success
+ }
+
+ /// Warnings on stderr for any unimplemented flags.
+ private func warnUnimplemented() {
+ var unimplemented: [String] = []
+ if bench { unimplemented.append("--bench") }
+ if benchmem { unimplemented.append("--benchmem") }
+ if capabilities != nil { unimplemented.append("--capabilities") }
+ if coverage { unimplemented.append("--coverage") }
+ if explain != nil { unimplemented.append("--explain") }
+ if format != nil { unimplemented.append("--format") }
+ if !ignore.isEmpty { unimplemented.append("--ignore") }
+ if maxErrors != nil { unimplemented.append("--max-errors") }
+ if parallel != nil { unimplemented.append("--parallel") }
+ if schema != nil { unimplemented.append("--schema") }
+ if sort { unimplemented.append("--sort") }
+ if target != nil { unimplemented.append("--target") }
+ if threshold != nil { unimplemented.append("--threshold") }
+ if timeout != nil { unimplemented.append("--timeout") }
+ if v0Compatible { unimplemented.append("--v0-compatible") }
+ if varValues != nil { unimplemented.append("--var-values") }
+ if watch { unimplemented.append("--watch") }
+
+ for flag in unimplemented {
+ FileHandle.standardError.write(Data("option `\(flag)` is not implemented.\n".utf8))
+ }
+ }
+}
diff --git a/Sources/CLI/TestReporter.swift b/Sources/CLI/TestReporter.swift
new file mode 100644
index 00000000..ac2d81c8
--- /dev/null
+++ b/Sources/CLI/TestReporter.swift
@@ -0,0 +1,64 @@
+import Foundation
+import TestRunner
+
+/// Formats ``TestResult`` collections in a style close to `opa test`'s pretty output.
+///
+/// Output lists only non-passing tests (failures, errors, skips) by default,
+/// followed by a summary. Verbose output lists every test.
+///
+/// We do not support printing failure traces, so the FAILURES section is omitted.
+struct TestReporter {
+ let verbose: Bool
+
+ private static let separator = String(repeating: "-", count: 80)
+
+ /// Renders the report body (per-test lines + separator + summary).
+ func render(_ results: [TestResult]) -> String {
+ var lines: [String] = []
+
+ for result in results {
+ guard verbose || result.outcome != .passed else {
+ continue
+ }
+ if let file = result.testCase.file, let row = result.testCase.row {
+ lines.append("\(file):\(row):")
+ }
+ lines.append(statusLine(result))
+ }
+
+ lines.append(Self.separator)
+ lines.append(contentsOf: summary(results))
+ return lines.joined(separator: "\n")
+ }
+
+ private func statusLine(_ result: TestResult) -> String {
+ let duration = result.duration.formatted(.adaptive)
+ switch result.outcome {
+ case .passed:
+ return "\(result.testCase.name): PASS (\(duration))"
+ case .failed:
+ return "\(result.testCase.name): FAIL (\(duration))"
+ case .skipped:
+ return "\(result.testCase.name): SKIPPED"
+ case .errored(let message):
+ return "\(result.testCase.name): ERROR (\(duration))\n \(message)"
+ }
+ }
+
+ private func summary(_ results: [TestResult]) -> [String] {
+ let total = results.count
+ let passed = results.count(where: { $0.outcome == .passed })
+ let failed = results.count(where: { $0.outcome == .failed })
+ let skipped = results.count(where: { $0.outcome == .skipped })
+ let errored = results.count(where: {
+ guard case .errored = $0.outcome else { return false }
+ return true
+ })
+
+ var lines = ["PASS: \(passed)/\(total)"]
+ if failed > 0 { lines.append("FAIL: \(failed)/\(total)") }
+ if errored > 0 { lines.append("ERROR: \(errored)/\(total)") }
+ if skipped > 0 { lines.append("SKIPPED: \(skipped)/\(total)") }
+ return lines
+ }
+}
diff --git a/Sources/Rego/BundleLoader.swift b/Sources/Rego/BundleLoader.swift
index 39dba319..2b1f0ddd 100644
--- a/Sources/Rego/BundleLoader.swift
+++ b/Sources/Rego/BundleLoader.swift
@@ -1,7 +1,7 @@
import AST
import Foundation
-struct BundleLoader {
+package struct BundleLoader {
var bundleFiles: any Sequence>
init(fromFileSequence files: any Sequence>) {
@@ -84,14 +84,14 @@ struct BundleLoader {
return bundle
}
- public static func load(fromDirectory url: URL) throws -> OPA.Bundle {
+ package static func load(fromDirectory url: URL) throws -> OPA.Bundle {
let files = DirectoryLoader(baseURL: url)
return try BundleLoader(fromFileSequence: files).load()
}
// Accept either a directory to load a bundle from or a path to an individual file
// which will be treated as a bundle tarball.
- public static func load(fromFile url: URL) throws -> OPA.Bundle {
+ package static func load(fromFile url: URL) throws -> OPA.Bundle {
let isDir = (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false
if isDir {
return try load(fromDirectory: url)
diff --git a/Sources/TestRunner/TestCase.swift b/Sources/TestRunner/TestCase.swift
new file mode 100644
index 00000000..c9902119
--- /dev/null
+++ b/Sources/TestRunner/TestCase.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+/// A single Rego test case found in a compiled IR policy.
+///
+/// Test cases are Rego rules whose names begin with `test_`. Rules
+/// beginning with `todo_test_` are found but marked ``skipped`` and never
+/// evaluated.
+public struct TestCase: Hashable, Sendable {
+ /// Fully-qualified query name, e.g. `data.authz_test.test_post_allowed`.
+ public let name: String
+ /// IR plan name used to invoke the generated wrapper, e.g. `authz_test/test_post_allowed`.
+ public let planName: String
+ /// The IR func name that implements the test, e.g. `g0.data.authz_test.test_post_allowed`.
+ public let funcName: String
+ /// Whether this test is skipped (a `todo_test_` rule).
+ public let skipped: Bool
+ /// Source file name the test was compiled from, when available (for verbose output).
+ public let file: String?
+ /// Source row the test was compiled from, when available (for verbose output).
+ public let row: Int?
+
+ public init(
+ name: String,
+ planName: String,
+ funcName: String,
+ skipped: Bool,
+ file: String? = nil,
+ row: Int? = nil
+ ) {
+ self.name = name
+ self.planName = planName
+ self.funcName = funcName
+ self.skipped = skipped
+ self.file = file
+ self.row = row
+ }
+}
+
+/// The outcome of running (or skipping) a single ``TestCase``.
+public enum TestOutcome: Hashable, Sendable {
+ /// The test rule result was defined and its result was `true`.
+ case passed
+ /// The test rule result was undefined.
+ case failed
+ /// The test was a `todo_test_` rule and was not evaluated.
+ case skipped
+ /// Evaluating the test threw an error.
+ case errored(String)
+}
+
+/// The result of a single test execution.
+public struct TestResult: Sendable {
+ public let testCase: TestCase
+ public let outcome: TestOutcome
+ public let duration: Duration
+
+ public init(testCase: TestCase, outcome: TestOutcome, duration: Duration) {
+ self.testCase = testCase
+ self.outcome = outcome
+ self.duration = duration
+ }
+}
diff --git a/Sources/TestRunner/TestFinder.swift b/Sources/TestRunner/TestFinder.swift
new file mode 100644
index 00000000..85d7673c
--- /dev/null
+++ b/Sources/TestRunner/TestFinder.swift
@@ -0,0 +1,72 @@
+import IR
+
+/// Finds Rego test cases within a compiled IR policy.
+///
+/// When OPA compiles a bundle to an IR plan, every reachable rule becomes a
+/// `func` even when it is not exposed as a runnable `plan`. Test rules therefore
+/// live in `policy.funcs` and can be fished out by inspecting func names.
+public enum TestFinder {
+ /// The `data.` prefix OPA uses for the query name of a rule.
+ private static let queryPrefix = "data"
+
+ /// Returns the test cases defined by `policy`, in the order the funcs appear.
+ ///
+ /// A func is considered a test when:
+ /// - It is a top-level rule (its path is `["g0", , ]`).
+ /// - Args are only the implicit `input` and `data` parameters.
+ /// - Its rule name begins with `test_` (runnable) or `todo_test_` (skipped).
+ public static func findTests(in policy: IR.Policy) -> [TestCase] {
+ guard let funcs = policy.funcs?.funcs else {
+ return []
+ }
+
+ let files = policy.staticData?.files ?? []
+
+ var tests: [TestCase] = []
+ for f in funcs {
+ // Test rules only use the implicit (input, data) params.
+ guard f.params == [0, 1] else {
+ continue
+ }
+ // Path looks like ["g0", "", ""]. We need at least the
+ // group tag plus a rule name.
+ guard f.path.count >= 2, let rule = f.path.last else {
+ continue
+ }
+
+ let skipped: Bool
+ if rule.hasPrefix("todo_test_") {
+ skipped = true
+ } else if rule.hasPrefix("test_") {
+ skipped = false
+ } else {
+ continue // Not a test rule? Skip to next func.
+ }
+
+ // Drop the leading group tag (e.g. "g0").
+ // The rest is the package path + rule name.
+ let pathComponents = Array(f.path.dropFirst())
+ let planName = pathComponents.joined(separator: "/")
+ let name = "\(queryPrefix).\(pathComponents.joined(separator: "."))"
+
+ let location = f.blocks.first?.statements.first?.location
+ let file = location.flatMap { loc -> String? in
+ guard loc.file >= 0, loc.file < files.count else { return nil }
+ return files[loc.file].value
+ }
+ let row = location.flatMap { $0.row > 0 ? $0.row : nil }
+
+ tests.append(
+ TestCase(
+ name: name,
+ planName: planName,
+ funcName: f.name,
+ skipped: skipped,
+ file: file,
+ row: row
+ )
+ )
+ }
+ return tests
+ }
+}
diff --git a/Sources/TestRunner/TestPlanGenerator.swift b/Sources/TestRunner/TestPlanGenerator.swift
new file mode 100644
index 00000000..bd84cf89
--- /dev/null
+++ b/Sources/TestRunner/TestPlanGenerator.swift
@@ -0,0 +1,61 @@
+import IR
+
+/// Generates ad-hoc IR plans that invoke a test func and surface its result.
+///
+/// swift-opa resolves called funcs *per policy*, so a wrapper plan must live in
+/// the same ``IR/Policy`` as the func it calls. ``TestRunner`` uses these helpers
+/// to integrate wrapper plans into a copy of the policy that already holds the
+/// test funcs.
+public enum TestPlanGenerator {
+ /// The object key in the result set that stores the test's return value.
+ /// Mirrors OPA's own plan wrapper, of the form: `{"result": }`
+ public static let resultKey = "result"
+
+ // Reserved implicit locals present in every plan/func frame.
+ private static let inputLocal: Local = 0
+ private static let dataLocal: Local = 1
+ // Locals introduced by the wrapper itself.
+ private static let callResultLocal: Local = 2
+ private static let objectLocal: Local = 3
+
+ /// Builds the wrapper plan for a runnable test.
+ ///
+ /// The plan calls `funcName` with the implicit `input` and `data` arguments,
+ /// wraps its return value in `{"result": }`, and adds that object to
+ /// the result set. When the test func is undefined (such as when the test failed),
+ /// the call result local stays undefined and the `ObjectInsert`/`ResultSetAdd`
+ /// statements are skipped, leaving an empty result set.
+ ///
+ /// - Parameters:
+ /// - planName: The IR plan name, e.g. `authz_test/test_post_allowed`.
+ /// - funcName: The IR func name to invoke, e.g. `g0.data.authz_test.test_post_allowed`.
+ /// - resultStringIndex: Index of the `"result"` string in the policy's static string table.
+ public static func makeWrapperPlan(
+ planName: String,
+ funcName: String,
+ resultStringIndex: Int
+ ) -> IR.Plan {
+ let statements: [IR.Statement] = [
+ .callStmt(
+ IR.CallStatement(
+ callFunc: funcName,
+ args: [
+ IR.Operand(type: .local, value: .localIndex(Int(inputLocal))),
+ IR.Operand(type: .local, value: .localIndex(Int(dataLocal))),
+ ],
+ result: callResultLocal
+ )
+ ),
+ .makeObjectStmt(IR.MakeObjectStatement(target: objectLocal)),
+ .objectInsertStmt(
+ IR.ObjectInsertStatement(
+ key: IR.Operand(type: .stringIndex, value: .stringIndex(resultStringIndex)),
+ value: IR.Operand(type: .local, value: .localIndex(Int(callResultLocal))),
+ object: objectLocal
+ )
+ ),
+ .resultSetAddStmt(IR.ResultSetAddStatement(value: objectLocal)),
+ ]
+ return IR.Plan(name: planName, blocks: [IR.Block(statements: statements)])
+ }
+}
diff --git a/Sources/TestRunner/TestRunner.swift b/Sources/TestRunner/TestRunner.swift
new file mode 100644
index 00000000..cacc2393
--- /dev/null
+++ b/Sources/TestRunner/TestRunner.swift
@@ -0,0 +1,181 @@
+import AST
+import Foundation
+import IR
+import Rego
+
+/// Runs Rego `test_*` rules found in compiled IR plan bundles.
+///
+/// ``TestFinder`` finds test funcs, then ``integrate(_:)`` replaces a policy's
+/// plans with one ad-hoc wrapper plan per runnable test, and
+/// ``run(bundles:runFilter:count:)`` evaluates each plan wrapper.
+public enum TestRunner {
+ /// Replaces `policy`'s plans with one test plan per test func.
+ ///
+ /// We drop the original plans because the test runner only needs the
+ /// test funcs and the data, never the original entrypoints. The test
+ /// plans generated here are thin wrapper around each test func.
+ ///
+ /// The returned policy retains the original funcs and static data (with a
+ /// `"result"` string added, if needed). Skipped tests are reported in the
+ /// returned list but get no plan. A policy with no runnable tests is
+ /// returned unchanged.
+ public static func integrate(_ policy: IR.Policy) -> (policy: IR.Policy, tests: [TestCase]) {
+ let tests = TestFinder.findTests(in: policy)
+ let runnable = tests.filter { !$0.skipped }
+ guard !runnable.isEmpty else {
+ return (policy, tests)
+ }
+
+ var newPolicy = policy
+
+ // Ensure the "result" key exists in the static string table and
+ // record its index for use in the plan generator.
+ var staticData = newPolicy.staticData ?? IR.Static()
+ var strings = staticData.strings ?? []
+ let resultIndex: Int
+ if let idx = strings.firstIndex(where: { $0.value == TestPlanGenerator.resultKey }) {
+ resultIndex = idx
+ } else {
+ resultIndex = strings.count
+ strings.append(IR.ConstString(value: TestPlanGenerator.resultKey))
+ }
+ staticData.strings = strings
+ newPolicy.staticData = staticData
+
+ // Replace the original plans with our "wrapper" plans.
+ // Names keep the test's package path (e.g. `authz/rbac_test/test_x`),
+ // so they should be unique and stay under the bundle's roots.
+ let wrapperPlans = runnable.map { test in
+ TestPlanGenerator.makeWrapperPlan(
+ planName: test.planName,
+ funcName: test.funcName,
+ resultStringIndex: resultIndex
+ )
+ }
+ newPolicy.plans = IR.Plans(plans: wrapperPlans)
+
+ return (newPolicy, tests)
+ }
+
+ /// Finds and runs every test found in the bundles at `paths`.
+ ///
+ /// Each path is loaded with ``BundleLoader``. Bundles' names are
+ /// the filesystem paths they were loaded from.
+ public static func run(
+ paths: [URL],
+ runFilter: String? = nil,
+ count: Int = 1
+ ) async throws -> [TestResult] {
+ var bundles: [String: OPA.Bundle] = [:]
+ for path in paths {
+ bundles[path.path] = try BundleLoader.load(fromFile: path)
+ }
+ return try await run(bundles: bundles, runFilter: runFilter, count: count)
+ }
+
+ /// Finds and runs every test found in `bundles`.
+ ///
+ /// Each bundle's plan files are integrated (see ``integrate(_:)``) and
+ /// re-encoded in place, then all of those bundles are loaded onto a single
+ /// ``OPA/Engine``. This means that any conflicts between bundles will
+ /// surface exactly the same as loading and running those bundles normally.
+ ///
+ /// - Parameters:
+ /// - bundles: Loaded plan bundles keyed by name.
+ /// - runFilter: Optional regular expression. Only tests with matching names are run.
+ /// - count: Number of times to repeat each test (default: 1).
+ /// - Returns: One ``TestResult`` per (test, repetition), in the order tests are found.
+ public static func run(
+ bundles: [String: OPA.Bundle],
+ runFilter: String? = nil,
+ count: Int = 1
+ ) async throws -> [TestResult] {
+ var integratedBundles: [String: OPA.Bundle] = [:]
+ var tests: [TestCase] = []
+
+ // Sort by name for deterministic test ordering across bundles.
+ for (name, bundle) in bundles.sorted(by: { $0.key < $1.key }) {
+ var newPlanFiles: [BundleFile] = []
+ for planFile in bundle.planFiles {
+ let policy = try IR.Policy(jsonData: planFile.data)
+ let (integrated, found) = integrate(policy)
+ tests.append(contentsOf: found)
+ let encoded = try JSONEncoder().encode(integrated)
+ newPlanFiles.append(BundleFile(url: planFile.url, data: encoded))
+ }
+ // Preserve the manifest, rego files, and data. Only the plan
+ // files change. The engine validates roots/overlap when preparing.
+ integratedBundles[name] = try OPA.Bundle(
+ manifest: bundle.manifest,
+ planFiles: newPlanFiles,
+ regoFiles: bundle.regoFiles,
+ data: bundle.data
+ )
+ }
+
+ var engine = OPA.Engine(bundles: integratedBundles)
+ return try await execute(engine: &engine, tests: tests, runFilter: runFilter, count: count)
+ }
+
+ /// Evaluates each test against `engine`.
+ ///
+ /// Skipped tests are reported without evaluation. Any error thrown while
+ /// preparing/evaluating a test are thrown upward.
+ private static func execute(
+ engine: inout OPA.Engine,
+ tests: [TestCase],
+ runFilter: String?,
+ count: Int
+ ) async throws -> [TestResult] {
+ let filtered = try filter(tests, runFilter: runFilter)
+
+ let clock = ContinuousClock()
+ var results: [TestResult] = []
+
+ for _ in 0.. Bool {
+ for value in resultSet {
+ if case .object(let object) = value,
+ object[.string(TestPlanGenerator.resultKey)] == .boolean(true)
+ {
+ return true
+ }
+ }
+ return false
+ }
+
+ /// Applies the `--run` regular-expression filter to `tests`, matching against
+ /// each test's fully-qualified name.
+ static func filter(_ tests: [TestCase], runFilter: String?) throws -> [TestCase] {
+ guard let runFilter, !runFilter.isEmpty else {
+ return tests
+ }
+ let regex = try Regex(runFilter)
+ return try tests.filter { try regex.firstMatch(in: $0.name) != nil }
+ }
+}
diff --git a/Tests/TestRunnerTests/Fixtures/README.md b/Tests/TestRunnerTests/Fixtures/README.md
new file mode 100644
index 00000000..776ddf1b
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/README.md
@@ -0,0 +1,23 @@
+# TestRunner test fixtures
+
+Each subdirectory is a compiled Rego **plan bundle** (`opa build -t plan` output),
+committed with its `.rego` sources so the fixtures are easy to inspect and regenerate.
+The `.rego` files are not needed at runtime (swift-opa executes `plan.json`). They are
+kept for debugging and to document what each plan was compiled from.
+
+`test_*` rules only appear in `plan.json` as funcs when they are reachable from the
+build's entrypoints. The commands below choose entrypoints accordingly.
+
+## `example-bundle/`
+
+```sh
+cd example-bundle
+opa build -b . -t plan -e example_test -o ../b.tar.gz && tar -xzf ../b.tar.gz
+```
+
+## `nested-bundle/`
+
+```sh
+cd nested-bundle
+opa build -b . -t plan -e authz/allow -e authz/rbac/allow -o ../b.tar.gz && tar -xzf ../b.tar.gz
+```
diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest b/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest
new file mode 100644
index 00000000..602dc133
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/example-bundle/.manifest
@@ -0,0 +1 @@
+{"revision":"","roots":[""],"rego_version":1}
diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/data.json b/Tests/TestRunnerTests/Fixtures/example-bundle/data.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/example-bundle/data.json
@@ -0,0 +1 @@
+{}
diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego b/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego
new file mode 100644
index 00000000..3427f401
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/example-bundle/example.rego
@@ -0,0 +1,7 @@
+package example
+
+default allow := false
+
+allow if {
+ input.role == "admin"
+}
diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego b/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego
new file mode 100644
index 00000000..0ae60975
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/example-bundle/example_test.rego
@@ -0,0 +1,19 @@
+package example_test
+
+import data.example
+
+test_pass if {
+ true
+}
+
+test_allow_admin if {
+ example.allow with input as {"role": "admin"}
+}
+
+test_fail if {
+ false
+}
+
+todo_test_skip if {
+ true
+}
diff --git a/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json b/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json
new file mode 100644
index 00000000..83b60ce1
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/example-bundle/plan.json
@@ -0,0 +1 @@
+{"static":{"strings":[{"value":"result"},{"value":"test_allow_admin"},{"value":"role"},{"value":"admin"},{"value":"test_fail"},{"value":"test_pass"},{"value":"todo_test_skip"},{"value":"example_test"}],"files":[{"value":"example_test.rego"},{"value":"example.rego"}]},"plans":{"plans":[{"name":"example_test","blocks":[{"stmts":[{"type":"MakeObjectStmt","stmt":{"target":2,"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_allow_admin","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":3,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"local","value":3},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_fail","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":4},"value":{"type":"local","value":4},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.test_pass","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":5},"value":{"type":"local","value":5},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example_test.todo_test_skip","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":6},"value":{"type":"local","value":6},"object":2,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"BlockStmt","stmt":{"blocks":[{"stmts":[{"type":"DotStmt","stmt":{"source":{"type":"local","value":1},"key":{"type":"string_index","value":7},"target":8,"file":0,"col":0,"row":0}},{"type":"ObjectMergeStmt","stmt":{"a":8,"b":2,"target":7,"file":0,"col":0,"row":0}},{"type":"BreakStmt","stmt":{"index":1,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":7,"file":0,"col":0,"row":0}}]}],"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":7},"target":9,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":10,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":9},"object":10,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":10,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.example.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":1,"col":1,"row":5}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":2},"target":4,"file":1,"col":2,"row":6}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":3},"file":1,"col":2,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":1,"col":1,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":1,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":1,"col":1,"row":5}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":1,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":1,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":1,"col":9,"row":3}}]}],"path":["g0","example","allow"]},{"name":"g0.data.example_test.test_allow_admin","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":9}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":2,"row":10}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":2},"value":{"type":"string_index","value":3},"object":4,"file":0,"col":2,"row":10}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":0,"col":2,"row":10}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.example.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":0,"col":2,"row":10}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":0,"col":2,"row":10}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":9}}]},"file":0,"col":2,"row":10}}]},"file":0,"col":2,"row":10}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":9}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":9}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":9}}]}],"path":["g0","example_test","test_allow_admin"]},{"name":"g0.data.example_test.test_fail","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":13}},{"type":"BreakStmt","stmt":{"index":0,"file":0,"col":2,"row":14}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":13}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":13}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":13}}]}],"path":["g0","example_test","test_fail"]},{"name":"g0.data.example_test.test_pass","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":5}}]}],"path":["g0","example_test","test_pass"]},{"name":"g0.data.example_test.todo_test_skip","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":17}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":17}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":17}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":17}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":1,"row":17}}]}],"path":["g0","example_test","todo_test_skip"]}]}}
\ No newline at end of file
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest b/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest
new file mode 100644
index 00000000..602dc133
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/.manifest
@@ -0,0 +1 @@
+{"revision":"","roots":[""],"rego_version":1}
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego
new file mode 100644
index 00000000..c0400bb0
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz.rego
@@ -0,0 +1,4 @@
+package authz
+
+default allow := false
+allow if { input.role == "admin" }
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego
new file mode 100644
index 00000000..f55a3961
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/authz_test.rego
@@ -0,0 +1,6 @@
+package authz_test
+
+import data.authz
+
+test_admin_allowed if { authz.allow with input as {"role": "admin"} }
+test_anon_denied if { not authz.allow with input as {"role": "guest"} }
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json b/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/data.json
@@ -0,0 +1 @@
+{}
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json b/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json
new file mode 100644
index 00000000..e20f14cf
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/plan.json
@@ -0,0 +1 @@
+{"static":{"strings":[{"value":"result"},{"value":"role"},{"value":"admin"},{"value":"perm"},{"value":"read"},{"value":"write"},{"value":"guest"}],"files":[{"value":"authz.rego"},{"value":"rbac.rego"},{"value":"rbac_test.rego"},{"value":"authz_test.rego"}]},"plans":{"plans":[{"name":"authz/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":2,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":2},"target":3,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":3},"object":4,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":4,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac/allow","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":5,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":5},"target":6,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":7,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":6},"object":7,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":7,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac_test/test_read_allowed","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac_test.test_read_allowed","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":8,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":8},"target":9,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":10,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":9},"object":10,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":10,"file":0,"col":0,"row":0}}]}]},{"name":"authz/rbac_test/test_write_denied","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac_test.test_write_denied","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":11,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":11},"target":12,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":13,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":12},"object":13,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":13,"file":0,"col":0,"row":0}}]}]},{"name":"authz_test/test_admin_allowed","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz_test.test_admin_allowed","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":14,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":14},"target":15,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":16,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":15},"object":16,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":16,"file":0,"col":0,"row":0}}]}]},{"name":"authz_test/test_anon_denied","blocks":[{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz_test.test_anon_denied","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":17,"file":0,"col":0,"row":0}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":17},"target":18,"file":0,"col":0,"row":0}},{"type":"MakeObjectStmt","stmt":{"target":19,"file":0,"col":0,"row":0}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":0},"value":{"type":"local","value":18},"object":19,"file":0,"col":0,"row":0}},{"type":"ResultSetAddStmt","stmt":{"value":19,"file":0,"col":0,"row":0}}]}]}]},"funcs":{"funcs":[{"name":"g0.data.authz.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":0,"col":1,"row":4}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":1},"target":4,"file":0,"col":12,"row":4}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":2},"file":0,"col":12,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":0,"col":1,"row":4}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":0,"col":1,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":0,"col":1,"row":4}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":0,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":0,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":0,"col":9,"row":3}}]}],"path":["g0","authz","allow"]},{"name":"g0.data.authz.rbac.allow","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":1,"col":1,"row":4}},{"type":"DotStmt","stmt":{"source":{"type":"local","value":0},"key":{"type":"string_index","value":3},"target":4,"file":1,"col":12,"row":4}},{"type":"EqualStmt","stmt":{"a":{"type":"local","value":4},"b":{"type":"string_index","value":4},"file":1,"col":12,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":1,"col":1,"row":4}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":1,"col":1,"row":4}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":1,"col":1,"row":4}}]},{"stmts":[{"type":"IsUndefinedStmt","stmt":{"source":2,"file":1,"col":9,"row":3}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":false},"target":2,"file":1,"col":9,"row":3}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":1,"col":9,"row":3}}]}],"path":["g0","authz","rbac","allow"]},{"name":"g0.data.authz.rbac_test.test_read_allowed","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":2,"col":1,"row":5}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":2,"col":24,"row":5}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"string_index","value":4},"object":4,"file":2,"col":24,"row":5}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":2,"col":24,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":2,"col":24,"row":5}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":2,"col":24,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":2,"col":1,"row":5}}]},"file":2,"col":24,"row":5}}]},"file":2,"col":24,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":2,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":2,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":2,"col":1,"row":5}}]}],"path":["g0","authz","rbac_test","test_read_allowed"]},{"name":"g0.data.authz.rbac_test.test_write_denied","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":2,"col":1,"row":6}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":2,"col":24,"row":6}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":3},"value":{"type":"string_index","value":5},"object":4,"file":2,"col":24,"row":6}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":2,"col":24,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"NotStmt","stmt":{"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.rbac.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":2,"col":24,"row":6}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":2,"col":24,"row":6}}]},"file":2,"col":24,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":2,"col":1,"row":6}}]},"file":2,"col":24,"row":6}}]},"file":2,"col":24,"row":6}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":2,"col":1,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":2,"col":1,"row":6}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":2,"col":1,"row":6}}]}],"path":["g0","authz","rbac_test","test_write_denied"]},{"name":"g0.data.authz_test.test_admin_allowed","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":3,"col":1,"row":5}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":3,"col":25,"row":5}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"string_index","value":2},"object":4,"file":3,"col":25,"row":5}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":3,"col":25,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":3,"col":25,"row":5}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":3,"col":25,"row":5}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":3,"col":1,"row":5}}]},"file":3,"col":25,"row":5}}]},"file":3,"col":25,"row":5}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":3,"col":1,"row":5}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":3,"col":1,"row":5}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":3,"col":1,"row":5}}]}],"path":["g0","authz_test","test_admin_allowed"]},{"name":"g0.data.authz_test.test_anon_denied","params":[0,1],"return":2,"blocks":[{"stmts":[{"type":"ResetLocalStmt","stmt":{"target":3,"file":3,"col":1,"row":6}},{"type":"MakeObjectStmt","stmt":{"target":4,"file":3,"col":23,"row":6}},{"type":"ObjectInsertStmt","stmt":{"key":{"type":"string_index","value":1},"value":{"type":"string_index","value":6},"object":4,"file":3,"col":23,"row":6}},{"type":"AssignVarStmt","stmt":{"source":{"type":"local","value":0},"target":5,"file":3,"col":23,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":[],"value":{"type":"local","value":4},"block":{"stmts":[{"type":"NotStmt","stmt":{"block":{"stmts":[{"type":"CallStmt","stmt":{"func":"g0.data.authz.allow","args":[{"type":"local","value":0},{"type":"local","value":1}],"result":6,"file":3,"col":23,"row":6}},{"type":"NotEqualStmt","stmt":{"a":{"type":"local","value":6},"b":{"type":"bool","value":false},"file":3,"col":23,"row":6}}]},"file":3,"col":23,"row":6}},{"type":"WithStmt","stmt":{"local":0,"path":null,"value":{"type":"local","value":5},"block":{"stmts":[{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"bool","value":true},"target":3,"file":3,"col":1,"row":6}}]},"file":3,"col":23,"row":6}}]},"file":3,"col":23,"row":6}}]},{"stmts":[{"type":"IsDefinedStmt","stmt":{"source":3,"file":3,"col":1,"row":6}},{"type":"AssignVarOnceStmt","stmt":{"source":{"type":"local","value":3},"target":2,"file":3,"col":1,"row":6}}]},{"stmts":[{"type":"ReturnLocalStmt","stmt":{"source":2,"file":3,"col":1,"row":6}}]}],"path":["g0","authz_test","test_anon_denied"]}]}}
\ No newline at end of file
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego
new file mode 100644
index 00000000..35a6df7a
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac.rego
@@ -0,0 +1,4 @@
+package authz.rbac
+
+default allow := false
+allow if { input.perm == "read" }
diff --git a/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego
new file mode 100644
index 00000000..faa1ffa0
--- /dev/null
+++ b/Tests/TestRunnerTests/Fixtures/nested-bundle/rbac_test.rego
@@ -0,0 +1,6 @@
+package authz.rbac_test
+
+import data.authz.rbac
+
+test_read_allowed if { rbac.allow with input as {"perm": "read"} }
+test_write_denied if { not rbac.allow with input as {"perm": "write"} }
diff --git a/Tests/TestRunnerTests/TestRunnerTests.swift b/Tests/TestRunnerTests/TestRunnerTests.swift
new file mode 100644
index 00000000..336a96f7
--- /dev/null
+++ b/Tests/TestRunnerTests/TestRunnerTests.swift
@@ -0,0 +1,166 @@
+import AST
+import Foundation
+import IR
+import Rego
+import Testing
+
+@testable import TestRunner
+
+@Suite("TestRunner")
+struct TestRunnerTests {
+ /// URL of the extracted plan bundle fixture (built via
+ /// `opa build -b . -t plan -e example_test`, so all test rules appear as funcs).
+ static var bundleURL: URL {
+ Bundle.module.resourceURL!.appending(path: "Fixtures/example-bundle")
+ }
+
+ /// Decodes the fixture bundle's single plan file into an IR policy.
+ static func fixturePolicy() throws -> IR.Policy {
+ let bundle = try BundleLoader.load(fromFile: bundleURL)
+ let planFile = try #require(bundle.planFiles.first)
+ return try IR.Policy(jsonData: planFile.data)
+ }
+
+ // MARK: - Finding tests
+
+ @Test("finds test funcs, classifying todo_ as skipped")
+ func findTests() throws {
+ let policy = try Self.fixturePolicy()
+ let tests = TestFinder.findTests(in: policy)
+
+ let byName = Dictionary(uniqueKeysWithValues: tests.map { ($0.name, $0) })
+ #expect(
+ Set(byName.keys) == [
+ "data.example_test.test_pass",
+ "data.example_test.test_fail",
+ "data.example_test.test_allow_admin",
+ "data.example_test.todo_test_skip",
+ ])
+
+ let pass = try #require(byName["data.example_test.test_pass"])
+ #expect(pass.planName == "example_test/test_pass")
+ #expect(pass.funcName == "g0.data.example_test.test_pass")
+ #expect(pass.skipped == false)
+ #expect(pass.file == "example_test.rego")
+ #expect(pass.row != nil)
+
+ let skip = try #require(byName["data.example_test.todo_test_skip"])
+ #expect(skip.skipped == true)
+ }
+
+ // MARK: - Integration (plan generation)
+
+ @Test("integrate replaces plans with exactly one wrapper per runnable test")
+ func integrate() throws {
+ let policy = try Self.fixturePolicy()
+ let (integrated, tests) = TestRunner.integrate(policy)
+
+ let runnable = tests.filter { !$0.skipped }
+ #expect(runnable.count == 3)
+
+ // The plan list is exactly the runnable-test wrappers. The fixture's
+ // original package-level plan ("example_test") is dropped, and the
+ // skipped todo_ test gets no plan.
+ let planNames = Set((integrated.plans?.plans ?? []).map(\.name))
+ #expect(
+ planNames == [
+ "example_test/test_pass",
+ "example_test/test_fail",
+ "example_test/test_allow_admin",
+ ])
+
+ // The "result" key must be present in the static string table.
+ let strings = integrated.staticData?.strings?.map(\.value) ?? []
+ #expect(strings.contains(TestPlanGenerator.resultKey))
+
+ // Funcs are preserved.
+ #expect(integrated.funcs?.funcs?.count == policy.funcs?.funcs?.count)
+ }
+
+ // MARK: - Strict result-set interpretation
+
+ @Test("passed() requires a result object whose `result` field is true")
+ func strictResultSemantics() {
+ let key = AST.RegoValue.string(TestPlanGenerator.resultKey)
+
+ // Bare true value under `result` -> pass.
+ #expect(TestRunner.passed([.object([key: .boolean(true)])]))
+ // Extra annotation keys are tolerated as long as `result` is true.
+ #expect(TestRunner.passed([.object([key: .boolean(true), .string("trace"): .array([])])]))
+ // `result` is false -> not a pass.
+ #expect(!TestRunner.passed([.object([key: .boolean(false)])]))
+ // Object without a `result` key -> not a pass.
+ #expect(!TestRunner.passed([.object([.string("other"): .boolean(true)])]))
+ // A bare true (not wrapped in an object) -> not a pass.
+ #expect(!TestRunner.passed([.boolean(true)]))
+ // Empty result set (undefined test) -> not a pass.
+ #expect(!TestRunner.passed([]))
+ }
+
+ // MARK: - End-to-end run
+
+ @Test("run classifies pass/fail/skip against the fixture bundle")
+ func endToEnd() async throws {
+ let results = try await TestRunner.run(paths: [Self.bundleURL])
+ let outcomes = Dictionary(
+ uniqueKeysWithValues: results.map { ($0.testCase.name, $0.outcome) })
+
+ #expect(outcomes["data.example_test.test_pass"] == .passed)
+ #expect(outcomes["data.example_test.test_allow_admin"] == .passed)
+ #expect(outcomes["data.example_test.test_fail"] == .failed)
+ #expect(outcomes["data.example_test.todo_test_skip"] == .skipped)
+ }
+
+ /// URL of a bundle with nested packages and two separate test packages,
+ /// built with production entrypoints only (`-e authz/allow -e authz/rbac/allow`)
+ /// so tests are pulled in via reachability and appear as funcs.
+ static var nestedBundleURL: URL {
+ Bundle.module.resourceURL!.appending(path: "Fixtures/nested-bundle")
+ }
+
+ @Test("nested packages yield one unique plan/result per test")
+ func nestedPackages() async throws {
+ // Discovery derives a distinct per-test plan name for each test, including
+ // the nested `authz.rbac_test` package — no package-level plan is shared.
+ let bundle = try BundleLoader.load(fromFile: Self.nestedBundleURL)
+ let policy = try IR.Policy(jsonData: #require(bundle.planFiles.first).data)
+ let (integrated, tests) = TestRunner.integrate(policy)
+
+ #expect(tests.count == 4)
+ #expect(
+ Set((integrated.plans?.plans ?? []).map(\.name)) == [
+ "authz_test/test_admin_allowed",
+ "authz_test/test_anon_denied",
+ "authz/rbac_test/test_read_allowed",
+ "authz/rbac_test/test_write_denied",
+ ])
+
+ // End-to-end: every test runs and passes, one result each.
+ let results = try await TestRunner.run(paths: [Self.nestedBundleURL])
+ #expect(results.count == 4)
+ #expect(results.allSatisfy { $0.outcome == .passed })
+ #expect(
+ Set(results.map(\.testCase.name)) == [
+ "data.authz_test.test_admin_allowed",
+ "data.authz_test.test_anon_denied",
+ "data.authz.rbac_test.test_read_allowed",
+ "data.authz.rbac_test.test_write_denied",
+ ])
+ }
+
+ @Test("--run filter restricts which tests execute")
+ func runFilter() async throws {
+ let results = try await TestRunner.run(paths: [Self.bundleURL], runFilter: "test_pass")
+ #expect(results.count == 1)
+ #expect(results.first?.testCase.name == "data.example_test.test_pass")
+ #expect(results.first?.outcome == .passed)
+ }
+
+ @Test("--count repeats each test")
+ func count() async throws {
+ let single = try await TestRunner.run(paths: [Self.bundleURL], runFilter: "test_pass")
+ let doubled = try await TestRunner.run(
+ paths: [Self.bundleURL], runFilter: "test_pass", count: 2)
+ #expect(doubled.count == single.count * 2)
+ }
+}