Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions scripts/run-suites.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Runs a package's test suites: every `*.test.mjs` in `test/`, then every one at
// the package root, in that order, one at a time.
//
// WHY THIS EXISTS. Both package.json files used to carry the suite list by hand
// — `node a.test.mjs && node b.test.mjs && …` on one line. Every PR that added
// a test edited that line, so any two such PRs conflicted by construction (six
// times in the last few days), and the natural resolution — take one side —
// silently drops the other PR's suite from the run. It stays green and nobody
// notices the coverage is gone. Discovery removes the shared line: adding
// `test/foo.test.mjs` is enough to make it run.
//
// SEQUENTIAL, DELIBERATELY. `node --test` would also discover these files, but
// it runs them in PARALLEL by default, and several suites here start a real
// server on a fixed port (migration on 7893, resize on 7895, trace-download on
// 7898) or drive Chromium. Those ports do not collide today, but only because
// whoever added each one picked a free number — nothing enforces it, and the
// first suite that copies an existing PORT constant produces a flake that reads
// as a product bug. This fleet has already lost time to exactly that symptom.
// One at a time costs wall-clock and nothing else.
//
// EXIT SEMANTICS match the `&&` chain it replaces: the first failing suite stops
// the run and its exit code is this process's exit code. That holds for both
// kinds of file here — the standalone scripts that print their own results and
// call process.exit, and the two `node:test` suites, which node exits non-zero
// for when a test fails (verified, not assumed).
//
// OPTING OUT. A suite that must not run in the default set says so in its own
// header, on a line containing `am-test: manual` plus the reason. It is declared
// where a reader will see it rather than by absence from a list somewhere else,
// and every run prints what it skipped and why — on the failure path too, since
// that is the moment someone is actually reading this output.
//
// DISCOVERY IS TWO DEEP, ON PURPOSE: `test/` and the package root, so a
// `test/fixtures/` directory is fixtures rather than a source of surprise runs.
// A `*.test.mjs` anywhere below that is reported at the end of every run instead
// of being ignored — a file that looks like a suite and never runs is the exact
// failure this script exists to end, and it does not matter that the cause is a
// subdirectory rather than a hand-edited list.
//
// Usage: node ../scripts/run-suites.mjs [substring …] [--manual]
// a substring filters to matching suites — for running one by hand;
// --manual lets that filter reach the suites marked manual.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';

const MANUAL = /am-test:\s*manual\s*[—:-]?\s*(.*)/;
const HEAD_BYTES = 4096; // the marker belongs in the header, not line 900

const listDir = (dir) => {
let names = [];
try { names = fs.readdirSync(dir); } catch { return []; }
return names
.filter((n) => n.endsWith('.test.mjs'))
.sort((a, b) => a.localeCompare(b, 'en'))
.map((n) => path.join(dir, n))
.filter((p) => fs.statSync(p).isFile());
};

// `test/` first, then the package root: the root files are the older ones, and
// keeping them behind the directory keeps the common case (a new suite in
// test/) at the front of the run.
const found = [...listDir('test'), ...listDir('.')];

const args = process.argv.slice(2);
const wantManual = args.includes('--manual');
const filters = args.filter((a) => !a.startsWith('--'));
const matches = (file) => !filters.length || filters.some((f) => file.includes(f));
const suites = [];
const skipped = [];
for (const file of found) {
let head = '';
try {
const fd = fs.openSync(file, 'r');
const buf = Buffer.alloc(HEAD_BYTES);
head = buf.subarray(0, fs.readSync(fd, buf, 0, HEAD_BYTES, 0)).toString('utf8');
fs.closeSync(fd);
} catch { /* unreadable: let node report it */ }
const manual = head.match(MANUAL);
// Named explicitly with --manual, a manual suite runs: the filter is the
// run-one-by-hand path, and the suites worth running by hand are mostly these.
if (manual && !(wantManual && filters.length && matches(file))) {
if (matches(file)) skipped.push({ file, why: manual[1].trim() });
continue;
}
if (!matches(file)) continue;
suites.push(file);
}

// Anything that looks like a suite but sits below the two scanned depths.
const stray = [];
(function walk(dir, depth) {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (['node_modules', '.git', 'dist', 'coverage'].includes(e.name)) continue;
walk(p, depth + 1);
} else if (e.name.endsWith('.test.mjs') && depth > 0 && path.dirname(p) !== 'test') {
stray.push(p);
}
}
}('.', 0));

