mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
8cce0ef6b8
* test: ratchet mutation score over enumerated decision kernels Adds a Stryker (vitest runner) mutation lane scoped to the decision kernels, a per-module baseline with tool/config provenance, and a ratchet that only lets scores rise. Non-gating until two consecutive stable weekly sweeps. Refs #1415 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: declare the mutation test-scope seam for production-export analysis Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: own kernel tests in the mutation registry and ship the #1430 lane envelope - restore bench:help-conformance, broken by a formatting-path edit - kernel test files select their module on PRs (registry `tests` + workflow paths), asserted to reach the kernel through the import graph - every mutation run writes the standard scheduled-lane artifact envelope - move src/utils/__tests__/errors.test.ts beside its source per the mirror rule Refs #1415, #1430 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: derive kernel test ownership and land the scheduled-lane health monitor Ownership of a kernel's tests is now computed from the static import graph (scripts/mutation/ownership.ts) instead of a hand-listed set, so a test that reaches a kernel indirectly -- src/__tests__/daemon-error.test.ts through src/daemon.ts -- selects that kernel on a PR. The PR lane triggers on every src test and shards the derived modules, keeping wall clock at one module. The lane envelope (#1430) is now written on every exit path with the stage it reached, so a crash before any mutant runs is distinguishable from a lane that never ran. Adds the derived cadence monitor (scripts/lane-health, daily workflow): scheduled lanes are enumerated from .github/workflows/ and reported dark, failing, or never-run against their own cron cadence. * fix: merge only Stryker reports from a shard directory The shard artifacts now carry the lane envelope beside mutation.json, and the merge globbed every .json under the download path, so the ratchet job fed the envelope to the report parser and died after the mutants had already run. * fix(mutation): fail on incomplete shard sets and envelope pre-run failures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mutation): downgrade a passing envelope when a later lane step fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(mutation): shard by registry, defer the PR lane, drop the bundled watcher Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs: describe registry sharding and the deferred PR mutation lane Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mutation): make the pre-graduation tooling exception select real mutants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(mutation): give the worktree fixture commits their own identity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
136 lines
5.7 KiB
TypeScript
136 lines
5.7 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/`: the unit suite also hosts the help-conformance
|
|
// gates from `scripts/__tests__`, which assert over the repo's own registries
|
|
// rather than over any decision kernel and own their CI job.
|
|
//
|
|
// Nothing here weakens the ratchet: 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 { walkFiles } from '../lib/walk-files.ts';
|
|
import { 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';
|
|
|
|
/** Source modules that own a `node:worker_threads` worker. */
|
|
function workerThreadModules(repoRoot: string): string[] {
|
|
return walkFiles(
|
|
path.join(repoRoot, 'src'),
|
|
(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 walkFiles(path.join(repoRoot, 'src'), (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) => file.startsWith('src/') && !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`);
|
|
}
|