@@ -1,309 +1,134 @@ 1 1 const std = ; 2 2 const compat = ; 3 3 4 - /// Configuration file format 5 - pub const ConfigFormat = enum { 6 - json, 7 - toml, // Future support 8 - }; 4 + /// JSON is the only configuration format currently supported by the CLI. 5 + pub const ConfigFormat = enum { json }; 9 6 10 - /// Test framework configuration 7 + /// Configuration values mirror options that the current execution paths can 8 + /// actually honor. Defaults intentionally match `CLIOptions`. 11 9 pub const TestConfig = struct { 12 - // Test discovery options 13 10 test_options: TestOptions = .{}, 14 - 15 - /// Parallel execution options 16 11 parallel: ParallelOptions = .{}, 17 - 18 - /// Reporter options 19 12 reporter: ReporterOptions = .{}, 20 - 21 - /// Snapshot testing options 22 13 snapshot: SnapshotOptions = .{}, 23 - 24 - /// Watch mode options 25 14 watch: WatchOptions = .{}, 26 - 27 - /// Memory profiling options 28 15 memory: MemoryOptions = .{}, 29 - 30 - /// UI server options 31 16 ui: UIOptions = .{}, 32 - 33 - /// Coverage options 34 17 coverage: CoverageOptions = .{}, 35 18 }; 36 19 37 20 pub const TestOptions = struct { 38 - /// Test file pattern 39 21 pattern: []const u8 = "*.test.zig", 40 - /// Test directory 41 - test_dir: []const u8 = "src", 42 - /// Recursive search 22 + test_dir: []const u8 = ".", 43 23 recursive: bool = true, 44 - /// Filter tests by name 45 24 filter: ?[]const u8 = null, 46 - /// Timeout per test in milliseconds 47 - timeout: u64 = 5000, 25 + timeout: ?u64 = null, 48 26 }; 49 27 50 28 pub const ParallelOptions = struct { 51 - /// Enable parallel execution 52 29 enabled: bool = false, 53 - /// Number of worker threads (0 = auto-detect) 54 - jobs: usize = 0, 30 + jobs: ?usize = null, 55 31 }; 56 32 57 33 pub const ReporterOptions = struct { 58 - /// Reporter type: spec, dot, json, tap, junit 59 34 reporter: []const u8 = "spec", 60 - /// JUnit XML output file 61 35 junit_output: ?[]const u8 = null, 62 - /// Verbose output 63 36 verbose: bool = false, 64 37 }; 65 38 66 39 pub const SnapshotOptions = struct { 67 - /// Snapshot directory 68 40 snapshot_dir: []const u8 = ".snapshots", 69 - /// Update snapshots 70 41 update: bool = false, 71 - /// Pretty print snapshots 72 - pretty_print: bool = true, 73 42 }; 74 43 75 44 pub const WatchOptions = struct { 76 - /// Enable watch mode 77 45 enabled: bool = false, 78 - /// Directory to watch 79 - watch_dir: []const u8 = ".", 80 - /// Debounce delay in milliseconds 81 46 debounce_ms: u64 = 300, 82 - /// Clear screen between runs 83 - clear_screen: bool = true, 84 47 }; 85 48 86 49 pub const MemoryOptions = struct { 87 - /// Enable memory profiling 88 50 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 94 51 report_threshold: usize = 0, 95 - /// Fail tests on memory leaks 96 52 fail_on_leak: bool = false, 97 53 }; 98 54 99 55 pub const UIOptions = struct { 100 - /// Enable UI server 101 56 enabled: bool = false, 102 - /// Port for UI server 103 57 port: u16 = 8080, 104 - /// Open browser automatically 105 - open_browser: bool = true, 106 58 }; 107 59 108 60 pub const CoverageOptions = struct { 109 - /// Enable coverage tracking 110 61 enabled: bool = false, 111 - /// Coverage output directory 112 62 output_dir: []const u8 = "coverage", 113 - /// Minimum coverage threshold 114 - threshold: f64 = 0.0, 115 63 }; 116 64 117 - /// Configuration loader 65 + const 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 + 76 + pub 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. 118 86 pub const ConfigLoader = struct { 119 87 allocator: std.mem.Allocator, 88 + arena: std.heap.ArenaAllocator, 120 89 121 90 const Self = @This(); 122 91 123 92 pub fn init(allocator: std.mem.Allocator) Self { 124 93 return .{ 125 94 .allocator = allocator, 95 + .arena = .init(allocator), 126 96 }; 127 97 } 128 98 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(); 143 101 } 144 102 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 + 147 108 const content = try compat.readFileAlloc(self.allocator, path); 148 109 defer self.allocator.free(content); 149 110 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); 303 128 return config; 304 129 } 305 130 306 - /// Try to find and load configuration file 131 + /// Try conventional JSON configuration locations. 307 132 pub fn autoLoad(self: *Self, config_name: []const u8) !?TestConfig { 308 133 const search_paths = [_][]const u8{ 309 134 "zig-test.json", ⋯ 2 lines Show the 2 lines above this hunk @@ -312,55 +137,82 @@ pub const ConfigLoader = struct { 312 137 ".config/zig-test.json", 313 138 }; 314 139 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, 326 146 }; 147 + } 327 148 149 + for (search_paths) |path| { 150 + const config = self.loadFromFile(path) catch |err| switch (err) { 151 + error.FileNotFound => continue, 152 + else => return err, 153 + }; 328 154 return config; 329 155 } 330 - 331 156 return null; 332 157 } 333 158 }; 334 159 335 - // Tests 336 - test "TestConfig default values" { 337 - const config = TestConfig{}; 160 + fn 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 + } 338 179 180 + test "TestConfig defaults match CLI discovery defaults" { 181 + const config = TestConfig{}; 339 182 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); 343 187 try std.testing.expectEqualStrings("spec", config.reporter.reporter); 344 188 } 345 189 346 - test "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); 190 + test "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")); 351 194 } 352 195 353 - test "ConfigLoader parse empty JSON" { 354 - const allocator = std.testing.allocator; 355 - 356 - var loader = ConfigLoader.init(allocator); 196 + test "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 + } 357 204 358 - var parsed = try std.json.parseFromSlice(std.json.Value, allocator, "{}", .{}); 359 - defer parsed.deinit(); 205 + test "ConfigLoader loads supported JSON and rejects unknown keys" { 206 + var loader = ConfigLoader.init(std.testing.allocator); 207 + defer loader.deinit(); 360 208 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); 362 213 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 + ); 366 218 }