Spaces:
Running
Running
| // 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(); | |