mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
Thirteen test harnesses executed workflow `run:` bodies with their own bash
flags. Four of them used `bash -eo pipefail` "as in Actions", but the runner
executes a step without `shell:` as `bash -e {0}`: pipefail comes only from an
explicit `shell: bash` or from `set -o pipefail` in the body. The harness
supplied protection the step did not have, so a step that lost its pipefail
stayed green in tests (#729, #737, #751); the reverse also happened - the
#793 guard test was red only because of the harness flag.
test/helpers/workflow-step.mjs resolves the shell like the runner (step ->
jobs.<id>.defaults.run.shell -> workflow defaults.run.shell -> unset), maps it
to the runner's command lines (unset -> `bash -e {0}`, bash -> `bash
--noprofile --norc -e -o pipefail {0}`, sh, python, custom templates with
{0}), writes the body to a file and executes it by path. Unsupported YAML or
shells are refused loudly instead of guessed. All step-executing tests now go
through runStep(findStep(...)).
Witnesses: a step whose left pipeline side fails is green without a shell
(negative test) and red with `shell: bash`, job defaults or `set -o pipefail`;
the #751 executed test now also shows that the same real steps without their
pipefail line stay green, i.e. the test sees a removed pipefail; a guard fails
on any test that runs a step with its own bash flags. TESTING.md rule 7 names
the helper.
Issue: #766
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
208 lines
14 KiB
JavaScript
208 lines
14 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
import { findStep, runStep as runnerStep } from './helpers/workflow-step.mjs';
|
||
|
||
// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его
|
||
// исход — успешный dispatch не равен успешной проверке.
|
||
|
||
const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8');
|
||
|
||
test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => {
|
||
const nightly = read('_nightly.yml');
|
||
assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
|
||
// найти именно свой прогон: dispatch на dev, созданный не раньше запуска
|
||
assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/);
|
||
assert.match(nightly, /--event workflow_dispatch/);
|
||
assert.match(nightly, /createdAt >= /);
|
||
// отсутствие прогона — ошибка, не тихий успех
|
||
assert.match(nightly, /прогон Validate не появился[^\n]*\n\s+exit 1/);
|
||
// ждать с наследованием кода возврата
|
||
assert.match(nightly, /gh run watch "\$run_id" --repo "\$REPO" --exit-status/);
|
||
assert.match(nightly, /timeout-minutes: 90/);
|
||
assert.match(nightly, /set -euo pipefail/);
|
||
});
|
||
|
||
test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => {
|
||
const nightly = read('_nightly.yml');
|
||
assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/);
|
||
assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом');
|
||
});
|
||
|
||
// ---------- #727 К6: ночное пакетное ревью ship после Validate ----------
|
||
|
||
/** Блок job верхнего уровня `jobs:` по имени. */
|
||
function job(text, name) {
|
||
const start = text.indexOf(`\n ${name}:\n`);
|
||
assert.ok(start >= 0, `job ${name}`);
|
||
const rest = text.slice(start + 1);
|
||
const end = rest.slice(1).search(/\n [a-z_]+:\n/);
|
||
return end < 0 ? rest : rest.slice(0, end + 1);
|
||
}
|
||
|
||
/** Тело `run:` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */
|
||
function stepRun(text, name) {
|
||
const start = text.indexOf(` - name: "${name}"\n`);
|
||
assert.ok(start >= 0, `шаг «${name}»`);
|
||
const lines = text.slice(start).split('\n');
|
||
const from = lines.indexOf(' run: |');
|
||
assert.ok(from > 0, `у шага «${name}» есть run: |`);
|
||
const body = [];
|
||
for (const line of lines.slice(from + 1)) {
|
||
if (line.trim() && !/^ {10}/.test(line)) break;
|
||
body.push(line.replace(/^ {10}/, ''));
|
||
}
|
||
return body.join('\n').replace(/\$\{\{ github\.server_url \}\}/g, 'https://github.com');
|
||
}
|
||
|
||
/** Шаг для исполнения (#766): разобранный шаг — его shell по правилам раннера — и тело. */
|
||
const runnable = (text, name) => ({ step: findStep(text, ` - name: "${name}"\n`), script: stepRun(text, name) });
|
||
|
||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
|
||
|
||
/**
|
||
* Шаг на настоящем bash с подменённым `gh`: `workflow run` — в журнал (или отказ
|
||
* при FAKE_DISPATCH=fail), `run list --jq …` — FAKE_RUN_ID (то, что вернул бы
|
||
* фильтр), `run view --jq .headSha` — FAKE_HEAD, `run watch` — код FAKE_WATCH.
|
||
*/
|
||
function runStep(t, { step, script }, env = {}) {
|
||
const root = mkdtempSync(join(tmpdir(), 'hp-727-night-'));
|
||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||
const bin = join(root, 'bin');
|
||
mkdirSync(bin);
|
||
writeFileSync(join(bin, 'gh'), [
|
||
'#!/usr/bin/env bash',
|
||
'echo "gh $*" >> "$FAKE_LOG"',
|
||
'case "$1 $2" in',
|
||
' "workflow run") [ "${FAKE_DISPATCH:-ok}" = ok ] || { echo "HTTP 403" >&2; exit 1; } ;;',
|
||
' "run list") printf "%s\\n" "${FAKE_RUN_ID:-}" ;;',
|
||
' "run view") printf "%s\\n" "$FAKE_HEAD" ;;',
|
||
' "run watch") exit "${FAKE_WATCH:-0}" ;;',
|
||
' *) echo "unexpected gh $*" >&2; exit 1 ;;',
|
||
'esac',
|
||
'',
|
||
].join('\n'), { mode: 0o755 });
|
||
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "sleep $*" >> "$FAKE_LOG"\n', { mode: 0o755 });
|
||
const files = { log: join(root, 'log'), output: join(root, 'output'), summary: join(root, 'summary.md') };
|
||
const r = runnerStep(step, script, {
|
||
encoding: 'utf8',
|
||
env: {
|
||
...process.env, PATH: `${bin}:${process.env.PATH}`, REPO: 'o/r', GH_TOKEN: 'x',
|
||
FAKE_LOG: files.log, GITHUB_OUTPUT: files.output, GITHUB_STEP_SUMMARY: files.summary, ...env,
|
||
},
|
||
});
|
||
const text = (path) => { try { return readFileSync(path, 'utf8'); } catch { return ''; } };
|
||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, log: text(files.log).split('\n').filter(Boolean), output: text(files.output), summary: text(files.summary) };
|
||
}
|
||
|
||
const HEAD = 'abcdef0123456789abcdef0123456789abcdef01';
|
||
|
||
test('#727 AC6 К6: ночь после Validate при любом его исходе запускает ship-ревью на SHA прогона Validate', () => {
|
||
const nightly = read('_nightly.yml');
|
||
const dispatch = job(nightly, 'dispatch');
|
||
const ship = job(nightly, 'ship_review');
|
||
assert.match(ship, /\n needs: dispatch\n/);
|
||
assert.match(ship, /\n if: always\(\)\n/, 'при любом исходе Validate');
|
||
assert.match(ship, /\n continue-on-error: true\n/, 'цвет ночи — цвет Validate');
|
||
assert.match(ship, /gh workflow run ship-review\.yml --ref dev -f tag=nightly -f candidate="\$CANDIDATE"/);
|
||
assert.match(ship, /CANDIDATE: \$\{\{ needs\.dispatch\.outputs\.head_sha \}\}/, 'SHA — из прогона Validate');
|
||
assert.doesNotMatch(ship, /gh run watch/, 'ждёт только появления прогона, не конца');
|
||
assert.match(ship, /for _ in \$\(seq 1 18\); do\n\s+sleep 10/, 'до трёх минут, как у Validate');
|
||
assert.doesNotMatch(ship, /permissions:/, 'права — ночи (actions: write), тонкий файл не меняется');
|
||
// SHA прогона Validate выводится отдельным шагом до ожидания.
|
||
assert.match(dispatch, /head_sha: \$\{\{ steps\.validate\.outputs\.head_sha \}\}/);
|
||
const found = dispatch.indexOf(' - name: "Запустить Validate и найти его прогон"\n id: validate\n');
|
||
const watch = dispatch.indexOf(' - name: "Дождаться Validate"');
|
||
assert.ok(found > 0 && watch > found, 'вывод SHA — до шага ожидания');
|
||
assert.match(stepRun(nightly, 'Запустить Validate и найти его прогон'), /head_sha=\$\(gh run view "\$run_id" --repo "\$REPO" --json headSha --jq \.headSha\)\necho "run_id=\$run_id" >> "\$GITHUB_OUTPUT"\necho "head_sha=\$head_sha" >> "\$GITHUB_OUTPUT"/);
|
||
assert.doesNotMatch(stepRun(nightly, 'Запустить Validate и найти его прогон'), /gh run watch/);
|
||
for (const name of ['Запустить Validate и найти его прогон', 'Дождаться Validate', 'Запустить ship-ревью и дождаться появления прогона']) {
|
||
const body = stepRun(nightly, name);
|
||
assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, `bash -n: ${name}`);
|
||
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${name}: heredoc в run`);
|
||
}
|
||
});
|
||
|
||
test('#727 AC6 на настоящем bash: SHA прогона Validate — в выходах до ожидания; красный Validate — красная job ожидания', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const nightly = read('_nightly.yml');
|
||
const found = runStep(t, runnable(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '42', FAKE_HEAD: HEAD });
|
||
assert.equal(found.status, 0, found.stderr);
|
||
assert.match(found.output, /^run_id=42$/m);
|
||
assert.match(found.output, new RegExp(`^head_sha=${HEAD}$`, 'm'));
|
||
assert.ok(found.log.includes('gh workflow run validate.yml --repo o/r --ref dev -f full=true'));
|
||
assert.ok(!found.log.some((call) => call.startsWith('gh run watch')), 'шаг вывода не ждёт');
|
||
const red = runStep(t, runnable(nightly, 'Дождаться Validate'), { RUN_ID: '42', FAKE_WATCH: '1' });
|
||
assert.equal(red.status, 1, 'красный Validate — красная ночь');
|
||
assert.ok(red.log.includes('gh run watch 42 --repo o/r --exit-status --interval 30'));
|
||
const lost = runStep(t, runnable(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '' });
|
||
assert.equal(lost.status, 1);
|
||
assert.match(lost.stdout, /::error::прогон Validate не появился за 3 минуты/);
|
||
});
|
||
|
||
test('#727 AC6 на настоящем bash: ship-ревью ночью — dispatch с tag=nightly, ждёт только появления; сбой — предупреждение', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const step = runnable(read('_nightly.yml'), 'Запустить ship-ревью и дождаться появления прогона');
|
||
const ok = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '77' });
|
||
assert.equal(ok.status, 0, ok.stderr);
|
||
assert.equal(ok.log[0], `gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate=${HEAD} --repo o/r`);
|
||
assert.ok(ok.log.some((call) => call.startsWith('gh run list --repo o/r --workflow ship-review.yml --branch dev --event workflow_dispatch')));
|
||
assert.ok(!ok.log.some((call) => call.startsWith('gh run watch')), 'конца прогона не ждёт');
|
||
assert.equal(ok.summary, `- Ship-ревью ночью (\`${HEAD}\`): https://github.com/o/r/actions/runs/77\n`);
|
||
// Прогон не появился за три минуты — предупреждение, не красная ночь.
|
||
const late = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '' });
|
||
assert.equal(late.status, 0);
|
||
assert.equal(late.log.filter((call) => call === 'sleep 10').length, 18);
|
||
assert.match(late.stdout, /::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится/);
|
||
// Dispatch отклонён — предупреждение; SHA нет (Validate не появился) — dispatch не делается.
|
||
const refused = runStep(t, step, { CANDIDATE: HEAD, FAKE_DISPATCH: 'fail' });
|
||
assert.equal(refused.status, 0);
|
||
assert.match(refused.stdout, /::warning::ship-ревью ночью не запущено: dispatch отклонён/);
|
||
const empty = runStep(t, step, { CANDIDATE: '' });
|
||
assert.equal(empty.status, 0);
|
||
assert.deepEqual(empty.log, []);
|
||
assert.match(empty.stdout, /::warning::нет SHA прогона Validate — ночное ship-ревью не запущено/);
|
||
});
|
||
|
||
// ---------- #736: красная ночь — комментарий в задачи диапазона ----------
|
||
|
||
test('#736 AC3: night_red после dispatch только при красном Validate с прогоном; права — чтение, issue — HP_PROCESS_TOKEN', () => {
|
||
const nightly = read('_nightly.yml');
|
||
const dispatch = job(nightly, 'dispatch');
|
||
const red = job(nightly, 'night_red');
|
||
// Прогон Validate — выход dispatch тем же шагом, что SHA, до ожидания.
|
||
assert.match(dispatch, /\n run_id: \$\{\{ steps\.validate\.outputs\.run_id \}\}\n/);
|
||
assert.match(red, /\n needs: dispatch\n/);
|
||
assert.match(red, /\n if: always\(\) && needs\.dispatch\.result == 'failure' && needs\.dispatch\.outputs\.run_id != ''\n/,
|
||
'только красный dispatch с найденным прогоном');
|
||
assert.match(red, /\n continue-on-error: true\n/, 'цвет ночи — цвет Validate');
|
||
assert.match(red, /\n permissions:\n actions: read\n contents: read\n steps:\n/, 'права job — только чтение');
|
||
assert.equal((red.match(/permissions:/g) || []).length, 1);
|
||
// Checkout dev со всей историей без блобов; скрипту npm ci не нужен.
|
||
assert.match(red, /uses: actions\/checkout@[0-9a-f]{40} # v\d+\n with:\n ref: dev\n fetch-depth: 0\n filter: blob:none\n persist-credentials: false\n/);
|
||
assert.match(red, /uses: actions\/setup-node@[0-9a-f]{40} # v\d+\n with:\n node-version: 22\n/);
|
||
assert.doesNotMatch(red.replace(/^\s*#.*$/gm, ''), /npm (ci|install)/);
|
||
// Actions — токеном ночи, issue — токеном процесса.
|
||
assert.match(red, /\n ACTIONS_TOKEN: \$\{\{ github\.token \}\}\n/);
|
||
assert.match(red, /\n GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}\n/);
|
||
assert.match(red, /\n RED_RUN: \$\{\{ needs\.dispatch\.outputs\.run_id \}\}\n/);
|
||
const body = stepRun(nightly, 'Комментарий в задачи диапазона от последней зелёной ночи до красной');
|
||
assert.match(body, /node scripts\/night-red\.mjs --repo="\$REPO" --red-run="\$RED_RUN"/);
|
||
assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, 'bash -n');
|
||
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, 'heredoc в run');
|
||
// Канон: адресат сигнала в шапке и абзац в PROCESS.md §10.4.
|
||
assert.match(nightly.slice(0, nightly.indexOf('\nname:')), /Адресат сигнала \(#736\)[\s\S]*night_red/);
|
||
const canon = readFileSync(new URL('../PROCESS.md', import.meta.url), 'utf8');
|
||
const section = canon.slice(canon.indexOf('### 10.4 '), canon.indexOf('\n## 11. '));
|
||
const paragraph = section.slice(section.indexOf('**Красная ночь** (#736)'));
|
||
assert.ok(section.includes('**Красная ночь** (#736)'), 'абзац в §10.4');
|
||
for (const key of ['`night_red`', '`scripts/night-red.mjs`', 'conclusion: failure', '`ci-proof`', '`release`', '--no-merges',
|
||
'`Release:`', '`hp:night-red', '`HP_PROCESS_TOKEN`', 'серия']) {
|
||
assert.ok(paragraph.slice(0, paragraph.indexOf('\n\n')).includes(key), `абзац называет ${key}`);
|
||
}
|
||
});
|