also looking at this
Add deterministic test sharding for distributed CI
#22
10 files
+322
-5
| @@ -95,6 +95,8 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d | ||
| 95 | 95 | - `--no-recursive` - Disable recursive directory search |
| 96 | 96 | - `--bail` - Stop on first failure |
| 97 | 97 | - `--filter <text>` / `--grep <text>` - Run only Zig tests whose names contain the text |
| 98 | - `--shard-index <N>` - Run one one-based file shard | |
| 99 | - `--shard-count <N>` - Set the total number of file shards | |
| 98 | 100 | - `--no-color` - Disable color in child Zig test processes |
| 99 | 101 | - `--verbose` - Show detailed output |
| 100 | 102 | |
| @@ -117,6 +119,34 @@ zig-test --test-dir tests --no-recursive | ||
| 117 | 119 | |
| 118 | 120 | # Stop on first failure |
| 119 | 121 | zig-test --test-dir tests --bail |
| 122 | ||
| 123 | # Run the second of four deterministic file shards | |
| 124 | zig-test --test-dir tests --shard-index 2 --shard-count 4 | |
| 125 | ``` | |
| 126 | ||
| 127 | #### Distributed CI sharding | |
| 128 | ||
| 129 | Sharding is opt-in and file-based. Both options are required, shard indices are | |
| 130 | one-based, and `1 <= shard-index <= shard-count`. Test files are assigned from | |
| 131 | their normalized relative paths with a fixed FNV-1a 64 hash, so the same paths | |
| 132 | and shard count always produce the same assignment on every platform. Each file | |
| 133 | belongs to exactly one shard; a valid shard with no matching files succeeds. | |
| 134 | ||
| 135 | Discovery and file-pattern selection happen before sharding. `--filter` is then | |
| 136 | forwarded to `zig test` for every file in the selected shard. Coverage and bail | |
| 137 | behavior apply only to that shard. The summary prints the shard, selected file | |
| 138 | count, and total discovered file count. | |
| 139 | ||
| 140 | ```yaml | |
| 141 | jobs: | |
| 142 | test: | |
| 143 | strategy: | |
| 144 | matrix: | |
| 145 | shard: [1, 2, 3, 4] | |
| 146 | steps: | |
| 147 | - uses: actions/checkout@v4 | |
| 148 | - run: zig build | |
| 149 | - run: zig build run -- --test-dir tests --shard-index ${{ matrix.shard }} --shard-count 4 | |
| 120 | 150 | ``` |
| 121 | 151 | |
| 122 | 152 | ### Configuration files |
| @@ -135,6 +165,10 @@ precedence over configured values. | ||
| 135 | 165 | "recursive": true, |
| 136 | 166 | "filter": "database" |
| 137 | 167 | }, |
| 168 | "sharding": { | |
| 169 | "index": 1, | |
| 170 | "count": 4 | |
| 171 | }, | |
| 138 | 172 | "coverage": { |
| 139 | 173 | "enabled": false, |
| 140 | 174 | "output_dir": "coverage" |
| @@ -142,8 +176,8 @@ precedence over configured values. | ||
| 142 | 176 | } |
| 143 | 177 | ``` |
| 144 | 178 | |
| 145 | The accepted top-level sections are `test`, `parallel`, `reporter`, `snapshot`, | |
| 146 | `watch`, `memory`, `ui`, and `coverage`. Options retain the same execution-mode | |
| 179 | The accepted top-level sections are `test`, `sharding`, `parallel`, `reporter`, | |
| 180 | `snapshot`, `watch`, `memory`, `ui`, and `coverage`. Options retain the same execution-mode | |
| 147 | 181 | limitations as their CLI equivalents; unsupported combinations fail explicitly. |
| 148 | 182 | |
| 149 | 183 | ### Parallel execution semantics |
| @@ -607,6 +641,8 @@ zig-test --no-color | ||
| 607 | 641 | | `--test-dir <dir>` | | Directory to search for tests | |
| 608 | 642 | | `--pattern <pattern>` | | Test file pattern (default: *.test.zig) | |
| 609 | 643 | | `--no-recursive` | | Disable recursive directory search | |
| 644 | | `--shard-index <N>` | | Run one one-based file shard | | |
| 645 | | `--shard-count <N>` | | Set the total number of file shards | | |
| 610 | 646 | | `--coverage` | | Enable code coverage collection | |
| 611 | 647 | | `--coverage-dir <dir>` | | Coverage output directory (default: coverage) | |
| 612 | 648 | | `--coverage-tool <tool>` | | Coverage tool to use (kcov or grindcov) | |
| @@ -276,6 +276,16 @@ pub fn build(b: *std.Build) void { | ||
| 276 | 276 | run_invalid_config.expectExitCode(2); |
| 277 | 277 | test_step.dependOn(&run_invalid_config.step); |
| 278 | 278 | |
| 279 | // Exercise both sides of a file-level shard split. One shard runs the | |
| 280 | // fixture and the other is intentionally empty; both are valid CI jobs. | |
| 281 | const run_shard_one = b.addRunArtifact(exe); | |
| 282 | run_shard_one.addArgs(&.{ "--test-dir", "tests", "--pattern", "sample.test.zig", "--shard-index", "1", "--shard-count", "2", "--no-color" }); | |
| 283 | test_step.dependOn(&run_shard_one.step); | |
| 284 | ||
| 285 | const run_shard_two = b.addRunArtifact(exe); | |
| 286 | run_shard_two.addArgs(&.{ "--test-dir", "tests", "--pattern", "sample.test.zig", "--shard-index", "2", "--shard-count", "2", "--no-color" }); | |
| 287 | test_step.dependOn(&run_shard_two.step); | |
| 288 | ||
| 279 | 289 | const run_version = b.addRunArtifact(exe); |
| 280 | 290 | run_version.addArg("--version"); |
| 281 | 291 | run_version.expectStdErrEqual(b.fmt("Zig Test Framework v{s}\n", .{package_version})); |
| @@ -16,6 +16,8 @@ pub const CLIOptions = struct { | ||
| 16 | 16 | test_dir: ?[]const u8 = ".", |
| 17 | 17 | pattern: []const u8 = "*.test.zig", |
| 18 | 18 | no_recursive: bool = false, |
| 19 | shard_index: ?usize = null, | |
| 20 | shard_count: ?usize = null, | |
| 19 | 21 | // Coverage options |
| 20 | 22 | coverage: bool = false, |
| 21 | 23 | coverage_dir: []const u8 = "coverage", |
| @@ -73,6 +75,8 @@ pub const CLI = struct { | ||
| 73 | 75 | self.options.no_recursive = !config.test_options.recursive; |
| 74 | 76 | self.options.filter = config.test_options.filter; |
| 75 | 77 | self.options.timeout = config.test_options.timeout; |
| 78 | self.options.shard_index = config.sharding.index; | |
| 79 | self.options.shard_count = config.sharding.count; | |
| 76 | 80 | self.options.parallel = config.parallel.enabled; |
| 77 | 81 | self.options.jobs = config.parallel.jobs; |
| 78 | 82 | self.options.reporter = try reporterFromName(config.reporter.reporter); |
| @@ -144,6 +148,26 @@ pub const CLI = struct { | ||
| 144 | 148 | self.options.pattern = args[i]; |
| 145 | 149 | } else if (std.mem.eql(u8, arg, "--no-recursive")) { |
| 146 | 150 | self.options.no_recursive = true; |
| 151 | } else if (std.mem.eql(u8, arg, "--shard-index")) { | |
| 152 | if (i + 1 >= args.len) { | |
| 153 | std.debug.print("Error: --shard-index requires a value\n", .{}); | |
| 154 | return CLIError.MissingValue; | |
| 155 | } | |
| 156 | i += 1; | |
| 157 | self.options.shard_index = std.fmt.parseInt(usize, args[i], 10) catch { | |
| 158 | std.debug.print("Error: --shard-index must be a valid number\n", .{}); | |
| 159 | return CLIError.InvalidArgument; | |
| 160 | }; | |
| 161 | } else if (std.mem.eql(u8, arg, "--shard-count")) { | |
| 162 | if (i + 1 >= args.len) { | |
| 163 | std.debug.print("Error: --shard-count requires a value\n", .{}); | |
| 164 | return CLIError.MissingValue; | |
| 165 | } | |
| 166 | i += 1; | |
| 167 | self.options.shard_count = std.fmt.parseInt(usize, args[i], 10) catch { | |
| 168 | std.debug.print("Error: --shard-count must be a valid number\n", .{}); | |
| 169 | return CLIError.InvalidArgument; | |
| 170 | }; | |
| 147 | 171 | } else if (std.mem.eql(u8, arg, "--coverage")) { |
| 148 | 172 | self.options.coverage = true; |
| 149 | 173 | } else if (std.mem.eql(u8, arg, "--coverage-dir")) { |
| @@ -273,6 +297,24 @@ pub const CLI = struct { | ||
| 273 | 297 | self.options.test_dir = arg; |
| 274 | 298 | } |
| 275 | 299 | } |
| 300 | ||
| 301 | const has_shard_index = self.options.shard_index != null; | |
| 302 | const has_shard_count = self.options.shard_count != null; | |
| 303 | if (has_shard_index != has_shard_count) { | |
| 304 | std.debug.print("Error: --shard-index and --shard-count must be provided together\n", .{}); | |
| 305 | return CLIError.InvalidArgument; | |
| 306 | } | |
| 307 | if (self.options.shard_count) |count| { | |
| 308 | const index = self.options.shard_index.?; | |
| 309 | if (count == 0) { | |
| 310 | std.debug.print("Error: --shard-count must be greater than zero\n", .{}); | |
| 311 | return CLIError.InvalidArgument; | |
| 312 | } | |
| 313 | if (index == 0 or index > count) { | |
| 314 | std.debug.print("Error: --shard-index must be between 1 and --shard-count\n", .{}); | |
| 315 | return CLIError.InvalidArgument; | |
| 316 | } | |
| 317 | } | |
| 276 | 318 | } |
| 277 | 319 | |
| 278 | 320 | /// Return the first option that discovery mode cannot currently honor. |
| @@ -325,6 +367,8 @@ pub const CLI = struct { | ||
| 325 | 367 | \\ --test-dir <dir> Directory to search for tests (default: .) |
| 326 | 368 | \\ --pattern <pattern> Test file pattern (default: *.test.zig) |
| 327 | 369 | \\ --no-recursive Disable recursive directory search |
| 370 | \\ --shard-index <N> One-based shard to run | |
| 371 | \\ --shard-count <N> Total number of shards | |
| 328 | 372 | \\ |
| 329 | 373 | \\COVERAGE: |
| 330 | 374 | \\ --coverage Enable code coverage collection |
| @@ -458,6 +502,27 @@ test "CLI parse coverage flag" { | ||
| 458 | 502 | try std.testing.expect(cli.options.coverage); |
| 459 | 503 | } |
| 460 | 504 | |
| 505 | test "CLI parse shard options" { | |
| 506 | var cli = CLI.init(std.testing.allocator); | |
| 507 | try cli.parse(&.{ "zig-test", "--shard-count", "5", "--shard-index", "3" }); | |
| 508 | try std.testing.expectEqual(@as(?usize, 3), cli.options.shard_index); | |
| 509 | try std.testing.expectEqual(@as(?usize, 5), cli.options.shard_count); | |
| 510 | } | |
| 511 | ||
| 512 | test "CLI rejects incomplete and out-of-range shards" { | |
| 513 | var incomplete = CLI.init(std.testing.allocator); | |
| 514 | try std.testing.expectError( | |
| 515 | CLIError.InvalidArgument, | |
| 516 | incomplete.parse(&.{ "zig-test", "--shard-count", "2" }), | |
| 517 | ); | |
| 518 | ||
| 519 | var out_of_range = CLI.init(std.testing.allocator); | |
| 520 | try std.testing.expectError( | |
| 521 | CLIError.InvalidArgument, | |
| 522 | out_of_range.parse(&.{ "zig-test", "--shard-index", "0", "--shard-count", "2" }), | |
| 523 | ); | |
| 524 | } | |
| 525 | ||
| 461 | 526 | test "CLI parse coverage-dir" { |
| 462 | 527 | var cli = CLI.init(std.testing.allocator); |
| 463 | 528 | const args = [_][]const u8{ "zig-test", "--coverage-dir", "my-coverage" }; |
| @@ -8,6 +8,7 @@ pub const ConfigFormat = enum { json }; | ||
| 8 | 8 | /// actually honor. Defaults intentionally match `CLIOptions`. |
| 9 | 9 | pub const TestConfig = struct { |
| 10 | 10 | test_options: TestOptions = .{}, |
| 11 | sharding: ShardingOptions = .{}, | |
| 11 | 12 | parallel: ParallelOptions = .{}, |
| 12 | 13 | reporter: ReporterOptions = .{}, |
| 13 | 14 | snapshot: SnapshotOptions = .{}, |
| @@ -17,6 +18,11 @@ pub const TestConfig = struct { | ||
| 17 | 18 | coverage: CoverageOptions = .{}, |
| 18 | 19 | }; |
| 19 | 20 | |
| 21 | pub const ShardingOptions = struct { | |
| 22 | index: ?usize = null, | |
| 23 | count: ?usize = null, | |
| 24 | }; | |
| 25 | ||
| 20 | 26 | pub const TestOptions = struct { |
| 21 | 27 | pattern: []const u8 = "*.test.zig", |
| 22 | 28 | test_dir: []const u8 = ".", |
| @@ -64,6 +70,7 @@ pub const CoverageOptions = struct { | ||
| 64 | 70 | |
| 65 | 71 | const FileConfig = struct { |
| 66 | 72 | @"test": TestOptions = .{}, |
| 73 | sharding: ShardingOptions = .{}, | |
| 67 | 74 | parallel: ParallelOptions = .{}, |
| 68 | 75 | reporter: ReporterOptions = .{}, |
| 69 | 76 | snapshot: SnapshotOptions = .{}, |
| @@ -80,6 +87,7 @@ pub const ConfigError = error{ | ||
| 80 | 87 | InvalidPort, |
| 81 | 88 | InvalidTimeout, |
| 82 | 89 | InvalidDebounce, |
| 90 | InvalidShard, | |
| 83 | 91 | }; |
| 84 | 92 | |
| 85 | 93 | /// Owns strings parsed from configuration files until `deinit` is called. |
| @@ -116,6 +124,7 @@ pub const ConfigLoader = struct { | ||
| 116 | 124 | ); |
| 117 | 125 | const config = TestConfig{ |
| 118 | 126 | .test_options = file_config.@"test", |
| 127 | .sharding = file_config.sharding, | |
| 119 | 128 | .parallel = file_config.parallel, |
| 120 | 129 | .reporter = file_config.reporter, |
| 121 | 130 | .snapshot = file_config.snapshot, |
| @@ -175,6 +184,13 @@ fn validate(config: TestConfig) !void { | ||
| 175 | 184 | if (timeout == 0) return ConfigError.InvalidTimeout; |
| 176 | 185 | } |
| 177 | 186 | if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce; |
| 187 | const has_shard_index = config.sharding.index != null; | |
| 188 | const has_shard_count = config.sharding.count != null; | |
| 189 | if (has_shard_index != has_shard_count) return ConfigError.InvalidShard; | |
| 190 | if (config.sharding.count) |count| { | |
| 191 | const index = config.sharding.index.?; | |
| 192 | if (count == 0 or index == 0 or index > count) return ConfigError.InvalidShard; | |
| 193 | } | |
| 178 | 194 | } |
| 179 | 195 | |
| 180 | 196 | test "TestConfig defaults match CLI discovery defaults" { |
| @@ -183,6 +199,8 @@ test "TestConfig defaults match CLI discovery defaults" { | ||
| 183 | 199 | try std.testing.expectEqualStrings(".", config.test_options.test_dir); |
| 184 | 200 | try std.testing.expect(config.test_options.recursive); |
| 185 | 201 | try std.testing.expect(config.test_options.timeout == null); |
| 202 | try std.testing.expect(config.sharding.index == null); | |
| 203 | try std.testing.expect(config.sharding.count == null); | |
| 186 | 204 | try std.testing.expect(!config.parallel.enabled); |
| 187 | 205 | try std.testing.expectEqualStrings("spec", config.reporter.reporter); |
| 188 | 206 | } |
| @@ -200,6 +218,11 @@ test "configuration validation rejects invalid runtime values" { | ||
| 200 | 218 | invalid.reporter.reporter = "spec"; |
| 201 | 219 | invalid.parallel.jobs = 0; |
| 202 | 220 | try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid)); |
| 221 | invalid.parallel.jobs = null; | |
| 222 | invalid.sharding.index = 2; | |
| 223 | try std.testing.expectError(ConfigError.InvalidShard, validate(invalid)); | |
| 224 | invalid.sharding.count = 1; | |
| 225 | try std.testing.expectError(ConfigError.InvalidShard, validate(invalid)); | |
| 203 | 226 | } |
| 204 | 227 | |
| 205 | 228 | test "ConfigLoader loads supported JSON and rejects unknown keys" { |
| @@ -210,6 +233,8 @@ test "ConfigLoader loads supported JSON and rejects unknown keys" { | ||
| 210 | 233 | try std.testing.expectEqualStrings("tests/fixtures", config.test_options.test_dir); |
| 211 | 234 | try std.testing.expectEqualStrings("selected test passes", config.test_options.filter.?); |
| 212 | 235 | try std.testing.expect(!config.test_options.recursive); |
| 236 | try std.testing.expectEqual(@as(?usize, 1), config.sharding.index); | |
| 237 | try std.testing.expectEqual(@as(?usize, 1), config.sharding.count); | |
| 213 | 238 | |
| 214 | 239 | try std.testing.expectError( |
| 215 | 240 | error.UnknownField, |
| @@ -9,6 +9,7 @@ pub const matchers = ; | ||
| 9 | 9 | pub const mock = ; |
| 10 | 10 | pub const cli = ; |
| 11 | 11 | pub const discovery = ; |
| 12 | pub const sharding = ; | |
| 12 | 13 | pub const test_loader = ; |
| 13 | 14 | pub const coverage = ; |
| 14 | 15 | pub const parallel = ; |
| @@ -106,6 +107,11 @@ pub const DiscoveryResult = discovery.DiscoveryResult; | ||
| 106 | 107 | pub const TestFile = discovery.TestFile; |
| 107 | 108 | pub const discoverTests = discovery.discoverTests; |
| 108 | 109 | |
| 110 | // Test Sharding | |
| 111 | pub const ShardOptions = sharding.ShardOptions; | |
| 112 | pub const ShardError = sharding.ShardError; | |
| 113 | pub const shardIndex = sharding.shardIndex; | |
| 114 | ||
| 109 | 115 | // Test Loader |
| 110 | 116 | pub const LoaderOptions = test_loader.LoaderOptions; |
| 111 | 117 | pub const runDiscoveredTests = test_loader.runDiscoveredTests; |
| @@ -161,6 +161,10 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 161 | 161 | } |
| 162 | 162 | |
| 163 | 163 | var all_passed: bool = undefined; |
| 164 | const shard_options: ?lib.ShardOptions = if (cli_parser.options.shard_index) |index| | |
| 165 | .{ .index = index, .count = cli_parser.options.shard_count.? } | |
| 166 | else | |
| 167 | null; | |
| 164 | 168 | |
| 165 | 169 | // Check if watch mode is enabled |
| 166 | 170 | if (cli_parser.options.watch) { |
| @@ -201,6 +205,7 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 201 | 205 | .filter = cli_parser.options.filter, |
| 202 | 206 | .verbose = cli_parser.options.verbose, |
| 203 | 207 | .use_colors = !cli_parser.options.no_color, |
| 208 | .shard = shard_options, | |
| 204 | 209 | .coverage_options = cov_opts, |
| 205 | 210 | .ui_server = if (ui_server) |*server| server else null, |
| 206 | 211 | }; |
| @@ -239,6 +244,7 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 239 | 244 | .filter = cli_parser.options.filter, |
| 240 | 245 | .verbose = cli_parser.options.verbose, |
| 241 | 246 | .use_colors = !cli_parser.options.no_color, |
| 247 | .shard = shard_options, | |
| 242 | 248 | .coverage_options = cov_opts, |
| 243 | 249 | .ui_server = if (ui_server) |*server| server else null, |
| 244 | 250 | }; |
| @@ -0,0 +1,87 @@ | ||
| 1 | const std = ; | |
| 2 | ||
| 3 | pub const ShardError = error{ | |
| 4 | InvalidShardCount, | |
| 5 | InvalidShardIndex, | |
| 6 | }; | |
| 7 | ||
| 8 | /// A one-based shard selection suitable for CI matrix values. | |
| 9 | pub const ShardOptions = struct { | |
| 10 | index: usize, | |
| 11 | count: usize, | |
| 12 | ||
| 13 | pub fn validate(self: ShardOptions) ShardError!void { | |
| 14 | if (self.count == 0) return ShardError.InvalidShardCount; | |
| 15 | if (self.index == 0 or self.index > self.count) return ShardError.InvalidShardIndex; | |
| 16 | } | |
| 17 | ||
| 18 | pub fn includes(self: ShardOptions, identity: []const u8) ShardError!bool { | |
| 19 | try self.validate(); | |
| 20 | return (try shardIndex(identity, self.count)) + 1 == self.index; | |
| 21 | } | |
| 22 | }; | |
| 23 | ||
| 24 | /// Assign a normalized test identity to a zero-based shard using FNV-1a 64. | |
| 25 | /// Path separators are normalized so the same relative path maps identically | |
| 26 | /// on Unix and Windows. The algorithm and seed are intentionally fixed. | |
| 27 | pub fn shardIndex(identity: []const u8, shard_count: usize) ShardError!usize { | |
| 28 | if (shard_count == 0) return ShardError.InvalidShardCount; | |
| 29 | ||
| 30 | var hash: u64 = 14_695_981_039_346_656_037; | |
| 31 | for (identity) |byte| { | |
| 32 | const normalized = if (byte == '\\') @as(u8, '/') else byte; | |
| 33 | hash ^= normalized; | |
| 34 | hash *%= 1_099_511_628_211; | |
| 35 | } | |
| 36 | ||
| 37 | const count: u64 = @intCast(shard_count); | |
| 38 | return @intCast(hash % count); | |
| 39 | } | |
| 40 | ||
| 41 | test "fixed identities have stable shard assignments" { | |
| 42 | try std.testing.expectEqual(@as(usize, 1), try shardIndex("tests/api.test.zig", 4)); | |
| 43 | try std.testing.expectEqual(@as(usize, 1), try shardIndex("tests/db.test.zig", 4)); | |
| 44 | try std.testing.expectEqual(@as(usize, 2), try shardIndex("tests/unit/math.test.zig", 4)); | |
| 45 | } | |
| 46 | ||
| 47 | test "path separators do not change shard assignment" { | |
| 48 | try std.testing.expectEqual( | |
| 49 | try shardIndex("tests/unit/math.test.zig", 7), | |
| 50 | try shardIndex("tests\\unit\\math.test.zig", 7), | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | test "every identity belongs to exactly one shard" { | |
| 55 | const identities = [_][]const u8{ | |
| 56 | "tests/api.test.zig", | |
| 57 | "tests/db.test.zig", | |
| 58 | "tests/unit/math.test.zig", | |
| 59 | "tests/unit/string.test.zig", | |
| 60 | }; | |
| 61 | ||
| 62 | for (identities) |identity| { | |
| 63 | var matches: usize = 0; | |
| 64 | for (1..5) |index| { | |
| 65 | if (try (ShardOptions{ .index = index, .count = 4 }).includes(identity)) { | |
| 66 | matches += 1; | |
| 67 | } | |
| 68 | } | |
| 69 | try std.testing.expectEqual(@as(usize, 1), matches); | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | test "invalid shard options are rejected" { | |
| 74 | try std.testing.expectError( | |
| 75 | ShardError.InvalidShardCount, | |
| 76 | (ShardOptions{ .index = 1, .count = 0 }).validate(), | |
| 77 | ); | |
| 78 | try std.testing.expectError(ShardError.InvalidShardCount, shardIndex("test.zig", 0)); | |
| 79 | try std.testing.expectError( | |
| 80 | ShardError.InvalidShardIndex, | |
| 81 | (ShardOptions{ .index = 0, .count = 2 }).validate(), | |
| 82 | ); | |
| 83 | try std.testing.expectError( | |
| 84 | ShardError.InvalidShardIndex, | |
| 85 | (ShardOptions{ .index = 3, .count = 2 }).validate(), | |
| 86 | ); | |
| 87 | } | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | const std = ; |
| 2 | 2 | const discovery = ; |
| 3 | const sharding = ; | |
| 3 | 4 | const coverage = ; |
| 4 | 5 | const ui_server = ; |
| 5 | 6 | const compat = ; |
| @@ -14,6 +15,8 @@ pub const LoaderOptions = struct { | ||
| 14 | 15 | verbose: bool = false, |
| 15 | 16 | /// Whether child Zig test processes may emit colors |
| 16 | 17 | use_colors: bool = true, |
| 18 | /// Optional one-based file shard to execute | |
| 19 | shard: ?sharding.ShardOptions = null, | |
| 17 | 20 | /// Coverage options |
| 18 | 21 | coverage_options: ?coverage.CoverageOptions = null, |
| 19 | 22 | /// UI server for real-time updates |
| @@ -31,8 +34,17 @@ pub fn runDiscoveredTests( | ||
| 31 | 34 | return false; |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | std.debug.print("Found {} test file(s):\n", .{discovered.files.items.len}); | |
| 37 | const selected_files = try selectedFileCount(discovered, options.shard); | |
| 38 | if (options.shard) |shard| { | |
| 39 | std.debug.print( | |
| 40 | "Shard {}/{}: selected {} of {} test file(s).\n", | |
| 41 | .{ shard.index, shard.count, selected_files, discovered.files.items.len }, | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | std.debug.print("Found {} test file(s):\n", .{selected_files}); | |
| 35 | 46 | for (discovered.files.items) |file| { |
| 47 | if (!try fileSelected(file.relative_path, options.shard)) continue; | |
| 36 | 48 | std.debug.print(" - {s}\n", .{file.relative_path}); |
| 37 | 49 | } |
| 38 | 50 | std.debug.print("\n", .{}); |
| @@ -40,7 +52,7 @@ pub fn runDiscoveredTests( | ||
| 40 | 52 | // Notify UI of run start |
| 41 | 53 | if (options.ui_server) |server| { |
| 42 | 54 | var buffer: [256]u8 = undefined; |
| 43 | const json = try std.fmt.bufPrint(&buffer, "", .{discovered.files.items.len}); | |
| 55 | const json = try std.fmt.bufPrint(&buffer, "", .{selected_files}); | |
| 44 | 56 | try server.broadcast("run_start", json); |
| 45 | 57 | } |
| 46 | 58 | |
| @@ -57,6 +69,8 @@ pub fn runDiscoveredTests( | ||
| 57 | 69 | } |
| 58 | 70 | |
| 59 | 71 | for (discovered.files.items) |file| { |
| 72 | if (!try fileSelected(file.relative_path, options.shard)) continue; | |
| 73 | ||
| 60 | 74 | // Run each test file using zig test command |
| 61 | 75 | std.debug.print("Running {s}...\n", .{file.relative_path}); |
| 62 | 76 | |
| @@ -102,7 +116,7 @@ pub fn runDiscoveredTests( | ||
| 102 | 116 | |
| 103 | 117 | // Print coverage summary if enabled |
| 104 | 118 | if (options.coverage_options) |cov_opts| { |
| 105 | if (cov_opts.enabled) { | |
| 119 | if (cov_opts.enabled and files_run > 0) { | |
| 106 | 120 | std.debug.print("\n", .{}); |
| 107 | 121 | const cov_result = coverage.parseCoverageReport(allocator, cov_opts.output_dir) catch |err| { |
| 108 | 122 | std.debug.print("Warning: Could not parse coverage report: {any}\n", .{err}); |
| @@ -122,6 +136,10 @@ pub fn runDiscoveredTests( | ||
| 122 | 136 | |
| 123 | 137 | std.debug.print("\n", .{}); |
| 124 | 138 | std.debug.print("Test Summary:\n", .{}); |
| 139 | if (options.shard) |shard| { | |
| 140 | std.debug.print(" Shard: {}/{}\n", .{ shard.index, shard.count }); | |
| 141 | std.debug.print(" Selected: {} of {} files\n", .{ selected_files, discovered.files.items.len }); | |
| 142 | } | |
| 125 | 143 | std.debug.print(" Files run: {}\n", .{files_run}); |
| 126 | 144 | std.debug.print(" Passed: {}\n", .{total_passed}); |
| 127 | 145 | std.debug.print(" Failed: {}\n", .{total_failed}); |
| @@ -129,6 +147,21 @@ pub fn runDiscoveredTests( | ||
| 129 | 147 | return total_failed == 0; |
| 130 | 148 | } |
| 131 | 149 | |
| 150 | fn fileSelected(relative_path: []const u8, shard: ?sharding.ShardOptions) !bool { | |
| 151 | return if (shard) |selection| selection.includes(relative_path) else true; | |
| 152 | } | |
| 153 | ||
| 154 | fn selectedFileCount( | |
| 155 | discovered: *const discovery.DiscoveryResult, | |
| 156 | shard: ?sharding.ShardOptions, | |
| 157 | ) !usize { | |
| 158 | var count: usize = 0; | |
| 159 | for (discovered.files.items) |file| { | |
| 160 | if (try fileSelected(file.relative_path, shard)) count += 1; | |
| 161 | } | |
| 162 | return count; | |
| 163 | } | |
| 164 | ||
| 132 | 165 | /// Run a single test file using `zig test` |
| 133 | 166 | fn runTestFile( |
| 134 | 167 | allocator: std.mem.Allocator, |
| @@ -207,6 +240,7 @@ test "LoaderOptions default values" { | ||
| 207 | 240 | try std.testing.expectEqual(@as(?[]const u8, null), options.filter); |
| 208 | 241 | try std.testing.expectEqual(false, options.verbose); |
| 209 | 242 | try std.testing.expectEqual(true, options.use_colors); |
| 243 | try std.testing.expectEqual(@as(?sharding.ShardOptions, null), options.shard); | |
| 210 | 244 | try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options); |
| 211 | 245 | } |
| 212 | 246 | |
| @@ -256,3 +290,24 @@ test "LoaderOptions with coverage disabled" { | ||
| 256 | 290 | |
| 257 | 291 | try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options); |
| 258 | 292 | } |
| 293 | ||
| 294 | test "file sharding partitions discovered files exactly once" { | |
| 295 | const allocator = std.testing.allocator; | |
| 296 | var discovered = discovery.DiscoveryResult.init(allocator); | |
| 297 | defer discovered.deinit(); | |
| 298 | ||
| 299 | try discovered.addFile("tests/api.test.zig", "api.test.zig", "api.test.zig"); | |
| 300 | try discovered.addFile("tests/db.test.zig", "db.test.zig", "db.test.zig"); | |
| 301 | try discovered.addFile("tests/math.test.zig", "math.test.zig", "math.test.zig"); | |
| 302 | try discovered.addFile("tests/string.test.zig", "string.test.zig", "string.test.zig"); | |
| 303 | ||
| 304 | var total_selected: usize = 0; | |
| 305 | for (1..4) |index| { | |
| 306 | total_selected += try selectedFileCount( | |
| 307 | &discovered, | |
| 308 | .{ .index = index, .count = 3 }, | |
| 309 | ); | |
| 310 | } | |
| 311 | ||
| 312 | try std.testing.expectEqual(discovered.files.items.len, total_selected); | |
| 313 | } | |
| @@ -24,12 +24,33 @@ test "CLI rejects missing and invalid values" { | ||
| 24 | 24 | try std.testing.expectError(ztf.cli.CLIError.InvalidArgument, invalid.parse(&.{ "zig-test", "--jobs", "0" })); |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | test "CLI parses and validates one-based sharding" { | |
| 28 | var cli = ztf.CLI.init(std.testing.allocator); | |
| 29 | try cli.parse(&.{ "zig-test", "--shard-index", "2", "--shard-count", "4" }); | |
| 30 | try std.testing.expectEqual(@as(?usize, 2), cli.options.shard_index); | |
| 31 | try std.testing.expectEqual(@as(?usize, 4), cli.options.shard_count); | |
| 32 | ||
| 33 | var missing_count = ztf.CLI.init(std.testing.allocator); | |
| 34 | try std.testing.expectError( | |
| 35 | ztf.cli.CLIError.InvalidArgument, | |
| 36 | missing_count.parse(&.{ "zig-test", "--shard-index", "1" }), | |
| 37 | ); | |
| 38 | ||
| 39 | var out_of_range = ztf.CLI.init(std.testing.allocator); | |
| 40 | try std.testing.expectError( | |
| 41 | ztf.cli.CLIError.InvalidArgument, | |
| 42 | out_of_range.parse(&.{ "zig-test", "--shard-index", "3", "--shard-count", "2" }), | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 27 | 46 | test "CLI flags override configuration defaults" { |
| 28 | 47 | var cli = ztf.CLI.init(std.testing.allocator); |
| 29 | 48 | var config = ztf.TestConfig{}; |
| 30 | 49 | config.test_options.test_dir = "configured-tests"; |
| 31 | 50 | config.test_options.filter = "configured-filter"; |
| 32 | 51 | config.test_options.recursive = false; |
| 52 | config.sharding.index = 1; | |
| 53 | config.sharding.count = 3; | |
| 33 | 54 | config.reporter.verbose = true; |
| 34 | 55 | |
| 35 | 56 | try cli.applyConfig(config); |
| @@ -39,4 +60,6 @@ test "CLI flags override configuration defaults" { | ||
| 39 | 60 | try std.testing.expectEqualStrings("cli-filter", cli.options.filter.?); |
| 40 | 61 | try std.testing.expect(cli.options.no_recursive); |
| 41 | 62 | try std.testing.expect(cli.options.verbose); |
| 63 | try std.testing.expectEqual(@as(?usize, 1), cli.options.shard_index); | |
| 64 | try std.testing.expectEqual(@as(?usize, 3), cli.options.shard_count); | |
| 42 | 65 | } |