From 21efdafa2253248143d04f9a836403cd89348e7b Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Tue, 29 Sep 2026 23:28:09 +0800 Subject: [PATCH 1/2] feat: load strict JSON configuration in the CLI --- src/cli.zig | 53 +++- src/config.zig | 378 ++++++++------------------- src/lib.zig | 1 + src/main.zig | 14 + tests/cli_test.zig | 17 ++ tests/fixtures/invalid-zig-test.json | 5 + tests/fixtures/zig-test.json | 8 + 7 files changed, 199 insertions(+), 277 deletions(-) create mode 100644 tests/fixtures/invalid-zig-test.json create mode 100644 tests/fixtures/zig-test.json diff --git a/src/cli.zig b/src/cli.zig index 56be47d..112e65f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const config_mod = @import("config.zig"); const test_runner = @import("test_runner.zig"); pub const CLIOptions = struct { @@ -61,6 +62,32 @@ pub const CLI = struct { }; } + /// Apply configuration defaults before parsing command-line arguments. + /// A subsequent `parse` call gives explicitly supplied CLI flags priority. + pub fn applyConfig(self: *Self, config: config_mod.TestConfig) !void { + self.options.test_dir = config.test_options.test_dir; + self.options.pattern = config.test_options.pattern; + self.options.no_recursive = !config.test_options.recursive; + self.options.filter = config.test_options.filter; + self.options.timeout = config.test_options.timeout; + self.options.parallel = config.parallel.enabled; + self.options.jobs = config.parallel.jobs; + self.options.reporter = try reporterFromName(config.reporter.reporter); + self.options.junit_output = config.reporter.junit_output; + self.options.verbose = config.reporter.verbose; + self.options.update_snapshots = config.snapshot.update; + self.options.snapshot_dir = config.snapshot.snapshot_dir; + self.options.watch = config.watch.enabled; + self.options.watch_debounce = config.watch.debounce_ms; + self.options.profile_memory = config.memory.enabled; + self.options.memory_threshold = config.memory.report_threshold; + self.options.fail_on_leak = config.memory.fail_on_leak; + self.options.ui = config.ui.enabled; + self.options.ui_port = config.ui.port; + self.options.coverage = config.coverage.enabled; + self.options.coverage_dir = config.coverage.output_dir; + } + /// Parse command-line arguments pub fn parse(self: *Self, args: []const []const u8) !void { var i: usize = 1; // Skip program name @@ -87,20 +114,10 @@ pub const CLI = struct { i += 1; const reporter_name = args[i]; - if (std.mem.eql(u8, reporter_name, "spec")) { - self.options.reporter = .spec; - } else if (std.mem.eql(u8, reporter_name, "dot")) { - self.options.reporter = .dot; - } else if (std.mem.eql(u8, reporter_name, "json")) { - self.options.reporter = .json; - } else if (std.mem.eql(u8, reporter_name, "tap")) { - self.options.reporter = .tap; - } else if (std.mem.eql(u8, reporter_name, "junit")) { - self.options.reporter = .junit; - } else { + self.options.reporter = reporterFromName(reporter_name) catch { std.debug.print("Error: Unknown reporter '{s}'. Available: spec, dot, json, tap, junit\n", .{reporter_name}); return CLIError.InvalidArgument; - } + }; } else if (std.mem.eql(u8, arg, "--filter") or std.mem.eql(u8, arg, "--grep")) { if (i + 1 >= args.len) { std.debug.print("Error: --filter/--grep requires a value\n", .{}); @@ -273,7 +290,6 @@ pub const CLI = struct { if (self.options.profile_memory) return "--profile-memory"; if (self.options.memory_threshold != 0) return "--memory-threshold"; if (self.options.fail_on_leak) return "--fail-on-leak"; - if (self.options.config != null) return "--config"; if (self.options.junit_output != null) return "--junit-output"; if (self.options.timeout != null) return "--timeout"; if (self.options.ui) return "--ui"; @@ -300,7 +316,7 @@ pub const CLI = struct { \\ -q, --quiet Minimal output \\ --no-color Disable colored output \\ --timeout Global timeout for all tests in milliseconds - \\ -c, --config Load configuration from file + \\ -c, --config Load strict JSON configuration \\ \\TEST DISCOVERY: \\ --test-dir Directory to search for tests (default: .) @@ -384,6 +400,15 @@ pub const CLI = struct { } }; +fn reporterFromName(name: []const u8) !test_runner.ReporterType { + if (std.mem.eql(u8, name, "spec")) return .spec; + if (std.mem.eql(u8, name, "dot")) return .dot; + if (std.mem.eql(u8, name, "json")) return .json; + if (std.mem.eql(u8, name, "tap")) return .tap; + if (std.mem.eql(u8, name, "junit")) return .junit; + return CLIError.InvalidArgument; +} + test "CLI parse help" { var cli = CLI.init(std.testing.allocator); const args = [_][]const u8{ "zig-test", "--help" }; diff --git a/src/config.zig b/src/config.zig index feb4128..9855c92 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,309 +1,134 @@ const std = @import("std"); const compat = @import("compat.zig"); -/// Configuration file format -pub const ConfigFormat = enum { - json, - toml, // Future support -}; +/// JSON is the only configuration format currently supported by the CLI. +pub const ConfigFormat = enum { json }; -/// Test framework configuration +/// Configuration values mirror options that the current execution paths can +/// actually honor. Defaults intentionally match `CLIOptions`. pub const TestConfig = struct { - // Test discovery options test_options: TestOptions = .{}, - - /// Parallel execution options parallel: ParallelOptions = .{}, - - /// Reporter options reporter: ReporterOptions = .{}, - - /// Snapshot testing options snapshot: SnapshotOptions = .{}, - - /// Watch mode options watch: WatchOptions = .{}, - - /// Memory profiling options memory: MemoryOptions = .{}, - - /// UI server options ui: UIOptions = .{}, - - /// Coverage options coverage: CoverageOptions = .{}, }; pub const TestOptions = struct { - /// Test file pattern pattern: []const u8 = "*.test.zig", - /// Test directory - test_dir: []const u8 = "src", - /// Recursive search + test_dir: []const u8 = ".", recursive: bool = true, - /// Filter tests by name filter: ?[]const u8 = null, - /// Timeout per test in milliseconds - timeout: u64 = 5000, + timeout: ?u64 = null, }; pub const ParallelOptions = struct { - /// Enable parallel execution enabled: bool = false, - /// Number of worker threads (0 = auto-detect) - jobs: usize = 0, + jobs: ?usize = null, }; pub const ReporterOptions = struct { - /// Reporter type: spec, dot, json, tap, junit reporter: []const u8 = "spec", - /// JUnit XML output file junit_output: ?[]const u8 = null, - /// Verbose output verbose: bool = false, }; pub const SnapshotOptions = struct { - /// Snapshot directory snapshot_dir: []const u8 = ".snapshots", - /// Update snapshots update: bool = false, - /// Pretty print snapshots - pretty_print: bool = true, }; pub const WatchOptions = struct { - /// Enable watch mode enabled: bool = false, - /// Directory to watch - watch_dir: []const u8 = ".", - /// Debounce delay in milliseconds debounce_ms: u64 = 300, - /// Clear screen between runs - clear_screen: bool = true, }; pub const MemoryOptions = struct { - /// Enable memory profiling enabled: bool = false, - /// Detect memory leaks - detect_leaks: bool = true, - /// Track peak memory usage - track_peak: bool = true, - /// Report threshold in bytes report_threshold: usize = 0, - /// Fail tests on memory leaks fail_on_leak: bool = false, }; pub const UIOptions = struct { - /// Enable UI server enabled: bool = false, - /// Port for UI server port: u16 = 8080, - /// Open browser automatically - open_browser: bool = true, }; pub const CoverageOptions = struct { - /// Enable coverage tracking enabled: bool = false, - /// Coverage output directory output_dir: []const u8 = "coverage", - /// Minimum coverage threshold - threshold: f64 = 0.0, }; -/// Configuration loader +const FileConfig = struct { + @"test": TestOptions = .{}, + parallel: ParallelOptions = .{}, + reporter: ReporterOptions = .{}, + snapshot: SnapshotOptions = .{}, + watch: WatchOptions = .{}, + memory: MemoryOptions = .{}, + ui: UIOptions = .{}, + coverage: CoverageOptions = .{}, +}; + +pub const ConfigError = error{ + UnsupportedFormat, + InvalidReporter, + InvalidJobs, + InvalidPort, + InvalidTimeout, + InvalidDebounce, +}; + +/// Owns strings parsed from configuration files until `deinit` is called. pub const ConfigLoader = struct { allocator: std.mem.Allocator, + arena: std.heap.ArenaAllocator, const Self = @This(); pub fn init(allocator: std.mem.Allocator) Self { return .{ .allocator = allocator, + .arena = .init(allocator), }; } - /// Load configuration from file - pub fn loadFromFile(self: *Self, path: []const u8) !TestConfig { - // Detect format from extension - const format = if (std.mem.endsWith(u8, path, ".json")) - ConfigFormat.json - else if (std.mem.endsWith(u8, path, ".toml")) - ConfigFormat.toml - else - return error.UnsupportedFormat; - - switch (format) { - .json => return try self.loadFromJson(path), - .toml => return error.TomlNotImplementedYet, - } + pub fn deinit(self: *Self) void { + self.arena.deinit(); } - /// Load configuration from JSON file - fn loadFromJson(self: *Self, path: []const u8) !TestConfig { + /// Load a strict JSON configuration. Unknown keys, duplicate keys, and + /// values of the wrong type are rejected by the standard JSON parser. + pub fn loadFromFile(self: *Self, path: []const u8) !TestConfig { + if (!std.mem.endsWith(u8, path, ".json")) return ConfigError.UnsupportedFormat; + const content = try compat.readFileAlloc(self.allocator, path); defer self.allocator.free(content); - var parsed = try std.json.parseFromSlice(std.json.Value, self.allocator, content, .{}); - defer parsed.deinit(); - - return try self.parseConfig(parsed.value); - } - - /// Parse configuration from JSON value - fn parseConfig(self: *Self, value: std.json.Value) !TestConfig { - _ = self; - - var config = TestConfig{}; - - if (value != .object) return config; - - const root = value.object; - - // Parse test options - if (root.get("test")) |test_value| { - if (test_value == .object) { - const test_obj = test_value.object; - if (test_obj.get("pattern")) |v| { - if (v == .string) config.test_options.pattern = v.string; - } - if (test_obj.get("test_dir")) |v| { - if (v == .string) config.test_options.test_dir = v.string; - } - if (test_obj.get("recursive")) |v| { - if (v == .bool) config.test_options.recursive = v.bool; - } - if (test_obj.get("timeout")) |v| { - if (v == .integer) config.test_options.timeout = @intCast(v.integer); - } - } - } - - // Parse parallel options - if (root.get("parallel")) |parallel_value| { - if (parallel_value == .object) { - const parallel_obj = parallel_value.object; - if (parallel_obj.get("enabled")) |v| { - if (v == .bool) config.parallel.enabled = v.bool; - } - if (parallel_obj.get("jobs")) |v| { - if (v == .integer) config.parallel.jobs = @intCast(v.integer); - } - } - } - - // Parse reporter options - if (root.get("reporter")) |reporter_value| { - if (reporter_value == .object) { - const reporter_obj = reporter_value.object; - if (reporter_obj.get("reporter")) |v| { - if (v == .string) config.reporter.reporter = v.string; - } - if (reporter_obj.get("junit_output")) |v| { - if (v == .string) config.reporter.junit_output = v.string; - } - if (reporter_obj.get("verbose")) |v| { - if (v == .bool) config.reporter.verbose = v.bool; - } - } - } - - // Parse snapshot options - if (root.get("snapshot")) |snapshot_value| { - if (snapshot_value == .object) { - const snapshot_obj = snapshot_value.object; - if (snapshot_obj.get("snapshot_dir")) |v| { - if (v == .string) config.snapshot.snapshot_dir = v.string; - } - if (snapshot_obj.get("update")) |v| { - if (v == .bool) config.snapshot.update = v.bool; - } - if (snapshot_obj.get("pretty_print")) |v| { - if (v == .bool) config.snapshot.pretty_print = v.bool; - } - } - } - - // Parse watch options - if (root.get("watch")) |watch_value| { - if (watch_value == .object) { - const watch_obj = watch_value.object; - if (watch_obj.get("enabled")) |v| { - if (v == .bool) config.watch.enabled = v.bool; - } - if (watch_obj.get("watch_dir")) |v| { - if (v == .string) config.watch.watch_dir = v.string; - } - if (watch_obj.get("debounce_ms")) |v| { - if (v == .integer) config.watch.debounce_ms = @intCast(v.integer); - } - if (watch_obj.get("clear_screen")) |v| { - if (v == .bool) config.watch.clear_screen = v.bool; - } - } - } - - // Parse memory options - if (root.get("memory")) |memory_value| { - if (memory_value == .object) { - const memory_obj = memory_value.object; - if (memory_obj.get("enabled")) |v| { - if (v == .bool) config.memory.enabled = v.bool; - } - if (memory_obj.get("detect_leaks")) |v| { - if (v == .bool) config.memory.detect_leaks = v.bool; - } - if (memory_obj.get("track_peak")) |v| { - if (v == .bool) config.memory.track_peak = v.bool; - } - if (memory_obj.get("report_threshold")) |v| { - if (v == .integer) config.memory.report_threshold = @intCast(v.integer); - } - if (memory_obj.get("fail_on_leak")) |v| { - if (v == .bool) config.memory.fail_on_leak = v.bool; - } - } - } - - // Parse UI options - if (root.get("ui")) |ui_value| { - if (ui_value == .object) { - const ui_obj = ui_value.object; - if (ui_obj.get("enabled")) |v| { - if (v == .bool) config.ui.enabled = v.bool; - } - if (ui_obj.get("port")) |v| { - if (v == .integer) config.ui.port = @intCast(v.integer); - } - if (ui_obj.get("open_browser")) |v| { - if (v == .bool) config.ui.open_browser = v.bool; - } - } - } - - // Parse coverage options - if (root.get("coverage")) |coverage_value| { - if (coverage_value == .object) { - const coverage_obj = coverage_value.object; - if (coverage_obj.get("enabled")) |v| { - if (v == .bool) config.coverage.enabled = v.bool; - } - if (coverage_obj.get("output_dir")) |v| { - if (v == .string) config.coverage.output_dir = v.string; - } - if (coverage_obj.get("threshold")) |v| { - if (v == .float) config.coverage.threshold = v.float; - } - } - } - + const file_config = try std.json.parseFromSliceLeaky( + FileConfig, + self.arena.allocator(), + content, + .{ .allocate = .alloc_always }, + ); + const config = TestConfig{ + .test_options = file_config.@"test", + .parallel = file_config.parallel, + .reporter = file_config.reporter, + .snapshot = file_config.snapshot, + .watch = file_config.watch, + .memory = file_config.memory, + .ui = file_config.ui, + .coverage = file_config.coverage, + }; + try validate(config); return config; } - /// Try to find and load configuration file + /// Try conventional JSON configuration locations. pub fn autoLoad(self: *Self, config_name: []const u8) !?TestConfig { const search_paths = [_][]const u8{ "zig-test.json", @@ -312,55 +137,82 @@ pub const ConfigLoader = struct { ".config/zig-test.json", }; - for (search_paths) |path| { - const full_path = if (std.mem.eql(u8, config_name, "zig-test")) - path - else - try std.fmt.allocPrint(self.allocator, "{s}.json", .{config_name}); - - defer if (!std.mem.eql(u8, config_name, "zig-test")) self.allocator.free(full_path); - - const config = self.loadFromFile(full_path) catch |err| { - if (err == error.FileNotFound) continue; - return err; + if (!std.mem.eql(u8, config_name, "zig-test")) { + const path = try std.fmt.allocPrint(self.allocator, "{s}.json", .{config_name}); + defer self.allocator.free(path); + return self.loadFromFile(path) catch |err| switch (err) { + error.FileNotFound => null, + else => return err, }; + } + for (search_paths) |path| { + const config = self.loadFromFile(path) catch |err| switch (err) { + error.FileNotFound => continue, + else => return err, + }; return config; } - return null; } }; -// Tests -test "TestConfig default values" { - const config = TestConfig{}; +fn validate(config: TestConfig) !void { + const reporter = config.reporter.reporter; + if (!std.mem.eql(u8, reporter, "spec") and + !std.mem.eql(u8, reporter, "dot") and + !std.mem.eql(u8, reporter, "json") and + !std.mem.eql(u8, reporter, "tap") and + !std.mem.eql(u8, reporter, "junit")) + { + return ConfigError.InvalidReporter; + } + if (config.parallel.jobs) |jobs| { + if (jobs == 0) return ConfigError.InvalidJobs; + } + if (config.ui.port == 0) return ConfigError.InvalidPort; + if (config.test_options.timeout) |timeout| { + if (timeout == 0) return ConfigError.InvalidTimeout; + } + if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce; +} +test "TestConfig defaults match CLI discovery defaults" { + const config = TestConfig{}; try std.testing.expectEqualStrings("*.test.zig", config.test_options.pattern); - try std.testing.expectEqualStrings("src", config.test_options.test_dir); - try std.testing.expectEqual(true, config.test_options.recursive); - try std.testing.expectEqual(false, config.parallel.enabled); + try std.testing.expectEqualStrings(".", config.test_options.test_dir); + try std.testing.expect(config.test_options.recursive); + try std.testing.expect(config.test_options.timeout == null); + try std.testing.expect(!config.parallel.enabled); try std.testing.expectEqualStrings("spec", config.reporter.reporter); } -test "ConfigLoader initialization" { - const allocator = std.testing.allocator; - - const loader = ConfigLoader.init(allocator); - try std.testing.expect(loader.allocator.ptr == allocator.ptr); +test "ConfigLoader rejects unsupported formats" { + var loader = ConfigLoader.init(std.testing.allocator); + defer loader.deinit(); + try std.testing.expectError(ConfigError.UnsupportedFormat, loader.loadFromFile("zig-test.toml")); } -test "ConfigLoader parse empty JSON" { - const allocator = std.testing.allocator; - - var loader = ConfigLoader.init(allocator); +test "configuration validation rejects invalid runtime values" { + var invalid = TestConfig{}; + invalid.reporter.reporter = "pretty"; + try std.testing.expectError(ConfigError.InvalidReporter, validate(invalid)); + invalid.reporter.reporter = "spec"; + invalid.parallel.jobs = 0; + try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid)); +} - var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{}", .{}); - defer parsed.deinit(); +test "ConfigLoader loads supported JSON and rejects unknown keys" { + var loader = ConfigLoader.init(std.testing.allocator); + defer loader.deinit(); - const config = try loader.parseConfig(parsed.value); + const config = try loader.loadFromFile("tests/fixtures/zig-test.json"); + try std.testing.expectEqualStrings("tests/fixtures", config.test_options.test_dir); + try std.testing.expectEqualStrings("selected test passes", config.test_options.filter.?); + try std.testing.expect(!config.test_options.recursive); - // Should return defaults - try std.testing.expectEqualStrings("spec", config.reporter.reporter); - try std.testing.expectEqual(false, config.parallel.enabled); + try std.testing.expectError( + error.UnknownField, + loader.loadFromFile("tests/fixtures/invalid-zig-test.json"), + ); } diff --git a/src/lib.zig b/src/lib.zig index afa6f23..b1bf6af 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -161,6 +161,7 @@ pub const ProfileOptions = memory_profiler.ProfileOptions; pub const TestConfig = config.TestConfig; pub const ConfigLoader = config.ConfigLoader; pub const ConfigFormat = config.ConfigFormat; +pub const ConfigError = config.ConfigError; // Async Support pub const Future = async_support.Future; diff --git a/src/main.zig b/src/main.zig index 2675463..5ed3d50 100644 --- a/src/main.zig +++ b/src/main.zig @@ -71,6 +71,20 @@ pub fn main(init: std.process.Init.Minimal) !void { return; } + // Configuration establishes defaults; parsing the arguments again makes + // every explicitly supplied CLI flag take precedence. + var config_loader = lib.ConfigLoader.init(allocator); + defer config_loader.deinit(); + if (cli_parser.options.config) |config_path| { + const config = config_loader.loadFromFile(config_path) catch |err| { + std.debug.print("Error: invalid configuration '{s}': {s}\n", .{ config_path, @errorName(err) }); + std.process.exit(2); + }; + cli_parser = lib.CLI.init(allocator); + try cli_parser.applyConfig(config); + try cli_parser.parse(args); + } + // Check if we should use test discovery or programmatic tests const use_discovery = cli_parser.options.test_dir != null; diff --git a/tests/cli_test.zig b/tests/cli_test.zig index 8cd90f0..ea55326 100644 --- a/tests/cli_test.zig +++ b/tests/cli_test.zig @@ -23,3 +23,20 @@ test "CLI rejects missing and invalid values" { var invalid = ztf.CLI.init(std.testing.allocator); try std.testing.expectError(ztf.cli.CLIError.InvalidArgument, invalid.parse(&.{ "zig-test", "--jobs", "0" })); } + +test "CLI flags override configuration defaults" { + var cli = ztf.CLI.init(std.testing.allocator); + var config = ztf.TestConfig{}; + config.test_options.test_dir = "configured-tests"; + config.test_options.filter = "configured-filter"; + config.test_options.recursive = false; + config.reporter.verbose = true; + + try cli.applyConfig(config); + try cli.parse(&.{ "zig-test", "--test-dir", "cli-tests", "--filter", "cli-filter" }); + + try std.testing.expectEqualStrings("cli-tests", cli.options.test_dir.?); + try std.testing.expectEqualStrings("cli-filter", cli.options.filter.?); + try std.testing.expect(cli.options.no_recursive); + try std.testing.expect(cli.options.verbose); +} diff --git a/tests/fixtures/invalid-zig-test.json b/tests/fixtures/invalid-zig-test.json new file mode 100644 index 0000000..aca1199 --- /dev/null +++ b/tests/fixtures/invalid-zig-test.json @@ -0,0 +1,5 @@ +{ + "test": { + "unknown_option": true + } +} diff --git a/tests/fixtures/zig-test.json b/tests/fixtures/zig-test.json new file mode 100644 index 0000000..55c673b --- /dev/null +++ b/tests/fixtures/zig-test.json @@ -0,0 +1,8 @@ +{ + "test": { + "test_dir": "tests/fixtures", + "pattern": "filter_fixture.zig", + "recursive": false, + "filter": "selected test passes" + } +} From 759090e0648fe8d038de953c959f431bd8d24a15 Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Tue, 29 Sep 2026 23:28:09 +0800 Subject: [PATCH 2/2] test: verify config execution and CLI precedence --- FEATURES_SUMMARY.md | 8 +++----- README.md | 35 +++++++++++++++++++++++++++++++---- build.zig | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/FEATURES_SUMMARY.md b/FEATURES_SUMMARY.md index 9e138de..bfe2c77 100644 --- a/FEATURES_SUMMARY.md +++ b/FEATURES_SUMMARY.md @@ -258,12 +258,10 @@ This document provides a comprehensive overview of all implemented features in t - **Status:** Production Ready - **Module:** `src/config.zig` - **Features:** - - YAML configuration support - - JSON configuration support - - TOML configuration support - - Configuration validation + - Strict JSON configuration support + - Unknown-key, type, and runtime-value validation + - CLI-over-config precedence - Default configuration - - Environment-specific configs ### 16. Test History ✅ diff --git a/README.md b/README.md index acdf167..e9fc481 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A modern, feature-rich testing framework for Zig inspired by Jest, Vitest, and B - **Watch Mode** - Automatically re-run tests on file changes - **Memory Profiling** - Track memory usage and detect leaks - **Parallel Execution** - Run tests in parallel for faster execution -- **Configuration Files** - YAML/JSON/TOML config file support +- **Configuration Files** - Strict JSON config files with CLI overrides ## Installation @@ -100,8 +100,8 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d Discovery mode rejects options that it cannot honor yet instead of silently ignoring them. Reporter selection, quiet output, timeouts, parallel jobs, -snapshot updates, memory profiling, configuration files, JUnit output, and the -web UI require the shared execution pipeline planned for future releases. +snapshot updates, memory profiling, JUnit output, and the web UI require the +shared execution pipeline planned for future releases. **Examples:** @@ -119,6 +119,33 @@ zig-test --test-dir tests --no-recursive zig-test --test-dir tests --bail ``` +### Configuration files + +`--config ` loads a strict JSON configuration before test discovery. +Unknown keys, duplicate keys, wrong value types, unsupported extensions, and +invalid runtime values fail with a configuration error. TOML and YAML are not +currently supported. Explicit CLI options are parsed after the file and take +precedence over configured values. + +```json +{ + "test": { + "test_dir": "tests", + "pattern": "*.test.zig", + "recursive": true, + "filter": "database" + }, + "coverage": { + "enabled": false, + "output_dir": "coverage" + } +} +``` + +The accepted top-level sections are `test`, `parallel`, `reporter`, `snapshot`, +`watch`, `memory`, `ui`, and `coverage`. Options retain the same execution-mode +limitations as their CLI equivalents; unsupported combinations fail explicitly. + ### Programmatic Mode For more control, you can manually register tests: @@ -824,7 +851,7 @@ MIT License - see [LICENSE](LICENSE) for details. - [x] Watch mode for file changes - [x] TAP/JUnit reporters - [x] Memory profiling -- [x] Configuration files (YAML/JSON/TOML) +- [x] Strict JSON configuration files - [x] Parallel test execution - [ ] Parameterized tests (it.each) - [ ] Property-based testing diff --git a/build.zig b/build.zig index b740699..14897b6 100644 --- a/build.zig +++ b/build.zig @@ -242,6 +242,22 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_snapshot_usage_tests.step); test_step.dependOn(&run_time_tests.step); + // Exercise configuration through the real CLI. The second invocation + // proves that an explicit CLI filter overrides the configured filter. + const run_config_fixture = b.addRunArtifact(exe); + run_config_fixture.addArgs(&.{ "--config", "tests/fixtures/zig-test.json" }); + test_step.dependOn(&run_config_fixture.step); + + const run_config_override = b.addRunArtifact(exe); + run_config_override.addArgs(&.{ "--config", "tests/fixtures/zig-test.json", "--filter", "unselected test fails" }); + run_config_override.expectExitCode(1); + test_step.dependOn(&run_config_override.step); + + const run_invalid_config = b.addRunArtifact(exe); + run_invalid_config.addArgs(&.{ "--config", "tests/fixtures/invalid-zig-test.json" }); + run_invalid_config.expectExitCode(2); + test_step.dependOn(&run_invalid_config.step); + // Examples const basic_example = b.addExecutable(.{ .name = "basic_example",