Files
openchamber/scripts/run-isolated-tests.mjs
T

124 lines
4.4 KiB
JavaScript

#!/usr/bin/env node
// Runs every test file under the given roots, each in its own process.
//
// Two properties of this repository make that the working arrangement rather
// than a preference:
//
// - The shared UI and extension suites keep module-level singletons (runtime
// endpoint, relay tunnel, stores, registries). Executed in one process they
// leak state into each other and fail by load order, which is why the relay
// guidance already says to run those files one at a time.
// - The same directories mix `bun:test` and `node:test` files, so no single
// runner command covers them. The framework is read from the file's imports
// instead of being listed here, so adding a test never requires editing a
// list that then rots.
//
// Usage: node scripts/run-isolated-tests.mjs <root> [...roots]
import { spawn } from 'node:child_process';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { resolveBunExecutable } from './lib/bun-executable.mjs';
const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/;
const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']);
const MAX_PARALLEL = 4;
const bunExecutable = resolveBunExecutable();
const collect = (root, found = []) => {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (entry.name.startsWith('.') && entry.name !== '.') continue;
const full = path.join(root, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) collect(full, found);
continue;
}
if (TEST_FILE.test(entry.name)) found.push(full);
}
return found;
};
/** `null` when the file names no known runner, so it is reported instead of skipped silently. */
const resolveCommand = (file) => {
const source = readFileSync(file, 'utf8');
const isTypeScript = /\.tsx?$/.test(file);
// TypeScript goes to Bun even when the file imports `node:test`, which Bun
// implements. Node's ESM loader cannot resolve the extensionless local
// specifiers these files use (`./sseProxy`), so it never ran them at all.
if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) {
return { label: 'bun', command: bunExecutable, args: ['test', file] };
}
if (/from\s+['"]node:test['"]/.test(source)) {
return { label: 'node', command: 'node', args: ['--test', file] };
}
return null;
};
const run = ({ command, args }) => new Promise((resolve) => {
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', (chunk) => { output += chunk; });
child.stderr.on('data', (chunk) => { output += chunk; });
child.on('error', (error) => resolve({ code: 1, output: `${output}${error.message}` }));
child.on('close', (code) => resolve({ code: code ?? 1, output }));
});
const roots = process.argv.slice(2);
if (roots.length === 0) {
console.error('run-isolated-tests: expected at least one root directory');
process.exit(1);
}
const files = [];
for (const root of roots) {
const resolved = path.resolve(root);
if (!statSync(resolved).isDirectory()) {
console.error(`run-isolated-tests: not a directory: ${root}`);
process.exit(1);
}
files.push(...collect(resolved));
}
files.sort();
const failures = [];
const unknown = [];
let passed = 0;
let next = 0;
const worker = async () => {
while (next < files.length) {
const file = files[next++];
const relative = path.relative(process.cwd(), file);
const resolved = resolveCommand(file);
if (!resolved) {
unknown.push(relative);
continue;
}
const { code, output } = await run(resolved);
if (code === 0) {
passed += 1;
} else {
failures.push({ relative, label: resolved.label, output });
console.error(`FAIL (${resolved.label}) ${relative}`);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL, files.length) }, worker));
for (const failure of failures) {
console.error(`\n===== ${failure.relative} (${failure.label}) =====\n${failure.output}`);
}
for (const file of unknown) {
console.error(`UNKNOWN RUNNER ${file}: imports neither bun:test nor node:test`);
}
console.log(`\n${passed}/${files.length} test files passed${failures.length ? `, ${failures.length} failed` : ''}${unknown.length ? `, ${unknown.length} with no known runner` : ''}`);
process.exit(failures.length > 0 || unknown.length > 0 ? 1 : 0);