test/scripts/test-groups.test.ts
Rama main · réplica de lectura del repositorio original.
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 = <Result>(
run: (root: string) => Result | Promise<Result>,
): Promise<Result> =>
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<string, string | undefined>,
): Promise<void> => {
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",
);
});
});
});