const plural = (n) => `${n} suite${n === 1 ? '' : 's'}`;
// Printed by BOTH exits. What did not run is most worth saying when something
// failed, and that is exactly when an early `process.exit` used to swallow it.
const report = () => {
for (const { file, why } of skipped) console.log(` skipped ${file} — ${why || 'marked manual'}`);
for (const file of stray) {
console.log(` NOT RUN ${file} — below \`test/\` and the package root, where discovery looks.`);
console.log(' Move it up, or mark it `am-test: manual` with a reason, or rename it.');
}
};

const pkg = path.basename(process.cwd());
if (!suites.length) {
// Blaming the filter here sends people looking for a typo when the file was
// found and deliberately excluded.
const manualOnly = skipped.length && filters.length;
console.error(manualOnly
? `${pkg}/: ${plural(skipped.length)} matched ${filters.join(', ')}, all marked manual:`
: `no suites found in ${pkg}/${filters.length ? ` matching ${filters.join(', ')}` : ''}`);
report();
if (manualOnly) console.error(`Run one anyway with: npm test -- ${filters.join(' ')} --manual`);
process.exit(1);
}
console.log(`${pkg}: ${plural(suites.length)}\n`);

for (const [i, file] of suites.entries()) {
console.log(`── [${i + 1}/${suites.length}] ${file}`);
const r = spawnSync(process.execPath, [file], { stdio: 'inherit' });
const code = r.status === null ? 1 : r.status;
if (code !== 0) {
console.error(`\n${file} FAILED (${r.signal ? `signal ${r.signal}` : `exit ${code}`})`);
console.error(`${plural(i)} had passed before it; the rest were not started.`);
report();
process.exit(code);
}
console.log('');
}

console.log(`${pkg}: ${plural(suites.length)} passed`);
report();
1 change: 1 addition & 0 deletions server/mobile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// visual viewport is replaced with a controllable EventTarget so the keyboard
// test covers both viewport height and iOS's non-zero offsetTop.
//
// am-test: manual — Chromium, a full web build and port 7896; `npm run test:mobile`.
// npm run test:mobile
import fs from 'node:fs';
import os from 'node:os';
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
"test:screenshots": "node screenshot-input.test.mjs",
"test:mobile": "node mobile.test.mjs",
"test": "node test/archive.test.mjs && node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/input-required.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs"
"test": "node ../scripts/run-suites.mjs"
},
"engines": {
"node": ">=20.19"
Expand Down
1 change: 1 addition & 0 deletions server/reader-info.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*
* Set READER_INFO_PUBLIC_DIR to a prebuilt web/dist to skip the build, and
* READER_INFO_PORT to move off the default when suites run in parallel.
* am-test: manual — Chromium, a full web build and READER_INFO_PORT; `npm run test:ui`.
*/
import fs from 'node:fs';
import os from 'node:os';
Expand Down
1 change: 1 addition & 0 deletions server/screenshot-input.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* - the creation dialog has no redundant file picker.
*
* Set SCREENSHOT_PUBLIC_DIR to a prebuilt web/dist to skip the build.
* am-test: manual — Chromium, a full web build and SCREENSHOT_PORT; `npm run test:ui`.
*/
import fs from 'node:fs';
import os from 'node:os';
Expand Down
1 change: 1 addition & 0 deletions server/terminal-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* - ...without letting that lingering selection shadow Ctrl+C's SIGINT
*
* Set TERMUI_PUBLIC_DIR to a prebuilt web/dist to skip the build.
* am-test: manual — Chromium, a full web build and port 7897; `npm run test:ui`.
*/
import fs from 'node:fs';
import os from 'node:os';
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node test/stepMarkdown.test.mjs && node test/statusMark.test.mjs && node test/mobileBack.test.mjs && node test/pendingExchange.test.mjs && node test/composerAlign.test.mjs && node test/fileWrapToggle.test.mjs && node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/settingsMobile.test.mjs && node test/traceWindows.test.mjs && node test/sidebar-dnd.test.mjs",
"test": "node ../scripts/run-suites.mjs",
"test:render": "node test/statusMark.render.test.mjs",
"preview": "vite preview"
},
Expand Down
6 changes: 4 additions & 2 deletions web/test/statusMark.render.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
// mark, and a state pseudo-element never paints over the inline provider/CLI
// colours carried by bare `.status` dots.
//
// Needs Chromium; run with: node test/statusMark.render.test.mjs
// (not in `npm test`, which stays browser-free — see package.json's test:render)
// am-test: manual — needs Chromium; run with `npm run test:render`.
// Kept out of the default suite as its own script, unchanged by the discovery
// change. (The note that used to sit here said `npm test` stays browser-free;
// that stopped being true when traceWindows.test.mjs joined it.)
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
Expand Down