mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 03:38:47 +00:00
ci: отчёт об отказе расписания мутантов — issue и Telegram
User-Visible: no Issue: #472
This commit is contained in:
@@ -31,8 +31,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
group: mutation-gate-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -88,5 +93,101 @@ jobs:
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Сохранить лог шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: mutants
|
||||
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
|
||||
@@ -61,20 +61,26 @@ jobs:
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
@@ -158,7 +164,7 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Отчёт об отказе полного мутационного прогона по расписанию (#472).
|
||||
*
|
||||
* Что случилось. Еженедельный прогон `mutation-gate.yml` дважды подряд не дал
|
||||
* зелёного результата (24.08 отменён, 31.08 красный во всех шардах), и никто
|
||||
* этого не открыл: у отказа не было адресата. Пять дней спустя ручной прогон
|
||||
* перед стабильной v1.72.0 остановил релиз теми же сбежавшими мутантами
|
||||
* (#465–#467). Механизм есть — его выход никто не читает.
|
||||
*
|
||||
* Этот модуль — чистая функция «логи шардов → отчёт» и тонкий CLI. Обвязка
|
||||
* (`gh issue`, `curl` в Telegram) остаётся в workflow, потому что у shell там
|
||||
* нет тестов, а у разбора логов — есть (урок #454).
|
||||
*
|
||||
* Две формы `FAIL` в логе раннера различаются явно:
|
||||
*
|
||||
* FAIL <mutant-id>: тест остался зелёным на сломанном коде → escaped
|
||||
* FAIL чистый прогон: <guard> красный без мутанта → redGuards
|
||||
*
|
||||
* Наивный парсер «id — это слово после FAIL» сделал бы из второй формы
|
||||
* мутанта по имени «чистый», которого в реестре нет, и команда `--id=чистый`
|
||||
* в письме не сработала бы. Поэтому id обязан существовать в реестре; всё
|
||||
* остальное уходит в `unparsed` с текстом как есть — потерять строку нельзя,
|
||||
* но и выдумывать из неё сущность тоже.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
|
||||
export const REPORT_TITLE_MARKER = '[mutation-gate] отказ прогона по расписанию';
|
||||
|
||||
const ESCAPED_LINE = /^FAIL (\S+): тест остался зелёным на сломанном коде\s*$/;
|
||||
const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный без мутанта\s*$/;
|
||||
const ANY_FAIL_LINE = /^FAIL /;
|
||||
|
||||
/**
|
||||
* Разобрать логи шардов.
|
||||
*
|
||||
* @param {Array<{ shard: number, text: string | null }>} logs — `text: null`
|
||||
* означает, что артефакт шарда не пришёл. Это тоже отказ: «лога нет» не
|
||||
* значит «сбежавших нет», это значит «мы не знаем».
|
||||
* @param {Set<string>|string[]} knownIds — id реестра.
|
||||
*/
|
||||
export function parseShardLogs(logs, knownIds) {
|
||||
const known = new Set(knownIds);
|
||||
const escaped = new Set();
|
||||
const redGuards = new Set();
|
||||
const unparsed = [];
|
||||
const shards = [];
|
||||
for (const { shard, text } of logs) {
|
||||
if (text == null) { shards.push({ shard, status: 'missing' }); continue; }
|
||||
let failed = false;
|
||||
for (const raw of String(text).split('\n')) {
|
||||
const line = raw.replace(/\r$/, '');
|
||||
if (!ANY_FAIL_LINE.test(line)) continue;
|
||||
failed = true;
|
||||
const asEscaped = ESCAPED_LINE.exec(line);
|
||||
if (asEscaped && known.has(asEscaped[1])) { escaped.add(asEscaped[1]); continue; }
|
||||
const asRed = RED_GUARD_LINE.exec(line);
|
||||
if (asRed) { redGuards.add(asRed[1]); continue; }
|
||||
unparsed.push({ shard, line });
|
||||
}
|
||||
shards.push({ shard, status: failed ? 'failed' : 'ok' });
|
||||
}
|
||||
return {
|
||||
escaped: [...escaped].sort(),
|
||||
redGuards: [...redGuards].sort(),
|
||||
unparsed,
|
||||
shards: shards.sort((a, b) => a.shard - b.shard),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать заголовок и тело issue.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {Array<{ shard: number, text: string | null }>} input.logs
|
||||
* @param {Map<string, string>|Record<string, string>} input.guards — id → guard
|
||||
* @param {string} input.runUrl
|
||||
* @param {string} input.ref
|
||||
* @param {string} input.sha
|
||||
* @param {string} input.date — ISO
|
||||
*/
|
||||
export function mutationGateReport(input) {
|
||||
const guards = input.guards instanceof Map ? input.guards : new Map(Object.entries(input.guards || {}));
|
||||
const parsed = parseShardLogs(input.logs || [], [...guards.keys()]);
|
||||
const failed = parsed.shards.some((s) => s.status !== 'ok')
|
||||
|| parsed.escaped.length > 0 || parsed.redGuards.length > 0 || parsed.unparsed.length > 0;
|
||||
const lines = [];
|
||||
lines.push(`Полный мутационный прогон по расписанию не прошёл: ${input.date}, \`${input.ref}\` @ \`${String(input.sha || '').slice(0, 12)}\`.`);
|
||||
lines.push(`Прогон: ${input.runUrl}`);
|
||||
lines.push('');
|
||||
lines.push('| шард | результат |');
|
||||
lines.push('|---|---|');
|
||||
for (const s of parsed.shards) {
|
||||
const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' : '**артефакт не пришёл**';
|
||||
lines.push(`| ${s.shard} | ${label} |`);
|
||||
}
|
||||
if (parsed.escaped.length) {
|
||||
lines.push('');
|
||||
lines.push(`## Сбежавшие мутанты (${parsed.escaped.length})`);
|
||||
lines.push('');
|
||||
lines.push('Тест остался зелёным на сломанном коде — свидетель разучился краснеть. Воспроизведение:');
|
||||
lines.push('');
|
||||
for (const id of parsed.escaped) {
|
||||
lines.push(`- \`${id}\` — \`node scripts/mutation-gate.mjs --id=${id}\``);
|
||||
const guard = guards.get(id);
|
||||
if (guard) lines.push(` guard: \`${guard}\``);
|
||||
}
|
||||
}
|
||||
if (parsed.redGuards.length) {
|
||||
lines.push('');
|
||||
lines.push(`## Гарды, красные без мутанта (${parsed.redGuards.length})`);
|
||||
lines.push('');
|
||||
lines.push('Это не сбежавший мутант: тест падает и на исправном коде, доказать им ничего нельзя. Команда как есть:');
|
||||
lines.push('');
|
||||
for (const guard of parsed.redGuards) lines.push(`- \`${guard}\``);
|
||||
}
|
||||
if (parsed.unparsed.length) {
|
||||
lines.push('');
|
||||
lines.push(`## Неразобранные строки FAIL (${parsed.unparsed.length})`);
|
||||
lines.push('');
|
||||
for (const { shard, line } of parsed.unparsed) lines.push(`- шард ${shard}: \`${line}\``);
|
||||
}
|
||||
const missing = parsed.shards.filter((s) => s.status === 'missing');
|
||||
if (missing.length) {
|
||||
lines.push('');
|
||||
lines.push(`Артефакты шардов ${missing.map((s) => s.shard).join(', ')} не пришли — это отказ, а не отсутствие сбежавших.`);
|
||||
}
|
||||
return {
|
||||
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
|
||||
body: `${lines.join('\n')}\n`,
|
||||
failed,
|
||||
...parsed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Короткий текст для Telegram: заголовок, сбежавшие, ссылка. */
|
||||
export function telegramSummary(report, issueUrl) {
|
||||
const head = `⛔ houseplan-card: ${REPORT_TITLE_MARKER}`;
|
||||
const escaped = report.escaped.length
|
||||
? `сбежали: ${report.escaped.slice(0, 8).join(', ')}${report.escaped.length > 8 ? ` +${report.escaped.length - 8}` : ''}`
|
||||
: 'сбежавших не разобрано';
|
||||
const shards = report.shards.filter((s) => s.status !== 'ok').map((s) => `${s.shard}:${s.status}`).join(' ');
|
||||
return `${head}\n${escaped}\nшарды: ${shards || '—'}\n${issueUrl}`;
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
||||
if (invokedDirectly) {
|
||||
const argv = process.argv.slice(2);
|
||||
const value = (name, fallback = '') => {
|
||||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||||
return found ? found.slice(name.length + 3) : fallback;
|
||||
};
|
||||
const shardCount = Number(value('shards', '4'));
|
||||
const dir = value('logs', 'artifacts/mutation-logs');
|
||||
const logs = [];
|
||||
for (let shard = 1; shard <= shardCount; shard++) {
|
||||
const path = `${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`;
|
||||
logs.push({ shard, text: existsSync(path) ? readFileSync(path, 'utf8') : null });
|
||||
}
|
||||
const { MUTANTS } = await import('./mutation-gate.mjs');
|
||||
const guards = new Map(MUTANTS.map((m) => [m.id, m.guard]));
|
||||
const report = mutationGateReport({
|
||||
logs, guards,
|
||||
runUrl: value('run-url'), ref: value('ref', 'dev'), sha: value('sha'),
|
||||
date: value('date', new Date().toISOString().slice(0, 10)),
|
||||
});
|
||||
const bodyPath = value('body-out', 'artifacts/mutation-report.md');
|
||||
writeFileSync(bodyPath, report.body, 'utf8');
|
||||
const summaryPath = value('telegram-out', 'artifacts/mutation-telegram.txt');
|
||||
writeFileSync(summaryPath, telegramSummary(report, value('issue-url', '(issue)')), 'utf8');
|
||||
console.log(`title=${report.title}`);
|
||||
console.log(`marker=${REPORT_TITLE_MARKER}`);
|
||||
console.log(`body=${bodyPath}`);
|
||||
console.log(`escaped=${report.escaped.join(',')}`);
|
||||
console.log(`failed=${report.failed}`);
|
||||
}
|
||||
@@ -2839,6 +2839,40 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if (true) return null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutation-report-drops-missing-shard',
|
||||
guard: 'node --test --test-name-pattern="отсутствующий лог шарда" test/mutation-gate-report.test.mjs',
|
||||
because: 'a shard whose artifact never arrived is an unknown, not a clean shard — '
|
||||
+ 'treating it as ok would let a whole quarter of the registry escape silently (#472)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate-report.mjs',
|
||||
find: " if (text == null) { shards.push({ shard, status: 'missing' }); continue; }",
|
||||
replace: " if (text == null) { shards.push({ shard, status: 'ok' }); continue; }",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutation-report-duplicates-escaped',
|
||||
guard: 'node --test --test-name-pattern="без дублей и по порядку" test/mutation-gate-report.test.mjs',
|
||||
because: 'the same mutant reported by two shards must be one line with one command, '
|
||||
+ 'otherwise the report double-counts and the owner chases a phantom (#472)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate-report.mjs',
|
||||
find: " escaped: [...escaped].sort(),",
|
||||
replace: " escaped: logs.flatMap(() => [...escaped]).sort(),",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutation-report-red-guard-as-mutant',
|
||||
guard: 'node --test --test-name-pattern="красный гард без мутанта" test/mutation-gate-report.test.mjs',
|
||||
because: 'runCleanGuards prints `FAIL чистый прогон: …` into the same log; a parser that '
|
||||
+ 'takes the word after FAIL invents a mutant named «чистый» whose --id command does '
|
||||
+ 'not exist (#472, spec review r1)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate-report.mjs',
|
||||
find: " if (asEscaped && known.has(asEscaped[1])) { escaped.add(asEscaped[1]); continue; }",
|
||||
replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-comment-source-ignores-issue-number',
|
||||
guard: 'node --test --test-name-pattern="по документу ЭТОЙ задачи|чужой номер задачи" '
|
||||
|
||||
@@ -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