mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
423927fdd8
* chore(mutation): shrink the lane to report-only (#1457, #1781 wave 2) The mutation harness's two real catches (#1474, #1475) both came from humans reading the weekly score report. The ratchet half never operated: the baseline was committed exactly twice (8cce0ef6b,60400d04b), both times with `stableRuns: 0, gating: false`, and was never updated after the very fixes it triggered — the weekly job computed a new baseline and then `git checkout --`d it, uploading a proposal nobody applied in 3+ weeks. A gate nobody arms is harness weight; the report is the part that paid. Deletes ratchet.ts + ratchet.test.ts, mutation-baselines/, and every baseline/graduation/gating path in run.ts (`--update`, `mutation:baseline`). run.ts now exits non-zero only on a harness failure, never on a score. The report renders the per-kernel table (kernel, score, killed, survived, total, timeouts) plus the surviving mutants a strengthening PR works from. Kernel scoping stays: stryker.config.json and KERNEL_MODULES are untouched. * fix(mutation): restore denominator coverage and publish the table before judging the shard set Review of #1828: - `report.test.ts` re-asserts that Ignored/CompileError/RuntimeError leave the denominator — the one behaviour `ratchet.test.ts` covered and nothing replaced. A `tally()` edit that counted tool noise would have deflated every published score with a green `mutation:test`. - `assertShardsCoverModules` now runs after `emit()`, so an incomplete shard set still publishes the kernels that completed instead of only an error string. This makes the workflow comments' claim about the job summary true rather than re-wording them down. * chore(mutation): trigger the affected lane on exactly the paths that can select mutants The PR lane returns an empty matrix unless the diff touches the harness, so the kernel-source and `**/*.test.ts` triggers only bought a 1-4 min no-op job on ~96% of PRs. `on.pull_request.paths` is now exactly `LANE_TOOLING` plus the workflow file, asserted in both directions by workflow.test.ts against the exported constant — a missing path would let a harness change merge unproven, an extra one starts a job that can only answer `[]`. Also drops the workflow header's contradictory scope paragraph: it claimed the lane selects on kernel sources and any test reaching one, which has not been true since the ratchet went. * fix(mutation): score and publish a short shard set before failing on the count The expected-count check ran inside readShardedReports, before anything was summarized, so on the weekly's real `--expect-shards 10` one dead shard threw away the nine that had reported — the earlier reorder only moved the zero-mutants check. The merge now returns the shard count, and both verdicts run after emit() with the same exit code and `score` stage. Regression uses the weekly argument shape (`--expect-shards 10`, one shard present) and asserts the reporting kernel's row reaches stdout while the run still fails.
154 lines
6.4 KiB
TypeScript
154 lines
6.4 KiB
TypeScript
// Which tests the mutation lane runs, derived from Vitest's module graph.
|
|
//
|
|
// Stryker replays the configured suite for every mutant, so the suite it is
|
|
// pointed at decides whether the weekly sweep fits its 30-minute budget. Pointing
|
|
// it at the whole unit suite (487 files) makes the initial dry run alone cost
|
|
// minutes; hand-listing per-kernel test files would be a second source of truth
|
|
// that silently rots. So the scope is derived the same way `pnpm check:affected`
|
|
// derives affected tests: `vitest related` over the mutated files, i.e. Vitest's
|
|
// own static module graph.
|
|
//
|
|
// Two files are removed from whatever Vitest returns:
|
|
// - the subprocess-stub group (it spawns stubbed binaries and waits real
|
|
// subprocess/retry/poll time — out of scope by the issue's constraint, and
|
|
// thousands of mutant runs would turn it into timeout noise);
|
|
// - tests that cannot run in the thread pool Stryker's vitest runner forces:
|
|
// the in-process CLI-capture tests (`process.chdir` throws in a worker
|
|
// thread) and the `node:worker_threads` PNG pipeline tests (a worker inside
|
|
// a worker raises uncaught MessagePort errors that kill the runner);
|
|
// - anything outside `src/` or a workspace package's `src/`: the unit suite
|
|
// also hosts the help-conformance gates from `scripts/__tests__`, which
|
|
// assert over the repo's own registries rather than any decision kernel
|
|
// and own their CI job.
|
|
//
|
|
// Nothing here inflates the score: a mutant only an excluded test could kill
|
|
// shows up as a survivor — visible work, never a silent pass.
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { runCmdSync } from '../../src/utils/exec.ts';
|
|
import { readWorkspacePackages } from '../layering/package-boundaries.ts';
|
|
import { walkFiles } from '../lib/walk-files.ts';
|
|
import { isKernelTestFile, normalizePath } from './modules.ts';
|
|
|
|
/** Env var carrying the resolved scope file to `vitest.mutation.config.ts`. */
|
|
export const TEST_SCOPE_ENV = 'AGENT_DEVICE_MUTATION_TEST_FILES';
|
|
const CLI_CAPTURE_HARNESS = 'src/__tests__/cli-capture.ts';
|
|
|
|
/**
|
|
* Every directory the mutation lane can find a test file under: root `src/`
|
|
* plus each workspace package's `src/` (`unit-core`'s own `include` list in
|
|
* `vitest.config.ts`). A kernel living in `packages/*` — `kernel-errors`
|
|
* reaches its tests only indirectly, but `target-annotation-serde` is tested
|
|
* directly from inside its package — must not lose its owning tests to a
|
|
* root-only walk.
|
|
*/
|
|
function testFileRoots(repoRoot: string): string[] {
|
|
return [
|
|
path.join(repoRoot, 'src'),
|
|
...readWorkspacePackages(repoRoot).map((pkg) => path.join(repoRoot, pkg.dir, 'src')),
|
|
];
|
|
}
|
|
|
|
/** Source modules that own a `node:worker_threads` worker. */
|
|
function workerThreadModules(repoRoot: string): string[] {
|
|
return testFileRoots(repoRoot)
|
|
.flatMap((root) =>
|
|
walkFiles(root, (file) => file.endsWith('.ts') && !file.endsWith('.test.ts')),
|
|
)
|
|
.filter((file) => fs.readFileSync(file, 'utf8').includes('node:worker_threads'))
|
|
.map((file) => path.basename(file, '.ts'));
|
|
}
|
|
|
|
/**
|
|
* Repo-relative test files that cannot survive Stryker's thread pool, derived
|
|
* from what they import rather than listed: the chdir-using CLI capture harness
|
|
* and any module that itself starts a worker thread.
|
|
*/
|
|
export function threadHostileTestFiles(repoRoot: string): string[] {
|
|
const modules = [path.basename(CLI_CAPTURE_HARNESS, '.ts'), ...workerThreadModules(repoRoot)];
|
|
const importsHostileModule = new RegExp(`from '[^']*/(${modules.join('|')})(\\.ts)?'`);
|
|
return testFileRoots(repoRoot)
|
|
.flatMap((root) => walkFiles(root, (file) => file.endsWith('.test.ts')))
|
|
.filter((file) => importsHostileModule.test(fs.readFileSync(file, 'utf8')))
|
|
.map((file) => normalizePath(path.relative(repoRoot, file)))
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* Concrete source files behind a module's mutate globs. Stryker's `!`-prefixed
|
|
* exclusions are applied here too — `fs.globSync` has no notion of them, and
|
|
* dropping them would feed selector *tests* into the scope derivation.
|
|
*/
|
|
export function expandMutateFiles(globs: readonly string[], repoRoot: string): string[] {
|
|
const negated = globs.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1));
|
|
const excluded = new Set(fs.globSync(negated, { cwd: repoRoot }).map(normalizePath));
|
|
return fs
|
|
.globSync(
|
|
globs.filter((glob) => !glob.startsWith('!')),
|
|
{ cwd: repoRoot },
|
|
)
|
|
.map(normalizePath)
|
|
.filter((file) => !excluded.has(file))
|
|
.sort();
|
|
}
|
|
|
|
type VitestJsonReport = { testResults?: readonly { name: string }[] };
|
|
|
|
/**
|
|
* Test files Vitest considers related to `sourceFiles`, minus the groups this
|
|
* lane cannot run. `vitest related` executes them once (seconds), which also
|
|
* proves the scope is green before Stryker's dry run depends on it.
|
|
*/
|
|
export function relatedTestFiles(
|
|
sourceFiles: readonly string[],
|
|
repoRoot: string,
|
|
excluded: readonly string[] = threadHostileTestFiles(repoRoot),
|
|
): string[] {
|
|
const reportFile = path.join(repoRoot, '.tmp/mutation/related-tests.json');
|
|
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
|
|
fs.rmSync(reportFile, { force: true });
|
|
runCmdSync(
|
|
'pnpm',
|
|
[
|
|
'exec',
|
|
'vitest',
|
|
'related',
|
|
...sourceFiles,
|
|
'--project',
|
|
'unit-core',
|
|
'--run',
|
|
'--reporter=json',
|
|
`--outputFile=${reportFile}`,
|
|
],
|
|
{ cwd: repoRoot, allowFailure: true },
|
|
);
|
|
if (!fs.existsSync(reportFile)) {
|
|
throw new Error(
|
|
`vitest related produced no report at ${reportFile} — cannot derive the mutation test scope.`,
|
|
);
|
|
}
|
|
const report = JSON.parse(fs.readFileSync(reportFile, 'utf8')) as VitestJsonReport;
|
|
const excludedSet = new Set(excluded);
|
|
return [
|
|
...new Set(
|
|
(report.testResults ?? [])
|
|
.map((result) => normalizePath(path.relative(repoRoot, result.name)))
|
|
.filter((file) => isKernelTestFile(file) && !excludedSet.has(file)),
|
|
),
|
|
].sort();
|
|
}
|
|
|
|
/** Read the scope Stryker was handed, or `undefined` for "whole unit suite". */
|
|
export function readTestScope(): string[] | undefined {
|
|
const file = process.env[TEST_SCOPE_ENV];
|
|
if (!file || !fs.existsSync(file)) return undefined;
|
|
const files = JSON.parse(fs.readFileSync(file, 'utf8')) as string[];
|
|
return files.length > 0 ? files : undefined;
|
|
}
|
|
|
|
export function writeTestScope(files: readonly string[], file: string): void {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(files, null, 2)}\n`);
|
|
}
|