From e76f3909d969c4a95c8f2b4e2ba0ec2bdd1bbe13 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 6 Sep 2026 14:10:02 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=BE?= =?UTF-8?q?=D0=B1=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B5=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BC=D1=83?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=82=D0=BE=D0=B2=20=E2=80=94=20issue=20?= =?UTF-8?q?=D0=B8=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Visible: no Issue: #472 --- .github/workflows/mutation-gate.yml | 105 ++++++++++++++++- .github/workflows/validate.yml | 28 +++-- scripts/mutation-gate-report.mjs | 177 ++++++++++++++++++++++++++++ scripts/mutation-gate.mjs | 34 ++++++ test/mutation-gate-report.test.mjs | 99 ++++++++++++++++ test/mutation-gate.test.mjs | 49 ++++++++ 6 files changed, 479 insertions(+), 13 deletions(-) create mode 100755 scripts/mutation-gate-report.mjs create mode 100644 test/mutation-gate-report.test.mjs diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index fd3a9706..b8e9caf8 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -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 : тест остался зелёным…` — единственное место, где названо, + # ЧТО сбежало. Без артефакта отказ безымянный. `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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 36ee82bc..cdaeecc2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/scripts/mutation-gate-report.mjs b/scripts/mutation-gate-report.mjs new file mode 100755 index 00000000..f7c9e5d7 --- /dev/null +++ b/scripts/mutation-gate-report.mjs @@ -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 : тест остался зелёным на сломанном коде → escaped + * FAIL чистый прогон: красный без мутанта → 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[]} 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|Record} 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}`); +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index f618ba4c..c6fd4d54 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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="по документу ЭТОЙ задачи|чужой номер задачи" ' diff --git a/test/mutation-gate-report.test.mjs b/test/mutation-gate-report.test.mjs new file mode 100644 index 00000000..0e65ad99 --- /dev/null +++ b/test/mutation-gate-report.test.mjs @@ -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, []); +}); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 8c367a2e..d83b2139 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -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/); +});