import { assertArrayIncludes, assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { rethrowUnlessNotFound } from "#scripts/not-found.ts"; import { CHILD_FIXTURE_MARKER, collectTestFiles, defaultGroupCount, GROUPS_DIR, groupEntryImport, mustRunAlone, planTestGroups, RUN_ALONE_MARKER, renderGroupEntry, rethrowUnlessLeftoverDir, shardRoundRobin, writeTestGroups, } from "#scripts/test-groups.ts"; import { parseWorkerCount } from "#scripts/workers.ts"; import { withEnv } from "#test-utils/env.ts"; import { withTempDir } from "#test-utils/files.ts"; describe("test-groups", () => { describe("mustRunAlone", () => { test("a module-level hook forces a solo isolate", () => { expect( mustRunAlone('beforeAll(() => {});\ndescribe("x", () => {});'), ).toBe(true); expect(mustRunAlone("afterEach(() => {});")).toBe(true); }); test("hooks inside a describe do not force a solo isolate", () => { expect( mustRunAlone('describe("x", () => {\n beforeEach(() => {});\n});'), ).toBe(false); }); test("a module-level useSetting call in an assignment forces a solo isolate", () => { // The assignment carries the same root-scoped hooks as a bare call, so // the file cannot share an isolate. expect( mustRunAlone( 'const settings = useSetting({ timezone: "UTC" });\ndescribe("x", () => {});', ), ).toBe(true); }); test("a useSetting call inside a braceless arrow does not force a solo isolate", () => { // The arrow body runs when the caller calls it, not at import time. expect( mustRunAlone( 'const pin = (overrides) => useSetting(overrides);\ndescribe("x", () => {});', ), ).toBe(false); }); test("a useSetting mention inside a string does not force a solo isolate", () => { expect( mustRunAlone( 'const sample = "useSetting({})";\ndescribe("x", () => {});', ), ).toBe(false); }); test("a useSetting call inside a comment does not force a solo isolate", () => { expect(mustRunAlone("// useSetting({});")).toBe(false); expect(mustRunAlone("/* useSetting({}); */")).toBe(false); expect(mustRunAlone("/* useSetting({}); */ useSetting({});")).toBe(true); }); test("an escaped quote ends the string at the real quote", () => { // The scan reads the apostrophe as text, so the statement after the // string still names a module-level call. expect( mustRunAlone( 'const s = "it\\\'s"; useSetting({});\ndescribe("x", () => {});', ), ).toBe(true); }); test("an unterminated literal or comment reads as text to the file end", () => { expect(mustRunAlone('const s = "unterminated useSetting({});')).toBe( false, ); expect(mustRunAlone("/* unterminated useSetting({});")).toBe(false); }); test("a useSetting call on another object is not a module-level call", () => { expect( mustRunAlone( 'const settings = helper.useSetting({ timezone: "UTC" });\ndescribe("x", () => {});', ), ).toBe(false); }); test("a tab between the helper name and its call still reads as a call", () => { expect(mustRunAlone('useSetting\t({ timezone: "UTC" });')).toBe(true); }); test("a useSetting call inside a top-level initialiser forces a solo isolate", () => { // The initialiser runs at module load, so a call inside brackets, // parens, or an object literal executes just the same as a bare // assignment. expect( mustRunAlone( 'const state = [useSetting({ timezone: "UTC" })];\ndescribe("x", () => {});', ), ).toBe(true); expect( mustRunAlone( 'const state = { tz: useSetting({ timezone: "UTC" }) };\ndescribe("x", () => {});', ), ).toBe(true); expect( mustRunAlone( 'const state = (useSetting({ timezone: "UTC" }));\ndescribe("x", () => {});', ), ).toBe(true); }); test("a useSetting call inside a function body does not force a solo isolate", () => { // A function body only runs when the function is invoked, so a call // behind one is not a module-load execution. expect( mustRunAlone( 'function read() { return useSetting({}); }\ndescribe("x", () => {});', ), ).toBe(false); expect( mustRunAlone( 'const read = () => { return useSetting({}); };\ndescribe("x", () => {});', ), ).toBe(false); }); test("the run-alone marker forces a solo isolate", () => { expect(mustRunAlone(`// ${RUN_ALONE_MARKER}\ndescribe("x");`)).toBe(true); // The solo files carry a reason after the marker, so a stricter match // would silently drop them back into a shared isolate. expect( mustRunAlone( `// ${RUN_ALONE_MARKER} - a sibling's pending timer\ndescribe("x");`, ), ).toBe(true); }); test("a plain test file can share an isolate", () => { expect(mustRunAlone('describe("x", () => {});')).toBe(false); }); }); describe("shardRoundRobin", () => { test("deals items round-robin so pile sizes differ by at most one", () => { const piles = shardRoundRobin([1, 2, 3, 4, 5, 6, 7], 3); expect(piles).toEqual([ [1, 4, 7], [2, 5], [3, 6], ]); }); test("never makes more piles than there are items", () => { expect(shardRoundRobin(["a", "b"], 5)).toEqual([["a"], ["b"]]); }); test("clamps a zero group count to one pile", () => { expect(shardRoundRobin(["a", "b"], 0)).toEqual([["a", "b"]]); }); }); describe("renderGroupEntry", () => { test("renders one import per member file", () => { const source = renderGroupEntry([ "../test/a.test.ts", "../test/b.test.ts", ]); // Each import sits on its own line, so a generated entry stays readable // when a group needs eyeballing. const lines = source.split("\n"); assertArrayIncludes(lines, ['import "../test/a.test.ts";']); assertArrayIncludes(lines, ['import "../test/b.test.ts";']); assertStringIncludes(source, "do not edit"); }); }); describe("planTestGroups", () => { test("splits solo files out and shards the rest", () => { const plan = planTestGroups( [ { path: "a.test.ts", runsAlone: false }, { path: "b.test.ts", runsAlone: true }, { path: "c.test.ts", runsAlone: false }, ], 1, ); expect(plan.grouped).toEqual([["a.test.ts", "c.test.ts"]]); expect(plan.solo).toEqual(["b.test.ts"]); }); }); describe("parseWorkerCount", () => { test("uses a positive whole number and falls back on anything else", () => { expect(parseWorkerCount("2", 8)).toBe(2); expect(parseWorkerCount("1", 8)).toBe(1); expect(parseWorkerCount(undefined, 8)).toBe(8); expect(parseWorkerCount("", 8)).toBe(8); // Number("") is 0 — not positive expect(parseWorkerCount("0", 8)).toBe(8); expect(parseWorkerCount("-3", 8)).toBe(8); expect(parseWorkerCount("2.5", 8)).toBe(8); expect(parseWorkerCount("abc", 8)).toBe(8); }); }); describe("defaultGroupCount", () => { test("gives each worker several groups to draw from", () => { expect(defaultGroupCount(4)).toBe(16); }); test("never drops below the floor on small machines", () => { expect(defaultGroupCount(1)).toBe(8); }); test("splits the file count so no isolate holds too many files", () => { // The suite deals about two thousand three hundred files; at eight // workers the worker term alone would hold seventy-odd files per // isolate, and the file-count term keeps every group near the size a // coverage run survives. expect(defaultGroupCount(8, 2300)).toBe(58); expect(defaultGroupCount(8, 2000)).toBe(50); }); test("keeps the worker term when the file count is small", () => { expect(defaultGroupCount(8, 40)).toBe(32); }); }); describe("cleanup error filters", () => { test("rethrowUnlessNotFound lets only a missing file pass", () => { expect(() => rethrowUnlessNotFound(new Deno.errors.NotFound("gone")), ).not.toThrow(); expect(() => rethrowUnlessNotFound(new Error("boom"))).toThrow("boom"); }); test("rethrowUnlessLeftoverDir lets only the expected leftovers pass", () => { expect(() => rethrowUnlessLeftoverDir(new Deno.errors.NotFound("gone")), ).not.toThrow(); expect(() => rethrowUnlessLeftoverDir( new Error("Directory not empty (os error 39): remove '/x'"), ), ).not.toThrow(); expect(() => rethrowUnlessLeftoverDir(new Error("boom"))).toThrow("boom"); }); }); describe("writeTestGroups", () => { /** A scratch project root with a test/ dir: one groupable file, one * global-hook file, and one non-test helper that must be ignored. */ const withScratchRoot = ( run: (root: string) => Result | Promise, ): Promise => withTempDir( async (root) => { await Deno.mkdir(`${root}/test`, { recursive: true }); await Deno.writeTextFile( `${root}/test/plain.test.ts`, 'describe("plain", () => {});\n', ); await Deno.writeTextFile( `${root}/test/global-hooks.test.ts`, "beforeAll(() => {});\n", ); await Deno.writeTextFile( `${root}/test/helper.ts`, "export const x = 1;\n", ); return await run(root); }, { prefix: "tickets-test-groups-" }, ); test("collectTestFiles accepts a marked child fixture with a global hook", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/hooks-child-fixture.ts`, `// ${CHILD_FIXTURE_MARKER}\nbeforeAll(() => {});\n`, ); expect(await collectTestFiles(root)).toEqual([ `${root}/test/global-hooks.test.ts`, `${root}/test/plain.test.ts`, ]); }); }); test("collectTestFiles rejects a shared helper with a global hook", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/hooky-helper.ts`, "afterEach(() => {});\n", ); await expect(collectTestFiles(root)).rejects.toThrow( "registers a global BDD hook — export a setup function and call it " + "from each test file's own suite instead", ); }); }); test("collectTestFiles rejects a test file whose top level calls useSetting", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/pin.test.ts`, 'import { useSetting } from "#test-utils/settings.ts";\n' + 'useSetting({ timezone: "UTC" });\n' + 'describe("pin", () => {});\n', ); await expect(collectTestFiles(root)).rejects.toThrow( "calls useSetting at the top level", ); await expect(collectTestFiles(root)).rejects.toThrow( "Call it inside the describe instead", ); }); }); test("collectTestFiles rejects a helper whose module level calls useSetting", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/hooky-helper.ts`, 'import { useSetting } from "#test-utils/settings.ts";\n' + 'export const settings = useSetting({ timezone: "UTC" });\n', ); await expect(collectTestFiles(root)).rejects.toThrow( "registers a global BDD hook", ); }); }); test("collectTestFiles accepts a useSetting call inside a describe", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/pin.test.ts`, 'import { useSetting } from "#test-utils/settings.ts";\n' + 'describe("pin", () => {\n useSetting({ timezone: "UTC" });\n});\n', ); // The scratch root seeds plain and global-hooks fixtures; the pin // file sorts between them. expect(await collectTestFiles(root)).toEqual([ `${root}/test/global-hooks.test.ts`, `${root}/test/pin.test.ts`, `${root}/test/plain.test.ts`, ]); }); }); test("collectTestFiles finds .test.ts and .test.tsx files, sorted", async () => { await withScratchRoot(async (root) => { await Deno.writeTextFile( `${root}/test/component.test.tsx`, 'describe("component", () => {});\n', ); // A test file OUTSIDE test/ must not be picked up — the walk is // rooted at the test directory, not the whole project. await Deno.writeTextFile( `${root}/stray.test.ts`, 'describe("stray", () => {});\n', ); const files = await collectTestFiles(root); expect(files).toEqual([ `${root}/test/component.test.tsx`, `${root}/test/global-hooks.test.ts`, `${root}/test/plain.test.ts`, ]); }); }); test("writes group entries, keeps solo files separate, and cleans up", async () => { await withScratchRoot(async (root) => { const groups = await writeTestGroups(root, 2); // One groupable file → one group entry; the global-hook file is solo. expect(groups.runArgs).toEqual([ `${root}/${GROUPS_DIR}/group-0.test.ts`, `${root}/test/global-hooks.test.ts`, ]); expect(groups.testFiles).toEqual([ `${root}/test/global-hooks.test.ts`, `${root}/test/plain.test.ts`, ]); const entry = await Deno.readTextFile(groups.runArgs[0]!); assertStringIncludes(entry, 'import "../test/plain.test.ts";'); await groups.cleanup(); await groups.cleanup(); // a second pass finds nothing left — no throw // The entries and (now empty) groups dir are gone. await expect(Deno.stat(`${root}/${GROUPS_DIR}`)).rejects.toThrow(); }); }); test("cleanup leaves a groups dir holding someone else's files", async () => { await withScratchRoot(async (root) => { const groups = await writeTestGroups(root, 1); await Deno.writeTextFile(`${root}/${GROUPS_DIR}/keep.txt`, "mine"); await groups.cleanup(); expect(await Deno.readTextFile(`${root}/${GROUPS_DIR}/keep.txt`)).toBe( "mine", ); // A later run must reuse that leftover dir rather than choke on it. const again = await writeTestGroups(root, 1); assertStringIncludes(again.runArgs[0] ?? "", GROUPS_DIR); await again.cleanup(); }); }); /** Run writeTestGroups with its default group count under `env`, and * expect the scratch root's single groupable file to land in one entry — * piles clamp to the file count whichever worker default applies. */ const expectOneGroupEntryWithEnv = async ( env: Record, ): Promise => { using _env = withEnv(env); await withScratchRoot(async (root) => { const groups = await writeTestGroups(root); expect( groups.runArgs.filter((arg) => arg.includes(GROUPS_DIR)), ).toHaveLength(1); await groups.cleanup(); }); }; test("group count defaults from DENO_JOBS when set", async () => { await expectOneGroupEntryWithEnv({ DENO_JOBS: "2" }); }); test("group count falls back to the machine's cores without DENO_JOBS", async () => { await expectOneGroupEntryWithEnv({ DENO_JOBS: undefined }); }); }); describe("groupEntryImport", () => { test("climbs out of the entries directory to a project-relative path", () => { expect(groupEntryImport("/repo", "/repo/test/a.test.ts")).toBe( "../test/a.test.ts", ); }); test("treats a member that is already project-relative as such", () => { expect(groupEntryImport("/repo", "test/a.test.ts")).toBe( "../test/a.test.ts", ); }); }); });