/** * Group test files into generated entry modules so many files share one * isolate. * * `deno test` runs every test file in its own isolate, and each isolate * re-evaluates the whole app module graph — several hundred milliseconds of * CPU per file, times ~940 files per full run. A group entry file simply * imports a shard of test files, so all of them register their tests into one * isolate and the module graph is evaluated once per group instead. The * focused runner (`deno task test:files`) never groups — you always get the * exact files you asked for, each in its own isolate. * * Files with global (module-level) BDD hooks cannot be merged: with * @std/testing/bdd a module-level `beforeEach(...)` applies to the whole * module, and registering a global hook after another module's tests is an * error. Those files run as their own entries, exactly as before. The same * escape is available to any file that genuinely needs its own isolate: give * it a module-level `// test-groups: run-alone` comment. */ import { isAbsolute, join, relative } from "node:path"; import { isOneOf } from "#fp"; import { rethrowUnlessNotFound } from "./not-found.ts"; import { isSourcePath, isTestPath } from "./unit-tests-report-lib.ts"; import { collectFiles } from "./walk-files.ts"; import { parseWorkerCount } from "./workers.ts"; /** Where the generated group entry files live, relative to the project root. * Outside test/ so tree-walking checks (code quality, i18n coverage) never see * them, and gitignored. */ export const GROUPS_DIR = ".test-groups"; /** Marker a test file can carry to opt out of isolate sharing. */ export const RUN_ALONE_MARKER = "test-groups: run-alone"; /** Marker a helper file can carry when it is a spawned test child's entry, * never an import of a grouped suite: its module-level hook is deliberate. */ export const CHILD_FIXTURE_MARKER = "test-groups: child-fixture"; // A hook call at column 0 is a *global* BDD hook (inside a describe it is // always indented — Biome enforces the formatting). A module-level // useSetting call hides the same bug behind an expression. The helper wraps // beforeEach and afterEach, so a top-level call pins every test in the // isolate under root-scoped hooks. A worker whose isolate carries such // hooks dies at shutdown with no failed test and no stderr (found by the // bisect behind issue #2508). const GLOBAL_HOOK_SOURCES = [/^(?:beforeAll|beforeEach|afterAll|afterEach)\(/m]; /** The test-utils helper whose module-level call registers root hooks. */ const WRAPPER_HOOK = "useSetting"; /** The index past the quoted string or template literal that opens at * `start`, honouring escapes. The scan reads a template interpolation's * body as literal text, so its braces never move the depth count. */ const skipPastQuote = ( source: string, start: number, quote: string, ): number => { let index = start + 1; while (index < source.length) { if (source[index] === "\\") { index += 2; continue; } if (source[index] === quote) return index + 1; index += 1; } return index; }; /** Whether the name at `index` is `callee`, stands alone, and is called: no * letter, digit, `_`, `$`, or dot before it, and a `(` after any spaces. */ const callsCallee = ( source: string, index: number, callee: string, ): boolean => { if (!source.startsWith(callee, index)) return false; const before = index === 0 ? "" : source[index - 1]!; if (/[A-Za-z0-9_$.]/.test(before)) return false; let after = index + callee.length; while (source[after] === " " || source[after] === "\t") after += 1; return source[after] === "("; }; /** The index past the comment or string literal at `index`, or null when * the position holds ordinary code. */ const skipCommentOrString = (source: string, index: number): number | null => { const char = source[index]!; const next = source[index + 1] ?? ""; if (char === "/" && next === "/") { const end = source.indexOf("\n", index); return end === -1 ? source.length : end; } if (char === "/" && next === "*") { const end = source.indexOf("*/", index + 2); return end === -1 ? source.length : end + 2; } if (char === '"' || char === "'" || char === "`") { return skipPastQuote(source, index, char); } return null; }; const isOpener = isOneOf(["(", "[", "{"]); const isCloser = isOneOf([")", "]", "}"]); /** The arrow-body state after one code character: a braceless arrow opens a * body, and the statement's `;` closes it. */ const arrowStateAfter = ( char: string, next: string, inArrowBody: boolean, ): boolean => (char === ";" ? false : inArrowBody || next === ">"); /** Whether the brace at `index` opens a function body: the previous code * character is a signature's `)` or an arrow's `>`. Any other brace is an * object, block, or class literal whose body still runs at module load. */ const opensFunctionBody = (source: string, index: number): boolean => { let look = index - 1; while (look >= 0 && /\s/.test(source[look]!)) look -= 1; const char = source[look]; return char === ")" || char === ">"; }; /** One scanner state: the open delimiters (each true when it opened a * function body), how many of those are function bodies, and whether a * braceless arrow body is open. */ type ScanState = { bodies: boolean[]; functionDepth: number; inArrowBody: boolean; }; /** The state after the code character at `index`: delimiters push and pop * their scopes, a brace opens a function body when it follows `)` or `=>`, * and a braceless arrow's body runs to the statement's `;`. */ const scanStateAfter = ( source: string, index: number, state: ScanState, ): ScanState => { const char = source[index]!; if (isOpener(char)) { const body = char === "{" && opensFunctionBody(source, index); return { bodies: [...state.bodies, body], functionDepth: state.functionDepth + (body ? 1 : 0), inArrowBody: state.inArrowBody, }; } if (isCloser(char)) { const closed = state.bodies.at(-1) === true; return { bodies: state.bodies.slice(0, -1), functionDepth: state.functionDepth - (closed ? 1 : 0), inArrowBody: state.inArrowBody, }; } return { ...state, inArrowBody: arrowStateAfter( char, source[index + 1] ?? "", state.inArrowBody, ), }; }; /** Whether `callee` is called at the file's top level. The scan skips * comments and string literals and reads a call outside every function * body. A parenthesised, bracket, or object initialiser runs at module * load, so a call inside one is a module-level call; only a function body * suppresses, and a `{` opens one when it follows `)` or `=>`. A braceless * arrow's body is one statement: the scan suppresses calls from the * arrow's `=>` to the statement's `;`. */ const hasModuleScopeCall = (source: string, callee: string): boolean => { let state: ScanState = { bodies: [], functionDepth: 0, inArrowBody: false }; let index = 0; while (index < source.length) { const past = skipCommentOrString(source, index); if (past !== null) { index = past; continue; } state = scanStateAfter(source, index, state); if ( state.functionDepth === 0 && !state.inArrowBody && callsCallee(source, index, callee) ) { return true; } index += 1; } return false; }; const registersGlobalHooks = (source: string): boolean => GLOBAL_HOOK_SOURCES.some((pattern) => pattern.test(source)) || hasModuleScopeCall(source, WRAPPER_HOOK); /** True when a test file must run in its own isolate instead of a group. */ export const mustRunAlone = (source: string): boolean => registersGlobalHooks(source) || source.includes(RUN_ALONE_MARKER); /** Deal `items` round-robin into `groupCount` piles (sorted input stays * spread across piles, so no pile ends up with one directory's heavy files). */ export const shardRoundRobin = (items: T[], groupCount: number): T[][] => { const count = Math.max(1, Math.min(groupCount, items.length)); const piles: T[][] = Array.from({ length: count }, () => []); items.forEach((item, index) => { piles[index % count]!.push(item); }); return piles; }; /** The source of one generated group entry: an import per member file. */ export const renderGroupEntry = (importPaths: string[]): string => `// Generated by scripts/test-groups.ts — do not edit, do not commit.\n${importPaths .map((path) => `import "${path}";`) .join("\n")}\n`; export type TestGroupPlan = { /** Shards of files that share an isolate via a generated entry. */ grouped: string[][]; /** Files that keep their own isolate (global hooks or run-alone marker). */ solo: string[]; }; /** Read each file once and pair it with whether it must run alone. */ export const classifyRunAlone = ( paths: string[], ): Promise<{ path: string; runsAlone: boolean }[]> => Promise.all( paths.map(async (path) => ({ path, runsAlone: mustRunAlone(await Deno.readTextFile(path)), })), ); /** Split test files into isolate-sharing shards and solo files. Pure. */ export const planTestGroups = ( files: { path: string; runsAlone: boolean }[], groupCount: number, ): TestGroupPlan => { const groupable = files.filter((file) => !file.runsAlone); return { grouped: shardRoundRobin( groupable.map((file) => file.path), groupCount, ), solo: files.filter((file) => file.runsAlone).map((file) => file.path), }; }; /** Every test file under `root`/test, sorted for determinism. Throws if a * shared helper (a non-test .ts/.tsx file) registers global hooks: its * importers look groupable but would blow up the whole group at load, so the * helper must expose a setup function the test files call from their own * suites. */ export const collectTestFiles = async (root: string): Promise => { const sources = await collectFiles(join(root, "test"), isSourcePath); for (const helper of sources.filter((path) => !isTestPath(path))) { const source = await Deno.readTextFile(helper); if (source.includes(CHILD_FIXTURE_MARKER)) continue; if (registersGlobalHooks(source)) { throw new Error( `${helper} is a shared test helper but registers a global BDD hook — ` + "export a setup function and call it from each test file's own suite instead", ); } } for (const file of sources.filter(isTestPath)) { if (hasModuleScopeCall(await Deno.readTextFile(file), WRAPPER_HOOK)) { throw new Error( `${file} calls useSetting at the top level. Call it inside the ` + "describe instead: a module-level call registers root hooks for " + "every test in the shared isolate, and the worker dies at shutdown.", ); } } return sources.filter(isTestPath); }; /** * How many groups to shard into: a few per test worker, so `--parallel` * (which runs one entry per worker at a time) always has entries queued and * an unlucky heavy shard cannot dominate the tail. A group entry shares one * isolate, whose coverage buffers and module graph grow with its file count, * so the count also rises with the file count to keep every group's isolate * inside the memory budget the coverage gate runs under. */ export const defaultGroupCount = ( workers: number, fileCount?: number, ): number => { const byWorkers = Math.max(8, workers * 4); if (fileCount === undefined) return byWorkers; return Math.max(byWorkers, Math.ceil(fileCount / 40)); }; const testWorkerCount = (): number => parseWorkerCount(Deno.env.get("DENO_JOBS"), navigator.hardwareConcurrency); /** Swallow the two expected ways removing the groups dir can fail — already * gone, or still holding files someone else put there (Deno reports a * non-empty dir as a plain Error, so it is matched by message) — and rethrow * anything else, e.g. a permissions failure. */ export const rethrowUnlessLeftoverDir = (error: unknown): void => { if (error instanceof Deno.errors.NotFound) return; if (String(error).includes("Directory not empty")) return; throw error; }; /** How an entry imports one of its members. Entries sit one level below the * project root, so the specifier climbs out of the entries directory to the * member's project-relative path — which keeps the project's import map * applying to it, and is what lets a mutated module bind through its alias. */ export const groupEntryImport = (root: string, member: string): string => `../${relative(root, isAbsolute(member) ? member : join(root, member))}`; /** * Write one entry module per shard into the entries directory, naming each by * its index. Returns the entry paths to run and a cleanup that removes them. */ export const writeGroupEntries = async ( root: string, shards: string[][], nameFor: (index: number) => string, ): Promise<{ cleanup: () => Promise; paths: string[] }> => { const entriesDir = join(root, GROUPS_DIR); await Deno.mkdir(entriesDir, { recursive: true }); const paths: string[] = []; for (const [index, members] of shards.entries()) { const entry = join(entriesDir, nameFor(index)); await Deno.writeTextFile( entry, renderGroupEntry(members.map((member) => groupEntryImport(root, member))), ); paths.push(entry); } return { // Only an already-removed entry is expected; anything else must surface // rather than leave generated files behind silently. cleanup: async () => { for (const entry of paths) { await Deno.remove(entry).catch(rethrowUnlessNotFound); } }, paths, }; }; export type WrittenTestGroups = { /** Paths to hand to `deno test`: group entries plus solo files. */ runArgs: string[]; /** The real test files, for the progress bar's test-count estimate. */ testFiles: string[]; /** Remove the generated entries. */ cleanup: () => Promise; }; /** * Plan and write the group entry files for a full-suite run. Entries import * their members with paths relative to GROUPS_DIR, so the project's import * map applies unchanged. */ export const writeTestGroups = async ( root: string, groupCount?: number, ): Promise => { const paths = await collectTestFiles(root); const plan = planTestGroups( await classifyRunAlone(paths), groupCount ?? defaultGroupCount(testWorkerCount(), paths.length), ); const entries = await writeGroupEntries( root, plan.grouped, (index) => `group-${index}.test.ts`, ); return { cleanup: async () => { await entries.cleanup(); await Deno.remove(join(root, GROUPS_DIR)).catch(rethrowUnlessLeftoverDir); }, runArgs: [...entries.paths, ...plan.solo], testFiles: paths, }; };