diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe73e468..1f9e694a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 `, `--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 `, `--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. diff --git a/package.json b/package.json index efd9251e..aa34bb0f 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/test/ci.test.js b/test/ci.test.js new file mode 100644 index 00000000..e45379b2 --- /dev/null +++ b/test/ci.test.js @@ -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 ' { + 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"/); +}); diff --git a/tools/ci.mjs b/tools/ci.mjs new file mode 100644 index 00000000..c4f3d733 --- /dev/null +++ b/tools/ci.mjs @@ -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 /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, 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, 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 ` 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} */ +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 (!/} 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 ] [--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; });