mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
ci: отчёт об отказе расписания мутантов — issue и Telegram
User-Visible: no Issue: #472
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
REPORT_TITLE_MARKER, mutationGateReport, parseShardLogs, telegramSummary,
|
||||
} 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' };
|
||||
|
||||
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' },
|
||||
] });
|
||||
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('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' },
|
||||
{ shard: 2, text: 'поймано 3 из 3\n' },
|
||||
] });
|
||||
assert.equal(report.failed, false);
|
||||
assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'ok']);
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
@@ -217,3 +217,52 @@ test('#458 у каждого модуля горячего пути отрисо
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// #472. У отказа еженедельного прогона не было адресата: права workflow не
|
||||
// позволяли завести issue, шага на отказ не было, а одна concurrency-группа на
|
||||
// всё позволяла ручному запуску молча отменить расписание.
|
||||
import { readFileSync as readWorkflowFile } from 'node:fs';
|
||||
const mutationWorkflow = readWorkflowFile(
|
||||
new URL('../.github/workflows/mutation-gate.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
const validateWorkflowText = readWorkflowFile(
|
||||
new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8',
|
||||
);
|
||||
|
||||
test('#472 AC1: у расписания и ручного запуска разные concurrency-группы', () => {
|
||||
assert.match(mutationWorkflow, /group: mutation-gate-\$\{\{ github\.event_name \}\}/);
|
||||
});
|
||||
|
||||
test('#472 AC2: каждый шард сохраняет свой лог артефактом при любом исходе', () => {
|
||||
assert.match(mutationWorkflow, /set -o pipefail\n\s+node scripts\/mutation-gate\.mjs --shard=[^\n]*\| tee artifacts\/mutation-shard-/);
|
||||
const upload = mutationWorkflow.slice(mutationWorkflow.indexOf('- name: Сохранить лог шарда'));
|
||||
assert.match(upload.slice(0, 400), /if: always\(\)/);
|
||||
assert.match(upload.slice(0, 400), /name: mutation-shard-\$\{\{ matrix\.shard \}\}/);
|
||||
});
|
||||
|
||||
test('#472 AC5: job report — только по расписанию, только при не-успехе, с полными правами', () => {
|
||||
const report = mutationWorkflow.slice(mutationWorkflow.indexOf(' report:'));
|
||||
assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && needs\.mutants\.result != 'success'/);
|
||||
const permissions = report.slice(report.indexOf('permissions:'), report.indexOf('steps:'));
|
||||
for (const grant of ['contents: read', 'actions: read', 'issues: write']) {
|
||||
assert.ok(permissions.includes(grant), `нет права ${grant} у job report`);
|
||||
}
|
||||
assert.match(report, /node scripts\/mutation-gate-report\.mjs/);
|
||||
});
|
||||
|
||||
test('#472 AC6: повторный отказ дописывает открытое issue, а не создаёт второе', () => {
|
||||
const report = mutationWorkflow.slice(mutationWorkflow.indexOf(' report:'));
|
||||
const search = report.indexOf('gh issue list');
|
||||
const create = report.indexOf('gh issue create');
|
||||
const comment = report.indexOf('gh issue comment');
|
||||
assert.ok(search > 0 && comment > search && create > search, 'поиск открытого issue идёт до create/comment');
|
||||
});
|
||||
|
||||
test('#472 AC7: отсутствие Telegram-секретов не роняет job', () => {
|
||||
const telegram = mutationWorkflow.slice(mutationWorkflow.indexOf('- name: Telegram'));
|
||||
assert.match(telegram, /if \[ -z "\$TOKEN" \] \|\| \[ -z "\$CHAT" \]; then\n\s+echo "::warning::[^\n]*"\n\s+exit 0/);
|
||||
});
|
||||
|
||||
test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => {
|
||||
assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml; do/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user