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:
1 parent
a2a3f52418
commit
1f147a288f
4 files changed
+531
-1
No files matched your search
+8
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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; });
|
||||
Reference in new issue
Block a user