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:
2026-07-06 23:33:26 -07:00
parent 38ddb29aa0
commit a4927a3bbd
14 changed files with 265 additions and 141 deletions
+3 -3
View File
@@ -55,9 +55,6 @@ jobs:
- name: Verify generated config examples - name: Verify generated config examples
run: bun run verify:config-example run: bun run verify:config-example
- name: Internal docs knowledge-base checks
run: bun run test:docs:kb
- name: Test suite (source) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
@@ -71,6 +68,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -70,6 +70,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -61,6 +61,9 @@ jobs:
path: coverage/test-src/lcov.info path: coverage/test-src/lcov.info
if-no-files-found: error if-no-files-found: error
- name: Stats UI tests
run: bun run test:stats
- name: Launcher smoke suite (source) - name: Launcher smoke suite (source)
run: bun run test:launcher:smoke:src run: bun run test:launcher:smoke:src
+3
View File
@@ -42,7 +42,10 @@ Start here, then leave this file.
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example` - Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
- Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Docs-only: `bun run docs:test`, then `bun run docs:build` - Docs-only: `bun run docs:test`, then `bun run docs:build`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep ## Docs Upkeep
+7
View File
@@ -0,0 +1,7 @@
type: internal
area: testing
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
- The change-verification skill gained a `stats` lane for `stats/` edits.
+11 -7
View File
@@ -80,18 +80,24 @@ Default lanes:
```bash ```bash
bun run test # alias for test:fast bun run test # alias for test:fast
bun run test:fast # default fast lane bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
bun run test:full # maintained source + launcher-unit + runtime compat surface
bun run test:runtime:compat # compiled/runtime compatibility slice only bun run test:runtime:compat # compiled/runtime compatibility slice only
bun run test:env # launcher/plugin + env-sensitive verification bun run test:env # launcher/plugin + env-sensitive verification
bun run test:stats # stats dashboard UI suite
bun run test:immersion:sqlite # SQLite persistence lane bun run test:immersion:sqlite # SQLite persistence lane
bun run test:subtitle # maintained alass/ffsubsync subtitle surface bun run test:subtitle # maintained alass/ffsubsync subtitle surface
``` ```
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks. Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`. directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
runs each test file in its own `bun test` process (per-file isolation) so a hanging
test or leaked global in one file cannot cascade into the rest of the lane; pass
`--jobs N` to parallelize or `--single-process` for one shared process.
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`. - `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane. - `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun. - `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`. The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
@@ -126,11 +132,11 @@ Focused commands:
```bash ```bash
bun run test:config # Source-level config schema/validation tests bun run test:config # Source-level config schema/validation tests
bun run test:launcher # Launcher regression tests (config discovery + command routing) bun run test:launcher # Launcher regression tests (config discovery + command routing)
bun run test:core # Source-level core regression tests (default lane)
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
bun run test:src # Bun-managed maintained src/** discovery lane bun run test:src # Bun-managed maintained src/** discovery lane
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
bun run test:scripts # Bun-managed scripts/** test lane
bun run test:immersion:sqlite:src # Bun source lane bun run test:immersion:sqlite:src # Bun source lane
``` ```
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
bun run build # compile dist artifacts bun run build # compile dist artifacts
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
bun run test:smoke:dist # explicit smoke scope for compiled runtime bun run test:smoke:dist # explicit smoke scope for compiled runtime
bun run test:config:dist # optional full dist config suite
bun run test:core:dist # optional full dist core suite
``` ```
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker. Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
+15 -1
View File
@@ -3,10 +3,22 @@
# Verification # Verification
Status: active Status: active
Last verified: 2026-05-23 Last verified: 2026-07-06
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change Read when: selecting the right verification lane for a change
## Lane Infrastructure
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory — new test files join their lane automatically; never hand-list test
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
`--single-process` restores the shared-process mode for debugging.
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
unit, `scripts/**`, and the compiled runtime-compat slice.
## Default Handoff Gate ## Default Handoff Gate
```bash ```bash
@@ -31,6 +43,8 @@ bun run docs:build
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Coverage for the maintained source lane: `bun run test:coverage:src` - Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above - Deep/local full gate: default handoff gate above
+7 -10
View File
File diff suppressed because one or more lines are too long
@@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif
- For `docs-site/`, `docs/`, and doc-only edits. - For `docs-site/`, `docs/`, and doc-only edits.
- `config` - `config`
- For `src/config/` and config-template-sensitive edits. - For `src/config/` and config-template-sensitive edits.
- `stats`
- For `stats/` dashboard UI edits.
- `core` - `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal. - For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin` - `launcher-plugin`
@@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do
;; ;;
esac esac
case "$path" in
stats/*)
add_lane "stats"
add_reason "$path -> stats"
specialized=1
;;
esac
case "$path" in case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs) launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin" add_lane "launcher-plugin"
@@ -482,6 +482,9 @@ for lane in "${SELECTED_LANES[@]}"; do
config) config)
run_step "$lane" "config" "bun run test:config" || break run_step "$lane" "config" "bun run test:config" || break
;; ;;
stats)
run_step "$lane" "stats" "bun run test:stats" || break
;;
core) core)
run_step "$lane" "typecheck" "bun run typecheck" || break run_step "$lane" "typecheck" "bun run typecheck" || break
run_step "$lane" "fast-tests" "bun run test:fast" || break run_step "$lane" "fast-tests" "bun run test:fast" || break
+3 -66
View File
@@ -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 { spawnSync } from 'node:child_process';
import { isAbsolute, join, relative, resolve } from 'node:path'; import { isAbsolute, join, relative, resolve } from 'node:path';
import { collectLaneFiles } from './test-lanes';
type LaneConfig = {
roots: string[];
include: string[];
exclude: Set<string>;
};
type LcovRecord = { type LcovRecord = {
sourceFile: string; sourceFile: string;
@@ -18,64 +13,6 @@ type LcovRecord = {
const repoRoot = resolve(__dirname, '..'); 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 { function parseCoverageDirArg(argv: string[]): string {
for (let index = 0; index < argv.length; index += 1) { for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--coverage-dir') { if (argv[index] === '--coverage-dir') {
@@ -277,7 +214,7 @@ function runCoverageLane(): number {
rmSync(shardRoot, { recursive: true, force: true }); rmSync(shardRoot, { recursive: true, force: true });
mkdirSync(shardRoot, { recursive: true }); mkdirSync(shardRoot, { recursive: true });
const files = getLaneFiles(laneName); const files = collectLaneFiles(repoRoot, laneName);
const reports: string[] = []; const reports: string[] = [];
try { try {
+100 -54
View File
@@ -1,73 +1,119 @@
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { relative, resolve } from 'node:path'; import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process'; 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 repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const lanes = { function parseArgs(argv) {
'bun-src-full': { const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
roots: ['src'], for (let index = 0; index < argv.length; index += 1) {
include: ['.test.ts', '.type-test.ts'], const arg = argv[index];
exclude: new Set([ if (arg === '--jobs') {
'src/core/services/anki-jimaku-ipc.test.ts', options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
'src/core/services/ipc.test.ts', } else if (arg === '--timeout-secs') {
'src/core/services/overlay-manager.test.ts', options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
'src/main/config-validation.test.ts', } else if (arg === '--single-process') {
'src/main/runtime/registry.test.ts', options.singleProcess = true;
'src/main/runtime/startup-config.test.ts', } else if (!arg.startsWith('--') && options.lane === undefined) {
]), options.lane = arg;
}, } else {
'bun-launcher-unit': { process.stderr.write(`Unknown argument: ${arg}\n`);
roots: ['launcher'], process.exit(1);
include: ['.test.ts'], }
exclude: new Set(['launcher/smoke.e2e.test.ts']), }
}, return options;
}; }
function collectFiles(rootDir, includeSuffixes, excludeSet) { function runFile(file, timeoutSecs) {
const out = []; return new Promise((resolvePromise) => {
const visit = (currentDir) => { const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
for (const entry of readdirSync(currentDir, { withFileTypes: true })) { let output = '';
const fullPath = resolve(currentDir, entry.name); let timedOut = false;
if (entry.isDirectory()) { child.stdout.on('data', (chunk) => (output += chunk));
visit(fullPath); child.stderr.on('data', (chunk) => (output += chunk));
continue; const timer = setTimeout(() => {
} timedOut = true;
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/'); child.kill('SIGKILL');
if (excludeSet.has(relPath)) continue; }, timeoutSecs * 1000);
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) { child.on('close', (code) => {
out.push(relPath); 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)); await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
out.sort();
return out; 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) { const options = parseArgs(process.argv.slice(2));
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
if (!options.lane) {
process.stderr.write('Missing test lane name\n');
process.exit(1); process.exit(1);
} }
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude)); let files;
try {
if (files.length === 0) { files = collectLaneFiles(repoRoot, options.lane);
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`); } catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
process.exit(1); process.exit(1);
} }
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], { if (options.singleProcess) {
cwd: repoRoot, process.exit(runSingleProcess(files));
stdio: 'inherit',
});
if (result.error) {
throw result.error;
} }
process.exit(result.status ?? 1); process.exit(await runIsolated(files, options));
+97
View File
@@ -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;
}