mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
refactor(tests): centralize lane definitions and add per-file isolation
- extract test lane config to scripts/test-lanes.ts (single source of truth) - run-test-lane.mjs: per-file bun process isolation with wall timeout; --jobs N, --single-process flags - drop hand-listed test file lists from package.json; lanes now discovered by directory - add test:stats (stats/src) and test:scripts (scripts/**) lanes; wire both into CI - test:fast now: test:src + launcher-unit + test:scripts + test:runtime:compat - remove obsolete test:full, test:core, test:core:dist, test:config:dist scripts
This commit is contained in:
@@ -1,12 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
type LaneConfig = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude: Set<string>;
|
||||
};
|
||||
import { collectLaneFiles } from './test-lanes';
|
||||
|
||||
type LcovRecord = {
|
||||
sourceFile: string;
|
||||
@@ -18,64 +13,6 @@ type LcovRecord = {
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
const lanes: Record<string, LaneConfig> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
function getLaneFiles(laneName: string): string[] {
|
||||
const lane = lanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown coverage lane: ${laneName}`);
|
||||
}
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for coverage lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseCoverageDirArg(argv: string[]): string {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--coverage-dir') {
|
||||
@@ -277,7 +214,7 @@ function runCoverageLane(): number {
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
mkdirSync(shardRoot, { recursive: true });
|
||||
|
||||
const files = getLaneFiles(laneName);
|
||||
const files = collectLaneFiles(repoRoot, laneName);
|
||||
const reports: string[] = [];
|
||||
|
||||
try {
|
||||
|
||||
+100
-54
@@ -1,73 +1,119 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { collectLaneFiles } from './test-lanes.ts';
|
||||
|
||||
// Runs a test lane with per-file process isolation: one `bun test` process per
|
||||
// test file so a hanging test or leaked global in one file cannot poison the
|
||||
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||
//
|
||||
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
|
||||
const lanes = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
function parseArgs(argv) {
|
||||
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--jobs') {
|
||||
options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
|
||||
} else if (arg === '--timeout-secs') {
|
||||
options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
|
||||
} else if (arg === '--single-process') {
|
||||
options.singleProcess = true;
|
||||
} else if (!arg.startsWith('--') && options.lane === undefined) {
|
||||
options.lane = arg;
|
||||
} else {
|
||||
process.stderr.write(`Unknown argument: ${arg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function collectFiles(rootDir, includeSuffixes, excludeSet) {
|
||||
const out = [];
|
||||
const visit = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
function runFile(file, timeoutSecs) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
|
||||
let output = '';
|
||||
let timedOut = false;
|
||||
child.stdout.on('data', (chunk) => (output += chunk));
|
||||
child.stderr.on('data', (chunk) => (output += chunk));
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, timeoutSecs * 1000);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut });
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({ file, code: 1, output: String(error), timedOut: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runIsolated(files, options) {
|
||||
const failures = [];
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < files.length) {
|
||||
const file = files[nextIndex];
|
||||
nextIndex += 1;
|
||||
const result = await runFile(file, options.timeoutSecs);
|
||||
completed += 1;
|
||||
if (result.code !== 0) {
|
||||
failures.push(result);
|
||||
const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed';
|
||||
process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`);
|
||||
process.stderr.write(result.output);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`);
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(`All ${files.length} test files passed.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lane = lanes[process.argv[2]];
|
||||
function runSingleProcess(files) {
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
if (!lane) {
|
||||
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!options.lane) {
|
||||
process.stderr.write('Missing test lane name\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
|
||||
if (files.length === 0) {
|
||||
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`);
|
||||
let files;
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, options.lane);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
if (options.singleProcess) {
|
||||
process.exit(runSingleProcess(files));
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
process.exit(await runIsolated(files, options));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
export type TestLane = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude?: string[];
|
||||
extraFiles?: string[];
|
||||
};
|
||||
|
||||
// Single source of truth for test-lane membership. Consumed by
|
||||
// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts
|
||||
// (per-file coverage shards). Lanes discover files by directory so new test
|
||||
// files join their lane automatically.
|
||||
export const testLanes: Record<string, TestLane> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
// Node-compat suites; their dist builds run via test:runtime:compat.
|
||||
exclude: [
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
],
|
||||
},
|
||||
config: {
|
||||
roots: ['src/config'],
|
||||
include: ['.test.ts'],
|
||||
extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'],
|
||||
},
|
||||
launcher: {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: ['launcher/smoke.e2e.test.ts'],
|
||||
},
|
||||
scripts: {
|
||||
roots: ['scripts'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
stats: {
|
||||
roots: ['stats/src'],
|
||||
include: ['.test.ts', '.test.tsx'],
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
repoRoot: string,
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string): void => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function collectLaneFiles(repoRoot: string, laneName: string): string[] {
|
||||
const lane = testLanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown test lane: ${laneName}`);
|
||||
}
|
||||
const excludeSet = new Set(lane.exclude ?? []);
|
||||
const files = lane.roots.flatMap((rootDir) =>
|
||||
collectFiles(repoRoot, rootDir, lane.include, excludeSet),
|
||||
);
|
||||
for (const extra of lane.extraFiles ?? []) {
|
||||
if (!files.includes(extra)) files.push(extra);
|
||||
}
|
||||
files.sort();
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
Reference in New Issue
Block a user