Files
houseplan-card/test/mutation-gate.test.mjs
T
Codex 273b0d5ecb test: the backend mutant follows the registry convention (#329 r2-H1)
The reviewer is right twice over. My previous commit fixed the red CI by
relaxing the contract — a guard could name a `.py` file — when the registry
already had a convention for exactly this case: every backend mutant runs
`node scripts/backend-test-guard.mjs <pattern> <file>`, which owns the python
executable choice and the `-k` selection. Bending a rule to fit my one-off is
the worse of the two possible fixes, so the contract goes back to demanding a
`.mjs` guard, and junction-limit-backend-raw-baseline now uses the helper and
targets the one test that proves the migration
(test_legacy_baseline_is_judged_after_the_same_migration).

Re-verified: registry --check clean, the mutant still catches its regression
1/1, npm test 1390 passed / 0 failed.

Issue: #329
User-Visible: no
2026-08-28 00:41:30 +03:00

109 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MUTANTS, applyPatches, guardNeedsTestBuild } from '../scripts/mutation-gate.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
// Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный
// прогон с пересборкой бандла на мутанта — предрелизный, он в
// .github/workflows/mutation-gate.yml.
//
// Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому
// дрейф якорей ловится здесь, а не при редком полном прогоне.
test('every mutant patch anchors exactly once in the current source', () => {
for (const mutant of MUTANTS) {
for (const patch of mutant.patches) {
const path = join(repoRoot, patch.file);
assert.ok(existsSync(path), `${mutant.id}: файла ${patch.file} больше нет`);
const source = readFileSync(path, 'utf8');
const hits = source.split(patch.find).length - 1;
assert.equal(hits, 1,
`${mutant.id}: якорь в ${patch.file} найден ${hits} раз(а) — реестр отстал от кода`);
assert.notEqual(patch.find, patch.replace, `${mutant.id}: патч ничего не меняет`);
}
}
});
test('every guard command points at a file that exists', () => {
for (const mutant of MUTANTS) {
const script = mutant.guard.split(' ').find((part) => part.endsWith('.mjs'));
assert.ok(script, `${mutant.id}: guard не называет исполняемый файл`);
assert.ok(existsSync(join(repoRoot, script)),
`${mutant.id}: guard-файла ${script} не существует`);
}
});
test('every mutant explains itself', () => {
const ids = new Set();
for (const mutant of MUTANTS) {
assert.ok(mutant.because && mutant.because.length > 40,
`${mutant.id}: без объяснения мутант превратится в карго-культ`);
assert.ok(!ids.has(mutant.id), `дубль id: ${mutant.id}`);
ids.add(mutant.id);
}
assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85');
});
test('applyPatches rewrites the anchor and refuses a stale one', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-mg-'));
try {
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(join(dir, 'src', 'a.ts'), 'const KEEP = 1;\nconst FEATHER = 2;\n');
applyPatches(dir, [{ file: 'src/a.ts', find: 'const FEATHER = 2;', replace: 'const FEATHER = 20;' }]);
assert.match(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), /FEATHER = 20/);
// Якоря нет — отказ, а не тихий пропуск: патч «в никуда» выглядит защитой.
assert.throws(
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'no such anchor', replace: 'x' }]),
/0 раз/,
);
// Якорь двоится — тоже отказ: патч лёг бы «куда попало».
writeFileSync(join(dir, 'src', 'a.ts'), 'twice\ntwice\n');
assert.throws(
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'twice', replace: 'x' }]),
/2 раз/,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// #235: в свежем worktree мутанта каталога test-build/ нет. Короткий гвард
// (сразу `node --test`) падал там с ERR_MODULE_NOT_FOUND, а гейт читал это как
// «мутант пойман» — тихий отказ, который девять мутантов держал мёртвыми, не
// краснея. Теперь компиляцию делает харнесс, и здесь проверяется, что он
// узнаёт нужный ему гвард.
test('the harness compiles test-build for exactly the guards that need it (#235)', () => {
assert.equal(guardNeedsTestBuild('node --test --test-name-pattern="x" test/a.test.mjs'), true);
assert.equal(guardNeedsTestBuild(
'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/a.test.mjs',
), false, 'длинный гвард компилирует сам — второй раз не надо');
assert.equal(guardNeedsTestBuild('node demo/smoke_x.mjs'), false, 'смоку хватает бандла');
assert.equal(guardNeedsTestBuild('node scripts/backend-test-guard.mjs x'), false);
});
test('no mutant guard is left unable to resolve test-build (#235)', () => {
for (const mutant of MUTANTS) {
const file = mutant.guard.split(/\s+/).find((part) => part.endsWith('.test.mjs'));
if (!file) continue;
const source = readFileSync(join(repoRoot, file), 'utf8');
if (!source.includes('test-build/')) continue;
// Либо гвард компилирует сам, либо это делает харнесс — третьего исхода
// (падение на резолве модуля) быть не должно.
assert.ok(
mutant.guard.includes('tsconfig.test.json') || guardNeedsTestBuild(mutant.guard),
`${mutant.id}: гвард читает ${file}, который импортирует test-build/, `
+ 'и никто этот каталог в мутанте не соберёт',
);
}
});