Ctrl K
ComunidadGuías…

scripts/test-groups.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * 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 = <T>(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<string[]> => {
  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<void>; 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<void>;
};

/**
 * 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<WrittenTestGroups> => {
  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,
  };
};