mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
test: a registry of known breakages that tests must catch
Validate / provenance (push) Successful in 46s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 35s
Validate / frontend (push) Successful in 6m11s
Validate / backend (push) Failing after 9m24s
Validate / golden (push) Failing after 10m10s
Validate / performance_smoke (push) Failing after 12m46s
Validate / smoke (push) Failing after 30m15s
Validate / provenance (push) Successful in 46s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 35s
Validate / frontend (push) Successful in 6m11s
Validate / backend (push) Failing after 9m24s
Validate / golden (push) Failing after 10m10s
Validate / performance_smoke (push) Failing after 12m46s
Validate / smoke (push) Failing after 30m15s
Five times in this project a green test meant nothing was checked. The continuity smoke stayed green after the entire mechanism it guards was cut out. The golden scene created to protect doorway light was empty — 1,177 warm pixels against 107,119, all of them icons. The shadow smoke passed while no shadow was drawn. Each time the test had been written alongside the code, went green at once, and nobody ever asked whether it could go red. The gate makes that question routine. Each mutant is a few lines of patch that reproduce a known breakage, plus the name of the test that must fail on it. A worktree is patched, the bundle rebuilt, the guard run — and a guard that stays green fails the gate. Six mutants cover the holes documented in #85; the anchors are exact strings from today's source, so the registry cannot silently drift — a unit test that runs with the ordinary suite refuses a stale anchor. The full run rebuilds the bundle per mutant, so it lives in its own workflow, before a stable release and on a weekly schedule, not in Validate. The rules for new tests are written at the top of docs/TESTING.md, and the sixth of them is the cheapest: an assertion that reads back the property the code just set is not written at all. Issue: #85 User-Visible: no
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
name: Mutation gate
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env node
|
||||
// Реестр известных поломок (mutation gate), issue #85.
|
||||
//
|
||||
// node scripts/mutation-gate.mjs --list мутанты и кого они проверяют
|
||||
// node scripts/mutation-gate.mjs --check патчи применимы к текущему коду
|
||||
// node scripts/mutation-gate.mjs полный прогон: все мутанты
|
||||
// node scripts/mutation-gate.mjs --id=<mutant> один мутант
|
||||
// node scripts/mutation-gate.mjs --build-only применить и собрать, тест не гонять
|
||||
//
|
||||
// Код выхода: 0 — каждый тест поймал свою поломку, 1 — хотя бы один не поймал,
|
||||
// 2 — не смог проверить (патч не лёг, сборка упала).
|
||||
//
|
||||
// Зачем. Зелёный тест в этом проекте несколько раз означал «ничего не
|
||||
// проверено», и выяснялось это после того, как баг доезжал до владельца:
|
||||
// смок непрерывности не заметил удаления механизма, который защищает; golden,
|
||||
// заведённый под #71, был пуст — 1 177 тёплых пикселей против 107 119, и все
|
||||
// 1 177 были иконками. Общее у всех случаев: тест ни разу не проверяли на
|
||||
// способность падать. Этот гейт делает такую проверку регулярной.
|
||||
//
|
||||
// Каждый мутант — маленький патч продуктового исходника, воспроизводящий
|
||||
// известную поломку, и имя теста, который ОБЯЗАН на ней покраснеть. Прогон:
|
||||
// worktree → патч → сборка бандла → бандл в demo/srv/assets → тест. Тест,
|
||||
// оставшийся зелёным, — это провал гейта, а не успех теста.
|
||||
//
|
||||
// Прогон дорогой (пересборка бандла на мутанта), поэтому его место — перед
|
||||
// стабильным релизом (.github/workflows/mutation-gate.yml), не на каждой бете.
|
||||
// Дешёвая часть — «патчи применимы, guard-файлы существуют» — живёт в
|
||||
// test/mutation-gate.test.mjs и идёт с обычными юнитами: реестр, отставший от
|
||||
// кода, хуже отсутствующего, потому что выглядит защитой.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// --- реестр ---------------------------------------------------------------
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
export const MUTANTS = [
|
||||
{
|
||||
id: 'continuity-long-resume-noop',
|
||||
guard: 'node demo/smoke_visual_continuity.mjs',
|
||||
because: 'смок обязан доказывать, что long-resume СРАБОТАЛ (токен ушёл вперёд), '
|
||||
+ 'а не что ничего не спрятали: в beta.7 он не заметил удаления всего механизма',
|
||||
patches: [{
|
||||
file: 'src/visual-continuity.ts',
|
||||
find: "if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {",
|
||||
replace: "if ((signal.kind === 'visible' || signal.kind === 'pageshow')) {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'opening-cut-degenerate',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт '
|
||||
+ 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);',
|
||||
replace: 'cuts.push([o.rx, o.ry, o.rx, o.ry]);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'column-shadow-removed',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать '
|
||||
+ 'тень; исторически смок теней был зелёным, пока тени физически не рисовались',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'for (const body of physical) occluders.push(...polygonSegments(body));',
|
||||
replace: 'void physical;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'feather-20px',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; '
|
||||
+ 'смок читает data-feather-px и обязан отвергнуть значение больше 3',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'const GLOW_EDGE_FEATHER_PX = 2;',
|
||||
replace: 'const GLOW_EDGE_FEATHER_PX = 20;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'barrier-cache-never-invalidated',
|
||||
guard: 'node demo/smoke_openwall.mjs',
|
||||
because: 'кэш барьеров, который не инвалидируется по содержимому, — это свет '
|
||||
+ 'сквозь стену, которая уже существует; смок переключает виртуальную границу '
|
||||
+ 'и обязан увидеть смену освещённости соседней комнаты',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;',
|
||||
replace: 'if (this._lightBarrierCache) return this._lightBarrierCache.value;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'golden-lamp-out-of-reach',
|
||||
guard: 'node demo/golden/run.mjs --mode=verify --scenario=lighting-opaque-glow-two-doorways-dark',
|
||||
because: 'сцена заведена как защита дверного света (#71) и однажды уже была '
|
||||
+ 'пустой: лампа стояла так, что пятно не доходило до стены. Уведённая лампа '
|
||||
+ 'обязана ронять семантический ассерт сцены, а не только пиксельный дифф',
|
||||
// Мутируется фикстура сцены, не продуктовый код: пустота сцены — свойство
|
||||
// фикстуры. Пересборка бандла всё равно нужна, путь тот же.
|
||||
patches: [{
|
||||
file: 'demo/golden/matrix.mjs',
|
||||
find: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },",
|
||||
replace: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.06, y: 0.90 } },",
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
export function applyPatches(root, patches) {
|
||||
for (const patch of patches) {
|
||||
const path = join(root, patch.file);
|
||||
const source = readFileSync(path, 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) {
|
||||
throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`);
|
||||
}
|
||||
writeFileSync(path, source.replace(patch.find, patch.replace));
|
||||
}
|
||||
}
|
||||
|
||||
function sh(cmd, cwd, extraEnv = {}) {
|
||||
return spawnSync(cmd, {
|
||||
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
|
||||
function makeWorktree() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
|
||||
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
|
||||
{ encoding: 'utf8' });
|
||||
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
|
||||
// node_modules не копируется — символическая ссылка на настоящий. Установка
|
||||
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
||||
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
|
||||
return dir;
|
||||
}
|
||||
|
||||
function dropWorktree(dir) {
|
||||
spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function buildBundle(dir) {
|
||||
// Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по
|
||||
// типам — он воспроизводит поломку, а не образцовый код.
|
||||
const built = sh('npx rollup -c', dir);
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
|
||||
}
|
||||
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
|
||||
}
|
||||
|
||||
function runMutant(mutant) {
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
applyPatches(dir, mutant.patches);
|
||||
buildBundle(dir);
|
||||
const guard = sh(mutant.guard, dir);
|
||||
if (guard.status === 0) {
|
||||
console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`);
|
||||
console.log(` guard: ${mutant.guard}`);
|
||||
console.log(` ${mutant.because}`);
|
||||
return false;
|
||||
}
|
||||
console.log(`ok ${mutant.id}: тест покраснел, как обязан`);
|
||||
return true;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
|
||||
// «ловит» поломку тривиально и не доказывает ничего.
|
||||
function runCleanGuards(mutants) {
|
||||
const guards = [...new Set(mutants.map((m) => m.guard))];
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
buildBundle(dir);
|
||||
for (const guard of guards) {
|
||||
const result = sh(guard, dir);
|
||||
if (result.status !== 0) {
|
||||
console.log(`FAIL чистый прогон: ${guard} красный без мутанта`);
|
||||
console.log((result.stderr || result.stdout).slice(-1500));
|
||||
return false;
|
||||
}
|
||||
console.log(`ok чистый прогон: ${guard}`);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
|
||||
const selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
|
||||
if (idArg && !selected.length) {
|
||||
console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (argv.includes('--list')) {
|
||||
for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (argv.includes('--check')) {
|
||||
let stale = 0;
|
||||
for (const m of selected) {
|
||||
try {
|
||||
for (const patch of m.patches) {
|
||||
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
|
||||
}
|
||||
console.log(`ok ${m.id}`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||||
stale++;
|
||||
}
|
||||
}
|
||||
return stale ? 2 : 0;
|
||||
}
|
||||
|
||||
if (argv.includes('--build-only')) {
|
||||
for (const m of selected) {
|
||||
const dir = makeWorktree();
|
||||
try {
|
||||
applyPatches(dir, m.patches);
|
||||
buildBundle(dir);
|
||||
console.log(`ok ${m.id}: патч лёг, бандл собрался`);
|
||||
} catch (error) {
|
||||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||||
return 2;
|
||||
} finally {
|
||||
dropWorktree(dir);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!runCleanGuards(selected)) return 2;
|
||||
let caught = 0;
|
||||
for (const m of selected) if (runMutant(m)) caught++;
|
||||
console.log(`\nпоймано ${caught} из ${selected.length}`);
|
||||
return caught === selected.length ? 0 : 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 } 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user