From ee7eff97f7f4eb7d68f7090cba448f7cc8285db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 25 Oct 2026 09:38:21 +0000 Subject: [PATCH] fix(ci): rebalance native tests and stabilize flaky gates (#3719) * fix(ci): stabilize timing gates and artifact uploads * fix(ci): rebalance undercharged native test shards --------- Co-authored-by: Gergo Magyar --- .../actions/upload-test-artifact/action.yml | 66 ++++++++++++++ .github/workflows/ci-tests.yml | 37 +++----- eval/tests/test_workflow_bench.py | 56 ++++++++++++ .../bench/callable-value-flow/measure.mjs | 89 ++++++++++--------- gitnexus/scripts/cross-platform-shard.ts | 11 ++- gitnexus/test/unit/cjk-segmentation.test.ts | 68 ++++++++------ .../test/unit/cross-platform-shard.test.ts | 20 +++++ 7 files changed, 252 insertions(+), 95 deletions(-) create mode 100644 .github/actions/upload-test-artifact/action.yml diff --git a/.github/actions/upload-test-artifact/action.yml b/.github/actions/upload-test-artifact/action.yml new file mode 100644 index 000000000..cf1b2df7e --- /dev/null +++ b/.github/actions/upload-test-artifact/action.yml @@ -0,0 +1,66 @@ +name: Upload test artifact +description: Upload required CI evidence with bounded transport retries + +inputs: + name: + description: Unique artifact name for this job + required: true + path: + description: Files to upload + required: true + retention-days: + description: Artifact retention in days + default: '5' + include-hidden-files: + description: Include explicitly selected hidden report directories + default: 'false' + +runs: + using: composite + steps: + - name: Upload + id: first + if: ${{ always() }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + if-no-files-found: error + + - name: Wait before retry + if: ${{ !cancelled() && steps.first.outcome == 'failure' }} + shell: bash + run: sleep 10 + + - name: Retry upload + id: second + if: ${{ !cancelled() && steps.first.outcome == 'failure' }} + continue-on-error: true + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + if-no-files-found: error + # A failed request may already have created an incomplete artifact. + overwrite: true + + - name: Wait before final attempt + if: ${{ !cancelled() && steps.second.outcome == 'failure' }} + shell: bash + run: sleep 30 + + - name: Final upload attempt + if: ${{ !cancelled() && steps.second.outcome == 'failure' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + include-hidden-files: ${{ inputs.include-hidden-files }} + if-no-files-found: error + overwrite: true diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 266097f20..48a108fc5 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -29,13 +29,12 @@ jobs: fi - name: Upload release accuracy evidence if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: release-tool-accuracy path: | ${{ runner.temp }}/tool-accuracy/accuracy.json ${{ runner.temp }}/tool-accuracy/accuracy.md - if-no-files-found: error retention-days: 30 # Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report @@ -108,7 +107,7 @@ jobs: working-directory: gitnexus - name: Upload coverage blob if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: coverage-blob-${{ matrix.shard }} path: gitnexus/.vitest/blob/ @@ -171,7 +170,7 @@ jobs: run: node --test docker-server.test.mjs - name: Upload test reports if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: coverage-reports path: | @@ -195,9 +194,9 @@ jobs: steps: - id: gen run: | - # Refreshed Windows timings leave eight shards too close to the - # 20-minute watchdog. Ten preserve headroom for runner variation. - TOTAL=10 # cross-platform (windows/macOS) shards per OS + # Keep the recorded native workload below 17 minutes, leaving + # headroom for runner variation under the 20-minute watchdog. + TOTAL=11 # cross-platform (windows/macOS) shards per OS # Four coverage shards exceeded the 25-minute job limit while tests # were still passing. Split the full suite further; keep the timeout # and merged coverage thresholds unchanged. @@ -297,11 +296,10 @@ jobs: working-directory: gitnexus - name: Upload platform execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-${{ matrix.os }}-${{ matrix.shard }} path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json - if-no-files-found: error retention-days: 5 # Branch protection still requires these six names from the three-shard @@ -1103,11 +1101,10 @@ jobs: working-directory: gitnexus - name: Upload benchmark execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-benchmarks path: gitnexus/benchmarks.json - if-no-files-found: error retention-days: 5 # Fresh-process measurements have their own runner and time budget so they @@ -1130,11 +1127,10 @@ jobs: working-directory: gitnexus - name: Upload scope-resolution measurements if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: scope-resolution-benchmark path: gitnexus/scope-resolution-benchmark.json - if-no-files-found: error retention-days: 5 # Locked eval suite. setup-uv and uv itself are immutable so CI exercises @@ -1158,11 +1154,10 @@ jobs: working-directory: eval - name: Upload pytest execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-pytest-locked path: eval/pytest-locked.xml - if-no-files-found: error retention-days: 5 - uses: ./.github/actions/setup-gitnexus with: @@ -1175,11 +1170,10 @@ jobs: working-directory: gitnexus - name: Upload preflight execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-preflight path: gitnexus/preflight.json - if-no-files-found: error retention-days: 5 test-completeness: @@ -1225,7 +1219,7 @@ jobs: run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json - name: Upload complete test reports if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: test-reports path: | @@ -1234,7 +1228,6 @@ jobs: gitnexus/test-results.json gitnexus-web/web-test-results.json eval/pytest-results.json - if-no-files-found: error retention-days: 5 # Native Linux ownership and Bubblewrap boundary. The environment flag makes @@ -1336,11 +1329,10 @@ jobs: working-directory: eval - name: Upload pytest execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-pytest-ubuntu path: eval/pytest-ubuntu.xml - if-no-files-found: error retention-days: 5 # Native Windows Job Object canary. POSIX-only tests skip by platform, while @@ -1367,9 +1359,8 @@ jobs: working-directory: eval - name: Upload pytest execution report if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: ./.github/actions/upload-test-artifact with: name: execution-pytest-windows path: eval/pytest-windows.xml - if-no-files-found: error retention-days: 5 diff --git a/eval/tests/test_workflow_bench.py b/eval/tests/test_workflow_bench.py index 2eff2f2b9..4ebd8bc52 100644 --- a/eval/tests/test_workflow_bench.py +++ b/eval/tests/test_workflow_bench.py @@ -352,6 +352,62 @@ def test_platform_ci_preserves_required_check_names_with_complete_execution_gate } +def test_test_artifact_retries_cannot_hide_missing_evidence(ci_test_jobs): + repo_root = Path(__file__).resolve().parents[2] + action_path = ".github/actions/upload-test-artifact/action.yml" + action = yaml.safe_load((repo_root / action_path).read_text()) + uploads = [step for step in action["runs"]["steps"] if "uses" in step] + assert len(uploads) == 3 + first, second, final = uploads + assert first["if"] == "${{ always() }}" + assert second["if"] == "${{ !cancelled() && steps.first.outcome == 'failure' }}" + assert final["if"] == "${{ !cancelled() && steps.second.outcome == 'failure' }}" + assert first["id"] == "first" and second["id"] == "second" + assert first["continue-on-error"] is True + assert second["continue-on-error"] is True + assert not final.get("continue-on-error", False) + assert not first["with"].get("overwrite", False) + assert second["with"]["overwrite"] is True + assert final["with"]["overwrite"] is True + for step in uploads: + assert re.fullmatch(r"actions/upload-artifact@[a-f0-9]{40}", step["uses"]) + assert step["with"]["if-no-files-found"] == "error" + for key in ("name", "path", "retention-days", "include-hidden-files"): + assert step["with"][key] == "${{ inputs." + key + " }}" + + evidence_jobs = { + "tests", "coverage-merge", "cross-platform", "benchmarks", + "scope-resolution-benchmark", "eval-tests", "test-completeness", + "eval-containment-windows", "eval-containment-linux", "tool-accuracy", + } + for name in evidence_jobs: + job = ci_test_jobs[name] + reports = [ + step for step in job["steps"] + if step.get("uses") == "./.github/actions/upload-test-artifact" + ] + assert reports, f"{name} must publish execution evidence" + assert not job.get("continue-on-error", False) + for step in reports: + assert not step.get("continue-on-error", False) + assert step["with"]["name"] and step["with"]["path"] + + artifact_names = set() + for job in ci_test_jobs.values(): + for step in job.get("steps", []): + assert not step.get("uses", "").startswith("actions/upload-artifact@") + if step.get("uses") != "./.github/actions/upload-test-artifact": + continue + name = step["with"]["name"] + assert name not in artifact_names, f"Retry could overwrite another job: {name}" + artifact_names.add(name) + for axis in job.get("strategy", {}).get("matrix", {}): + if axis not in {"include", "exclude"}: + assert "${{ matrix." + axis + " }}" in name, ( + f"Artifact {name} must distinguish matrix axis {axis}" + ) + + @pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""]) @pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""]) @pytest.mark.parametrize("scope_bench_result", ["success", "failure", "cancelled", "skipped", ""]) diff --git a/gitnexus/bench/callable-value-flow/measure.mjs b/gitnexus/bench/callable-value-flow/measure.mjs index e0a5f320b..6897ef960 100644 --- a/gitnexus/bench/callable-value-flow/measure.mjs +++ b/gitnexus/bench/callable-value-flow/measure.mjs @@ -15,7 +15,7 @@ * same-named callable in the file. * * For a synthetic corpus at two scales it reports: - * - elapsed_ms_small / elapsed_ms_large (fastest of REPS, see `fastest`) + a scaling ratio + * - elapsed_ms_small / elapsed_ms_large (median of window minima) + a scaling ratio * `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, ~3.x quadratic; * - `callable_only_ms_large`, the same corpus with the PRE-#2693 def * population, so the cost the widening actually added stays visible as @@ -48,8 +48,9 @@ const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); const SMALL = 250; const LARGE = 800; -const REPS = 15; -const WARMUP = 5; +const REPS = 7; +const WARMUP = 2; +const WINDOW_MS = 1000; /** * Deterministic synthetic corpus — no randomness, so the fingerprint is stable. @@ -144,34 +145,23 @@ function callableOnlyScopes(scopes) { return { defs: { byId } }; } -/** - * MIN, not median. Both scales are timed in one process, and every source of - * error here is additive — scheduler preemption, GC, a noisy neighbour on a - * shared CI runner. The fastest observed run is the closest estimate of the - * uncontended cost, so the derived ratios stay comparable across machines - * instead of tracking whatever else the box was doing. (Measured directly: the - * same build reported an overhead of 1.65 idle and 2.03 while a test shard was - * running — a median-based gate would have to be loosened until it could no - * longer detect the regression it exists to catch.) - */ -function fastest(values) { - return Math.min(...values); +function median(values) { + return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; } function timeIndex(scopes, nodeLookup, graph) { - // Warm up before timing: the first calls carry JIT compilation of the whole - // resolve chain, and the widened and callable-only runs would otherwise be - // measured at different optimisation tiers — which alone moved the reported - // overhead by ~30%. - for (let w = 0; w < WARMUP; w++) buildGraphTargetIndex(scopes, nodeLookup, undefined, graph); - const samples = []; + // A short handful of calls can all overlap JIT, GC or scheduler pauses. + // Keep the uncontended-cost estimator, but give each variant a full window + // to reach it. Retaining only the minimum also bounds sampling memory. + const start = performance.now(); + let best = Infinity; let last; - for (let r = 0; r < REPS; r++) { + do { const t0 = performance.now(); last = buildGraphTargetIndex(scopes, nodeLookup, undefined, graph); - samples.push(performance.now() - t0); - } - return { ms: fastest(samples), result: last }; + best = Math.min(best, performance.now() - t0); + } while (performance.now() - start < WINDOW_MS); + return { ms: best, result: last }; } function fingerprint(targets) { @@ -179,29 +169,46 @@ function fingerprint(targets) { return crypto.createHash('sha256').update(lines.join('\n')).digest('hex'); } -const scales = {}; -for (const [name, fileCount] of [ +const cases = [ ['small', SMALL], ['large', LARGE], -]) { - const { graph, scopes, nodeLookup } = buildCorpus(fileCount); - const widened = timeIndex(scopes, nodeLookup, graph); - const callableOnly = timeIndex(callableOnlyScopes(scopes), nodeLookup, graph); - scales[name] = { - files: fileCount, - defs: scopes.defs.byId.size, - ms: widened.ms, - callable_only_ms: callableOnly.ms, - targets: widened.result.size, - callable_only_targets: callableOnly.result.size, - fingerprint: fingerprint(widened.result), +].map(([name, files]) => ({ name, files, ...buildCorpus(files) })); +const variants = cases.flatMap((corpus) => [ + corpus, + { ...corpus, scopes: callableOnlyScopes(corpus.scopes) }, +]); +const samples = variants.map(() => []); +const results = []; +// Measure all four variants in each round, reversing their order to avoid +// consistently favouring one tier or scale. Discard complete warmup rounds. +// Pair ratios within rounds so a change in runner load does not compare a +// quiet control period with a busy widened period (or small with large). +for (let round = 0; round < WARMUP + REPS; round++) { + for (const i of round % 2 === 0 ? [0, 1, 2, 3] : [3, 2, 1, 0]) { + const { scopes, nodeLookup, graph } = variants[i]; + const timed = timeIndex(scopes, nodeLookup, graph); + results[i] = timed.result; + if (round >= WARMUP) samples[i].push(timed.ms); + } +} + +const scales = {}; +for (const [i, corpus] of cases.entries()) { + scales[corpus.name] = { + files: corpus.files, + defs: corpus.scopes.defs.byId.size, + ms: median(samples[i * 2]), + callable_only_ms: median(samples[i * 2 + 1]), + targets: results[i * 2].size, + callable_only_targets: results[i * 2 + 1].size, + fingerprint: fingerprint(results[i * 2]), }; } -const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL); +const scalingRatio = median(samples[2].map((ms, i) => ms / samples[0][i] / (LARGE / SMALL))); // How much slower the widened gate is than the pre-#2693 one on the same // corpus. 1.0 = free; 2.0 = the widening doubled the index build. -const wideningOverhead = scales.large.ms / scales.large.callable_only_ms; +const wideningOverhead = median(samples[2].map((ms, i) => ms / samples[3][i])); const report = { small: scales.small, diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 3ee8c938e..2f1d26676 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -59,13 +59,13 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { 'test/integration/rename-provider-c.test.ts': 202, 'test/integration/rename-provider-cobol.test.ts': 75, 'test/integration/rename-provider-cpp.test.ts': 250, - 'test/integration/rename-provider-csharp.test.ts': 200, + 'test/integration/rename-provider-csharp.test.ts': 266, 'test/integration/rename-provider-dart.test.ts': 425, 'test/integration/rename-provider-go.test.ts': 221, 'test/integration/rename-provider-java.test.ts': 300, 'test/integration/rename-provider-javascript.test.ts': 225, 'test/integration/rename-provider-kotlin.test.ts': 300, - 'test/integration/rename-provider-objective-c.test.ts': 285, + 'test/integration/rename-provider-objective-c.test.ts': 386, 'test/integration/rename-provider-php.test.ts': 100, 'test/integration/rename-provider-python.test.ts': 375, 'test/integration/rename-provider-ruby.test.ts': 100, @@ -78,7 +78,12 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { // Windows persisted-fixture profile above. Replace with observed CI timing. 'test/integration/rename-preview-plan.test.ts': 175, 'test/integration/mcp-index-generation.test.ts': 201, - 'test/unit/incremental-dirty-recovery.test.ts': 76, + // Windows run 39934695864 timed out after undercharged native suites; + // 40172490240 supplies the query-symbol and dirty-recovery measurements. + 'test/integration/incremental-method-integrity.test.ts': 145, + 'test/integration/fts-repair-warm-session.test.ts': 62, + 'test/integration/query-symbol-integrity.test.ts': 34, + 'test/unit/incremental-dirty-recovery.test.ts': 128, // Re-measured after the analyze --watch e2e landed in #3072. The previous // 361 s entry undercharged this suite and left shard 1 close to the watchdog. 'test/integration/cli-e2e.test.ts': 621, diff --git a/gitnexus/test/unit/cjk-segmentation.test.ts b/gitnexus/test/unit/cjk-segmentation.test.ts index 25ce7f12a..eee9d3232 100644 --- a/gitnexus/test/unit/cjk-segmentation.test.ts +++ b/gitnexus/test/unit/cjk-segmentation.test.ts @@ -74,38 +74,50 @@ describe('segmentCjkSpans', () => { expect(outputBytes).toBeLessThan(expectedBytes * 1.05); }); - it('scales linearly on realistic interleaved CJK/non-CJK content, not quadratically', () => { - // Regression guard: an earlier implementation indexed into the growing - // accumulated output string once per run boundary, which forces V8 to - // flatten its internal rope representation on every access — O(n^2) on - // content that alternates CJK and non-CJK runs (ordinary source code - // with inline CJK comments, the feature's actual target). A single-run - // input (like the growth-factor test above) never exercises this path, - // since there is only one run boundary regardless of size. - const unit = '采购订单自动审批流程 // approve the request after manual review\n'; - const build = (totalBytes: number) => unit.repeat(Math.ceil(totalBytes / unit.length)); + it.skipIf(!process.env.GITNEXUS_BENCH)( + 'scales linearly on realistic interleaved CJK/non-CJK content, not quadratically', + () => { + // Regression guard: an earlier implementation indexed into the growing + // accumulated output string once per run boundary, which forces V8 to + // flatten its internal rope representation on every access — O(n^2) on + // content that alternates CJK and non-CJK runs (ordinary source code + // with inline CJK comments, the feature's actual target). A single-run + // input (like the growth-factor test above) never exercises this path, + // since there is only one run boundary regardless of size. + const unit = '采购订单自动审批流程 // approve the request after manual review\n'; + const build = (totalBytes: number) => unit.repeat(Math.ceil(totalBytes / unit.length)); - const small = build(64 * 1024); - const large = build(512 * 1024); // 8x the input size + const small = build(64 * 1024); + const large = build(512 * 1024); // 8x the input size - const timeOf = (input: string) => { - const start = performance.now(); - segmentCjkSpans(input); - return performance.now() - start; - }; + const timeOf = (input: string) => { + const start = performance.now(); + segmentCjkSpans(input); + return performance.now() - start; + }; - // Warm up the JIT before measuring either size. - timeOf(small); - timeOf(large); + // run-benchmarks.ts discovers this opt-in test and runs it serially. + // Sample both sizes after warmup: one scheduler pause must not look like + // the quadratic regression this guard is intended to catch. + const smallSamples: number[] = []; + const largeSamples: number[] = []; + for (let sample = 0; sample < 10; sample++) { + const smallMs = timeOf(small); + const largeMs = timeOf(large); + if (sample >= 3) { + smallSamples.push(smallMs); + largeSamples.push(largeMs); + } + } + const smallMs = Math.min(...smallSamples); + const largeMs = Math.min(...largeSamples); - const smallMs = timeOf(small); - const largeMs = timeOf(large); - - // Linear scaling means ~8x input takes roughly ~8x time, with headroom - // for noise; quadratic scaling would mean ~64x time. 20x catches the - // regression while tolerating CI timing variance. - expect(largeMs).toBeLessThan(Math.max(smallMs, 1) * 20); - }); + // Linear scaling means ~8x input takes roughly ~8x time, with headroom + // for noise; quadratic scaling would mean ~64x time. 20x catches the + // regression while tolerating CI timing variance. + expect(largeMs).toBeLessThan(Math.max(smallMs, 1) * 20); + }, + ); }); describe('containsCjkIdeograph', () => { diff --git a/gitnexus/test/unit/cross-platform-shard.test.ts b/gitnexus/test/unit/cross-platform-shard.test.ts index 727c35cf6..caf8cca2b 100644 --- a/gitnexus/test/unit/cross-platform-shard.test.ts +++ b/gitnexus/test/unit/cross-platform-shard.test.ts @@ -134,6 +134,26 @@ describe('cross-platform shard partition', () => { expect(Math.max(...loads)).toBeLessThanOrEqual(17 * 60); }); + it('leaves watchdog headroom for the October 23 native-suite timeout', () => { + // Windows run 39934695864: shard 10 timed out with Kotlin still pending. + // Use its completed suite times, plus the October 25 green run 40172490240 + // for query-symbol-integrity and incremental-dirty-recovery. These measured + // costs must remain independent of the scheduling hints being tested. + const observed: Readonly> = { + 'test/integration/rename-provider-objective-c.test.ts': 385.174, + 'test/integration/rename-provider-csharp.test.ts': 265.99, + 'test/integration/incremental-method-integrity.test.ts': 144.974, + 'test/integration/fts-repair-warm-session.test.ts': 61.015, + 'test/integration/query-symbol-integrity.test.ts': 33.892, + 'test/unit/incremental-dirty-recovery.test.ts': 127.8, + }; + const floor = weightOf('test/unmeasured.test.ts'); + const loads = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL).map((files) => + files.reduce((sum, file) => sum + Math.max(weightOf(file), (observed[file] ?? 0) + floor), 0), + ); + expect(Math.max(...loads)).toBeLessThanOrEqual(17 * 60); + }); + it('keeps each shard within a shard of the ideal weight', () => { const shards = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL); const weights = shards.map(shardWeight);