Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -77,6 +86,7 @@ let package = Package(
name: "CLI",
dependencies: [
"Rego",
"TestRunner",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
Expand Down
2 changes: 1 addition & 1 deletion Sources/CLI/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
}
146 changes: 146 additions & 0 deletions Sources/CLI/TestCommand.swift
Original file line number Diff line number Diff line change
@@ -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 <dir> -t plan -e <test-package>`) 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 <dir> -t plan -e <test-package>`) 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))
}
}
}
64 changes: 64 additions & 0 deletions Sources/CLI/TestReporter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 3 additions & 3 deletions Sources/Rego/BundleLoader.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import AST
import Foundation

struct BundleLoader {
package struct BundleLoader {
var bundleFiles: any Sequence<Result<BundleFile, any Swift.Error>>

init(fromFileSequence files: any Sequence<Result<BundleFile, any Swift.Error>>) {
Expand Down Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions Sources/TestRunner/TestCase.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading