/** * What a test process learns about its own tests at registration time: the * test file each `Deno.test` belongs to, and whether this invocation was * asked not to run it. * * Both come from wrapping `Deno.test` in a `--preload` module, which runs * before every test module and so needs nothing from the test files * themselves. The file is the one the process was started on, whichever * module made the call, and it is written into the run's spool beside the * record fragments, where ingestion joins it onto the identities a JUnit * report names. The skip list is a file the environment points at, * holding the identities this invocation is not to run; a listed test is * registered as ignored rather than dropped, so it appears in the report * as skipped instead of vanishing. */ import { fromFileUrl, join, relative } from "@std/path"; import { isObjectNotArray, isObjectOrArray } from "@commonfabric/utils/types"; import { type Environment, readEnv, recordsDir, repositoryRelativePath, repositoryRoot, } from "./paths.ts"; /** What a name-map file's name starts with, inside the spool. */ export const NAME_MAP_PREFIX = "names-"; /** What it ends with; the whole name is `names-.json`. */ export const NAME_MAP_SUFFIX = ".json"; /** Variable naming the file holding this invocation's skip list. */ export const SKIP_LIST_VARIABLE = "CF_TEST_SKIP_LIST"; /** * The tails of the paths of modules that register a test on another * file's behalf. Deno names a JUnit case's class after the module that * registered the test, so a case registered through one of these names * that module rather than the test file: `registration.ts` is the * `Deno.test` wrapper the preload installs, `bdd.ts` the `describe` and * `it` this repository's import map resolves to, `fixture-runner.ts` * builds a suite over a directory of fixtures, `clock-preload.ts` * replaces `Deno.test` to give each test a clock, and the runner's * `silent-backstop-guard.ts` replaces it again to fail a test a * production backstop carried. Ingestion rejects a classname ending in * one of these rather than reading it as a test file, and the preload's * name map supplies the file instead. */ export const MACHINERY_MODULE_SUFFIXES: readonly string[] = [ "src/records/registration.ts", "src/records/bdd.ts", "src/fixture-runner.ts", "test/clock-preload.ts", "test/support/silent-backstop-guard.ts", ]; /** * The separator a bdd runner joins a describe chain with, and so the * separator between a registered test's name and the leaf names beneath * it. */ export const NAME_SEPARATOR = " > "; /** * The names of every test enclosing the test a name identifies, outermost * first: `"a > b > c"` is inside `"a"` and `"a > b"`. Each separator is a * place the name may be cut, overlapping ones included, so a step of a * test named `"a >"`, which reports as `"a > > b"`, is inside `"a >"`. */ export function enclosingNames(name: string): string[] { const names: string[] = []; for ( let at = name.indexOf(NAME_SEPARATOR); at >= 0; at = name.indexOf(NAME_SEPARATOR, at + 1) ) { names.push(name.slice(0, at)); } return names; } /** * A name map as it travels: where the test process ran, and a registered * name against the repository-relative file it came from. The names are * what `Deno.test` was called with, and, for a file written with * `describe` and `it`, the whole chain of each leaf as well — a title two * files share says nothing about either, where a leaf's whole chain * usually does. */ export interface NameMap { /** * The repository-relative directory the process ran in, which for a * workspace member's test task is the member's own directory and for a * process running at the repository root is the empty string. Absent * where no repository encloses it, and the reader then has only the * files to go on. */ dir?: string; /** Each registered name against the file it came from. */ names: Record; /** * Present where an `error` or `unhandledrejection` event reached the * process while its module was loading, before any of its hooks or tests * began. Any of them can stop a module part way through * loading, so the names may stop short of what their file holds. They * still say which file each name came from. */ interrupted?: true; } /** A name map as it comes back off disk, its values still unread. */ interface ParsedNameMap { dir?: string; names: Record; interrupted?: true; } /** One map off disk, or undefined for anything that is not one. */ function asNameMap(parsed: unknown): ParsedNameMap | undefined { if (!isObjectNotArray(parsed)) { return undefined; } const { dir, names, interrupted } = parsed as { dir?: unknown; names?: unknown; interrupted?: unknown; }; // An array is an object, and its entries are index keys against the // values, so names written as one would register a test named "0". if (!isObjectNotArray(names)) { return undefined; } return { ...(typeof dir === "string" ? { dir } : {}), names: names as Record, ...(interrupted === true ? { interrupted } : {}), }; } /** * The repository-relative directory this process is running in, which is * the empty string at the repository root. Undefined outside any * repository, and undefined when the climb is not permitted to read the * filesystem. */ export function runDirectory(): string | undefined { const cwd = Deno.cwd(); const root = repositoryRoot(cwd); return root === undefined ? undefined : relative(root, cwd) .replaceAll("\\", "/"); } /** * A skip list: the names this invocation is not to run, under the * repository-relative file that registers them. Keyed by file as well as * name because the same test name occurs in more than one file. */ export type SkipList = Record; /** * The repository-relative path of the file a `file:` URL names. Undefined * for a URL naming no file, for a file inside no repository, and where * finding the repository is not permitted to read the filesystem. */ export function repositoryPathOf(url: string): string | undefined { let path: string; try { path = fromFileUrl(url); } catch { return undefined; } return repositoryRelativePath(path); } /** The test file this process runs, resolved once. */ let runFile: string | undefined; let runFileResolved = false; /** * The repository-relative path of the test file this process runs, which * is the file every test it registers belongs to. Deno runs each test * file in a realm of its own, with that file as the main module, so the * path is the one the command named: the one a skip list is keyed by and * a suite knows as its unit. A test registered from a helper module the * file imports belongs to the file all the same. * * Undefined where the main module is no file inside a repository, and * where finding the repository is not permitted to read the filesystem. */ export function runningFile(): string | undefined { if (!runFileResolved) { runFileResolved = true; runFile = repositoryPathOf(Deno.mainModule); } return runFile; } /** * The file a leaf identity's name belongs to, given a name map. A leaf is * named as its describe chain, so the registered name is either the whole * of it or a prefix of it up to a separator; the longest such prefix wins, * because nested describes make several of them. */ export function fileForName( name: string, names: ReadonlyMap, ): string | undefined { for (const registered of [...enclosingNames(name), name].reverse()) { const file = names.get(registered); if (file !== undefined) return file; } return undefined; } /** Whether an entry of a map is one a read scoped to `ranIn` takes. */ function inScope( dir: string | undefined, file: string, ranIn: string | undefined, ): boolean { if (ranIn === undefined) return true; if (dir !== undefined) return dir === ranIn; return ranIn.length === 0 || file.startsWith(`${ranIn}/`); } /** * Merges the name maps a spool holds into one lookup. A name two files * both registered is dropped rather than attributed to either: the two * are one identity, and which file it came from is not a question the * spool can answer. Reading is best-effort, since a name map is metadata * and its absence costs only the file field. * * Every package of a workspace run writes into one spool, so a caller * ingesting one package's report passes `ranIn`: the directory that * package's test process ran in. A map naming the directory it was * written in belongs to that directory and to no other, whatever files * it names. A map naming none is judged by its files, and contributes * only those under `ranIn`. Either way the scope is applied before the * ambiguity is judged rather than after, or a name two packages happen * to share would cost both of them a file each had unambiguously. */ export async function readNameMaps( spool: string, options: { ranIn?: string } = {}, ): Promise> { const ranIn = options.ranIn?.replace(/\/$/, ""); const names = new Map(); const ambiguous = new Set(); for (const map of await nameMapsIn(spool)) { for (const [name, file] of Object.entries(map.names)) { if (typeof file !== "string" || file.length === 0) continue; if (!inScope(map.dir, file, ranIn)) continue; const known = names.get(name); if (known === undefined) { names.set(name, file); continue; } if (known !== file) ambiguous.add(name); } } for (const name of ambiguous) names.delete(name); return names; } /** * The names each test file registered, by repository-relative file, from the * name maps a spool holds. A test process writes a file's names as the * file's module registers them, so a file named here is one whose process * got that far. A file a map marked as interrupted names is left out: an * error that reached its process may have stopped its module part way * through loading, after some of its tests had registered. A module whose * loading never finishes, or that calls `Deno.exit`, leaves its map * unmarked, but none of its file's tests run, and its file records nothing. * So the names under a file are every name it registered: each `Deno.test`, * and each leaf's whole chain where the bdd re-export recorded leaves. A * file whose process registered nothing is not named, and nothing here says * what it holds. * * A name two files both registered stays under each of them. Which file * holds a test is not the question this answers, so nothing is ambiguous. */ export async function readRegistrations( spool: string, ): Promise>> { const registered = new Map>(); const interrupted = new Set(); for (const map of await nameMapsIn(spool)) { for (const [name, file] of Object.entries(map.names)) { if (typeof file !== "string" || file.length === 0) continue; if (map.interrupted) interrupted.add(file); const names = registered.get(file); if (names === undefined) registered.set(file, new Set([name])); else names.add(name); } } for (const file of interrupted) registered.delete(file); return registered; } /** * Every name map a spool holds, in the order of their names. One that * cannot be read or is not a map is left out: a name map is metadata, and * its absence costs what its reader would have learned from it. */ async function nameMapsIn(spool: string): Promise { let entries: string[] = []; try { for await (const entry of Deno.readDir(spool)) { if ( entry.isFile && entry.name.startsWith(NAME_MAP_PREFIX) && entry.name.endsWith(NAME_MAP_SUFFIX) ) { entries.push(entry.name); } } } catch { entries = []; } entries.sort(); const maps: ParsedNameMap[] = []; for (const entry of entries) { let parsed: unknown; try { parsed = JSON.parse(await Deno.readTextFile(join(spool, entry))); } catch { continue; } const map = asNameMap(parsed); if (map !== undefined) maps.push(map); } return maps; } /** Serializes a skip list for the file the environment points at. */ export function serializeSkipList(skips: SkipList): string { return JSON.stringify(skips, null, 2) + "\n"; } /** * Parses a skip list. Returns undefined for anything that is not one: a * skip list is an instruction to run less, so a malformed one runs * everything rather than an arbitrary subset. */ export function parseSkipList(text: string): SkipList | undefined { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return undefined; } if (!isObjectOrArray(parsed)) return undefined; if (Array.isArray(parsed)) return undefined; const skips: SkipList = {}; for (const [file, names] of Object.entries(parsed)) { if (!Array.isArray(names)) return undefined; for (const name of names) { if (typeof name !== "string") return undefined; } skips[file] = names as string[]; } return skips; } /** What one test process captured, and what it was told not to run. */ export interface RegistrationCapture { /** The file each registered name came from, repository-relative. */ readonly names: ReadonlyMap; /** * Notes that `name` was registered from `file`, and writes it into the * spool once the code registering it has run, before any test does. A * test run that is interrupted ends without unloading, and the names * its report needs are in the spool already. */ attribute(name: string, file: string): void; /** Whether a name registered from a file is on the skip list. */ skipped(file: string | undefined, name: string): boolean; /** Writes the names noted since the last write into the spool. */ flush(): void; /** * Notes that the module registering the tests has settled, having * finished loading or stopped, and that the file's hooks and tests are * about to run. */ begin(): void; /** * Notes that an `error` or `unhandledrejection` event reached the * process before `begin`, while the module was loading, and writes every * name noted so far into the spool again in a map that says so. Every * later write says so too. After `begin`, nothing is noted. */ interrupt(): void; } let installed: RegistrationCapture | undefined; /** Whether anything has asked for the capture yet. */ let consulted = false; /** * The capture this process installed, for the bdd re-exports to consult. * Undefined when no preload ran, which is every invocation that is not * recording. */ export function activeCapture(): RegistrationCapture | undefined { consulted = true; return installed; } /** * Wraps `Deno.test` so that every registration is attributed to its file * and checked against the skip list, and arranges for the captured names * to reach the spool as they are registered and when the process unloads. * Returns undefined when there was nothing to do and `Deno.test` was left * alone. * * Called from a `--preload` module, so it runs before any test module and * needs nothing from the test files. Installing twice is a no-op: a * process that loads two preloads naming this module captures once. */ export function installRegistrationCapture( env: Environment = Deno.env.get, ): RegistrationCapture | undefined { if (installed !== undefined) return installed; const skips = loadSkipList(env); const spool = recordsDir(env); // Wrapping `Deno.test` costs the report its own file attribution: Deno // names each case's class after the module that registered it, so from // here on that is this module rather than the test file. Paying that is // right when there is a skip list to apply, or a spool this process can // write the replacement map into. With neither, the report is the // better source and nothing is wrapped. if (skips === undefined && !writableSpool(spool)) return undefined; // The bdd re-export decides whether to wrap as it is evaluated, by // asking for the capture. Finding the capture asked for already means // it decided before this ran and handed back the real `describe` and // `it`, so nothing of ours sits inside a describe chain any more: no // leaf reaches the name map, and no leaf is checked against the skip // list. A skip list says what this invocation is not to run, so an // invocation that cannot apply one stops rather than running what it // was told to leave alone. A name map is metadata, and losing it is // said out loud and carried. if (consulted) { const detail = "the bdd re-export loaded before this preload, so no " + "leaf reaches the name map or the skip list"; if (skips !== undefined) throw new Error(`test records: ${detail}`); console.warn(`test records: ${detail}`); } const dir = runDirectory(); const built = buildCapture({ registrar: Deno.test, ...(dir === undefined ? {} : { dir }), ...(skips === undefined ? {} : { skips }), ...(spool === undefined ? {} : { spool }), }); Reflect.set(Deno, "test", built.registrar); // Deno runs a file's `beforeAll` hooks once its module has settled, // having finished loading or stopped, before any other hook or test of // the file, and this one was registered first. A registrar another // preload put in place may carry no hooks; nothing then notes that the // module settled, and an error at any point marks the file's names. built.registrar.beforeAll?.(() => built.capture.begin()); globalThis.addEventListener("error", () => built.capture.interrupt()); globalThis.addEventListener( "unhandledrejection", () => built.capture.interrupt(), ); globalThis.addEventListener("unload", () => built.capture.flush()); installed = built.capture; return built.capture; } /** The lifecycle hooks `Deno.test` carries beside its overloads. */ const TEST_HOOKS = [ "beforeAll", "beforeEach", "afterEach", "afterAll", ] as const; /** Whichever of those hooks a registrar carries. */ type TestHooks = Partial>; /** What a capture is built from, and where its map goes. */ export interface CaptureOptions { /** The registrar the wrapper hands each definition on to. */ registrar: ((definition: Deno.TestDefinition) => void) & TestHooks; /** The identities this invocation is not to run. */ skips?: SkipList; /** Where `flush` writes the name map. Nothing is written without one. */ spool?: string; /** * The repository-relative directory the map says it was written in, as * it stood when the capture was built. `flush` runs as each test file * registers its tests, by which time a test of an earlier file may have * changed the working directory. */ dir?: string; } /** * The wrapper and the capture behind it, built against a registrar the * caller supplies. `installRegistrationCapture` supplies `Deno.test`; * anything else can supply its own and exercise the whole of this * without replacing a global that cannot be put back. */ export function buildCapture( options: CaptureOptions, ): { capture: RegistrationCapture; registrar: ((...args: unknown[]) => void) & TestHooks; } { const { dir, skips, spool } = options; const names = new Map(); // What the next write carries. Each write is a map of its own, and a // reader merges every map the spool holds. let unwritten = new Map(); let writing = false; let interrupted = false; let begun = false; const capture: RegistrationCapture = { names, attribute: (name, file) => { names.set(name, file); unwritten.set(name, file); if (writing) return; writing = true; queueMicrotask(() => { writing = false; capture.flush(); }); }, skipped: (file, name) => { if (skips === undefined || file === undefined) return false; return skips[file]?.includes(name) ?? false; }, flush: () => { if (spool === undefined || unwritten.size === 0) return; const map: NameMap = { ...(dir === undefined ? {} : { dir }), names: Object.fromEntries(unwritten), ...(interrupted ? { interrupted: true } : {}), }; try { Deno.mkdirSync(spool, { recursive: true }); // A random name rather than a sortable one: nothing orders these, // and `crypto.randomUUID` is built in, where a sortable id would // be one more specifier a test task's own import map has to // carry for the preload to load at all. Deno.writeTextFileSync( join( spool, `${NAME_MAP_PREFIX}${crypto.randomUUID()}${NAME_MAP_SUFFIX}`, ), JSON.stringify(map), ); unwritten = new Map(); } catch (error) { // The names stay unwritten, and the next write carries them. console.warn(`test records: cannot write a name map: ${error}`); } }, begin: () => { begun = true; }, interrupt: () => { if (interrupted || begun) return; interrupted = true; // The maps written already do not carry the mark, so every name // noted so far is written again under it. unwritten = new Map(names); capture.flush(); }, }; const realTest = options.registrar; const register = ( through: (definition: Deno.TestDefinition) => void, extra: Partial, ) => (...args: unknown[]): void => { const definition = asDefinition(args); if (definition === undefined) { // A shape this wrapper does not model reaches the real registrar // untouched and with its own arguments, so an unfamiliar overload // still runs and still reports its own error. // deno-lint-ignore no-explicit-any (through as any)(...args); return; } const file = runningFile(); if (file !== undefined) capture.attribute(definition.name, file); if (capture.skipped(file, definition.name)) { through({ ...definition, ...extra, ignore: true }); return; } through({ ...definition, ...extra }); }; const capturingTest = register(realTest, {}); Reflect.set(capturingTest, "ignore", register(realTest, { ignore: true })); Reflect.set(capturingTest, "only", register(realTest, { only: true })); // A hook registers a function to run around the tests rather than a test, // so there is nothing to attribute or skip, and it is the registrar's own. for (const hook of TEST_HOOKS) { if (realTest[hook]) Reflect.set(capturingTest, hook, realTest[hook]); } return { capture, registrar: capturingTest }; } /** * One definition, whichever way `Deno.test` was called. It takes a name * and a body, an options object and a body, a name and options and a * body, a whole definition, or a named function alone, and the options * carry the sanitizer and permission settings a test needs — so reading * only the first two arguments drops the body of several of them and * builds a definition Deno rejects. * * Undefined for a shape this does not model, including an anonymous * function with no name to take. The caller passes those through * untouched, so Deno reports them as it would have. */ export function asDefinition( args: readonly unknown[], ): Deno.TestDefinition | undefined { const [first, second, third] = args; const isBody = (value: unknown): value is Deno.TestDefinition["fn"] => typeof value === "function"; const isOptions = (value: unknown): value is Record => isObjectOrArray(value); if (typeof first === "string") { if (isBody(second)) return { name: first, fn: second }; if (isOptions(second) && isBody(third)) { return { ...second, name: first, fn: third } as Deno.TestDefinition; } return undefined; } if (isOptions(first)) { if (isBody(first.fn) && typeof first.name === "string") { return first as unknown as Deno.TestDefinition; } if (isBody(second)) { const name = typeof first.name === "string" ? first.name : second.name; if (name.length > 0) { return { ...first, name, fn: second } as Deno.TestDefinition; } } return undefined; } if (isBody(first) && first.name.length > 0) { return { name: first.name, fn: first }; } return undefined; } /** * Whether this process may write into the spool. Querying a permission * needs no permission, so this answers before anything is attempted and * without a warning for a process that was never going to record. */ export function writableSpool(spool: string | undefined): boolean { if (spool === undefined) return false; try { return Deno.permissions.querySync({ name: "write", path: spool }).state === "granted"; } catch { return false; } } function loadSkipList(env: Environment): SkipList | undefined { const path = readEnv(SKIP_LIST_VARIABLE, env); if (path === undefined || path.length === 0) return undefined; let text: string; try { text = Deno.readTextFileSync(path); } catch (error) { console.warn(`test records: cannot read the skip list ${path}: ${error}`); return undefined; } const skips = parseSkipList(text); if (skips === undefined) { console.warn(`test records: the skip list ${path} is malformed`); } return skips; }