/** * What a test file actually exercises. * * A test rarely names its subject directly. It calls a shared helper — a * factory, a `shared.ts` next to its siblings — and that helper is what * imports the `src/` module under test. Reading only the test's own import * list therefore credits the test to whichever `src/` file it happened to * mention (a database client it seeds rows with, a CSRF helper it reads a * token from) and misses the module its assertions are really about. * * This module follows the imports: from the test file, through every `test/` * helper it reaches, collecting the `src/` files named on the way. It stops * at the `src/` boundary — a source's own imports are not the test's * subjects, or every test would exercise the whole tree. Helpers under * `test/test-utils/` name no subjects (see `TestSubjects`). * * The unit-test coverage report uses this to work out which source each test * covers. The mutation gate deliberately does not: it selects tests by the * mirror path alone, so a source whose test sits elsewhere is reported as * missing its direct suite and gets moved, rather than quietly running whatever * reaches it through a shared helper. * * Reading files is the caller's job: pass a `readText`, and the walk stays pure * enough to unit-test from an in-memory map. */ import { remembered } from "#shared/remembered.ts"; import { type ImportMap, parseImportSpecifiers, resolveImportToSourceOrNull, } from "./unit-tests-report-imports.ts"; export type { ImportMap }; /** Reads a project-relative file's text. Missing files must throw. */ export type ReadText = (path: string) => Promise; /** Resolve a relative specifier against the file that wrote it. */ const resolveRelative = (fromFile: string, spec: string): string => { const parts = fromFile.split("/").slice(0, -1); for (const segment of spec.split("/")) { if (segment === ".") continue; if (segment === "..") parts.pop(); else parts.push(segment); } return parts.join("/"); }; /** * Resolve one import specifier written in `fromFile` to the project file it * names, or `null` when it names something outside the project (an npm or jsr * module, a std package). Both forms a project file can use are handled: a * `./`-relative path, and a `#` alias from the import map. */ export const resolveProjectImportOrNull = ( spec: string, importMap: ImportMap, fromFile: string, ): string | null => { if (spec.startsWith("./") || spec.startsWith("../")) return resolveRelative(fromFile, spec); if (!spec.startsWith("#")) return null; for (const root of ["src", "test", "scripts", "cli"]) { const resolved = resolveImportToSourceOrNull(spec, importMap, root); if (resolved !== null) return resolved; } return null; }; /** * Helpers under `test/test-utils/` are followed but add no subjects. Their * imports — a database row, a config value, a rendered page — are plumbing * nearly every test needs on the way, not what the test is about. Counting * them buries single-source findings under dozens of incidental imports. * * The walk still reads them, because a helper that drives the app (it imports * the `#routes` entry, or a route module under `#routes/…`) says the test is * an integration suite however it reaches the pages. `loadsApp` carries that * out; the misplaced-test list refuses to move such a test (issue #2312). */ export type TestSubjects = { /** True when a helper the test reaches imports the `#routes` app entry or a * route module under `#routes/…`. The test drives real pages, so it is an * integration suite, never a single-source unit. */ loadsApp: boolean; /** Every `src/` file the test exercises: the ones it imports itself, plus * the ones imported on its behalf by helpers outside `test/test-utils/`. */ subjects: string[]; }; export const collectTestSubjects = async ( testFile: string, readText: ReadText, importMap: ImportMap, testTreeFiles: ReadonlySet, ): Promise => { const walk: Walk = { importMap, loadsApp: false, queue: [testFile], subjects: new Set(), testFile, testTreeFiles, visited: new Set([testFile]), }; while (walk.queue.length > 0) { await readFileIntoWalk(walk, walk.queue.shift()!, readText); } return { loadsApp: walk.loadsApp, subjects: [...walk.subjects] }; }; /** Everything the walk learns about one test, in the state it gathers it. */ type Walk = { importMap: ImportMap; /** Set when a helper the test reaches imports `#routes` or a module under * `#routes/…` — the test drives real pages, so it is an integration suite. */ loadsApp: boolean; /** Test-tree files still to read; starts at the test file itself. */ queue: string[]; subjects: Set; testFile: string; testTreeFiles: ReadonlySet; visited: Set; }; /** Whether `file` sits under `test/test-utils/` — the shared plumbing nearly * every test reaches — however the walk's root spelled its path: an absolute * root walks absolute paths, and Windows writes `\` separators. */ const underTestUtils = (file: string): boolean => /(^|\/)test\/test-utils\//.test(file.replaceAll("\\", "/")); /** Read one file and fold each of its specifiers into `walk`. */ const readFileIntoWalk = async ( walk: Walk, file: string, readText: ReadText, ): Promise => { // The test names its own imports as subjects; so does a helper beside it. // The shared helpers under test/test-utils/ are plumbing nearly every test // needs, so their imports name no subject (see `TestSubjects`). const namesSubjects = file === walk.testFile || !underTestUtils(file); for (const spec of parseImportSpecifiers(await readText(file))) { foldIntoWalk(walk, file, namesSubjects, spec); } }; /** Fold one specifier into the walk: app reach from a helper, a subject a * naming file imports, and a still-unread helper queued for reading. */ const foldIntoWalk = ( walk: Walk, file: string, namesSubjects: boolean, spec: string, ): void => { // `#routes` names the app entry point; `#routes/…` are its route modules. if (file !== walk.testFile && /^#routes(\/|$)/.test(spec)) { walk.loadsApp = true; } const resolved = resolveProjectImportOrNull(spec, walk.importMap, file); if (resolved === null) return; if (resolved.startsWith("src/")) { if (namesSubjects) walk.subjects.add(resolved); } else if (followsFrom(walk, resolved)) { walk.visited.add(resolved); walk.queue.push(resolved); } }; /** A helper worth reading: a test-tree file the walk has not read yet. */ const followsFrom = (walk: Walk, path: string): boolean => walk.testTreeFiles.has(path) && !walk.visited.has(path); /** A `readText` that reads from disk once per path and caches the text, so a * helper shared by fifty tests is read one time for the whole walk. */ export const cachingReader = (read: ReadText): ReadText => remembered(read);