Files
houseplan-card/test/mutation-gate-report.test.mjs
T
Claude cc2300bf86 ci(mutation-gate): шесть шардов ночного прогона, прерванный шард — отказ, а не «ok» (#604)
Шард 2/4 прогона 35565222849 снят по timeout-minutes: реестр вырос до 810
мутантов (~203 на шард), длительность за 11 дней 42 → 61 мин при потолке 60.
Отчёт назвал его «ok»: лог без строк FAIL считался зелёным, а обрыв по
таймауту строк FAIL не содержит. Агрегатор проверял identity, но не
завершённость — evidence шага `if: always()` было на месте.

- mutation-gate.yml: matrix из шести шардов, `--shard=i/6`, `--shards=6`;
  шаг прогона получил id, его `outcome` пишется в evidence.
- mutation-gate-report.mjs: шард `ok` только с итоговой строкой
  `поймано N из M`, N = M, без FAIL и с исходом шага `success`; лог без
  итога или исход `cancelled`/`skipped` — `interrupted`, отказ; агрегатор
  отвергает прерванный шард как неполный. `outcome` в evidence необязателен
  ради старых артефактов, но, если назван, обязан быть из известного набора.
- тесты: обрыв → interrupted, исходы шага, evidence с outcome; делитель
  шардов один во всех местах workflow; фикстуры зелёных логов получили итог.
- мутанты: mutation-report-truncated-log-is-ok,
  mutation-evidence-ignores-cancelled-step.
- docs/TESTING.md: шесть шардов, итоговая строка.

Потолок 60 минут остаётся стражем от зависшего Chromium. Ledger в ночном
прогоне не включён: ночь гоняет всё.

Issue: #604
User-Visible: no
2026-09-21 14:12:17 +03:00

209 lines
12 KiB
JavaScript
Raw Permalink 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 test from 'node:test';
import assert from 'node:assert/strict';
import {
REPORT_TITLE_MARKER, mutationGateReport, mutationShardEvidence, parseShardLogs,
telegramSummary, validateMutationShardEvidence,
} from '../scripts/mutation-gate-report.mjs';
// #472. Еженедельный полный прогон падал дважды подряд, и никто не смотрел:
// у отказа не было адресата. Отчёт обязан назвать сбежавших так, чтобы
// команда воспроизведения из письма работала, — а не выдумывать сущности из
// строк, которые выглядят похоже.
const KNOWN = ['alpha-mutant', 'beta-mutant', 'gamma-mutant'];
const guards = new Map([
['alpha-mutant', 'node --test test/a.test.mjs'],
['beta-mutant', 'node demo/smoke_b.mjs'],
['gamma-mutant', 'node --test test/g.test.mjs'],
]);
const meta = { runUrl: 'https://x/runs/1', ref: 'dev', sha: 'abcdef1234567890', date: '2026-09-08' };
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const expectedEvidence = {
materialSha: A, materialTree: B, workflowSha: C,
runId: 549, runAttempt: 2, shardCount: 4,
};
const evidenceRow = (shard, overrides = {}) => ({
file: `attempt-${overrides.runAttempt || 1}/shard-${shard}/evidence.json`,
evidence: mutationShardEvidence({
...expectedEvidence, shard, runAttempt: 1, ...overrides,
}),
});
test('#549: четыре шарда одного material образуют доказанный результат', () => {
const result = validateMutationShardEvidence(
[1, 2, 3, 4].map((shard) => evidenceRow(shard)), expectedEvidence,
);
assert.equal(result.ok, true);
assert.deepEqual(result.selected.map((row) => row.evidence.shard), [1, 2, 3, 4]);
});
test('#549: foreign SHA и отсутствующий шард отвергаются fail-closed', () => {
const rows = [evidenceRow(1), evidenceRow(2, { materialSha: 'd'.repeat(40) }), evidenceRow(4)];
const result = validateMutationShardEvidence(rows, expectedEvidence);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.includes('foreign material SHA')));
assert.ok(result.errors.some((error) => error.includes('shard 3: evidence is missing')));
});
test('#549: partial retry выбирает новый attempt, но сохраняет единый material', () => {
const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard));
rows.push(evidenceRow(2, { runAttempt: 2 }));
const result = validateMutationShardEvidence(rows, expectedEvidence);
assert.equal(result.ok, true);
assert.equal(result.selected.find((row) => row.evidence.shard === 2).evidence.runAttempt, 2);
assert.equal(result.selected.find((row) => row.evidence.shard === 1).evidence.runAttempt, 1);
});
test('#549: partial retry с другим material не склеивается со старыми шардами', () => {
const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard));
rows.push(evidenceRow(2, { runAttempt: 2, materialSha: 'd'.repeat(40) }));
const result = validateMutationShardEvidence(rows, expectedEvidence);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.includes('foreign material SHA')));
});
test('сбежавшие собираются из нескольких шардов без дублей и по порядку (#472 AC3)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'ok x\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n guard: node demo/smoke_b.mjs\n' },
{ shard: 2, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n' },
{ shard: 3, text: 'ok gamma-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n' },
] });
assert.deepEqual(report.escaped, ['alpha-mutant', 'beta-mutant']);
assert.deepEqual(report.shards.map((s) => s.status), ['failed', 'failed', 'ok']);
assert.equal(report.failed, true);
assert.match(report.body, /--id=alpha-mutant/);
assert.match(report.body, /guard: `node demo\/smoke_b\.mjs`/);
});
test('красный гард без мутанта — не сбежавший мутант (#472 AC3, ревью r1)', () => {
// runCleanGuards пишет в тот же лог. Наивный парсер сделал бы мутанта «чистый».
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'FAIL чистый прогон: node --test test/g.test.mjs красный без мутанта\n' },
] });
assert.deepEqual(report.escaped, []);
assert.deepEqual(report.redGuards, ['node --test test/g.test.mjs']);
assert.ok(!report.body.includes('--id=чистый'));
assert.match(report.body, /красные без мутанта/);
assert.equal(report.failed, true);
});
test('#550: setup/invalid/interruption reported as unverified, never escaped or caught', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: [
'FAIL alpha-mutant: ошибка подготовки до заявленного теста',
'FAIL beta-mutant: неприменимый мутант',
'FAIL gamma-mutant: прерывание инфраструктуры',
'FAIL чистая подготовка: npx tsc -p tsconfig.test.json',
].join('\n') },
] });
assert.deepEqual(report.escaped, []);
assert.deepEqual(report.redGuards, []);
assert.equal(report.unverifiable.length, 4);
assert.equal(report.unparsed.length, 0);
assert.match(report.body, /Свидетели без доказательства \(4\)/);
assert.match(report.body, /не записывается в ledger/);
assert.ok(!report.body.includes('--id=alpha-mutant'));
assert.equal(report.failed, true);
});
test('id вне реестра не выдумывается, строка сохраняется как есть (#472 AC3)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'FAIL ghost-mutant: тест остался зелёным на сломанном коде\n' },
] });
assert.deepEqual(report.escaped, []);
assert.equal(report.unparsed.length, 1);
assert.match(report.unparsed[0].line, /ghost-mutant/);
assert.match(report.body, /Неразобранные строки FAIL/);
});
test('отсутствующий лог шарда — отказ, а не «сбежавших нет» (#472 AC3)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n' },
{ shard: 2, text: null },
] });
assert.deepEqual(report.escaped, []);
assert.equal(report.shards[1].status, 'missing');
assert.equal(report.failed, true, 'нет артефакта — не знаем, что сбежало');
assert.match(report.body, /Артефакты шардов 2 не пришли/);
});
test('заголовок несёт постоянный маркер, тело — прогон, SHA и дату (#472 AC4)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [{ shard: 1, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\n' }] });
assert.ok(report.title.startsWith(REPORT_TITLE_MARKER));
assert.match(report.body, /https:\/\/x\/runs\/1/);
assert.match(report.body, /abcdef123456/);
assert.match(report.body, /2026-09-08/);
});
test('зелёный набор логов даёт failed=false (#472)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n', outcome: 'success' },
{ shard: 2, text: 'поймано 3 из 3\n' },
] });
assert.equal(report.failed, false);
assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'ok']);
});
// #604. Шард 2/4 ночного прогона 21.09 снят по timeout-minutes на 60-й минуте
// после ≥155 зелёных строк; строк FAIL в обрывке нет, и отчёт написал «ok» при
// красном прогоне. Зелёным считается только лог, дошедший до итоговой строки.
test('#604: лог без итоговой строки — прерван, а не «ok»', () => {
const truncated = 'ok alpha-mutant: тест покраснел, как обязан\nok beta-mutant: тест покраснел, как обязан\n';
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'поймано 2 из 2\n' },
{ shard: 2, text: truncated },
] });
assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'interrupted']);
assert.equal(report.failed, true, 'обрыв — отказ: до сбежавших могли не дойти');
assert.deepEqual(report.escaped, [], 'сбежавших из обрывка не выдумывается');
assert.match(report.body, /\| 2 \| \*\*прерван — лог без итоговой строки \(таймаут или отмена\)\*\* \|/);
assert.match(report.body, /Шарды 2 прерваны до итоговой строки/);
assert.match(telegramSummary(report, 'https://x/issues/9'), /2:interrupted/);
});
test('#604: исход шага cancelled прерывает шард даже при итоговой строке; failure без FAIL — красный', () => {
const finished = 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n';
const parsed = parseShardLogs([
{ shard: 1, text: finished, outcome: 'cancelled' },
{ shard: 2, text: finished, outcome: 'skipped' },
{ shard: 3, text: finished, outcome: 'failure' },
{ shard: 4, text: 'поймано 2 из 3\n', outcome: 'success' },
{ shard: 5, text: finished, outcome: 'success' },
{ shard: 6, text: finished },
], KNOWN);
assert.deepEqual(parsed.shards.map((s) => s.status), ['interrupted', 'interrupted', 'failed', 'failed', 'ok', 'ok']);
});
test('#604: evidence несёт исход шага, агрегатор отвергает прерванный шард как неполный', () => {
const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: shard === 2 ? 'cancelled' : 'success' }));
assert.equal(rows[1].evidence.outcome, 'cancelled');
assert.equal(evidenceRow(1).evidence.outcome, undefined, 'старые артефакты без исхода остаются валидными');
const result = validateMutationShardEvidence(rows, expectedEvidence);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.includes('shard 2: run was interrupted (step outcome cancelled)')));
// красный шаг — не нарушение identity: красноту называет лог, не агрегатор
const red = validateMutationShardEvidence([1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: 'failure' })), expectedEvidence);
assert.equal(red.ok, true);
assert.throws(() => mutationShardEvidence({ ...expectedEvidence, shard: 1, runAttempt: 1, outcome: 'timed-out' }), /unknown shard outcome/);
});
test('сводка для Telegram коротка и ведёт на issue (#472)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\n' },
{ shard: 2, text: null },
] });
const text = telegramSummary(report, 'https://x/issues/9');
assert.match(text, /alpha-mutant/);
assert.match(text, /2:missing/);
assert.match(text, /https:\/\/x\/issues\/9/);
assert.ok(text.length < 600);
});
test('parseShardLogs терпит CRLF и пустой ввод (#472)', () => {
const parsed = parseShardLogs([{ shard: 1, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\r\n' }], KNOWN);
assert.deepEqual(parsed.escaped, ['alpha-mutant']);
assert.deepEqual(parseShardLogs([], KNOWN).shards, []);
});