Spaces:
Running
Running
File size: 6,790 Bytes
cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e cf4bd43 dcfa89e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | // 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();
|