import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { fromFileUrl, join } from "@std/path"; import { runDenoCommandWithTemporaryLock } from "@commonfabric/test-support/isolated-deno"; import { collectRecords, gather, headCommitOfEvent, parseGatherArgs, parseJUnitSpec, } from "./test-records-gather.ts"; import { createRunSpool, FragmentWriter, type HeldSpool, parseRecordLine, RECORD_SCHEMA_VERSION, } from "@commonfabric/test-support/records"; const JUNIT = ` `; describe("test-records-gather", () => { describe("parseJUnitSpec()", () => { it("returns the four fields of a full specification", () => { expect( parseJUnitSpec( "kind=unit,scope=cli,prefix=packages/cli,glob=out/*.xml", ), ).toEqual({ kind: "unit", scope: "cli", prefix: "packages/cli", glob: "out/*.xml", }); }); it("returns a spec without the optional prefix", () => { expect(parseJUnitSpec("kind=pattern,scope=patterns,glob=x.xml")) .toEqual({ kind: "pattern", scope: "patterns", glob: "x.xml" }); }); it("throws when a required field is missing", () => { expect(() => parseJUnitSpec("kind=unit,glob=x.xml")).toThrow(); }); }); describe("headCommitOfEvent()", () => { it("returns the pull request head sha", () => { expect( headCommitOfEvent({ pull_request: { head: { sha: "abc123" } } }), ).toBe("abc123"); }); it("returns undefined for a push payload", () => { expect(headCommitOfEvent({ pusher: {} })).toBeUndefined(); }); it("returns undefined for a payload that is not an object", () => { expect(headCommitOfEvent(null)).toBeUndefined(); expect(headCommitOfEvent("pull_request")).toBeUndefined(); }); it("returns undefined for a pull request that names no head", () => { expect(headCommitOfEvent({ pull_request: { number: 1 } })) .toBeUndefined(); }); }); describe("gather()", () => { let dir: string; let held: HeldSpool | undefined; beforeEach(async () => { dir = await Deno.makeTempDir({ prefix: "test-records-gather-" }); held = undefined; }); afterEach(async () => { held?.close(); await Deno.remove(dir, { recursive: true }).catch(() => {}); }); it("writes job facts and the records of spool and JUnit inputs", async () => { const spoolRoot = join(dir, "spool"); held = await createRunSpool(spoolRoot, { schema: RECORD_SCHEMA_VERSION, line: "context", reportId: "01GATHERTEST000000000000", repo: "commonfabric/labs", commit: "c".repeat(40), dirty: false, env: "ci", os: "linux", arch: "x86_64", denoVersion: "2.9.4", startedAt: "2026-08-17T21:00:00.000Z", }); const writer = FragmentWriter.open(held.dir); writer?.append({ line: "record", test: { k: "gate", s: "repo", n: "check-docs" }, outcome: "pass", durationMs: 1500, }); writer?.close(); await Deno.writeTextFile(join(dir, "results.xml"), JUNIT); const out = join(dir, "out"); await gather({ out, job: "Test (3/8)", shard: "3/8", variant: "wood-fired", junit: [{ kind: "unit", scope: "cli", prefix: "packages/cli", glob: join(dir, "*.xml"), }], spoolDir: held.dir, }); const facts = JSON.parse( await Deno.readTextFile(join(out, "job.json")), ); expect(facts.job).toBe("Test (3/8)"); expect(facts.shard).toBe("3/8"); expect(facts.denoVersion).toBe(Deno.version.deno); expect(Number.isSafeInteger(facts.shuffleSeed)).toBe(true); const lines = (await Deno.readTextFile(join(out, "records.ndjson"))) .trimEnd().split("\n"); expect(lines.length).toBe(2); const records = lines.map((line) => parseRecordLine(line)); expect(records[0]?.test.n).toBe("check-docs"); expect(records[0]?.test.v).toBe("wood-fired"); expect(records[1]).toEqual({ line: "record", test: { k: "unit", s: "cli", n: "alpha", v: "wood-fired", }, outcome: "pass", durationMs: 250, file: "packages/cli/test/alpha.test.ts", }); }); it("writes an empty records file when there is nothing to gather", async () => { const out = join(dir, "out"); await gather({ out, job: "Check", junit: [] }); expect(await Deno.readTextFile(join(out, "records.ndjson"))).toBe(""); const facts = JSON.parse( await Deno.readTextFile(join(out, "job.json")), ); expect(facts.job).toBe("Check"); }); it("rejects an empty variant from a direct caller", async () => { await expect( gather({ out: join(dir, "out"), job: "Check", variant: "", junit: [] }), ).rejects.toThrow("a declared variant must not be empty"); }); it("records the commit, branch, and pull request head from the environment", async () => { const out = join(dir, "out"); const eventPath = join(dir, "event.json"); await Deno.writeTextFile( eventPath, JSON.stringify({ pull_request: { head: { sha: "f".repeat(40) } } }), ); await gather({ out, job: "Check", junit: [], env: (name) => { if (name === "GITHUB_SHA") return "a".repeat(40); if (name === "GITHUB_HEAD_REF") return "feature-branch"; if (name === "GITHUB_EVENT_PATH") return eventPath; if (name === "CF_TEST_SHUFFLE_SEED") return "7"; return undefined; }, }); const facts = JSON.parse( await Deno.readTextFile(join(out, "job.json")), ); expect(facts.commit).toBe("a".repeat(40)); expect(facts.branch).toBe("feature-branch"); expect(facts.headCommit).toBe("f".repeat(40)); // The seed a job's runners shuffled by, which an override the job // set decides. expect(facts.shuffleSeed).toBe(7); }); it("records no head commit where the event payload is not JSON", async () => { // The payload is context the job may lack, so a payload this cannot // read costs the one fact it would have given and nothing else. const out = join(dir, "out"); const eventPath = join(dir, "event.json"); await Deno.writeTextFile(eventPath, "{not json"); await gather({ out, job: "Check", junit: [], env: (name) => { if (name === "GITHUB_SHA") return "a".repeat(40); if (name === "GITHUB_EVENT_PATH") return eventPath; return undefined; }, }); const facts = JSON.parse( await Deno.readTextFile(join(out, "job.json")), ); expect(facts.commit).toBe("a".repeat(40)); expect(facts.headCommit).toBeUndefined(); }); it("ingests the files a JUnit glob matches and skips its directories", async () => { await Deno.writeTextFile(join(dir, "a-good.xml"), JUNIT); await Deno.mkdir(join(dir, "b-directory.xml")); const warned: string[] = []; const warn = console.warn; console.warn = (line: string) => warned.push(line); const out = join(dir, "out"); try { await gather({ out, job: "Check", junit: [{ kind: "unit", scope: "cli", glob: join(dir, "*.xml") }], }); } finally { console.warn = warn; } expect(warned).toEqual([]); const lines = (await Deno.readTextFile(join(out, "records.ndjson"))) .trimEnd().split("\n"); expect(lines.length).toBe(1); }); it("warns about a JUnit glob that matched nothing, and still writes", async () => { const glob = join(dir, "reports", "*.xml"); const warned: string[] = []; const warn = console.warn; console.warn = (line: string) => warned.push(line); const out = join(dir, "out"); try { await gather({ out, job: "Check", junit: [{ kind: "unit", scope: "cli", glob }], }); } finally { console.warn = warn; } expect(warned).toEqual([ `test records: no JUnit files matched ${glob}`, ]); expect(await Deno.readTextFile(join(out, "records.ndjson"))).toBe(""); }); it("keeps ingesting after one unreadable JUnit file", async () => { await Deno.writeTextFile(join(dir, "a-bad.xml"), " { it("returns every record of a spool holding half a million", async () => { // A spool holds a record for every test its job ran, and a job can // run more tests than one call takes as arguments. const dir = await Deno.makeTempDir({ prefix: "test-records-gather-" }); const count = 500_000; try { const writer = FragmentWriter.open(dir); for (let at = 0; at < count; at++) { writer?.append({ line: "record", test: { k: "unit", s: "cli", n: `bakes ${at}` }, outcome: "pass", durationMs: 1, }); } writer?.close(); const { records } = await collectRecords({ spoolDir: dir, junit: [] }); expect(records.length).toBe(count); expect(records.at(-1)?.test.n).toBe(`bakes ${count - 1}`); } finally { await Deno.remove(dir, { recursive: true }); } }); it("returns what each outermost describe's hooks took, in the declared variant", async () => { const dir = await Deno.makeTempDir({ prefix: "test-records-gather-" }); try { await Deno.writeTextFile( join(dir, "results.xml"), ` `, ); const { records, hooks } = await collectRecords({ junit: [{ kind: "integration", scope: "bakery", prefix: "packages/bakery", glob: join(dir, "*.xml"), }], variant: "wood-fired", }); expect(records.map((record) => record.test.n)).toEqual([ "oven > bakes", ]); expect(hooks).toEqual([{ line: "record", test: { k: "integration", s: "bakery", n: "oven", v: "wood-fired" }, outcome: "pass", durationMs: 3500, file: "packages/bakery/oven.test.ts", }]); } finally { await Deno.remove(dir, { recursive: true }); } }); }); describe("the command line continuous integration runs", () => { const ROOT = fromFileUrl(new URL("..", import.meta.url)); const SCRIPT = fromFileUrl( new URL("./test-records-gather.ts", import.meta.url), ); let out: string; beforeEach(async () => { out = await Deno.makeTempDir({ prefix: "test-records-gather-cli-" }); }); afterEach(async () => { await Deno.remove(out, { recursive: true }); }); /** Runs the gather step as a job does, with the environment given. */ const gatherAsAJob = (args: string[], env: Record) => runDenoCommandWithTemporaryLock({ root: ROOT, args: (lock) => [ "run", `--lock=${lock}`, "--allow-read", "--allow-write", "--allow-env", "--allow-run=git", SCRIPT, ...args, ], env, }); it("writes the seed the job's runners shuffled by into the job facts", async () => { const result = await gatherAsAJob( ["--out", join(out, "artifact"), "--job", "Probe"], { CF_TEST_SHUFFLE_SEED: "7", CF_TEST_RECORDS_DIR: "" }, ); expect(result.success).toBe(true); const facts = JSON.parse( await Deno.readTextFile(join(out, "artifact", "job.json")), ); expect(facts.job).toBe("Probe"); expect(facts.shuffleSeed).toBe(7); }); it("refuses a command line that names no output directory", async () => { const result = await gatherAsAJob(["--job", "Probe"], {}); expect(result.code).toBe(2); expect(new TextDecoder().decode(result.stderr)).toContain( "usage: test-records-gather.ts", ); }); }); describe("parseGatherArgs()", () => { it("returns the options of a full command line", () => { const options = parseGatherArgs([ "--out", "artifact", "--job", "Test (3/8)", "--shard", "3/8", "--variant", "wood-fired", "--junit", "kind=unit,scope=cli,glob=*.xml", ]); expect(options?.out).toBe("artifact"); expect(options?.job).toBe("Test (3/8)"); expect(options?.shard).toBe("3/8"); expect(options?.variant).toBe("wood-fired"); expect(options?.junit.length).toBe(1); }); it("skips a malformed junit specification and keeps the rest", () => { const options = parseGatherArgs([ "--out", "artifact", "--job", "Check", "--junit", "not a spec", "--junit", "kind=unit,scope=cli,glob=*.xml", ]); expect(options?.junit.length).toBe(1); }); it("returns undefined for unknown flags and missing values", () => { expect(parseGatherArgs(["--out"])).toBeUndefined(); expect(parseGatherArgs(["--mystery", "x"])).toBeUndefined(); expect(parseGatherArgs(["--out", "artifact"])).toBeUndefined(); expect( parseGatherArgs([ "--out", "artifact", "--job", "Check", "--variant", "", ]), ).toBeUndefined(); }); }); });