feedback7 G-TOOLS: tools/ci.mjs and npm run ci run the CI checks locally (PR #367 by @anAtheist987)

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).
This commit is contained in:
anAtheist987 authored and sganggs committed 2026-10-08 18:48:16 +08:00
1 parent a2a3f52418
commit 1f147a288f
4 files changed
+531 -1

No files matched your search

+8 -1
View File
@@ -31,6 +31,11 @@ npm run golden:update # 有意改变
npm run lint && npm run check:imports && npm run typecheck # 代码检查、导入边界、类型
```
提交 PR 前可以一条命令跑一遍 GitHub CI(`.github/workflows/ci.yml`)的全部检查:`npm run ci`(或 `node tools/ci.mjs`)。
依次是 setup 检查、`node --test`、服务器冒烟测试(在空闲端口启动、请求 `/healthz` 和首页、运行 doctor,然后关掉)、lint、导入边界、
类型;第一步失败就停下并打印每步结果和耗时。`--keep-going` 失败后继续,`--only lint,test` 只跑指定步骤,`--list` 列出步骤。
不包含 `npm ci`(依赖请自己装),也不跑 Node 版本 × 系统的矩阵。
浏览器测试默认不跑,需要本机 Chrome(路径不标准时设置 `CHROME_PATH`),用环境变量打开:
```bash
@@ -99,7 +104,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
- **Setup**: Node.js 22 or 24; `npm install`, `npm run setup` (art and audio — optional for tests), `npm run dev`.
The code map is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
- **Tests**: run the targeted files (`node --test <file>`, `--test-name-pattern`), then `node --test` and
`npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. Browser suites
`npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`.
`npm run ci` (or `node tools/ci.mjs`) runs the checks of `.github/workflows/ci.yml` in one go and stops at the first
failure (`--keep-going`, `--only <steps>`, `--list`); it does not run `npm ci`. Browser suites
are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome,
`CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs
`npm run golden:update` and commits the digests with the change, naming the scenarios that moved.
+1
View File
@@ -38,6 +38,7 @@
"lint": "eslint .",
"typecheck": "tsc --noEmit --checkJs -p jsconfig.json",
"check:imports": "node tools/check-imports.mjs",
"ci": "node tools/ci.mjs",
"package": "node tools/package.mjs",
"package:lite": "node tools/package.mjs --lite"
},
+149
View File
@@ -0,0 +1,149 @@
// 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"/);
});
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env node
// tools/ci.mjs — run the checks of .github/workflows/ci.yml locally, one after another (CONTRIBUTING.md §2).
//
// node tools/ci.mjs every step; stops at the first failing one (exit 1)
// node tools/ci.mjs --keep-going run every step even after a failure (exit 1 when any failed)
// node tools/ci.mjs --only lint,test only the named steps (in the workflow's order)
// node tools/ci.mjs --list print the steps and the ci.yml step each one mirrors
// npm run ci the same as the first line
//
// Steps, in the order of ci.yml (job `test`, then job `tooling`):
// setup node tools/setup.mjs --check --no-local
// test node --test
// smoke the server on a free port on 127.0.0.1: GET /healthz, / (must contain <html) and /vendor/pixi.min.js,
// then node tools/doctor.mjs (its exit code is ignored, like `|| true` in ci.yml), then the server stops
// lint eslint . (npm run lint; warnings do not fail it)
// imports node tools/check-imports.mjs (npm run check:imports)
// typecheck tsc --noEmit --checkJs -p jsconfig.json (npm run typecheck)
// The job `test` steps get ci.yml's env (SP_E2E=0, SP_REAL_E2E=0, RENDER_E2E=0, NO_COLOR=1) unless already set.
//
// Not mirrored: actions/checkout and actions/setup-node (GitHub runner setup) and `npm ci` (the install — run it
// yourself; replacing node_modules is not a check). The OS × Node matrix is whatever this machine has.
// Every child is `process.execPath` with a script path (eslint and tsc from node_modules/<pkg>/package.json `bin`),
// never npm or a shell, so the run does not depend on PATH or on the shell npm uses for scripts.
// test/ci.test.js keeps this list and ci.yml in step: a ci.yml step this file does not know fails it.
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** The env of ci.yml's job `test` (test/ci.test.js compares it with the workflow). */
export const CI_TEST_ENV = { SP_E2E: '0', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' };
/** The paths the smoke step requests (the curl URLs of ci.yml's smoke step; test/ci.test.js compares them). */
export const SMOKE_PATHS = ['/healthz', '/', '/vendor/pixi.min.js'];
/** ci.yml steps that only make sense on a GitHub runner, by `uses:` action or by step name. */
export const SKIPPED_CI_STEPS = [
{ uses: 'actions/checkout', why: 'GitHub runner: the checkout is this folder' },
{ uses: 'actions/setup-node', why: 'GitHub runner: the Node.js of this machine is used' },
{ name: 'Install (npm ci → postinstall copies public/vendor)', why: 'run `npm ci` yourself; it is not a check' },
];
/**
* The local steps (runStep runs them). `ci` names the ci.yml job and step it mirrors; `npm` the package.json script
* ci.yml runs there, and `describe` is that script's command (test/ci.test.js checks both).
* @typedef {{ id: string, ci: { job: string, name: string }, npm?: string, describe: string }} Step
* @type {Step[]}
*/
export const STEPS = [
{ id: 'setup', ci: { job: 'test', name: 'Setup check (no downloads)' }, describe: 'node tools/setup.mjs --check --no-local' },
{ id: 'test', ci: { job: 'test', name: 'Unit & integration tests' }, describe: 'node --test' },
{ id: 'smoke', ci: { job: 'test', name: 'Server smoke test (boot, /healthz, index.html, doctor)' },
describe: `server on a free port: GET ${SMOKE_PATHS.join(', ')}; node tools/doctor.mjs` },
{ id: 'lint', ci: { job: 'tooling', name: 'Lint (warnings allowed)' }, npm: 'lint', describe: 'eslint .' },
{ id: 'imports', ci: { job: 'tooling', name: 'Import boundaries' }, npm: 'check:imports', describe: 'node tools/check-imports.mjs' },
{ id: 'typecheck', ci: { job: 'tooling', name: 'Typecheck' }, npm: 'typecheck', describe: 'tsc --noEmit --checkJs -p jsconfig.json' },
];
// ---------------------------------------------------------------------------------------------------------------------
// ci.yml, read just far enough: jobs, each job's env (flat KEY: value) and steps (name, uses, run, shell; the names
// of any other step keys in `other`, so test/ci.test.js can refuse per-step config this script does not mirror).
const unquote = (s) => {
const v = s.trim();
return /^(['"]).*\1$/.test(v) ? v.slice(1, -1) : v;
};
/**
* A line-based reader for the shape of .github/workflows/ci.yml (block mappings, `- ` step lists, `run: |` blocks).
* Not a YAML parser: flow mappings, anchors and multi-document files are not handled.
* @param {string} text
* @returns {{ jobs: Record<string, { env: Record<string, string>, steps: { name?: string, uses?: string, run?: string, shell?: string, other?: string[] }[] }> }}
*/
export function parseWorkflow(text) {
const lines = text.replace(/\r\n?/g, '\n').split('\n');
const indentOf = (l) => l.length - l.trimStart().length;
const blank = (l) => l.trim() === '' || l.trim().startsWith('#');
/** @type {Record<string, { env: Record<string, string>, steps: any[] }>} */
const jobs = {};
let i = lines.findIndex((l) => /^jobs:\s*$/.test(l));
if (i < 0) return { jobs };
let jobIndent = -1;
let job = null;
let section = null; // 'env' | 'steps' | null
let sectionIndent = -1;
let step = null;
let stepIndent = -1;
for (i += 1; i < lines.length; i++) {
const line = lines[i];
if (blank(line)) continue;
const ind = indentOf(line);
if (ind === 0) break; // the next top-level key
const t = line.trim();
if (jobIndent < 0) jobIndent = ind;
if (ind === jobIndent) {
const m = /^([\w-]+):\s*$/.exec(t);
job = m ? (jobs[m[1]] = { env: {}, steps: [] }) : null;
section = null; step = null;
continue;
}
if (!job) continue;
if (section && ind <= sectionIndent) { section = null; step = null; }
if (!section) {
const m = /^(env|steps):\s*$/.exec(t);
if (m) { section = m[1]; sectionIndent = ind; }
continue;
}
if (section === 'env') {
const m = /^([\w-]+):\s*(.*)$/.exec(t);
if (m) job.env[m[1]] = unquote(m[2]);
continue;
}
// steps
let body = t;
if (t.startsWith('- ')) {
step = /** @type {{ name?: string, uses?: string, run?: string, shell?: string, other?: string[] }} */ ({});
job.steps.push(step);
stepIndent = ind + 2;
body = t.slice(2).trim();
} else if (!step || ind < stepIndent) continue;
else if (ind > stepIndent) continue; // nested (with: …)
const m = /^(name|uses|run|shell):\s*(.*)$/.exec(body);
if (!m) {
const k = /^([\w-]+):/.exec(body); // env, if, with, continue-on-error, working-directory, …: the key name only
if (k) (step.other ||= []).push(k[1]);
continue;
}
if (m[1] === 'run' && /^[|>][-+]?$/.test(m[2].trim())) {
const block = [];
let j = i + 1;
let blockIndent = -1;
for (; j < lines.length; j++) {
const l = lines[j];
if (l.trim() === '') { block.push(''); continue; }
const li = indentOf(l);
if (li < stepIndent || (blockIndent < 0 && li <= stepIndent)) break;
if (blockIndent < 0) blockIndent = li;
if (li < blockIndent) break;
block.push(l.slice(blockIndent));
}
i = j - 1;
step.run = block.join('\n').replace(/\n+$/, '');
} else {
step[m[1]] = unquote(m[2]);
}
}
return { jobs };
}
/** Is a ci.yml step one of SKIPPED_CI_STEPS? @param {{ name?: string, uses?: string }} s */
export function isSkippedCiStep(s) {
return SKIPPED_CI_STEPS.some((k) => (k.uses ? (s.uses || '').split('@')[0] === k.uses : s.name === k.name));
}
// ---------------------------------------------------------------------------------------------------------------------
// Arguments, env, summary.
/**
* @param {string[]} argv
* @returns {{ keepGoing: boolean, only: string[] | null, list: boolean, help: boolean }}
*/
export function parseArgs(argv) {
const o = { keepGoing: false, only: /** @type {string[] | null} */ (null), list: false, help: false };
const ids = new Set(STEPS.map((s) => s.id));
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--keep-going' || a === '-k') o.keepGoing = true;
else if (a === '--list') o.list = true;
else if (a === '--help' || a === '-h') o.help = true;
else if (a === '--only' || a.startsWith('--only=')) {
const v = a === '--only' ? argv[++i] : a.slice('--only='.length);
if (!v) throw new Error('--only needs step names: ' + [...ids].join(','));
for (const id of v.split(',').map((x) => x.trim()).filter(Boolean)) {
if (!ids.has(id)) throw new Error(`unknown step "${id}" (steps: ${[...ids].join(', ')})`);
(o.only ??= []).push(id);
}
} else throw new Error(`unknown option ${a} (node tools/ci.mjs --help)`);
}
return o;
}
/** The steps to run, in workflow order. @param {string[] | null} only */
export function selectSteps(only) {
return only ? STEPS.filter((s) => only.includes(s.id)) : STEPS.slice();
}
/** A child's env: `base` plus ci.yml's job env for the keys `base` does not set. */
export function stepEnv(step, base = process.env) {
const env = { ...base };
if (step.ci.job === 'test') for (const [k, v] of Object.entries(CI_TEST_ENV)) if (env[k] === undefined) env[k] = v;
return env;
}
/** @param {number} ms */
export function formatDuration(ms) {
if (ms < 1000) return `${Math.round(ms)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`;
}
/**
* The summary table. `status`: ok | fail | not run.
* @param {{ id: string, status: 'ok' | 'fail' | 'not run', ms?: number, note?: string }[]} results
*/
export function formatSummary(results) {
const w = Math.max(...results.map((r) => r.id.length), 4);
const label = { ok: 'ok', fail: 'FAIL', 'not run': '-' };
const rows = results.map((r) => {
const time = r.ms == null ? '' : formatDuration(r.ms);
return ` ${label[r.status].padEnd(5)} ${r.id.padEnd(w)} ${time.padStart(7)}${r.note ? ' ' + r.note : ''}`.trimEnd();
});
const failed = results.filter((r) => r.status === 'fail').map((r) => r.id);
const tail = failed.length ? `FAILED: ${failed.join(', ')}` : results.some((r) => r.status === 'ok') ? 'all steps passed' : 'nothing ran';
return ['', '── ci summary ──', ...rows, tail, ''].join('\n');
}
// ---------------------------------------------------------------------------------------------------------------------
// Running.
/** The JS entry of a dependency's bin, e.g. ('eslint', 'eslint') → node_modules/eslint/bin/eslint.js. */
export function binPath(pkg, name, root = ROOT) {
const dir = path.join(root, 'node_modules', pkg);
let manifest;
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch {
throw new Error(`${pkg} is not installed (node_modules/${pkg}) — run npm ci first`);
}
const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[name];
if (!rel) throw new Error(`node_modules/${pkg}/package.json has no bin "${name}"`);
return path.join(dir, rel);
}
/** Run `node <args>` in ROOT with inherited stdio; resolves to the exit code (1 when killed by a signal). */
function node(args, env) {
return new Promise((resolve) => {
const child = spawn(process.execPath, args, { cwd: ROOT, env, stdio: 'inherit' });
child.on('error', (e) => { console.error(e.message); resolve(1); });
child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
});
}
/** A free TCP port on 127.0.0.1. */
export function freePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const { port } = /** @type {net.AddressInfo} */ (srv.address());
srv.close(() => resolve(port));
});
});
}
/** Live server children, killed if this process exits early. @type {Set<import('node:child_process').ChildProcess>} */
const servers = new Set();
process.on('exit', () => { for (const c of servers) { try { c.kill('SIGKILL'); } catch { /* gone */ } } });
/** Stop a server child: SIGTERM (graceful on POSIX; on Windows kill() terminates), SIGKILL after 8 s. */
function stopServer(child) {
if (child.exitCode !== null || child.signalCode !== null) { servers.delete(child); return Promise.resolve(); }
return new Promise((resolve) => {
const force = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 8000);
child.once('exit', () => { clearTimeout(force); servers.delete(child); resolve(); });
try { child.kill('SIGTERM'); } catch { clearTimeout(force); servers.delete(child); resolve(); }
});
}
/** The smoke step of ci.yml's job `test`, on a free port instead of 3000 (a running game server must not clash). */
async function smoke(env) {
const port = await freePort();
// [ASSUMED] HOST=127.0.0.1 instead of ci.yml's default 0.0.0.0: the requests go to 127.0.0.1 either way, and
// listening only on loopback spares a firewall prompt on Windows / macOS.
const child = spawn(process.execPath, ['server/index.js'], {
cwd: ROOT, env: { ...env, PORT: String(port), HOST: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'pipe'],
});
servers.add(child);
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const fail = async (msg) => {
await stopServer(child);
console.error(`smoke: ${msg}\n── server log ──\n${log}`);
return 1;
};
try {
let health = null;
for (let i = 0; i < 30 && !health; i++) {
if (child.exitCode !== null) return await fail(`the server exited (code ${child.exitCode})`);
try {
const r = await fetch(base + '/healthz', { signal: AbortSignal.timeout(2000) });
if (r.ok) health = await r.text();
} catch { /* not up yet */ }
if (!health) await new Promise((r) => setTimeout(r, 1000));
}
if (!health) return await fail(`no 200 from ${base}/healthz within 30 s`);
console.log(`GET /healthz → ${health.trim()}`);
const index = await fetch(base + '/', { signal: AbortSignal.timeout(10000) });
const html = await index.text();
if (!index.ok) return await fail(`GET / → HTTP ${index.status}`);
if (!/<html/i.test(html)) return await fail('GET / does not contain <html');
console.log(`GET / → ${index.status}, ${html.length} bytes, <html found`);
const pixi = await fetch(base + '/vendor/pixi.min.js', { signal: AbortSignal.timeout(10000) });
await pixi.arrayBuffer();
if (!pixi.ok) return await fail(`GET /vendor/pixi.min.js → HTTP ${pixi.status} (public/vendor missing? node tools/vendor.mjs)`);
console.log(`GET /vendor/pixi.min.js → ${pixi.status}`);
// ci.yml: `node tools/doctor.mjs || true` — its exit code does not fail the step.
const doctor = await node(['tools/doctor.mjs', '--port', String(port), '--host', '127.0.0.1'], env);
if (doctor !== 0) console.log(`(doctor exited ${doctor}; ignored, as in ci.yml)`);
} catch (e) {
return await fail(e?.message || String(e));
}
await stopServer(child);
return 0;
}
/** @param {Step} step @param {Record<string, string | undefined>} env */
function runStep(step, env) {
switch (step.id) {
case 'setup': return node(['tools/setup.mjs', '--check', '--no-local'], env);
case 'test': return node(['--test'], env);
case 'smoke': return smoke(env);
case 'lint': return node([binPath('eslint', 'eslint'), '.'], env);
case 'imports': return node(['tools/check-imports.mjs'], env);
case 'typecheck': return node([binPath('typescript', 'tsc'), '--noEmit', '--checkJs', '-p', 'jsconfig.json'], env);
default: throw new Error(`no runner for step ${step.id}`);
}
}
export async function main(argv = process.argv.slice(2)) {
let opts;
try { opts = parseArgs(argv); } catch (e) { console.error(e.message); return 2; }
if (opts.help) {
console.log('node tools/ci.mjs [--keep-going] [--only <step,…>] [--list] — the checks of .github/workflows/ci.yml');
console.log('steps: ' + STEPS.map((s) => s.id).join(', '));
return 0;
}
if (opts.list) {
for (const s of STEPS) console.log(`${s.id.padEnd(10)} ${s.describe}\n${''.padEnd(10)} ← ci.yml ${s.ci.job}: ${s.ci.name}`);
for (const s of SKIPPED_CI_STEPS) console.log(`(skipped) ${s.uses || s.name} — ${s.why}`);
return 0;
}
const steps = selectSteps(opts.only);
const results = steps.map((s) => ({ id: s.id, status: /** @type {'ok' | 'fail' | 'not run'} */ ('not run'), ms: undefined, note: undefined }));
let stopped = false;
for (let i = 0; i < steps.length; i++) {
if (stopped) break;
const step = steps[i];
console.log(`\n▶ ${step.id} — ${step.describe}`);
const t0 = performance.now();
let code;
try { code = await runStep(step, stepEnv(step)); } catch (e) { console.error(e?.message || e); code = 1; }
results[i].ms = performance.now() - t0;
results[i].status = code === 0 ? 'ok' : 'fail';
if (code !== 0) {
results[i].note = `exit ${code}`;
if (!opts.keepGoing) stopped = true;
}
}
console.log(formatSummary(results));
return results.some((r) => r.status === 'fail') ? 1 : 0;
}
function isMain() {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
}
if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });