Files
Michał Pierzchała 104fe75248 fix(ci): run the fuzz corpus replay outside the coverage lane (#1994)
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies, and neither a missed
per-case budget nor STARTUP_BUDGET_MS is implicated. Partial test counts
(3/11 and 9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg goes through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. ci.yml sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them and the shard would die: Vitest refuses
`--shard=1/2` over this one-file project, and the blob reporter
overwrites the instrumented shard's report on its way out. Verified on
the merged tree — shard 1/2 (549 files), shard 2/2 (548), and the merge
job (1097 files, 90.38% lines) all pass, and the leg still fails without
the blanking.

Refs #1824
2026-08-24 17:03:25 +02:00

160 lines
6.0 KiB
TypeScript

// Load-bearing ownership, path-reachability, and suite-registration witnesses.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { audit } from './audit.ts';
import { categories, loadModel, type Model } from './model.ts';
import type { Lane } from './workflows.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean);
const base = loadModel(repoRoot, tracked);
function mutate(change: (model: Model) => Partial<Model>): Model {
return { ...base, ...change(base) };
}
function messages(model: Model): string[] {
return audit(model).map((failure) => failure.message);
}
function mapLane(
model: Model,
match: (lane: Lane) => boolean,
change: (lane: Lane) => Lane,
): Lane[] {
return model.lanes.map((lane) => (match(lane) ? change(lane) : lane));
}
test('the live tree is green — every planted failure below is a real difference', () => {
assert.deepEqual(messages(base), []);
});
test('deleting the lane that runs a gate reports exactly that gate, naming the runner', () => {
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.gates.includes('fuzz-parsers'),
(lane) => ({
...lane,
gates: lane.gates.filter((id) => id !== 'fuzz-parsers'),
}),
),
}));
const found = messages(model);
assert.equal(found.length, 1);
assert.match(
found[0] ?? '',
/check "fuzz-parsers" is not declared by any pull_request\/schedule lane/,
);
assert.match(found[0] ?? '', /run-gate action step for `fuzz-parsers`/);
});
test('a docs-only change still reaches the command-reference gate (#1420)', () => {
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.workflow === 'pr-preview.yml',
(lane) => ({ ...lane, paths: ['website/assets/**'] }),
),
}));
const found = messages(model);
assert.equal(found.length, 1);
assert.match(found[0] ?? '', /website\/docs\/docs\/commands\.md/);
assert.match(found[0] ?? '', /selects "command-docs"/);
});
test('a path filter that excludes a category fails, though the check still runs somewhere', () => {
// Take the category's path from the derivation rather than naming a file, so the
// case keeps exercising the real classification as the tree changes.
const category = categories(base).find((entry) => entry.rule === 'own:daemon-wire-compat');
assert.ok(category, 'the wire ledger must still be a category');
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.workflow === 'ci.yml',
(lane) => ({
...lane,
pathsIgnore: [...lane.pathsIgnore, category.path],
}),
),
}));
const found = messages(model);
assert.ok(
found.every((message) => !/is not run by any/.test(message)),
'the checks still run somewhere — only this path stops reaching them',
);
assert.ok(found.some((message) => message.includes(category.path)));
assert.ok(found.some((message) => /selects "daemon-wire-compat"/.test(message)));
});
// The Coverage lane's two legs together run every project the config declares — the instrumented
// one takes `--project=!fuzz-worker` and the nested `test:fuzz-worker` takes the rest — so an unrun
// project is still only representable once that script names its projects positively.
const projectScoped = (projects: readonly string[]): string =>
`vitest run --coverage ${projects.map((name) => `--project ${name}`).join(' ')}`;
test('a Vitest project no check runs is reported, and so is a suite script', () => {
const project = mutate((m) => ({
scripts: { ...m.scripts, 'test:coverage:ci': projectScoped(m.vitestProjects) },
vitestProjects: [...m.vitestProjects, 'new-lane'],
}));
assert.ok(
messages(project).some((message) =>
/Vitest project "new-lane" is run by no registered check/.test(message),
),
);
const script = mutate((m) => ({
scripts: {
...m.scripts,
'test:coverage:ci': projectScoped(m.vitestProjects),
'test:orphan': 'vitest run --project unit-core --project orphan-only',
},
vitestProjects: [...m.vitestProjects, 'orphan-only'],
}));
assert.ok(
messages(script).some((message) =>
/package script "test:orphan" runs vitest:orphan-only/.test(message),
),
);
});
test('a `test:*` script that is a suite by name, not by shape, needs an owner', () => {
// The five `test:replay:*` scripts run `node src/bin.ts test <dir>`, which resolves to a
// `script:` leaf. A shape-only rule could not see them: four were owned because someone
// hand-registered them, and `test:replay:android` was neither registered nor reported.
const model = mutate((m) => ({
scripts: { ...m.scripts, 'test:replay:freebsd': 'node src/bin.ts test test/replays/freebsd' },
}));
assert.ok(
messages(model).some((message) =>
/package script "test:replay:freebsd" runs script:test:replay:freebsd/.test(message),
),
'a new test:* script with no catalog entry must fail `registered`',
);
});
// The device-lane rules (#1781 A9-2) route Apple and Android paths to the parked replay lanes.
// Path coverage exempts a declared manual-only check the way `owned` does — the gap is already
// printed by name — but only while it is declared: drop the declaration and every path that
// selects the check reports it.
test('a parked check selected by a path is exempt from path-coverage only while declared', () => {
const declared = audit(base).filter((failure) => failure.assertion === 'path-coverage');
assert.deepEqual(declared, []);
const undeclared = audit(base, { manualOnly: {}, unprovable: {} }).filter(
(failure) => failure.assertion === 'path-coverage',
);
assert.ok(
undeclared.some((failure) => /selects "replay-ios"/.test(failure.message)),
'without the declaration, the iOS replay lane must surface as unreachable from its paths',
);
});