Applied as-is. tools/ci.mjs runs the checks already listed in .github/workflows/ci.yml, in that order, and skips checkout, setup-node and npm ci. Children are spawned with process.execPath, not a shell. test/ci.test.js keeps the step list, smoke script, job env and package.json scripts in step with the workflow. Tests: node --test test/ci.test.js — 8 pass, 0 fail. npm run ci was not run (it installs and runs the suite).
150 lines
8.3 KiB
JavaScript
150 lines
8.3 KiB
JavaScript
// test/ci.test.js — tools/ci.mjs (the local run of .github/workflows/ci.yml): its step list is ci.yml's, the reader of
|
|
// the workflow, the arguments, the env and the summary. Nothing here boots a server or runs a step.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
CI_TEST_ENV, ROOT, SKIPPED_CI_STEPS, SMOKE_PATHS, STEPS, binPath, formatDuration, formatSummary, isSkippedCiStep,
|
|
parseArgs, parseWorkflow, selectSteps, stepEnv,
|
|
} from '../tools/ci.mjs';
|
|
|
|
const workflow = parseWorkflow(fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'));
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
|
|
/** ci.yml's smoke run block as smoke() in tools/ci.mjs mirrors it (lines trimmed, comments dropped). */
|
|
const SMOKE_CI_RUN = [
|
|
'node server/index.js > server.log 2>&1 &',
|
|
'pid=$!',
|
|
'ok=',
|
|
'for i in $(seq 1 30); do',
|
|
'if curl -fsS http://127.0.0.1:3000/healthz; then ok=1; break; fi',
|
|
'sleep 1',
|
|
'done',
|
|
'echo',
|
|
'[ -n "$ok" ] || { cat server.log; exit 1; }',
|
|
'curl -fsS -o index.out http://127.0.0.1:3000/',
|
|
"grep -qi '<html' index.out",
|
|
'curl -fsS -o /dev/null http://127.0.0.1:3000/vendor/pixi.min.js',
|
|
'node tools/doctor.mjs || true',
|
|
'kill $pid || true',
|
|
];
|
|
|
|
test('every ci.yml step is mirrored by a tools/ci.mjs step or skipped on purpose, and none is invented', () => {
|
|
const ciSteps = Object.entries(workflow.jobs).flatMap(([job, j]) => j.steps.map((s) => ({ job, ...s })));
|
|
assert.ok(ciSteps.length >= 6, 'ci.yml was read');
|
|
const unknown = ciSteps.filter((s) => !isSkippedCiStep(s) && !STEPS.some((l) => l.ci.job === s.job && l.ci.name === s.name));
|
|
assert.deepEqual(unknown.map((s) => `${s.job}: ${s.name || s.uses}`), [], 'ci.yml steps tools/ci.mjs does not run');
|
|
for (const l of STEPS) {
|
|
assert.ok(ciSteps.some((s) => s.job === l.ci.job && s.name === l.ci.name), `${l.id} mirrors no ci.yml step (${l.ci.job}: ${l.ci.name})`);
|
|
}
|
|
for (const k of SKIPPED_CI_STEPS) assert.ok(ciSteps.some((s) => isSkippedCiStep(s) && (k.uses ? s.uses?.startsWith(k.uses) : s.name === k.name)), `stale skip ${k.uses || k.name}`);
|
|
// the local order is the workflow's (job test, then job tooling)
|
|
const order = ciSteps.filter((s) => !isSkippedCiStep(s)).map((s) => STEPS.find((l) => l.ci.job === s.job && l.ci.name === s.name).id);
|
|
assert.deepEqual(order, STEPS.map((s) => s.id));
|
|
});
|
|
|
|
test('the commands match ci.yml: the run lines, the npm scripts ci.yml calls, the smoke script, the job env, no step config', () => {
|
|
const run = (job, name) => workflow.jobs[job].steps.find((s) => s.name === name).run;
|
|
for (const l of STEPS) {
|
|
const r = run(l.ci.job, l.ci.name);
|
|
if (l.npm) {
|
|
assert.equal(r, `npm run ${l.npm}`, l.id);
|
|
assert.equal(pkg.scripts[l.npm], l.describe, `package.json script ${l.npm} is what ${l.id} runs`);
|
|
} else if (l.id !== 'smoke') assert.equal(r, l.describe, l.id);
|
|
}
|
|
const smokeRun = run('test', STEPS.find((s) => s.id === 'smoke').ci.name);
|
|
const urls = [...smokeRun.matchAll(/http:\/\/127\.0\.0\.1:3000([^\s;]*)/g)].map((m) => m[1] || '/');
|
|
assert.deepEqual([...new Set(urls)].sort(), [...SMOKE_PATHS].sort());
|
|
// The whole smoke script, comments and indentation aside: any edit to it (the wait loop, the page check, …) fails
|
|
// here, so that smoke() in tools/ci.mjs is looked at and this copy updated with it.
|
|
const normalized = smokeRun.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
assert.deepEqual(normalized, SMOKE_CI_RUN, "ci.yml's smoke step changed: update smoke() in tools/ci.mjs and SMOKE_CI_RUN");
|
|
// job env: ci.yml's job test env is CI_TEST_ENV; any other job has none (stepEnv gives the tooling steps nothing)
|
|
for (const [job, j] of Object.entries(workflow.jobs)) {
|
|
assert.deepEqual(j.env, job === 'test' ? CI_TEST_ENV : {}, `env of job ${job}`);
|
|
}
|
|
// per-step config (env, if, continue-on-error, working-directory, with, …) on a mirrored step is not mirrored
|
|
for (const l of STEPS) {
|
|
const s = workflow.jobs[l.ci.job].steps.find((x) => x.name === l.ci.name);
|
|
assert.deepEqual(s.other || [], [], `${l.id}: ci.yml step keys tools/ci.mjs does not mirror`);
|
|
assert.ok(!s.shell || l.id === 'smoke', `${l.id}: shell: ${s.shell}`);
|
|
}
|
|
assert.equal(pkg.scripts.ci, 'node tools/ci.mjs');
|
|
});
|
|
|
|
test('the workflow reader: jobs, flat env, named and uses steps, run blocks; comments and nested blocks skipped, other step keys named', () => {
|
|
const yml = [
|
|
'name: X', 'on:', ' push:', 'jobs:', ' a:', ' runs-on: x', ' env:', " K: '1'", ' L: "two"', ' steps:',
|
|
' - uses: actions/checkout@v7', ' - uses: actions/setup-node@v7', ' with:', ' node-version: 22',
|
|
' # a comment', ' - name: One', ' if: always()', ' run: echo 1', ' env:', ' X: 1',
|
|
' - name: Block', ' shell: bash', ' run: |',
|
|
' a', '', ' b', ' c', ' b:', ' steps:', ' - run: x', 'other: 1', ' ignored: 2',
|
|
].join('\r\n');
|
|
assert.deepEqual(parseWorkflow(yml), {
|
|
jobs: {
|
|
a: { env: { K: '1', L: 'two' }, steps: [
|
|
{ uses: 'actions/checkout@v7' }, { uses: 'actions/setup-node@v7', other: ['with'] }, { name: 'One', run: 'echo 1', other: ['if', 'env'] },
|
|
{ name: 'Block', shell: 'bash', run: 'a\n\n b\nc' },
|
|
] },
|
|
b: { env: {}, steps: [{ run: 'x' }] },
|
|
},
|
|
});
|
|
assert.deepEqual(parseWorkflow('name: no jobs\n'), { jobs: {} });
|
|
});
|
|
|
|
test('arguments: --keep-going, --only (comma list, repeated, = form) in workflow order, --list, --help; bad ones throw', () => {
|
|
assert.deepEqual(parseArgs([]), { keepGoing: false, only: null, list: false, help: false });
|
|
assert.deepEqual(parseArgs(['-k', '--only', 'lint,test', '--only=smoke']), { keepGoing: true, only: ['lint', 'test', 'smoke'], list: false, help: false });
|
|
assert.deepEqual(selectSteps(['lint', 'test', 'smoke']).map((s) => s.id), ['test', 'smoke', 'lint']);
|
|
assert.equal(selectSteps(null).length, STEPS.length);
|
|
assert.equal(parseArgs(['--list']).list, true);
|
|
assert.equal(parseArgs(['-h']).help, true);
|
|
assert.throws(() => parseArgs(['--only', 'golden']), /unknown step "golden"/);
|
|
assert.throws(() => parseArgs(['--only']), /needs step names/);
|
|
assert.throws(() => parseArgs(['--fast']), /unknown option --fast/);
|
|
});
|
|
|
|
test("env: the job test steps get ci.yml's env for unset keys only; the tooling steps get the caller's env as is", () => {
|
|
const test_ = STEPS.find((s) => s.id === 'test');
|
|
const lint = STEPS.find((s) => s.id === 'lint');
|
|
assert.deepEqual(stepEnv(test_, { PATH: 'p', SP_E2E: '1' }), { PATH: 'p', SP_E2E: '1', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' });
|
|
assert.deepEqual(stepEnv(lint, { PATH: 'p' }), { PATH: 'p' });
|
|
});
|
|
|
|
test('summary: one row per step (status, name, duration, note), the failed steps named; durations', () => {
|
|
assert.equal(formatDuration(42.4), '42ms');
|
|
assert.equal(formatDuration(1234), '1.2s');
|
|
assert.equal(formatDuration(125_000), '2m05s');
|
|
const s = formatSummary([
|
|
{ id: 'setup', status: 'ok', ms: 400 },
|
|
{ id: 'test', status: 'fail', ms: 61_000, note: 'exit 1' },
|
|
{ id: 'typecheck', status: 'not run' },
|
|
]);
|
|
assert.equal(s, [
|
|
'', '── ci summary ──',
|
|
' ok setup 400ms',
|
|
' FAIL test 1m01s exit 1',
|
|
' - typecheck',
|
|
'FAILED: test', '',
|
|
].join('\n'));
|
|
assert.match(formatSummary([{ id: 'lint', status: 'ok', ms: 1 }]), /all steps passed/);
|
|
});
|
|
|
|
test('the eslint and tsc entry points come from their package.json bin; a missing package says to run npm ci', () => {
|
|
assert.ok(fs.existsSync(binPath('eslint', 'eslint')));
|
|
assert.ok(fs.existsSync(binPath('typescript', 'tsc')));
|
|
assert.throws(() => binPath('no-such-package-sp', 'x'), /npm ci/);
|
|
});
|
|
|
|
test('node tools/ci.mjs --list and a bad option run without npm (exit 0 / 2)', () => {
|
|
const list = spawnSync(process.execPath, ['tools/ci.mjs', '--list'], { cwd: ROOT, encoding: 'utf8' });
|
|
assert.equal(list.status, 0, list.stderr);
|
|
for (const s of STEPS) assert.match(list.stdout, new RegExp(`^${s.id} `, 'm'));
|
|
const bad = spawnSync(process.execPath, ['tools/ci.mjs', '--only', 'nope'], { cwd: ROOT, encoding: 'utf8' });
|
|
assert.equal(bad.status, 2);
|
|
assert.match(bad.stderr, /unknown step "nope"/);
|
|
});
|