Files
houseplan-card/test/workflow-step.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

164 lines
9.7 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.
// #766: обвязка исполнения шагов workflow — shell как у раннера, без своих флагов.
//
// Тесты исполнения шагов гоняли тела `run:` под `bash -eo pipefail`, а раннер
// исполняет шаг без `shell:` под `bash -e {0}`. Защиту от упавшей левой части
// конвейера давала обвязка, а не шаг, и пропущенный в шаге pipefail тесты не
// видели. Здесь держится сама обвязка (test/helpers/workflow-step.mjs): выбор
// shell (шаг → job → workflow → не задан), команды раннера, исполнение файлом —
// и то, что ни один тест не исполняет шаг своими флагами bash.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findStep, runStep, runnerArgv, workflowSteps } from './helpers/workflow-step.mjs';
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const TESTS = fileURLToPath(new URL('.', import.meta.url));
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
const WORKFLOW = [
'name: x',
'on: push',
'defaults:',
' run:',
' shell: sh',
'jobs:',
' a:',
' defaults:',
' run:',
" shell: 'bash'",
' steps:',
' - name: own',
' shell: bash -x {0}',
' run: echo own',
' # комментарий между шагами',
' - name: from job',
' run: |',
' echo one',
'',
' echo two',
' - uses: actions/checkout@v4',
' b:',
' steps:',
' - id: from-workflow',
' run: >-',
' echo folded',
' line',
' - run: "echo \\"quoted\\""',
'',
].join('\n');
test('#766: shell шага — свой, иначе defaults job, иначе defaults workflow; соседний шаг не влияет', () => {
const steps = workflowSteps(WORKFLOW, 'синтетика');
assert.deepEqual(steps.map((s) => [s.job, s.name ?? s.id, s.shell, s.shellFrom]), [
['a', 'own', 'bash -x {0}', 'step'],
['a', 'from job', 'bash', 'job'],
['a', null, 'bash', 'job'],
['b', 'from-workflow', 'sh', 'workflow'],
['b', null, 'sh', 'workflow'],
]);
assert.deepEqual(steps.map((s) => s.run), ['echo own', 'echo one\n\necho two\n', null, 'echo folded line', 'echo "quoted"']);
// Без defaults — не задан; `shell:` соседа не переносится.
const plain = 'jobs:\n a:\n steps:\n - name: x\n shell: bash\n run: a\n - name: y\n run: b\n';
assert.deepEqual(workflowSteps(plain).map((s) => [s.name, s.shell, s.shellFrom]), [['x', 'bash', 'step'], ['y', null, null]]);
// Шаг ищется по подстроке (как маркеры тестов) и по полю.
assert.equal(findStep(WORKFLOW, ' echo two').name, 'from job');
assert.equal(findStep(WORKFLOW, { id: 'from-workflow' }).shell, 'sh');
assert.throws(() => findStep(WORKFLOW, ' b:\n'), /вне шагов/);
// Чего разбор не понимает — отказ, а не догадка.
assert.throws(() => workflowSteps('jobs:\n a:\n steps:\n - run: { x: 1 }\n'), /потоковый узел/);
assert.throws(() => workflowSteps('defaults: { run: { shell: bash } }\njobs: {}\n'), /defaults в одну строку/);
});
test('#766: команды раннера — bash -e без pipefail по умолчанию, pipefail только у shell: bash', () => {
const f = '/t/step.sh';
assert.deepEqual(runnerArgv(null, f), ['bash', '-e', f], 'не задан: bash -e {0}');
assert.deepEqual(runnerArgv('bash', f), ['bash', '--noprofile', '--norc', '-e', '-o', 'pipefail', f]);
assert.deepEqual(runnerArgv('sh', f), ['sh', '-e', f]);
assert.deepEqual(runnerArgv('python', f), ['python', f]);
assert.deepEqual(runnerArgv('bash -e {0}', f), ['bash', '-e', f], 'свой шаблон — как написан');
assert.deepEqual(runnerArgv('perl -w {0} --x', f), ['perl', '-w', f, '--x']);
assert.throws(() => runnerArgv('zsh', f), /без \{0\}/, 'не встроенный без {0} раннер отвергает');
assert.throws(() => runnerArgv('pwsh', f), /обвязка его не исполняет/);
assert.throws(() => runnerArgv("bash -c '{0}'", f), /кавычки/);
});
test('#766: левая часть конвейера падает — шаг без shell зелёный, как у раннера; pipefail даёт только шаг', (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
const dir = mkdtempSync(join(tmpdir(), 'hp-766-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const env = { ...process.env, OUT: join(dir, 'out') };
const body = 'echo before\nfalse | tee "$OUT"\necho after\n';
const wf = (stepExtra = '', jobExtra = '') => `jobs:\n a:\n${jobExtra} steps:\n - name: pipe\n${stepExtra} run: |\n${
body.trimEnd().split('\n').map((line) => ` ${line}`).join('\n')}\n`;
const run = (text) => runStep(findStep(text, { name: 'pipe' }), undefined, { env });
// Отрицательный тест: раннер исполнит это как `bash -e` — код конвейера = код
// tee, шаг зелёный. Обвязка, добавившая pipefail, покажет здесь красный.
const silent = run(wf());
assert.equal(silent.status, 0, `обвязка добавила pipefail к шагу без shell:\n${silent.stderr}`);
assert.equal(silent.stdout, 'before\nafter\n', 'шаг дошёл до конца');
for (const [label, text] of [
['shell: bash шага', wf(' shell: bash\n')],
['defaults.run.shell: bash job', wf('', ' defaults:\n run:\n shell: bash\n')],
['set -o pipefail в теле', wf().replace(' echo before', ' set -o pipefail\n echo before')],
]) {
const r = run(text);
assert.notEqual(r.status, 0, `${label}: упавшая левая часть должна ронять шаг`);
assert.doesNotMatch(r.stdout, /after/, label);
}
// `-e` у шага по умолчанию есть: простая упавшая команда шаг останавливает.
const errexit = runStep({ shell: null }, 'false\necho after\n', { env });
assert.notEqual(errexit.status, 0);
assert.equal(errexit.stdout, '');
// Тело исполняется файлом, как `{0}` у раннера, а не через -c.
const file = runStep({ shell: null }, 'echo "$0"\n', { env });
assert.match(file.stdout, /step\.sh\n$/);
});
test('#766: каждый шаг каждого workflow разбирается и исполним обвязкой', () => {
let runs = 0;
for (const name of readdirSync(WORKFLOWS).filter((file) => /\.ya?ml$/.test(file)).sort()) {
for (const step of workflowSteps(readFileSync(join(WORKFLOWS, name), 'utf8'), name)) {
if (step.run === null) continue;
runs += 1;
assert.doesNotThrow(() => runnerArgv(step.shell, '/t/step.sh'), `${name}:${step.line}`);
}
}
// Свидетель разбора: тел `run:` — сотни, не ноль.
assert.ok(runs > 150, `тел run: ${runs}`);
});
/** Вызовы bash в тесте, которые исполняют тело своими флагами, а не обвязкой. */
function ownBashRuns(source) {
const found = [];
for (const m of source.matchAll(/\b(?:spawnSync|spawn|execFileSync|execFile)\(\s*'bash',\s*\[([^\]]*)\]/g)) {
const args = m[1];
if (/'-n'/.test(args)) continue; // bash -n — проверка синтаксиса, не исполнение
const flags = /'(?:-e|-eo|-o|--noprofile|--norc|pipefail)'/.test(args);
const computed = /'-c',\s*(?![\s'])/.test(args); // -c с вычисленным телом, а не литералом
if (flags || computed) found.push(m[0].replace(/\s+/g, ' '));
}
return found;
}
test('#766: ни один тест не исполняет тело своими флагами bash — только через обвязку', () => {
assert.deepEqual(ownBashRuns("spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {"), [
"spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script]",
]);
assert.equal(ownBashRuns("spawnSync('bash', ['-c', script])").length, 1, 'bash -c без -e — тоже не раннер');
assert.equal(ownBashRuns("execFileSync('bash', ['-eo', 'pipefail', '-c', `x | y`])").length, 1);
assert.equal(ownBashRuns("spawnSync('bash', ['-n', '-c', body])").length, 0, 'bash -n — синтаксис');
assert.equal(ownBashRuns("spawnSync('bash', ['-c', 'command -v git'])").length, 0, 'литерал теста — не шаг');
assert.equal(ownBashRuns("spawnSync('bash', [STAND, stand])").length, 0, 'скрипт по пути — не шаг');
// Этот файл несёт образцы нарушений в строках — его не сканируем.
const offenders = readdirSync(TESTS).filter((name) => name.endsWith('.test.mjs') && name !== 'workflow-step.test.mjs').sort()
.flatMap((name) => ownBashRuns(readFileSync(join(TESTS, name), 'utf8')).map((call) => `${name}: ${call}`));
assert.deepEqual(offenders, [], 'шаг исполнять runStep(findStep(…)) из test/helpers/workflow-step.mjs');
});