ReviewOS

also looking at this

zig-utils/zig-test-framework

feat: load strict JSON configuration files

#19
Merged glennmichael123 wants to merge codex/issue-12-config into main
10 files +249 -286
FEATURES_SUMMARY.mdmodified+3-5
Changes to FEATURES_SUMMARY.md
@@ -258,12 +258,10 @@ This document provides a comprehensive overview of all implemented features in t
258258- **Status:** Production Ready
259259- **Module:** `src/config.zig`
260260- **Features:**
261 - YAML configuration support
262 - JSON configuration support
263 - TOML configuration support
264 - Configuration validation
261 - Strict JSON configuration support
262 - Unknown-key, type, and runtime-value validation
263 - CLI-over-config precedence
265264 - Default configuration
266 - Environment-specific configs
267265
268266### 16. Test History ✅
269267
README.mdmodified+31-4
Changes to README.md
@@ -24,7 +24,7 @@ A modern, feature-rich testing framework for Zig inspired by Jest, Vitest, and B
2424- **Watch Mode** - Automatically re-run tests on file changes
2525- **Memory Profiling** - Track memory usage and detect leaks
2626- **Parallel Execution** - Run tests in parallel for faster execution
27- **Configuration Files** - YAML/JSON/TOML config file support
27- **Configuration Files** - Strict JSON config files with CLI overrides
2828
2929## Installation
3030
@@ -100,8 +100,8 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
100100
101101Discovery mode rejects options that it cannot honor yet instead of silently
102102ignoring them. Reporter selection, quiet output, timeouts, parallel jobs,
103snapshot updates, memory profiling, configuration files, JUnit output, and the
104web UI require the shared execution pipeline planned for future releases.
103snapshot updates, memory profiling, JUnit output, and the web UI require the
104shared execution pipeline planned for future releases.
105105
106106**Examples:**
107107
@@ -119,6 +119,33 @@ zig-test --test-dir tests --no-recursive
119119zig-test --test-dir tests --bail
120120```
121121
122### Configuration files
123
124`--config <file>` loads a strict JSON configuration before test discovery.
125Unknown keys, duplicate keys, wrong value types, unsupported extensions, and
126invalid runtime values fail with a configuration error. TOML and YAML are not
127currently supported. Explicit CLI options are parsed after the file and take
128precedence over configured values.
129
130```json
131{
132 "test": {
133 "test_dir": "tests",
134 "pattern": "*.test.zig",
135 "recursive": true,
136 "filter": "database"
137 },
138 "coverage": {
139 "enabled": false,
140 "output_dir": "coverage"
141 }
142}
143```
144
145The accepted top-level sections are `test`, `parallel`, `reporter`, `snapshot`,
146`watch`, `memory`, `ui`, and `coverage`. Options retain the same execution-mode
147limitations as their CLI equivalents; unsupported combinations fail explicitly.
148
122149### Programmatic Mode
123150
124151For more control, you can manually register tests:
@@ -824,7 +851,7 @@ MIT License - see [LICENSE](LICENSE) for details.
824851- [x] Watch mode for file changes
825852- [x] TAP/JUnit reporters
826853- [x] Memory profiling
827- [x] Configuration files (YAML/JSON/TOML)
854- [x] Strict JSON configuration files
828855- [x] Parallel test execution
829856- [ ] Parameterized tests (it.each)
830857- [ ] Property-based testing
build.zigmodified+16-0
Changes to build.zig
@@ -242,6 +242,22 @@ pub fn build(b: *std.Build) void {
242242 test_step.dependOn(&run_snapshot_usage_tests.step);
243243 test_step.dependOn(&run_time_tests.step);
244244
245 // Exercise configuration through the real CLI. The second invocation
246 // proves that an explicit CLI filter overrides the configured filter.
247 const run_config_fixture = b.addRunArtifact(exe);
248 run_config_fixture.addArgs(&.{ "--config", "tests/fixtures/zig-test.json" });
249 test_step.dependOn(&run_config_fixture.step);
250
251 const run_config_override = b.addRunArtifact(exe);
252 run_config_override.addArgs(&.{ "--config", "tests/fixtures/zig-test.json", "--filter", "unselected test fails" });
253 run_config_override.expectExitCode(1);
254 test_step.dependOn(&run_config_override.step);
255
256 const run_invalid_config = b.addRunArtifact(exe);
257 run_invalid_config.addArgs(&.{ "--config", "tests/fixtures/invalid-zig-test.json" });
258 run_invalid_config.expectExitCode(2);
259 test_step.dependOn(&run_invalid_config.step);
260
245261 // Examples
246262 const basic_example = b.addExecutable(.{
247263 .name = "basic_example",
src/cli.zigmodified+39-14
Changes to src/cli.zig
@@ -1,4 +1,5 @@
11const std = ;
2const config_mod = ;
23const test_runner = ;
34
45pub const CLIOptions = struct {
@@ -61,6 +62,32 @@ pub const CLI = struct {
6162 };
6263 }
6364
65 /// Apply configuration defaults before parsing command-line arguments.
66 /// A subsequent `parse` call gives explicitly supplied CLI flags priority.
67 pub fn applyConfig(self: *Self, config: config_mod.TestConfig) !void {
68 self.options.test_dir = config.test_options.test_dir;
69 self.options.pattern = config.test_options.pattern;
70 self.options.no_recursive = !config.test_options.recursive;
71 self.options.filter = config.test_options.filter;
72 self.options.timeout = config.test_options.timeout;
73 self.options.parallel = config.parallel.enabled;
74 self.options.jobs = config.parallel.jobs;
75 self.options.reporter = try reporterFromName(config.reporter.reporter);
76 self.options.junit_output = config.reporter.junit_output;
77 self.options.verbose = config.reporter.verbose;
78 self.options.update_snapshots = config.snapshot.update;
79 self.options.snapshot_dir = config.snapshot.snapshot_dir;
80 self.options.watch = config.watch.enabled;
81 self.options.watch_debounce = config.watch.debounce_ms;
82 self.options.profile_memory = config.memory.enabled;
83 self.options.memory_threshold = config.memory.report_threshold;
84 self.options.fail_on_leak = config.memory.fail_on_leak;
85 self.options.ui = config.ui.enabled;
86 self.options.ui_port = config.ui.port;
87 self.options.coverage = config.coverage.enabled;
88 self.options.coverage_dir = config.coverage.output_dir;
89 }
90
6491 /// Parse command-line arguments
6592 pub fn parse(self: *Self, args: []const []const u8) !void {
6693 var i: usize = 1; // Skip program name
@@ -87,20 +114,10 @@ pub const CLI = struct {
87114 i += 1;
88115 const reporter_name = args[i];
89116
90 if (std.mem.eql(u8, reporter_name, "spec")) {
91 self.options.reporter = .spec;
92 } else if (std.mem.eql(u8, reporter_name, "dot")) {
93 self.options.reporter = .dot;
94 } else if (std.mem.eql(u8, reporter_name, "json")) {
95 self.options.reporter = .json;
96 } else if (std.mem.eql(u8, reporter_name, "tap")) {
97 self.options.reporter = .tap;
98 } else if (std.mem.eql(u8, reporter_name, "junit")) {
99 self.options.reporter = .junit;
100 } else {
117 self.options.reporter = reporterFromName(reporter_name) catch {
101118 std.debug.print("Error: Unknown reporter '{s}'. Available: spec, dot, json, tap, junit\n", .{reporter_name});
102119 return CLIError.InvalidArgument;
103 }
120 };
104121 } else if (std.mem.eql(u8, arg, "--filter") or std.mem.eql(u8, arg, "--grep")) {
105122 if (i + 1 >= args.len) {
106123 std.debug.print("Error: --filter/--grep requires a value\n", .{});
@@ -273,7 +290,6 @@ pub const CLI = struct {
273290 if (self.options.profile_memory) return "--profile-memory";
274291 if (self.options.memory_threshold != 0) return "--memory-threshold";
275292 if (self.options.fail_on_leak) return "--fail-on-leak";
276 if (self.options.config != null) return "--config";
277293 if (self.options.junit_output != null) return "--junit-output";
278294 if (self.options.timeout != null) return "--timeout";
279295 if (self.options.ui) return "--ui";
@@ -300,7 +316,7 @@ pub const CLI = struct {
300316 \\ -q, --quiet Minimal output
301317 \\ --no-color Disable colored output
302318 \\ --timeout <ms> Global timeout for all tests in milliseconds
303 \\ -c, --config <file> Load configuration from file
319 \\ -c, --config <file> Load strict JSON configuration
304320 \\
305321 \\TEST DISCOVERY:
306322 \\ --test-dir <dir> Directory to search for tests (default: .)
@@ -384,6 +400,15 @@ pub const CLI = struct {
384400 }
385401};
386402
403fn reporterFromName(name: []const u8) !test_runner.ReporterType {
404 if (std.mem.eql(u8, name, "spec")) return .spec;
405 if (std.mem.eql(u8, name, "dot")) return .dot;
406 if (std.mem.eql(u8, name, "json")) return .json;
407 if (std.mem.eql(u8, name, "tap")) return .tap;
408 if (std.mem.eql(u8, name, "junit")) return .junit;
409 return CLIError.InvalidArgument;
410}
411
387412test "CLI parse help" {
388413 var cli = CLI.init(std.testing.allocator);
389414 const args = [_][]const u8{ "zig-test", "--help" };
src/config.zigmodified+115-263
Changes to src/config.zig
@@ -1,309 +1,134 @@
11const std = ;
22const compat = ;
33
4/// Configuration file format
5pub const ConfigFormat = enum {
6 json,
7 toml, // Future support
8};
4/// JSON is the only configuration format currently supported by the CLI.
5pub const ConfigFormat = enum { json };
96
10/// Test framework configuration
7/// Configuration values mirror options that the current execution paths can
8/// actually honor. Defaults intentionally match `CLIOptions`.
119pub const TestConfig = struct {
12 // Test discovery options
1310 test_options: TestOptions = .{},
14
15 /// Parallel execution options
1611 parallel: ParallelOptions = .{},
17
18 /// Reporter options
1912 reporter: ReporterOptions = .{},
20
21 /// Snapshot testing options
2213 snapshot: SnapshotOptions = .{},
23
24 /// Watch mode options
2514 watch: WatchOptions = .{},
26
27 /// Memory profiling options
2815 memory: MemoryOptions = .{},
29
30 /// UI server options
3116 ui: UIOptions = .{},
32
33 /// Coverage options
3417 coverage: CoverageOptions = .{},
3518};
3619
3720pub const TestOptions = struct {
38 /// Test file pattern
3921 pattern: []const u8 = "*.test.zig",
40 /// Test directory
41 test_dir: []const u8 = "src",
42 /// Recursive search
22 test_dir: []const u8 = ".",
4323 recursive: bool = true,
44 /// Filter tests by name
4524 filter: ?[]const u8 = null,
46 /// Timeout per test in milliseconds
47 timeout: u64 = 5000,
25 timeout: ?u64 = null,
4826};
4927
5028pub const ParallelOptions = struct {
51 /// Enable parallel execution
5229 enabled: bool = false,
53 /// Number of worker threads (0 = auto-detect)
54 jobs: usize = 0,
30 jobs: ?usize = null,
5531};
5632
5733pub const ReporterOptions = struct {
58 /// Reporter type: spec, dot, json, tap, junit
5934 reporter: []const u8 = "spec",
60 /// JUnit XML output file
6135 junit_output: ?[]const u8 = null,
62 /// Verbose output
6336 verbose: bool = false,
6437};
6538
6639pub const SnapshotOptions = struct {
67 /// Snapshot directory
6840 snapshot_dir: []const u8 = ".snapshots",
69 /// Update snapshots
7041 update: bool = false,
71 /// Pretty print snapshots
72 pretty_print: bool = true,
7342};
7443
7544pub const WatchOptions = struct {
76 /// Enable watch mode
7745 enabled: bool = false,
78 /// Directory to watch
79 watch_dir: []const u8 = ".",
80 /// Debounce delay in milliseconds
8146 debounce_ms: u64 = 300,
82 /// Clear screen between runs
83 clear_screen: bool = true,
8447};
8548
8649pub const MemoryOptions = struct {
87 /// Enable memory profiling
8850 enabled: bool = false,
89 /// Detect memory leaks
90 detect_leaks: bool = true,
91 /// Track peak memory usage
92 track_peak: bool = true,
93 /// Report threshold in bytes
9451 report_threshold: usize = 0,
95 /// Fail tests on memory leaks
9652 fail_on_leak: bool = false,
9753};
9854
9955pub const UIOptions = struct {
100 /// Enable UI server
10156 enabled: bool = false,
102 /// Port for UI server
10357 port: u16 = 8080,
104 /// Open browser automatically
105 open_browser: bool = true,
10658};
10759
10860pub const CoverageOptions = struct {
109 /// Enable coverage tracking
11061 enabled: bool = false,
111 /// Coverage output directory
11262 output_dir: []const u8 = "coverage",
113 /// Minimum coverage threshold
114 threshold: f64 = 0.0,
11563};
11664
117/// Configuration loader
65const FileConfig = struct {
66 @"test": TestOptions = .{},
67 parallel: ParallelOptions = .{},
68 reporter: ReporterOptions = .{},
69 snapshot: SnapshotOptions = .{},
70 watch: WatchOptions = .{},
71 memory: MemoryOptions = .{},
72 ui: UIOptions = .{},
73 coverage: CoverageOptions = .{},
74};
75
76pub const ConfigError = error{
77 UnsupportedFormat,
78 InvalidReporter,
79 InvalidJobs,
80 InvalidPort,
81 InvalidTimeout,
82 InvalidDebounce,
83};
84
85/// Owns strings parsed from configuration files until `deinit` is called.
11886pub const ConfigLoader = struct {
11987 allocator: std.mem.Allocator,
88 arena: std.heap.ArenaAllocator,
12089
12190 const Self = @This();
12291
12392 pub fn init(allocator: std.mem.Allocator) Self {
12493 return .{
12594 .allocator = allocator,
95 .arena = .init(allocator),
12696 };
12797 }
12898
129 /// Load configuration from file
130 pub fn loadFromFile(self: *Self, path: []const u8) !TestConfig {
131 // Detect format from extension
132 const format = if (std.mem.endsWith(u8, path, ".json"))
133 ConfigFormat.json
134 else if (std.mem.endsWith(u8, path, ".toml"))
135 ConfigFormat.toml
136 else
137 return error.UnsupportedFormat;
138
139 switch (format) {
140 .json => return try self.loadFromJson(path),
141 .toml => return error.TomlNotImplementedYet,
142 }
99 pub fn deinit(self: *Self) void {
100 self.arena.deinit();
143101 }
144102
145 /// Load configuration from JSON file
146 fn loadFromJson(self: *Self, path: []const u8) !TestConfig {
103 /// Load a strict JSON configuration. Unknown keys, duplicate keys, and
104 /// values of the wrong type are rejected by the standard JSON parser.
105 pub fn loadFromFile(self: *Self, path: []const u8) !TestConfig {
106 if (!std.mem.endsWith(u8, path, ".json")) return ConfigError.UnsupportedFormat;
107
147108 const content = try compat.readFileAlloc(self.allocator, path);
148109 defer self.allocator.free(content);
149110
150 var parsed = try std.json.parseFromSlice(std.json.Value, self.allocator, content, .{});
151 defer parsed.deinit();
152
153 return try self.parseConfig(parsed.value);
154 }
155
156 /// Parse configuration from JSON value
157 fn parseConfig(self: *Self, value: std.json.Value) !TestConfig {
158 _ = self;
159
160 var config = TestConfig{};
161
162 if (value != .object) return config;
163
164 const root = value.object;
165
166 // Parse test options
167 if (root.get("test")) |test_value| {
168 if (test_value == .object) {
169 const test_obj = test_value.object;
170 if (test_obj.get("pattern")) |v| {
171 if (v == .string) config.test_options.pattern = v.string;
172 }
173 if (test_obj.get("test_dir")) |v| {
174 if (v == .string) config.test_options.test_dir = v.string;
175 }
176 if (test_obj.get("recursive")) |v| {
177 if (v == .bool) config.test_options.recursive = v.bool;
178 }
179 if (test_obj.get("timeout")) |v| {
180 if (v == .integer) config.test_options.timeout = @intCast(v.integer);
181 }
182 }
183 }
184
185 // Parse parallel options
186 if (root.get("parallel")) |parallel_value| {
187 if (parallel_value == .object) {
188 const parallel_obj = parallel_value.object;
189 if (parallel_obj.get("enabled")) |v| {
190 if (v == .bool) config.parallel.enabled = v.bool;
191 }
192 if (parallel_obj.get("jobs")) |v| {
193 if (v == .integer) config.parallel.jobs = @intCast(v.integer);
194 }
195 }
196 }
197
198 // Parse reporter options
199 if (root.get("reporter")) |reporter_value| {
200 if (reporter_value == .object) {
201 const reporter_obj = reporter_value.object;
202 if (reporter_obj.get("reporter")) |v| {
203 if (v == .string) config.reporter.reporter = v.string;
204 }
205 if (reporter_obj.get("junit_output")) |v| {
206 if (v == .string) config.reporter.junit_output = v.string;
207 }
208 if (reporter_obj.get("verbose")) |v| {
209 if (v == .bool) config.reporter.verbose = v.bool;
210 }
211 }
212 }
213
214 // Parse snapshot options
215 if (root.get("snapshot")) |snapshot_value| {
216 if (snapshot_value == .object) {
217 const snapshot_obj = snapshot_value.object;
218 if (snapshot_obj.get("snapshot_dir")) |v| {
219 if (v == .string) config.snapshot.snapshot_dir = v.string;
220 }
221 if (snapshot_obj.get("update")) |v| {
222 if (v == .bool) config.snapshot.update = v.bool;
223 }
224 if (snapshot_obj.get("pretty_print")) |v| {
225 if (v == .bool) config.snapshot.pretty_print = v.bool;
226 }
227 }
228 }
229
230 // Parse watch options
231 if (root.get("watch")) |watch_value| {
232 if (watch_value == .object) {
233 const watch_obj = watch_value.object;
234 if (watch_obj.get("enabled")) |v| {
235 if (v == .bool) config.watch.enabled = v.bool;
236 }
237 if (watch_obj.get("watch_dir")) |v| {
238 if (v == .string) config.watch.watch_dir = v.string;
239 }
240 if (watch_obj.get("debounce_ms")) |v| {
241 if (v == .integer) config.watch.debounce_ms = @intCast(v.integer);
242 }
243 if (watch_obj.get("clear_screen")) |v| {
244 if (v == .bool) config.watch.clear_screen = v.bool;
245 }
246 }
247 }
248
249 // Parse memory options
250 if (root.get("memory")) |memory_value| {
251 if (memory_value == .object) {
252 const memory_obj = memory_value.object;
253 if (memory_obj.get("enabled")) |v| {
254 if (v == .bool) config.memory.enabled = v.bool;
255 }
256 if (memory_obj.get("detect_leaks")) |v| {
257 if (v == .bool) config.memory.detect_leaks = v.bool;
258 }
259 if (memory_obj.get("track_peak")) |v| {
260 if (v == .bool) config.memory.track_peak = v.bool;
261 }
262 if (memory_obj.get("report_threshold")) |v| {
263 if (v == .integer) config.memory.report_threshold = @intCast(v.integer);
264 }
265 if (memory_obj.get("fail_on_leak")) |v| {
266 if (v == .bool) config.memory.fail_on_leak = v.bool;
267 }
268 }
269 }
270
271 // Parse UI options
272 if (root.get("ui")) |ui_value| {
273 if (ui_value == .object) {
274 const ui_obj = ui_value.object;
275 if (ui_obj.get("enabled")) |v| {
276 if (v == .bool) config.ui.enabled = v.bool;
277 }
278 if (ui_obj.get("port")) |v| {
279 if (v == .integer) config.ui.port = @intCast(v.integer);
280 }
281 if (ui_obj.get("open_browser")) |v| {
282 if (v == .bool) config.ui.open_browser = v.bool;
283 }
284 }
285 }
286
287 // Parse coverage options
288 if (root.get("coverage")) |coverage_value| {
289 if (coverage_value == .object) {
290 const coverage_obj = coverage_value.object;
291 if (coverage_obj.get("enabled")) |v| {
292 if (v == .bool) config.coverage.enabled = v.bool;
293 }
294 if (coverage_obj.get("output_dir")) |v| {
295 if (v == .string) config.coverage.output_dir = v.string;
296 }
297 if (coverage_obj.get("threshold")) |v| {
298 if (v == .float) config.coverage.threshold = v.float;
299 }
300 }
301 }
302
111 const file_config = try std.json.parseFromSliceLeaky(
112 FileConfig,
113 self.arena.allocator(),
114 content,
115 .{ .allocate = .alloc_always },
116 );
117 const config = TestConfig{
118 .test_options = file_config.@"test",
119 .parallel = file_config.parallel,
120 .reporter = file_config.reporter,
121 .snapshot = file_config.snapshot,
122 .watch = file_config.watch,
123 .memory = file_config.memory,
124 .ui = file_config.ui,
125 .coverage = file_config.coverage,
126 };
127 try validate(config);
303128 return config;
304129 }
305130
306 /// Try to find and load configuration file
131 /// Try conventional JSON configuration locations.
307132 pub fn autoLoad(self: *Self, config_name: []const u8) !?TestConfig {
308133 const search_paths = [_][]const u8{
309134 "zig-test.json",
@@ -312,55 +137,82 @@ pub const ConfigLoader = struct {
312137 ".config/zig-test.json",
313138 };
314139
315 for (search_paths) |path| {
316 const full_path = if (std.mem.eql(u8, config_name, "zig-test"))
317 path
318 else
319 try std.fmt.allocPrint(self.allocator, "{s}.json", .{config_name});
320
321 defer if (!std.mem.eql(u8, config_name, "zig-test")) self.allocator.free(full_path);
322
323 const config = self.loadFromFile(full_path) catch |err| {
324 if (err == error.FileNotFound) continue;
325 return err;
140 if (!std.mem.eql(u8, config_name, "zig-test")) {
141 const path = try std.fmt.allocPrint(self.allocator, "{s}.json", .{config_name});
142 defer self.allocator.free(path);
143 return self.loadFromFile(path) catch |err| switch (err) {
144 error.FileNotFound => null,
145 else => return err,
326146 };
147 }
327148
149 for (search_paths) |path| {
150 const config = self.loadFromFile(path) catch |err| switch (err) {
151 error.FileNotFound => continue,
152 else => return err,
153 };
328154 return config;
329155 }
330
331156 return null;
332157 }
333158};
334159
335// Tests
336test "TestConfig default values" {
337 const config = TestConfig{};
160fn validate(config: TestConfig) !void {
161 const reporter = config.reporter.reporter;
162 if (!std.mem.eql(u8, reporter, "spec") and
163 !std.mem.eql(u8, reporter, "dot") and
164 !std.mem.eql(u8, reporter, "json") and
165 !std.mem.eql(u8, reporter, "tap") and
166 !std.mem.eql(u8, reporter, "junit"))
167 {
168 return ConfigError.InvalidReporter;
169 }
170 if (config.parallel.jobs) |jobs| {
171 if (jobs == 0) return ConfigError.InvalidJobs;
172 }
173 if (config.ui.port == 0) return ConfigError.InvalidPort;
174 if (config.test_options.timeout) |timeout| {
175 if (timeout == 0) return ConfigError.InvalidTimeout;
176 }
177 if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce;
178}
338179
180test "TestConfig defaults match CLI discovery defaults" {
181 const config = TestConfig{};
339182 try std.testing.expectEqualStrings("*.test.zig", config.test_options.pattern);
340 try std.testing.expectEqualStrings("src", config.test_options.test_dir);
341 try std.testing.expectEqual(true, config.test_options.recursive);
342 try std.testing.expectEqual(false, config.parallel.enabled);
183 try std.testing.expectEqualStrings(".", config.test_options.test_dir);
184 try std.testing.expect(config.test_options.recursive);
185 try std.testing.expect(config.test_options.timeout == null);
186 try std.testing.expect(!config.parallel.enabled);
343187 try std.testing.expectEqualStrings("spec", config.reporter.reporter);
344188}
345189
346test "ConfigLoader initialization" {
347 const allocator = std.testing.allocator;
348
349 const loader = ConfigLoader.init(allocator);
350 try std.testing.expect(loader.allocator.ptr == allocator.ptr);
190test "ConfigLoader rejects unsupported formats" {
191 var loader = ConfigLoader.init(std.testing.allocator);
192 defer loader.deinit();
193 try std.testing.expectError(ConfigError.UnsupportedFormat, loader.loadFromFile("zig-test.toml"));
351194}
352195
353test "ConfigLoader parse empty JSON" {
354 const allocator = std.testing.allocator;
355
356 var loader = ConfigLoader.init(allocator);
196test "configuration validation rejects invalid runtime values" {
197 var invalid = TestConfig{};
198 invalid.reporter.reporter = "pretty";
199 try std.testing.expectError(ConfigError.InvalidReporter, validate(invalid));
200 invalid.reporter.reporter = "spec";
201 invalid.parallel.jobs = 0;
202 try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid));
203}
357204
358 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{}", .{});
359 defer parsed.deinit();
205test "ConfigLoader loads supported JSON and rejects unknown keys" {
206 var loader = ConfigLoader.init(std.testing.allocator);
207 defer loader.deinit();
360208
361 const config = try loader.parseConfig(parsed.value);
209 const config = try loader.loadFromFile("tests/fixtures/zig-test.json");
210 try std.testing.expectEqualStrings("tests/fixtures", config.test_options.test_dir);
211 try std.testing.expectEqualStrings("selected test passes", config.test_options.filter.?);
212 try std.testing.expect(!config.test_options.recursive);
362213
363 // Should return defaults
364 try std.testing.expectEqualStrings("spec", config.reporter.reporter);
365 try std.testing.expectEqual(false, config.parallel.enabled);
214 try std.testing.expectError(
215 error.UnknownField,
216 loader.loadFromFile("tests/fixtures/invalid-zig-test.json"),
217 );
366218}
src/lib.zigmodified+1-0
Changes to src/lib.zig
@@ -161,6 +161,7 @@ pub const ProfileOptions = memory_profiler.ProfileOptions;
161161pub const TestConfig = config.TestConfig;
162162pub const ConfigLoader = config.ConfigLoader;
163163pub const ConfigFormat = config.ConfigFormat;
164pub const ConfigError = config.ConfigError;
164165
165166// Async Support
166167pub const Future = async_support.Future;
src/main.zigmodified+14-0
Changes to src/main.zig
@@ -71,6 +71,20 @@ pub fn main(init: std.process.Init.Minimal) !void {
7171 return;
7272 }
7373
74 // Configuration establishes defaults; parsing the arguments again makes
75 // every explicitly supplied CLI flag take precedence.
76 var config_loader = lib.ConfigLoader.init(allocator);
77 defer config_loader.deinit();
78 if (cli_parser.options.config) |config_path| {
79 const config = config_loader.loadFromFile(config_path) catch |err| {
80 std.debug.print("Error: invalid configuration '{s}': {s}\n", .{ config_path, @errorName(err) });
81 std.process.exit(2);
82 };
83 cli_parser = lib.CLI.init(allocator);
84 try cli_parser.applyConfig(config);
85 try cli_parser.parse(args);
86 }
87
7488 // Check if we should use test discovery or programmatic tests
7589 const use_discovery = cli_parser.options.test_dir != null;
7690
tests/cli_test.zigmodified+17-0
Changes to tests/cli_test.zig
@@ -23,3 +23,20 @@ test "CLI rejects missing and invalid values" {
2323 var invalid = ztf.CLI.init(std.testing.allocator);
2424 try std.testing.expectError(ztf.cli.CLIError.InvalidArgument, invalid.parse(&.{ "zig-test", "--jobs", "0" }));
2525}
26
27test "CLI flags override configuration defaults" {
28 var cli = ztf.CLI.init(std.testing.allocator);
29 var config = ztf.TestConfig{};
30 config.test_options.test_dir = "configured-tests";
31 config.test_options.filter = "configured-filter";
32 config.test_options.recursive = false;
33 config.reporter.verbose = true;
34
35 try cli.applyConfig(config);
36 try cli.parse(&.{ "zig-test", "--test-dir", "cli-tests", "--filter", "cli-filter" });
37
38 try std.testing.expectEqualStrings("cli-tests", cli.options.test_dir.?);
39 try std.testing.expectEqualStrings("cli-filter", cli.options.filter.?);
40 try std.testing.expect(cli.options.no_recursive);
41 try std.testing.expect(cli.options.verbose);
42}
tests/fixtures/invalid-zig-test.jsonadded+5-0
Changes to tests/fixtures/invalid-zig-test.json
@@ -0,0 +1,5 @@
1{
2 "test": {
3 "unknown_option": true
4 }
5}
tests/fixtures/zig-test.jsonadded+8-0
Changes to tests/fixtures/zig-test.json
@@ -0,0 +1,8 @@
1{
2 "test": {
3 "test_dir": "tests/fixtures",
4 "pattern": "filter_fixture.zig",
5 "recursive": false,
6 "filter": "selected test passes"
7 }
8}