Ctrl K
ComunidadGuías…

scripts/test-subjects.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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<string>;

/** 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<string>,
): Promise<TestSubjects> => {
  const walk: Walk = {
    importMap,
    loadsApp: false,
    queue: [testFile],
    subjects: new Set<string>(),
    testFile,
    testTreeFiles,
    visited: new Set<string>([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<string>;
  testFile: string;
  testTreeFiles: ReadonlySet<string>;
  visited: Set<string>;
};

/** 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<void> => {
  // 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);