mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
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
This commit is contained in:
@@ -67,15 +67,21 @@ jobs:
|
||||
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
|
||||
needs: material
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
|
||||
# Час — потолок против зависшего Chromium.
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
|
||||
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
|
||||
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
|
||||
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
|
||||
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
|
||||
# равными. Бандл собирают только браузерные гварды. Час — потолок против
|
||||
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
|
||||
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -124,10 +130,14 @@ jobs:
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
id: gate
|
||||
run: |
|
||||
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
|
||||
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
|
||||
# строк FAIL сам по себе зелёным не считается.
|
||||
- name: Записать identity шарда
|
||||
if: always()
|
||||
run: |
|
||||
@@ -137,7 +147,8 @@ jobs:
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--shard=${{ matrix.shard }} --shards=4
|
||||
--shard=${{ matrix.shard }} --shards=6 \
|
||||
--outcome=${{ steps.gate.outcome }}
|
||||
- name: Сохранить лог и identity шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
@@ -147,7 +158,8 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Результат нельзя приписывать material, пока не доказаны все четыре шарда.
|
||||
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
|
||||
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
|
||||
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
|
||||
evidence:
|
||||
name: "Доказать единый material всех шардов"
|
||||
@@ -171,7 +183,7 @@ jobs:
|
||||
- name: Проверить полноту и identity
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs --verify-only \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
@@ -214,7 +226,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=4 \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
|
||||
+6
-4
@@ -29,11 +29,13 @@
|
||||
но не при её неработоспособности.
|
||||
|
||||
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
|
||||
полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда
|
||||
`--shard=i/4`,
|
||||
полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов
|
||||
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут на 810 мутантах, #604;
|
||||
один зафиксированный commit/tree для всего прогона; каждый артефакт несёт
|
||||
identity, а отдельный агрегатор fail-closed отвергает смешанные или неполные
|
||||
evidence даже при частичном rerun) каждую ночь по расписанию (01:00 UTC); в
|
||||
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
|
||||
смешанные, неполные или прерванные по таймауту evidence даже при частичном
|
||||
rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`)
|
||||
каждую ночь по расписанию (01:00 UTC); в
|
||||
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
|
||||
заводит issue с отчётом (#472, #513). Дешёвая половина
|
||||
идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи —
|
||||
|
||||
@@ -38,6 +38,16 @@ const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный б
|
||||
const UNVERIFIABLE_LINE = /^FAIL (\S+): (неприменимый мутант|ошибка подготовки до заявленного теста|прерывание инфраструктуры)\s*$/;
|
||||
const CLEAN_UNVERIFIABLE_LINE = /^FAIL чистая (подготовка|инфраструктура): (.+?)\s*$/;
|
||||
const ANY_FAIL_LINE = /^FAIL /;
|
||||
// Итоговая строка раннера (`scripts/mutation-gate.mjs`, конец main). Лог без
|
||||
// неё — обрыв: шард снят по timeout-minutes или отменён, и «FAIL не встретился»
|
||||
// значит лишь «до FAIL не дошли». 21.09 (#604) шард 2/4 умер на 60-й минуте
|
||||
// после ≥155 зелёных строк, и отчёт назвал его «ok».
|
||||
const SUMMARY_LINE = /^поймано (\d+) из (\d+)\s*$/;
|
||||
// Исходы шага GitHub Actions (`steps.<id>.outcome`), которые шард пишет в
|
||||
// evidence (#604). `cancelled` — timeout job или отмена прогона; такой шард
|
||||
// не завершён независимо от того, что успел попасть в лог.
|
||||
export const SHARD_OUTCOMES = Object.freeze(['success', 'failure', 'cancelled', 'skipped']);
|
||||
const INTERRUPTED_OUTCOMES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
const FULL_SHA = /^[0-9a-f]{40}$/;
|
||||
const positiveInteger = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
|
||||
@@ -54,6 +64,14 @@ export function mutationShardEvidence(input) {
|
||||
shard: Number(input.shard),
|
||||
shardCount: Number(input.shardCount),
|
||||
};
|
||||
// Исход шага прогона — необязателен ради артефактов, записанных до #604;
|
||||
// если назван, обязан быть одним из известных: опечатка в workflow не должна
|
||||
// превращаться в «неизвестно, значит ок».
|
||||
const outcome = input.outcome == null || input.outcome === '' ? undefined : String(input.outcome);
|
||||
if (outcome !== undefined) {
|
||||
if (!SHARD_OUTCOMES.includes(outcome)) throw new Error(`unknown shard outcome: ${outcome}`);
|
||||
evidence.outcome = outcome;
|
||||
}
|
||||
if (!FULL_SHA.test(evidence.materialSha) || !FULL_SHA.test(evidence.materialTree)
|
||||
|| !FULL_SHA.test(evidence.workflowSha) || !positiveInteger(evidence.runId)
|
||||
|| !positiveInteger(evidence.runAttempt) || !positiveInteger(evidence.shard)
|
||||
@@ -107,6 +125,12 @@ export function validateMutationShardEvidence(rows, expected) {
|
||||
|| Number(evidence.runAttempt) > Number(expected.runAttempt)) {
|
||||
errors.push(`shard ${shard}: impossible run attempt`);
|
||||
}
|
||||
// Полнота — это не только «артефакт пришёл», но и «шард дошёл до конца»
|
||||
// (#604): снятый по таймауту шаг оставляет evidence через `if: always()`,
|
||||
// и без этой проверки агрегатор приписывал material результат, которого нет.
|
||||
if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {
|
||||
errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors, selected };
|
||||
}
|
||||
@@ -136,7 +160,11 @@ export function loadMutationShardArtifacts(root, expected) {
|
||||
for (let shard = 1; shard <= Number(expected.shardCount); shard++) {
|
||||
const row = selectedByShard.get(shard);
|
||||
const path = row ? join(dirname(row.file), `mutation-shard-${shard}.log`) : '';
|
||||
logs.push({ shard, text: path && existsSync(path) ? readFileSync(path, 'utf8') : null });
|
||||
logs.push({
|
||||
shard,
|
||||
text: path && existsSync(path) ? readFileSync(path, 'utf8') : null,
|
||||
outcome: row?.evidence?.outcome,
|
||||
});
|
||||
if (row && (!path || !existsSync(path))) validation.errors.push(`shard ${shard}: log is missing`);
|
||||
}
|
||||
validation.ok = validation.errors.length === 0;
|
||||
@@ -146,10 +174,18 @@ export function loadMutationShardArtifacts(root, expected) {
|
||||
/**
|
||||
* Разобрать логи шардов.
|
||||
*
|
||||
* @param {Array<{ shard: number, text: string | null }>} logs — `text: null`
|
||||
* означает, что артефакт шарда не пришёл. Это тоже отказ: «лога нет» не
|
||||
* значит «сбежавших нет», это значит «мы не знаем».
|
||||
* @param {Array<{ shard: number, text: string | null, outcome?: string }>} logs —
|
||||
* `text: null` означает, что артефакт шарда не пришёл. Это тоже отказ: «лога
|
||||
* нет» не значит «сбежавших нет», это значит «мы не знаем». `outcome` —
|
||||
* исход шага прогона из evidence, если записан (#604).
|
||||
* @param {Set<string>|string[]} knownIds — id реестра.
|
||||
*
|
||||
* Статусы шарда: `ok` — лог дошёл до итоговой строки `поймано N из M`, N = M,
|
||||
* ни одной строки FAIL, исход шага не назван или `success`; `failed` — есть
|
||||
* FAIL либо итог не сходится либо шаг красный без FAIL; `interrupted` — лог
|
||||
* обрывается до итоговой строки или исход шага `cancelled`/`skipped` (таймаут,
|
||||
* отмена) — тоже отказ, потому что до сбежавших могли не дойти; `missing` —
|
||||
* артефакта нет.
|
||||
*/
|
||||
export function parseShardLogs(logs, knownIds) {
|
||||
const known = new Set(knownIds);
|
||||
@@ -158,11 +194,14 @@ export function parseShardLogs(logs, knownIds) {
|
||||
const unverifiable = [];
|
||||
const unparsed = [];
|
||||
const shards = [];
|
||||
for (const { shard, text } of logs) {
|
||||
for (const { shard, text, outcome } of logs) {
|
||||
if (text == null) { shards.push({ shard, status: 'missing' }); continue; }
|
||||
let failed = false;
|
||||
let summary = null;
|
||||
for (const raw of String(text).split('\n')) {
|
||||
const line = raw.replace(/\r$/, '');
|
||||
const asSummary = SUMMARY_LINE.exec(line);
|
||||
if (asSummary) { summary = { caught: Number(asSummary[1]), total: Number(asSummary[2]) }; continue; }
|
||||
if (!ANY_FAIL_LINE.test(line)) continue;
|
||||
failed = true;
|
||||
const asEscaped = ESCAPED_LINE.exec(line);
|
||||
@@ -181,7 +220,12 @@ export function parseShardLogs(logs, knownIds) {
|
||||
}
|
||||
unparsed.push({ shard, line });
|
||||
}
|
||||
shards.push({ shard, status: failed ? 'failed' : 'ok' });
|
||||
let status;
|
||||
if (failed) status = 'failed';
|
||||
else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';
|
||||
else if (summary.caught !== summary.total || (outcome !== undefined && outcome !== 'success')) status = 'failed';
|
||||
else status = 'ok';
|
||||
shards.push({ shard, status });
|
||||
}
|
||||
return {
|
||||
escaped: [...escaped].sort(),
|
||||
@@ -217,7 +261,10 @@ export function mutationGateReport(input) {
|
||||
lines.push('| шард | результат |');
|
||||
lines.push('|---|---|');
|
||||
for (const s of parsed.shards) {
|
||||
const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' : '**артефакт не пришёл**';
|
||||
const label = s.status === 'ok' ? 'ok'
|
||||
: s.status === 'failed' ? '**красный**'
|
||||
: s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**'
|
||||
: '**артефакт не пришёл**';
|
||||
lines.push(`| ${s.shard} | ${label} |`);
|
||||
}
|
||||
if (evidenceErrors.length) {
|
||||
@@ -270,6 +317,13 @@ export function mutationGateReport(input) {
|
||||
lines.push('');
|
||||
lines.push(`Артефакты шардов ${missing.map((s) => s.shard).join(', ')} не пришли — это отказ, а не отсутствие сбежавших.`);
|
||||
}
|
||||
const interrupted = parsed.shards.filter((s) => s.status === 'interrupted');
|
||||
if (interrupted.length) {
|
||||
lines.push('');
|
||||
lines.push(`Шарды ${interrupted.map((s) => s.shard).join(', ')} прерваны до итоговой строки \`поймано N из M\` — `
|
||||
+ 'timeout-minutes job или отмена прогона (#604). Сбежавших там не разобрано: до них могли не дойти. '
|
||||
+ 'Если это таймаут — реестр вырос, шардов не хватает.');
|
||||
}
|
||||
return {
|
||||
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
|
||||
body: `${lines.join('\n')}\n`,
|
||||
@@ -298,12 +352,12 @@ if (invokedDirectly) {
|
||||
return found ? found.slice(name.length + 3) : fallback;
|
||||
};
|
||||
const writeEvidence = value('write-evidence');
|
||||
const shardCount = Number(value('shards', '4'));
|
||||
const shardCount = Number(value('shards', '6'));
|
||||
if (writeEvidence) {
|
||||
const evidence = mutationShardEvidence({
|
||||
materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'),
|
||||
runId: value('run-id'), runAttempt: value('run-attempt'),
|
||||
shard: value('shard'), shardCount,
|
||||
shard: value('shard'), shardCount, outcome: value('outcome'),
|
||||
});
|
||||
mkdirSync(dirname(writeEvidence), { recursive: true });
|
||||
writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
||||
|
||||
@@ -3988,6 +3988,30 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if (false && evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);',
|
||||
}],
|
||||
},
|
||||
// #604: шард, снятый по таймауту, оставляет лог без строк FAIL и без итоговой
|
||||
// строки; отчёт называл его «ok», а агрегатор — доказанным.
|
||||
{
|
||||
id: 'mutation-report-truncated-log-is-ok',
|
||||
guard: 'node --test --test-name-pattern="#604: лог без итоговой строки" test/mutation-gate-report.test.mjs',
|
||||
because: 'a log cut by timeout-minutes has no FAIL lines only because the run never reached '
|
||||
+ 'them; calling it ok published "| 2 | ok |" for the cancelled shard of 21.09 (#604)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate-report.mjs',
|
||||
find: " else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';",
|
||||
replace: " else if (INTERRUPTED_OUTCOMES.has(outcome)) status = 'interrupted'; // mutant: no summary line still ok",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutation-evidence-ignores-cancelled-step',
|
||||
guard: 'node --test --test-name-pattern="#604: evidence несёт исход шага" test/mutation-gate-report.test.mjs',
|
||||
because: 'the evidence job proves completeness, not just identity: a shard whose gate step '
|
||||
+ 'was cancelled by the job timeout must not count towards a proven material (#604)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate-report.mjs',
|
||||
find: " if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }",
|
||||
replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }",
|
||||
}],
|
||||
},
|
||||
// #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем.
|
||||
{
|
||||
id: 'ledger-records-escaped',
|
||||
|
||||
@@ -68,7 +68,7 @@ test('сбежавшие собираются из нескольких шард
|
||||
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' },
|
||||
{ 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']);
|
||||
@@ -139,13 +139,56 @@ test('заголовок несёт постоянный маркер, тело
|
||||
|
||||
test('зелёный набор логов даёт failed=false (#472)', () => {
|
||||
const report = mutationGateReport({ ...meta, guards, logs: [
|
||||
{ shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n' },
|
||||
{ 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' },
|
||||
|
||||
@@ -305,6 +305,31 @@ test('#472 AC2 / #549: каждый шард сохраняет лог и identi
|
||||
assert.match(mutationWorkflow, /--write-evidence=.*evidence\.json/);
|
||||
});
|
||||
|
||||
// #604. Шард 2/4 снят по timeout-minutes: реестр вырос до 810 мутантов, ~203 на
|
||||
// шард, 55–61 мин при потолке 60. Шардов шесть, и делитель повторяется в
|
||||
// четырёх местах workflow — тест не даёт им разойтись при следующем делении.
|
||||
test('#604: делитель шардов ночного прогона один во всех местах workflow, шардов шесть', () => {
|
||||
const matrix = /shard: \[([0-9, ]+)\]/.exec(mutationWorkflow);
|
||||
assert.ok(matrix, 'matrix.shard объявлена списком');
|
||||
const shards = matrix[1].split(',').map((n) => Number(n.trim()));
|
||||
assert.deepEqual(shards, [1, 2, 3, 4, 5, 6]);
|
||||
const n = shards.length;
|
||||
assert.match(mutationWorkflow, new RegExp(`\\(шард \\$\\{\\{ matrix\\.shard \\}\\} из ${n}\\)`), 'имя job');
|
||||
assert.match(mutationWorkflow, new RegExp(`--shard=\\$\\{\\{ matrix\\.shard \\}\\}/${n} `), 'делитель раннера');
|
||||
// Только строки команд: комментарий про `--shards=6` — не место вызова.
|
||||
const shardsArgs = mutationWorkflow.split('\n').filter((line) => !/^\s*#/.test(line))
|
||||
.flatMap((line) => line.match(/--shards=\d+/g) || []);
|
||||
assert.equal(shardsArgs.length, 3, 'evidence шарда, агрегатор, отчёт');
|
||||
assert.ok(shardsArgs.every((arg) => arg === `--shards=${n}`), shardsArgs.join(' '));
|
||||
assert.ok(!/--shard=\$\{\{ matrix\.shard \}\}\/4\b|--shards=4\b|из 4\)/.test(mutationWorkflow), 'старый делитель 4 не остался');
|
||||
assert.match(mutationWorkflow, /timeout-minutes: 60/, 'потолок остаётся стражем от зависшего Chromium');
|
||||
});
|
||||
|
||||
test('#604: исход шага прогона едет в evidence шарда', () => {
|
||||
assert.match(mutationWorkflow, /- name: Каждый тест ловит свою поломку\n\s+id: gate\n/);
|
||||
assert.match(mutationWorkflow, /--write-evidence=[^\n]*\n(?:[^\n]*\n){5}\s+--outcome=\$\{\{ steps\.gate\.outcome \}\}/);
|
||||
});
|
||||
|
||||
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' \|\| needs\.evidence\.result != 'success'\)/);
|
||||
|
||||
Reference in New Issue
Block a user