Files
msd-core/tests/mutation-tap-runner-wiring.test.cjs
Tom Boucher 7e9d33c378 enhance(#3915): stryker on the official tap runner, 29m to 12m on the critical path (#3919)
* enhance(#3915): stryker on the official tap runner, per-test coverage

The 'command' runner is the one runner Stryker excludes from coverage
analysis, which forced coverageAnalysis:'off' and made mutation cost
strictly linear in (mutants x whole-shard test time). The frontmatter
shard measured 1751s against 212s for the next slowest.

Swap to @stryker-mutator/tap-runner (official plugin, peer-pinned to the
@stryker-mutator/core@9.6.1 already installed) and turn coverage analysis
on, so Stryker re-runs only the test files that cover each mutated line.

Per-shard injection moves from a MUTATION_TEST_CMD command string to
MUTATION_TEST_FILES, read by a new fail-closed resolveMutationTestFiles()
that mirrors resolveMutationBreak. It stays derived from
scripts/mutation-matrix.cjs and now has a single owner: the union moves
behind allCoveredTests() and stryker.config.mjs no longer imports COVERED.
The resolver existence-checks every entry, because the tap runner resolves
tap.testFiles with glob() and a non-matching pattern yields an empty list
silently - a fast, confident, meaningless run.

tap.forceBail is off by measurement, not preference: a structural AST
audit found 3 of 26 shard test files spawn subprocesses, and bail fires on
every killed mutant, so leaving it on would kill processes mid-spawnSync
and orphan their children.

The matrix 'isolation' field is removed - per-file process isolation is
inherent to the tap runner, so the field had no consumer left.

Score arithmetic is unchanged and now pinned: mutationScore counts
NoCoverage in the same denominator as Survived, which both Stryker's
thresholds.break and check-mutation-score-ratchet.cjs read. A new
non-vacuity test proves it diverges from mutationScoreBasedOnCoveredCode,
so the gate cannot be quietly swapped to the field that would make every
floor trivially satisfiable.

Refs #3915

* fix(#3915): enforce the resolver's documented containment contract

Review findings, all fixed in place.

resolveMutationTestFiles claimed to verify each entry exists 'relative to
the repo root' but used a bare fs.existsSync(path.join(...)), which
accepts an existing DIRECTORY and lets ../ segments escape the root
(path.join('/repo/root','../../etc/passwd') resolves to /etc/passwd).
Not reachable from PR content - the value only ever comes from the static
COVERED registry via CI env - but a fail-closed contract that overstates
its own guarantee is a defect in the contract. Each entry is now resolved,
rejected if path.relative puts it outside the root, and required to be a
regular file. Three hostile-input tests added; the original missing-file
wording is preserved so the existing assertion still binds.

Also: removed a stale buildResult comment still naming the isolation
field this branch deleted; hoisted one top-level path require in place of
three inline ones; de-duplicated the derived-test-list expression in the
tests to a single const, deliberately still re-derived from COVERED
rather than calling allCoveredTests() so the assertion cannot become a
tautology; tightened the workflow-parity assertion to exact equality;
changed the tap-runner range from an exact 9.6.1 to ^9.6.1 so it tracks
the caret-ranged core its peerDependency pins exactly.

Regenerated examples/dynamic-context-management/CONTEXT-INDEX.json, which
the earlier CONTEXT.md edit left stale - lint:ci was red on
lint-example-parser-parity until it was refreshed.

Refs #3915

* test(#3915): kill model-catalog survivors, set frontmatter budget from measurement

From mutation run 33026833181 (all 13 shards, dispatched on this branch before any PR).

WALL TIME: frontmatter measured 713s (11m53s) vs 1751s (29m11s) on the command runner, a 59% cut. timeoutMinutes 60 -> 20 (1.68x measured). Not deleted outright: the shared 15-minute default would leave only 21% headroom, and this module's mutant count grew 1.8x in one change.

MODEL-CATALOG: came back 57.91 against its floor of 58. Diagnosed from the report JSON, not assumed - all 24 of its new RuntimeError mutants are in the load-time catalog bootstrap, so mutating them makes the module throw at require. Under node --test that is a failed test file (Killed); under the tap runner the process dies before emitting TAP, which Stryker classifies RuntimeError and excludes from the denominator. Add them back as killed and the shard is 248/416 = 59.62, the pre-change number exactly. Detection did not regress, classification changed.

The floor is NOT lowered to absorb that. 11 new behavioural tests target ~42 genuinely surviving mutants: exact Set equality on the EFFORT_RENDERING/EFFORT_ARGV supported sets, an exact-string table render that makes the column-width arithmetic observable (padEnd never truncates, so the old substring assertion could not see a too-small width), clampEffortForHost's full null matrix plus a spoofed-toString host, and a prototype-pollution guard driven through Object.fromEntries.

Mutants judged equivalent were skipped rather than papered over; reasoning is in the phase artifacts. All 15 new assertions verified against unmutated code. lint:ci exit 0.

Refs #3915

* test(#3915): ratchet model-catalog's floor to 74 on measured 75.26%

CI run 33029755081 measured model-catalog at 75.26% (295 killed / 49 survived / 48 no-coverage / 24 runtime-error, totalValid 392), so check-mutation-score-ratchet.cjs correctly failed the shard for unclaimed headroom: 17.26 points above the declared floor of 58, well past the 5-point slack.

Floor raised 58 -> 74 (floor(75.26)-1, this file's documented convention), with RATCHET_BASELINE updated in the same diff as the equality assertion requires.

Worth recording why the number moved this far. The shard first came back at 57.91 under the tap runner and the temptation was to lower the floor to match. The drop was not a regression: all 24 of its RuntimeError mutants sit in the load-time catalog bootstrap, and adding them back as killed reproduces 248/416 = 59.62, the pre-swap figure exactly. Rather than absorb a reporting artifact by weakening the gate, 11 behavioural tests went after the genuinely surviving mutants and killed 68 of them - carrying the module from 59.62 past its old ceiling to 75.26, within reach of the ADR-456 target of 80.

The #3007 measurement is kept as clearly-labelled prior context rather than deleted, so the entry does not read as carrying two current numbers.

Refs #3915

---------

Co-authored-by: sim <sim@local>
2026-08-26 22:43:14 -04:00

190 lines
8.5 KiB
JavaScript

'use strict';
/**
* tests/mutation-tap-runner-wiring.test.cjs
*
* Pins the #3915 tap-runner wiring — the config contract AND the
* workflow<->config parity, because the injected env token lives on two
* surfaces (`.github/workflows/mutation.yml`'s per-shard `env:` block and
* `stryker.config.mjs`'s reader of it) and silent drift between them would
* make every shard silently fall back to running the FULL default test list
* instead of its own module's tests — slow, wrong, and (because Stryker
* would still find SOME test constraining each mutant) green.
*
* FAILING-FIRST (#3915): stryker.config.mjs still declares `testRunner:
* 'command'` with a `commandRunner.command` built from `MUTATION_TEST_CMD`,
* and mutation.yml still injects `MUTATION_TEST_CMD` / `matrix.isolation`.
* Every test below targets the tap-runner shape those files do not have yet.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { pathToFileURL } = require('node:url');
const yaml = require('js-yaml');
const REPO_ROOT = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(REPO_ROOT, 'stryker.config.mjs');
const WORKFLOW_PATH = path.join(REPO_ROOT, '.github', 'workflows', 'mutation.yml');
// Env keys this config is known to read. Every key is saved/restored so a
// test's env override can never leak into a sibling test.
const RELEVANT_ENV_KEYS = ['MUTATION_TEST_FILES', 'MUTATION_TEST_CMD', 'MUTATION_BREAK'];
// Cache-busting counter: importing the SAME file:// URL twice returns the
// SAME cached ES module record, so an env-dependent config test that reused
// one URL across calls would silently observe the FIRST call's env forever —
// every subsequent env-dependent assertion would pass or fail for the wrong
// reason. Incrementing this per call forces a fresh module evaluation.
let _importCounter = 0;
/**
* Load stryker.config.mjs with `env` applied on top of process.env for the
* duration of the import, then restore process.env exactly. `env` values of
* `undefined` delete the corresponding key rather than setting it.
*
* @param {Record<string, string|undefined>} env
* @returns {Promise<object>} the config module's default export
*/
async function loadConfig(env) {
// Save/restore the UNION of RELEVANT_ENV_KEYS and Object.keys(env), not just the fixed
// list: the PARITY test below discovers its env key NAME from the parsed workflow file
// rather than hardcoding it, so a key outside RELEVANT_ENV_KEYS can reach here — saving
// only the fixed list would let that discovered key leak into sibling tests.
const keysToRestore = new Set([...RELEVANT_ENV_KEYS, ...Object.keys(env)]);
const saved = {};
for (const key of keysToRestore) saved[key] = process.env[key];
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
const mod = await import(`${pathToFileURL(CONFIG_PATH).href}?v=${_importCounter++}`);
return mod.default;
} finally {
for (const key of keysToRestore) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
}
}
describe('stryker.config.mjs: tap runner contract (#3915)', () => {
test("testRunner === 'tap'", async () => {
const config = await loadConfig({});
assert.strictEqual(config.testRunner, 'tap');
});
test("coverageAnalysis === 'perTest'", async () => {
const config = await loadConfig({});
assert.strictEqual(config.coverageAnalysis, 'perTest');
});
test("coverageAnalysis !== 'off' (the literal condition #3915 requires to stop being true)", async () => {
const config = await loadConfig({});
assert.notStrictEqual(config.coverageAnalysis, 'off');
});
test('no own-property commandRunner (the command runner is gone)', async () => {
const config = await loadConfig({});
assert.ok(!Object.prototype.hasOwnProperty.call(config, 'commandRunner'));
});
test('tap.forceBail === false', async () => {
const config = await loadConfig({});
assert.strictEqual(config.tap.forceBail, false);
});
test('no own-property buildCommand, and tap has no own-property nodeArgs (no rebuild step reintroduced, ADR-457)', async () => {
const config = await loadConfig({});
assert.ok(!Object.prototype.hasOwnProperty.call(config, 'buildCommand'));
assert.ok(!Object.prototype.hasOwnProperty.call(config.tap, 'nodeArgs'));
});
test('MUTATION_TEST_FILES set → tap.testFiles deep-equals the parsed entries', async () => {
const config = await loadConfig({
MUTATION_TEST_FILES: 'tests/frontmatter.unit.test.cjs tests/unusable-input.test.cjs',
});
assert.deepStrictEqual(config.tap.testFiles, [
'tests/frontmatter.unit.test.cjs',
'tests/unusable-input.test.cjs',
]);
});
test('MUTATION_BREAK set → thresholds.break reflects it unchanged (re-proved after the runner swap)', async () => {
const config = await loadConfig({ MUTATION_BREAK: '72' });
assert.strictEqual(config.thresholds.break, 72);
});
test('MUTATION_TEST_FILES set but empty → fail-closed survives all the way to config load', async () => {
await assert.rejects(() => loadConfig({ MUTATION_TEST_FILES: '' }));
});
test('mutate scope unchanged by the runner swap', async () => {
const config = await loadConfig({});
assert.ok(Array.isArray(config.mutate));
assert.ok(config.mutate.includes('gsd-core/bin/lib/**/*.cjs'));
assert.ok(
config.mutate.some((entry) => entry.startsWith('!gsd-core/bin/lib/')),
'mutate array must still carry at least one !gsd-core/bin/lib/... exclusion'
);
});
});
describe('mutation.yml <-> stryker.config.mjs: injected env parity (#3915)', () => {
const workflowDoc = yaml.load(fs.readFileSync(WORKFLOW_PATH, 'utf8'));
const mutateJob = workflowDoc.jobs.mutate;
const runStrykerStep = mutateJob.steps.find(
(step) => typeof step.name === 'string' && step.name.startsWith('Run Stryker')
);
test("the 'Run Stryker' step exists", () => {
assert.ok(runStrykerStep, "no step in the mutate job's steps has a name starting with 'Run Stryker'");
});
test('step.env has key MUTATION_TEST_FILES', () => {
assert.ok(Object.prototype.hasOwnProperty.call(runStrykerStep.env, 'MUTATION_TEST_FILES'));
});
test('step.env does NOT have key MUTATION_TEST_CMD', () => {
assert.ok(!Object.prototype.hasOwnProperty.call(runStrykerStep.env, 'MUTATION_TEST_CMD'));
});
test('MUTATION_TEST_FILES value is exactly the ${{ matrix.tests }} expression (derived from mutation-matrix.cjs)', () => {
assert.strictEqual(String(runStrykerStep.env.MUTATION_TEST_FILES).trim(), '${{ matrix.tests }}');
});
test('MUTATION_BREAK still references matrix.minScore', () => {
assert.ok(Object.prototype.hasOwnProperty.call(runStrykerStep.env, 'MUTATION_BREAK'));
assert.ok(String(runStrykerStep.env.MUTATION_BREAK).includes('matrix.minScore'));
});
test('no value anywhere in the step env mentions --test-isolation', () => {
for (const value of Object.values(runStrykerStep.env)) {
assert.ok(!String(value).includes('--test-isolation'), `unexpected --test-isolation reference: ${value}`);
}
});
test('no value anywhere in the whole mutate job mentions matrix.isolation', () => {
const serialized = JSON.stringify(mutateJob);
assert.ok(!serialized.includes('matrix.isolation'), 'mutate job still references matrix.isolation');
});
test("the step's run string does not contain '${{' (no direct interpolation inside run:, CONTRIBUTING.md)", () => {
assert.ok(!runStrykerStep.run.includes('${{'), 'run: block contains a direct ${{ }} interpolation');
});
test('PARITY: the env key name discovered from the workflow drives the config test directly, so the two surfaces cannot drift', async () => {
// Find the key in step.env that starts with MUTATION_TEST_ — this is read
// from the PARSED workflow document, not hardcoded, so a rename on either
// surface (the workflow's env key, or stryker.config.mjs's reader of it)
// breaks this test instead of the two silently drifting apart.
const discoveredKey = Object.keys(runStrykerStep.env).find((k) => k.startsWith('MUTATION_TEST_'));
assert.ok(discoveredKey, 'no MUTATION_TEST_* key found in the Run Stryker step env');
const config = await loadConfig({ [discoveredKey]: 'tests/frontmatter.unit.test.cjs' });
assert.deepStrictEqual(config.tap.testFiles, ['tests/frontmatter.unit.test.cjs']);
});
});