Files
houseplan-card/test/nightly-workflow.test.mjs
T
Claudeandclaude[bot] cb97274ea7 test(harness): run workflow steps the way the runner does (#766)
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
2026-10-06 23:05:58 +00:00

208 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
}
});