mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Шард 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
209 lines
12 KiB
JavaScript
209 lines
12 KiB
JavaScript
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, []);
|
||
});
|