Public: anyone can read this; the crew can change it.
test/shards.test.ts runs in under 1 s again: it took 0.3 s on 2026-10-21 and 10.4 s on main today #8315
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8310-test-shards-test-ts-runs-in-under-1-s-ag → main 1 commit fork 1 thread · 1 unresolved
7 files
changed +441
−416
scripts/test-times.json +1
| @@ -46,6 +46,7 @@ | ||
| 46 | 46 | "test/offline.test.ts": 3.5, |
| 47 | 47 | "test/pulls.test.ts": 14.6, |
| 48 | 48 | "test/run-secrets.test.ts": 2.8, |
| 49 | "test/shards-run.test.ts": 12.5, | |
| 49 | 50 | "test/shards.test.ts": 0.3, |
| 50 | 51 | "test/share.test.ts": 16.2, |
| 51 | 52 | "test/spawn-plan.test.ts": 0.2, |
test/fixtures/shard-broken.test.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: fails alone too. | |
| 1 | // Fixture for test/shards-run.test.ts, kept out of the gate: fails alone too. | |
| 2 | 2 | import { expect, test } from "bun:test"; |
| 3 | 3 | |
| 4 | 4 | test("broken", () => { |
test/fixtures/shard-errors.test.ts +1 −1
| @@ -1,2 +1,2 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: errors while loading. | |
| 1 | // Fixture for test/shards-run.test.ts, kept out of the gate: errors while loading. | |
| 2 | 2 | throw new Error("boom at load"); |
test/fixtures/shard-poison.test.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: sets a global that | |
| 1 | // Fixture for test/shards-run.test.ts, kept out of the gate: sets a global that | |
| 2 | 2 | // shard-victim.test.ts cannot stand beside. Both pass alone. |
| 3 | 3 | import { test } from "bun:test"; |
| 4 | 4 |
test/fixtures/shard-victim.test.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | // Fixture for test/shards.test.ts, kept out of the gate: fails only in a | |
| 1 | // Fixture for test/shards-run.test.ts, kept out of the gate: fails only in a | |
| 2 | 2 | // process where shard-poison.test.ts has already run. |
| 3 | 3 | import { expect, test } from "bun:test"; |
| 4 | 4 |
test/shards-run.test.ts added +423
| @@ -0,0 +1,423 @@ | ||
| 1 | /** | |
| 2 | * The gate's runner (`scripts/test-shards.ts`) end to end: each test spawns | |
| 3 | * it, and it spawns `bun test`, so each costs a few hundred milliseconds. | |
| 4 | * The pure parts it is built from (`scripts/shards.ts`) are proved in | |
| 5 | * test/shards.test.ts, which stays fast because it spawns nothing. | |
| 6 | */ | |
| 7 | import { afterAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { CRASH_RERUNS } from "../scripts/shards.ts"; | |
| 12 | import { EXCLUDED, gateFiles, presentTests } from "../scripts/gate.ts"; | |
| 13 | ||
| 14 | describe("the shard's output", () => { | |
| 15 | // A check's log is this script's stdout, line by line, as the forge | |
| 16 | // reads it (`checks/service.ts`). Holding a shard's output until it | |
| 17 | // ended meant a forty-minute gate showed nothing at all until the end. | |
| 18 | test("one shard at a time: a line is written when the test says it, not when the shard ends", async () => { | |
| 19 | const dir = mkdtempSync(join(tmpdir(), "shards-live-")); | |
| 20 | writeFileSync( | |
| 21 | join(dir, "live.test.ts"), | |
| 22 | `import { test } from "bun:test"; | |
| 23 | test("says two things, a pause apart", async () => { | |
| 24 | console.log("tick-one"); | |
| 25 | await Bun.sleep(1500); | |
| 26 | console.log("tick-two"); | |
| 27 | }); | |
| 28 | `, | |
| 29 | ); | |
| 30 | const script = new URL("../scripts/test-shards.ts", import.meta.url) | |
| 31 | .pathname; | |
| 32 | const child = Bun.spawn(["bun", script, "live.test.ts"], { | |
| 33 | cwd: dir, | |
| 34 | env: { ...process.env, FORGE_CHECK_SHARDS: "1" }, | |
| 35 | stdin: "ignore", | |
| 36 | stdout: "pipe", | |
| 37 | stderr: "pipe", | |
| 38 | }); | |
| 39 | let text = ""; | |
| 40 | let firstAt = -1; | |
| 41 | const decoder = new TextDecoder(); | |
| 42 | const reading = (async () => { | |
| 43 | for await (const chunk of child.stdout) { | |
| 44 | text += decoder.decode(chunk, { stream: true }); | |
| 45 | if (firstAt < 0 && text.includes("tick-one")) | |
| 46 | firstAt = performance.now(); | |
| 47 | } | |
| 48 | })(); | |
| 49 | const code = await child.exited; | |
| 50 | const ended = performance.now(); | |
| 51 | await reading; | |
| 52 | rmSync(dir, { recursive: true, force: true }); | |
| 53 | expect(code).toBe(0); | |
| 54 | expect(firstAt).toBeGreaterThan(0); | |
| 55 | // The first line reached us while the test was still asleep. | |
| 56 | expect(ended - firstAt).toBeGreaterThan(1000); | |
| 57 | expect(text).toContain("tick-two"); | |
| 58 | expect(text).toContain("── shard 1/1: live.test.ts"); | |
| 59 | expect(text).toMatch(/── shard 1\/1 done \(exit 0, [\d.]+s\)/); | |
| 60 | }, 30_000); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("the gate audit before any shard", () => { | |
| 64 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 65 | const listed = presentTests(); | |
| 66 | const git = (dir: string, ...args: string[]) => | |
| 67 | Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { | |
| 68 | cwd: dir, | |
| 69 | stdout: "ignore", | |
| 70 | stderr: "ignore", | |
| 71 | }).exitCode; | |
| 72 | // `stopAt`: kill the runner once stderr carries it. The audit and the slice | |
| 73 | // banner come before any bun test spawns; what runs after them is the | |
| 74 | // machine's load, not the code under test. | |
| 75 | const run = async ( | |
| 76 | extra: string[], | |
| 77 | args: string[], | |
| 78 | drop: string[] = [], | |
| 79 | commit = false, | |
| 80 | stopAt?: string, | |
| 81 | ) => { | |
| 82 | const dir = mkdtempSync(join(tmpdir(), "forge-audit-")); | |
| 83 | mkdirSync(join(dir, "test")); | |
| 84 | for (const file of listed) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 85 | if (commit) { | |
| 86 | expect(git(dir, "init", "-q")).toBe(0); | |
| 87 | expect(git(dir, "add", "-A")).toBe(0); | |
| 88 | expect(git(dir, "commit", "-qm", "base")).toBe(0); | |
| 89 | } | |
| 90 | for (const file of extra) writeFileSync(join(dir, file), ""); | |
| 91 | if (commit) { | |
| 92 | expect(git(dir, "add", "-A")).toBe(0); | |
| 93 | expect(git(dir, "commit", "-qm", "add a test")).toBe(0); | |
| 94 | } | |
| 95 | const started = performance.now(); | |
| 96 | const child = Bun.spawn(["bun", script, ...args], { | |
| 97 | cwd: dir, | |
| 98 | stdin: "ignore", | |
| 99 | stdout: "pipe", | |
| 100 | stderr: "pipe", | |
| 101 | }); | |
| 102 | const watch = async (stream: ReadableStream<Uint8Array>) => { | |
| 103 | let text = ""; | |
| 104 | for await (const chunk of stream) { | |
| 105 | text += new TextDecoder().decode(chunk); | |
| 106 | if (stopAt !== undefined && text.includes(stopAt)) child.kill(); | |
| 107 | } | |
| 108 | return text; | |
| 109 | }; | |
| 110 | const [out, err] = await Promise.all([new Response(child.stdout).text(), watch(child.stderr)]); | |
| 111 | const code = await child.exited; | |
| 112 | rmSync(dir, { recursive: true, force: true }); | |
| 113 | return { code, out, err, ms: performance.now() - started }; | |
| 114 | }; | |
| 115 | ||
| 116 | test("a stale exclusion is refused by name, last line, nothing spawned", async () => { | |
| 117 | const gone = EXCLUDED[0]!.file; | |
| 118 | for (const args of [["--gate"], ["--tier", "fast", "--diff", "a..b"]]) { | |
| 119 | const got = await run([], args, [gone]); | |
| 120 | expect(got.code).toBe(2); | |
| 121 | const last = got.err.trim().split("\n").pop()!; | |
| 122 | expect(last).toBe(`test-shards: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`); | |
| 123 | expect(got.out + got.err).not.toContain("start "); | |
| 124 | expect(got.out).not.toContain("── shard"); | |
| 125 | expect(got.ms).toBeLessThan(1000); | |
| 126 | } | |
| 127 | }); | |
| 128 | ||
| 129 | test("a new file is in the gate with no list naming it, and explicit files are not audited", async () => { | |
| 130 | // One slice holds every file, so the count includes the new one; a shard | |
| 131 | // per file would outgrow the three digits a shard spec allows. | |
| 132 | const all = String(gateFiles().length + 1); | |
| 133 | const got = await run(["test/zz-new.test.ts"], ["--gate", "--shard", "1/1"], [], false, "matrix slice"); | |
| 134 | expect(got.err).not.toContain("EXCLUDED"); | |
| 135 | expect(got.out).toContain(`matrix slice 1/1 (${all} of ${all} files)`); | |
| 136 | const explicit = await run(["test/zz-new.test.ts"], ["test/zz-new.test.ts"], [listed[0]!], false, "test-shards: 1 files"); | |
| 137 | expect(explicit.err).not.toContain("EXCLUDED"); | |
| 138 | }, 30_000); | |
| 139 | ||
| 140 | test("a pull that only adds test/x.test.ts is gated on it (#2037)", async () => { | |
| 141 | const got = await run(["test/zz-new.test.ts"], ["--tier", "fast", "--diff", "HEAD~1..HEAD"], [], true); | |
| 142 | expect(got.err).not.toContain("EXCLUDED"); | |
| 143 | expect(got.out).toContain("for 1 changed"); | |
| 144 | expect(got.out).toContain(" test/zz-new.test.ts: changed\n"); | |
| 145 | }, 30_000); | |
| 146 | ||
| 147 | test("a pull no test reaches is green with nothing to run, not a usage error (#3579)", async () => { | |
| 148 | const dir = mkdtempSync(join(tmpdir(), "forge-audit-")); | |
| 149 | mkdirSync(join(dir, "test")); | |
| 150 | for (const file of listed) writeFileSync(join(dir, file), ""); | |
| 151 | writeFileSync(join(dir, "impact.json"), JSON.stringify(Object.fromEntries(listed.map((f) => [f, []])))); | |
| 152 | expect(git(dir, "init", "-q")).toBe(0); | |
| 153 | expect(git(dir, "add", "-A")).toBe(0); | |
| 154 | expect(git(dir, "commit", "-qm", "base")).toBe(0); | |
| 155 | mkdirSync(join(dir, "scripts")); | |
| 156 | writeFileSync(join(dir, "scripts", "test-times.json"), "{}\n"); | |
| 157 | expect(git(dir, "add", "-A")).toBe(0); | |
| 158 | expect(git(dir, "commit", "-qm", "a table only")).toBe(0); | |
| 159 | const child = Bun.spawn(["bun", script, "--tier", "fast", "--diff", "HEAD~1..HEAD"], { | |
| 160 | cwd: dir, | |
| 161 | env: { ...process.env, FORGE_CHECK_IMPACT: "impact.json" }, | |
| 162 | stdin: "ignore", | |
| 163 | stdout: "pipe", | |
| 164 | stderr: "pipe", | |
| 165 | }); | |
| 166 | const [out, err] = await Promise.all([ | |
| 167 | new Response(child.stdout).text(), | |
| 168 | new Response(child.stderr).text(), | |
| 169 | ]); | |
| 170 | const code = await child.exited; | |
| 171 | rmSync(dir, { recursive: true, force: true }); | |
| 172 | expect(out).toContain(`0 of ${gateFiles().length} files for 1 changed`); | |
| 173 | expect(out).toContain("test-shards: the change reaches no tests; nothing to run"); | |
| 174 | expect(err).not.toContain("name the test files"); | |
| 175 | expect(code).toBe(0); | |
| 176 | }, 30_000); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("the gate audit alone, first in the check", () => { | |
| 180 | const script = new URL("../scripts/gate.ts", import.meta.url).pathname; | |
| 181 | const listed = presentTests(); | |
| 182 | // The check sets FORGE_CHECK_BASE; the scratch dir is no repo, so it has none. | |
| 183 | const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== "FORGE_CHECK_BASE")); | |
| 184 | const run = async (extra: string[], drop: string[] = []) => { | |
| 185 | const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); | |
| 186 | mkdirSync(join(dir, "test")); | |
| 187 | for (const file of [...listed, ...extra]) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 188 | const child = Bun.spawn(["bun", script], { cwd: dir, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); | |
| 189 | const err = await new Response(child.stderr).text(); | |
| 190 | const code = await child.exited; | |
| 191 | rmSync(dir, { recursive: true, force: true }); | |
| 192 | return { code, err }; | |
| 193 | }; | |
| 194 | ||
| 195 | test("a stale exclusion fails it by name; a new unlisted file passes", async () => { | |
| 196 | const gone = EXCLUDED[0]!.file; | |
| 197 | const red = await run([], [gone]); | |
| 198 | expect(red.code).toBe(2); | |
| 199 | expect(red.err.trim().split("\n").pop()).toBe( | |
| 200 | `gate: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`, | |
| 201 | ); | |
| 202 | const green = await run(["test/zz-new.test.ts"]); | |
| 203 | expect(green.code).toBe(0); | |
| 204 | // The scratch dir is no git repo, so the pin check says it had no base. | |
| 205 | expect(green.err).toBe("gate: no base to compare to (set FORGE_CHECK_BASE): migration pins not checked\n"); | |
| 206 | }, 30_000); | |
| 207 | }); | |
| 208 | ||
| 209 | describe("a failing file is run once more alone", () => { | |
| 210 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 211 | const dir = "test/fixtures"; | |
| 212 | // Heavy by bytes, so the splitter gives it a shard of its own and the | |
| 213 | // other fixtures share the second one. Written here, not committed. | |
| 214 | const scratch = mkdtempSync(join(tmpdir(), "shard-filler-")); | |
| 215 | const filler = join(scratch, "shard-filler.test.ts"); | |
| 216 | writeFileSync(filler, `import { test } from "bun:test";\ntest("filler", () => {});\n// ${"padding ".repeat(4000)}\n`); | |
| 217 | afterAll(() => rmSync(scratch, { recursive: true, force: true })); | |
| 218 | // The split must not follow the host: pin the count and the cell's CPUs, and | |
| 219 | // drop any FORGE_CHECK_* the surrounding gate exported (#2726). | |
| 220 | const env = { ...process.env, FORGE_CHECK_SHARDS: "2", FORGE_CELL_CPUS: "4" } as Record<string, string | undefined>; | |
| 221 | for (const name of Object.keys(env)) if (name.startsWith("FORGE_CHECK_") && name !== "FORGE_CHECK_SHARDS") delete env[name]; | |
| 222 | const run = async (names: string[]) => { | |
| 223 | const child = Bun.spawn( | |
| 224 | ["bun", script, ...names.map((name) => (name === "filler" ? filler : `${dir}/shard-${name}.test.ts`))], | |
| 225 | { | |
| 226 | stdin: "ignore", | |
| 227 | stdout: "pipe", | |
| 228 | stderr: "pipe", | |
| 229 | env, | |
| 230 | }, | |
| 231 | ); | |
| 232 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 233 | return { code: await child.exited, out, err }; | |
| 234 | }; | |
| 235 | ||
| 236 | test("red only beside another file: the shard is green and the tail names it", async () => { | |
| 237 | // filler is heavy by bytes, so poison and victim share the other shard. | |
| 238 | const got = await run(["filler", "poison", "victim"]); | |
| 239 | expect(got.out).toContain("fails only beside others: test/fixtures/shard-victim.test.ts (shard-mates: none)"); | |
| 240 | expect(got.code).toBe(0); | |
| 241 | }, 30_000); | |
| 242 | ||
| 243 | test("red alone too: it stays red", async () => { | |
| 244 | const got = await run(["filler", "poison", "victim", "broken"]); | |
| 245 | expect(got.out).toContain("fails only beside others: test/fixtures/shard-victim.test.ts (shard-mates: test/fixtures/shard-broken.test.ts)"); | |
| 246 | expect(got.code).toBe(1); | |
| 247 | expect(got.out).toContain("(fail) broken"); | |
| 248 | }, 30_000); | |
| 249 | ||
| 250 | test("one file fails beside others and another errors: the shard stays red", async () => { | |
| 251 | const got = await run(["filler", "poison", "victim", "errors"]); | |
| 252 | expect(got.code).toBe(1); | |
| 253 | expect(got.out).not.toContain("fails only beside others"); | |
| 254 | expect(got.out).toContain("boom at load"); | |
| 255 | }, 30_000); | |
| 256 | ||
| 257 | test("a lone file that fails stays red with no verdict line", async () => { | |
| 258 | const got = await run(["broken"]); | |
| 259 | expect(got.code).toBe(1); | |
| 260 | expect(got.out).not.toContain("fails only beside others"); | |
| 261 | }, 30_000); | |
| 262 | }); | |
| 263 | ||
| 264 | describe("every shard runs to the end, and a crashed one once more", () => { | |
| 265 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 266 | const scratch = mkdtempSync(join(tmpdir(), "shard-crash-")); | |
| 267 | // Heavy by bytes, so it runs first and gets a shard of its own. | |
| 268 | const heavyRed = join(scratch, "heavy-red.test.ts"); | |
| 269 | writeFileSync(heavyRed, `import { expect, test } from "bun:test";\ntest("heavy red", () => { expect(1).toBe(2); });\n// ${"padding ".repeat(4000)}\n`); | |
| 270 | const filler = join(scratch, "shard-filler.test.ts"); | |
| 271 | writeFileSync(filler, `import { test } from "bun:test";\ntest("filler", () => {});\n// ${"padding ".repeat(4000)}\n`); | |
| 272 | afterAll(() => rmSync(scratch, { recursive: true, force: true })); | |
| 273 | const run = async (shards: string, files: string[], mark: string) => { | |
| 274 | const env = { ...process.env, FORGE_CHECK_SHARDS: shards, FORGE_CELL_CPUS: "4", SHARD_CRASH_MARK: mark } as Record<string, string | undefined>; | |
| 275 | for (const name of Object.keys(env)) if (name.startsWith("FORGE_CHECK_") && name !== "FORGE_CHECK_SHARDS") delete env[name]; | |
| 276 | const child = Bun.spawn(["bun", script, ...files], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env }); | |
| 277 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 278 | return { code: await child.exited, out, err }; | |
| 279 | }; | |
| 280 | ||
| 281 | test("a red file does not stop the files after it", async () => { | |
| 282 | // One worker, heaviest first: the red file runs before the green one. | |
| 283 | const got = await run("1", [heavyRed, "test/fixtures/shard-victim.test.ts"], join(scratch, "unused")); | |
| 284 | expect(got.code).toBe(1); | |
| 285 | expect(got.out).toContain("(fail) heavy red"); | |
| 286 | expect(got.out).toContain("── shard 2/2: test/fixtures/shard-victim.test.ts"); | |
| 287 | expect(got.out).not.toContain("fail-fast"); | |
| 288 | }, 30_000); | |
| 289 | ||
| 290 | test("a crash between tests re-runs its shard once, and the re-run is the verdict", async () => { | |
| 291 | const got = await run("2", [filler, "test/fixtures/shard-crash-once.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "crashed-once")); | |
| 292 | expect(got.out).toContain("crashed outside any test (exit 1); re-running it once. The crashed run:"); | |
| 293 | expect(got.out).toContain(" │ error: Cannot use a closed database"); | |
| 294 | expect(got.code).toBe(0); | |
| 295 | // Green on the re-run: the crash is named for the check to record as a flake. | |
| 296 | expect(got.err).toMatch(/test-shards: crash flake \d+\/\d+: error: Cannot use a closed database/); | |
| 297 | }, 30_000); | |
| 298 | ||
| 299 | test("a single worker quotes the crashed run too: no bare error lines, and the re-run is green", async () => { | |
| 300 | const got = await run("1", ["test/fixtures/shard-crash-once.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "crashed-serial")); | |
| 301 | expect(got.out).toContain("crashed outside any test (exit 1); re-running it once. The crashed run:"); | |
| 302 | expect(got.out).toContain(" │ error: Cannot use a closed database"); | |
| 303 | const bare = got.out.split("\n").filter((line) => !line.startsWith(" │ ")); | |
| 304 | expect(bare.some((line) => line.includes("Cannot use a closed database"))).toBe(false); | |
| 305 | expect(bare.some((line) => line.includes("# Unhandled error"))).toBe(false); | |
| 306 | expect(got.code).toBe(0); | |
| 307 | }, 30_000); | |
| 308 | ||
| 309 | test("only CRASH_RERUNS crashed shards are re-run; the next stays red unre-run", async () => { | |
| 310 | const crashing = [1, 2, 3, 4].map((n) => { | |
| 311 | const file = join(scratch, `crash-${n}.test.ts`); | |
| 312 | writeFileSync(file, `throw new Error("boom at load ${n}");\n`); | |
| 313 | return file; | |
| 314 | }); | |
| 315 | const got = await run("4", crashing, join(scratch, "unused")); | |
| 316 | expect(got.out.split("re-running it once").length - 1).toBe(CRASH_RERUNS); | |
| 317 | expect(got.code).toBe(1); | |
| 318 | }, 60_000); | |
| 319 | ||
| 320 | test("a crash that happens again stays red", async () => { | |
| 321 | const got = await run("2", [filler, "test/fixtures/shard-errors.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "unused")); | |
| 322 | expect(got.out).toContain("re-running it once"); | |
| 323 | expect(got.out).toContain("boom at load"); | |
| 324 | expect(got.code).toBe(1); | |
| 325 | }, 30_000); | |
| 326 | }); | |
| 327 | ||
| 328 | describe("a fixture suite in six shards (#7617)", () => { | |
| 329 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 330 | const fixture = (files: Record<string, string>): string => { | |
| 331 | const dir = mkdtempSync(join(tmpdir(), "shards-fixture-")); | |
| 332 | for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); | |
| 333 | return dir; | |
| 334 | }; | |
| 335 | const run = async ( | |
| 336 | dir: string, | |
| 337 | shards: string, | |
| 338 | names: string[], | |
| 339 | env: Record<string, string> = {}, | |
| 340 | ): Promise<{ code: number; out: string; stdout: string }> => { | |
| 341 | const child = Bun.spawn(["bun", script, ...names], { | |
| 342 | cwd: dir, | |
| 343 | env: { ...process.env, FORGE_CHECK_SHARDS: shards, ...env }, | |
| 344 | stdin: "ignore", | |
| 345 | stdout: "pipe", | |
| 346 | stderr: "pipe", | |
| 347 | }); | |
| 348 | const [stdout, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 349 | const code = await child.exited; | |
| 350 | return { code, out: stdout + err, stdout }; | |
| 351 | }; | |
| 352 | const passing = (n: number): string => | |
| 353 | `import { test } from "bun:test";\ntest("passes ${n}", () => {});\n`; | |
| 354 | /** The files named on the `── shard N/M…: files` lines, in the order shards ran. */ | |
| 355 | const finished = (stdout: string): string[] => | |
| 356 | [...stdout.matchAll(/^── shard \d+\/\d+(?: \(exit \d+, [\d.]+s\))?: (.*)$/gm)].map((m) => m[1]!); | |
| 357 | ||
| 358 | test("twelve files in six shards: the plan has six slices and each file runs exactly once", async () => { | |
| 359 | const names = Array.from({ length: 12 }, (_, i) => `s${i}.test.ts`); | |
| 360 | const dir = fixture(Object.fromEntries(names.map((name, i) => [name, passing(i)]))); | |
| 361 | const six = await run(dir, "6", names); | |
| 362 | rmSync(dir, { recursive: true, force: true }); | |
| 363 | expect(six.code).toBe(0); | |
| 364 | expect(six.out).toContain("12 files in 6 shards"); | |
| 365 | expect(six.out.match(/test-shards: start \d+\/6 /g)?.length).toBe(6); | |
| 366 | const ran = finished(six.stdout).flatMap((line) => line.split(" ")); | |
| 367 | for (const name of names) expect(ran.filter((file) => file === name).length).toBe(1); | |
| 368 | expect(six.out).toMatch(/Ran 12 tests across 12 files/); | |
| 369 | }, 60_000); | |
| 370 | ||
| 371 | test("a failure in any shard reds the run and names the test", async () => { | |
| 372 | const files: Record<string, string> = {}; | |
| 373 | for (let i = 0; i < 5; i++) files[`ok${i}.test.ts`] = passing(i); | |
| 374 | files["bad.test.ts"] = `import { test, expect } from "bun:test";\ntest("the broken one", () => { expect(1).toBe(2); });\n`; | |
| 375 | const dir = fixture(files); | |
| 376 | const got = await run(dir, "3", Object.keys(files)); | |
| 377 | rmSync(dir, { recursive: true, force: true }); | |
| 378 | expect(got.code).not.toBe(0); | |
| 379 | expect(got.out).toContain("(fail) the broken one"); | |
| 380 | }, 60_000); | |
| 381 | ||
| 382 | test("each shard gets its own empty FORGE_APP_DATA and TMPDIR, removed after", async () => { | |
| 383 | const probe = (n: number): string => | |
| 384 | `import { test, expect } from "bun:test"; | |
| 385 | import { readdirSync, writeFileSync } from "node:fs"; | |
| 386 | import { tmpdir } from "node:os"; | |
| 387 | test("probe ${n}", () => { | |
| 388 | const data = process.env.FORGE_APP_DATA!; | |
| 389 | expect(readdirSync(data)).toEqual([]); | |
| 390 | expect(readdirSync(tmpdir())).toEqual([]); | |
| 391 | writeFileSync(data + "/mark", "${n}"); | |
| 392 | console.log("PROBE-DATA " + data + " PROBE-TMP " + tmpdir()); | |
| 393 | }); | |
| 394 | `; | |
| 395 | const names = ["p0.test.ts", "p1.test.ts", "p2.test.ts"]; | |
| 396 | const dir = fixture(Object.fromEntries(names.map((name, i) => [name, probe(i)]))); | |
| 397 | const root = mkdtempSync(join(tmpdir(), "shards-root-")); | |
| 398 | const got = await run(dir, "3", names, { TMPDIR: root, FORGE_APP_DATA: root }); | |
| 399 | rmSync(dir, { recursive: true, force: true }); | |
| 400 | const seen = [...got.out.matchAll(/PROBE-DATA (\S+) PROBE-TMP (\S+)/g)]; | |
| 401 | const leaked = readdirSync(root); | |
| 402 | rmSync(root, { recursive: true, force: true }); | |
| 403 | expect(got.code).toBe(0); | |
| 404 | expect(new Set(seen.map((m) => m[1])).size).toBe(3); | |
| 405 | expect(new Set(seen.map((m) => m[2])).size).toBe(3); | |
| 406 | for (const m of seen) expect(m[1]!.startsWith(root)).toBe(true); | |
| 407 | expect(leaked).toEqual([]); | |
| 408 | }, 60_000); | |
| 409 | ||
| 410 | test("a FORGE_APP_DATA the cell set is nested under, not replaced", async () => { | |
| 411 | const dir = fixture({ | |
| 412 | "d.test.ts": `import { test } from "bun:test";\ntest("d", () => { console.log("PROBE-DATA " + process.env.FORGE_APP_DATA); });\n`, | |
| 413 | }); | |
| 414 | const data = mkdtempSync(join(tmpdir(), "shards-data-")); | |
| 415 | const got = await run(dir, "1", ["d.test.ts"], { FORGE_APP_DATA: data }); | |
| 416 | rmSync(dir, { recursive: true, force: true }); | |
| 417 | const leaked = readdirSync(data); | |
| 418 | rmSync(data, { recursive: true, force: true }); | |
| 419 | expect(got.code).toBe(0); | |
| 420 | expect(got.out).toContain(`PROBE-DATA ${data}/data-0`); | |
| 421 | expect(leaked).toEqual([]); | |
| 422 | }, 60_000); | |
| 423 | }); |
test/shards.test.ts +13 −412
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The gate's shards (`scripts/shards.ts`): every file runs exactly once, |
| 3 | 3 | * the count follows the host and the override, and one red shard is a |
| 4 | * red run whose tail names every failing test from every shard. | |
| 4 | * red run whose tail names every failing test from every shard. Pure: it | |
| 5 | * spawns nothing, so it stays fast; the runner end to end is | |
| 6 | * test/shards-run.test.ts. | |
| 5 | 7 | */ |
| 6 | import { afterAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 8 | import { describe, expect, test } from "bun:test"; | |
| 9 | import { readFileSync } from "node:fs"; | |
| 10 | 10 | import { resolveLimits } from "../src/apps/contain.ts"; |
| 11 | 11 | import { |
| 12 | 12 | SECONDS_PER_BYTE_DEFAULT, |
| @@ -40,7 +40,7 @@ import { | ||
| 40 | 40 | type ShardResult, |
| 41 | 41 | } from "../scripts/shards.ts"; |
| 42 | 42 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 43 | import { EXCLUDED, gateFiles, presentTests } from "../scripts/gate.ts"; | |
| 43 | import { gateFiles } from "../scripts/gate.ts"; | |
| 44 | 44 | import { CHECK_CACHE } from "../src/checks/cache.ts"; |
| 45 | 45 | |
| 46 | 46 | const files = Array.from({ length: 23 }, (_, i) => `test/f${i}.test.ts`); |
| @@ -569,200 +569,7 @@ describe("sliceFiles", () => { | ||
| 569 | 569 | }); |
| 570 | 570 | }); |
| 571 | 571 | |
| 572 | describe("the shard's output", () => { | |
| 573 | // A check's log is this script's stdout, line by line, as the forge | |
| 574 | // reads it (`checks/service.ts`). Holding a shard's output until it | |
| 575 | // ended meant a forty-minute gate showed nothing at all until the end. | |
| 576 | test("one shard at a time: a line is written when the test says it, not when the shard ends", async () => { | |
| 577 | const dir = mkdtempSync(join(tmpdir(), "shards-live-")); | |
| 578 | writeFileSync( | |
| 579 | join(dir, "live.test.ts"), | |
| 580 | `import { test } from "bun:test"; | |
| 581 | test("says two things, a pause apart", async () => { | |
| 582 | console.log("tick-one"); | |
| 583 | await Bun.sleep(1500); | |
| 584 | console.log("tick-two"); | |
| 585 | }); | |
| 586 | `, | |
| 587 | ); | |
| 588 | const script = new URL("../scripts/test-shards.ts", import.meta.url) | |
| 589 | .pathname; | |
| 590 | const child = Bun.spawn(["bun", script, "live.test.ts"], { | |
| 591 | cwd: dir, | |
| 592 | env: { ...process.env, FORGE_CHECK_SHARDS: "1" }, | |
| 593 | stdin: "ignore", | |
| 594 | stdout: "pipe", | |
| 595 | stderr: "pipe", | |
| 596 | }); | |
| 597 | let text = ""; | |
| 598 | let firstAt = -1; | |
| 599 | const decoder = new TextDecoder(); | |
| 600 | const reading = (async () => { | |
| 601 | for await (const chunk of child.stdout) { | |
| 602 | text += decoder.decode(chunk, { stream: true }); | |
| 603 | if (firstAt < 0 && text.includes("tick-one")) | |
| 604 | firstAt = performance.now(); | |
| 605 | } | |
| 606 | })(); | |
| 607 | const code = await child.exited; | |
| 608 | const ended = performance.now(); | |
| 609 | await reading; | |
| 610 | rmSync(dir, { recursive: true, force: true }); | |
| 611 | expect(code).toBe(0); | |
| 612 | expect(firstAt).toBeGreaterThan(0); | |
| 613 | // The first line reached us while the test was still asleep. | |
| 614 | expect(ended - firstAt).toBeGreaterThan(1000); | |
| 615 | expect(text).toContain("tick-two"); | |
| 616 | expect(text).toContain("── shard 1/1: live.test.ts"); | |
| 617 | expect(text).toMatch(/── shard 1\/1 done \(exit 0, [\d.]+s\)/); | |
| 618 | }, 30_000); | |
| 619 | }); | |
| 620 | ||
| 621 | describe("the gate audit before any shard", () => { | |
| 622 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 623 | const listed = presentTests(); | |
| 624 | const git = (dir: string, ...args: string[]) => | |
| 625 | Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { | |
| 626 | cwd: dir, | |
| 627 | stdout: "ignore", | |
| 628 | stderr: "ignore", | |
| 629 | }).exitCode; | |
| 630 | // `stopAt`: kill the runner once stderr carries it. The audit and the slice | |
| 631 | // banner come before any bun test spawns; what runs after them is the | |
| 632 | // machine's load, not the code under test. | |
| 633 | const run = async ( | |
| 634 | extra: string[], | |
| 635 | args: string[], | |
| 636 | drop: string[] = [], | |
| 637 | commit = false, | |
| 638 | stopAt?: string, | |
| 639 | ) => { | |
| 640 | const dir = mkdtempSync(join(tmpdir(), "forge-audit-")); | |
| 641 | mkdirSync(join(dir, "test")); | |
| 642 | for (const file of listed) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 643 | if (commit) { | |
| 644 | expect(git(dir, "init", "-q")).toBe(0); | |
| 645 | expect(git(dir, "add", "-A")).toBe(0); | |
| 646 | expect(git(dir, "commit", "-qm", "base")).toBe(0); | |
| 647 | } | |
| 648 | for (const file of extra) writeFileSync(join(dir, file), ""); | |
| 649 | if (commit) { | |
| 650 | expect(git(dir, "add", "-A")).toBe(0); | |
| 651 | expect(git(dir, "commit", "-qm", "add a test")).toBe(0); | |
| 652 | } | |
| 653 | const started = performance.now(); | |
| 654 | const child = Bun.spawn(["bun", script, ...args], { | |
| 655 | cwd: dir, | |
| 656 | stdin: "ignore", | |
| 657 | stdout: "pipe", | |
| 658 | stderr: "pipe", | |
| 659 | }); | |
| 660 | const watch = async (stream: ReadableStream<Uint8Array>) => { | |
| 661 | let text = ""; | |
| 662 | for await (const chunk of stream) { | |
| 663 | text += new TextDecoder().decode(chunk); | |
| 664 | if (stopAt !== undefined && text.includes(stopAt)) child.kill(); | |
| 665 | } | |
| 666 | return text; | |
| 667 | }; | |
| 668 | const [out, err] = await Promise.all([new Response(child.stdout).text(), watch(child.stderr)]); | |
| 669 | const code = await child.exited; | |
| 670 | rmSync(dir, { recursive: true, force: true }); | |
| 671 | return { code, out, err, ms: performance.now() - started }; | |
| 672 | }; | |
| 673 | ||
| 674 | test("a stale exclusion is refused by name, last line, nothing spawned", async () => { | |
| 675 | const gone = EXCLUDED[0]!.file; | |
| 676 | for (const args of [["--gate"], ["--tier", "fast", "--diff", "a..b"]]) { | |
| 677 | const got = await run([], args, [gone]); | |
| 678 | expect(got.code).toBe(2); | |
| 679 | const last = got.err.trim().split("\n").pop()!; | |
| 680 | expect(last).toBe(`test-shards: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`); | |
| 681 | expect(got.out + got.err).not.toContain("start "); | |
| 682 | expect(got.out).not.toContain("── shard"); | |
| 683 | expect(got.ms).toBeLessThan(1000); | |
| 684 | } | |
| 685 | }); | |
| 686 | ||
| 687 | test("a new file is in the gate with no list naming it, and explicit files are not audited", async () => { | |
| 688 | // One slice holds every file, so the count includes the new one; a shard | |
| 689 | // per file would outgrow the three digits a shard spec allows. | |
| 690 | const all = String(gateFiles().length + 1); | |
| 691 | const got = await run(["test/zz-new.test.ts"], ["--gate", "--shard", "1/1"], [], false, "matrix slice"); | |
| 692 | expect(got.err).not.toContain("EXCLUDED"); | |
| 693 | expect(got.out).toContain(`matrix slice 1/1 (${all} of ${all} files)`); | |
| 694 | const explicit = await run(["test/zz-new.test.ts"], ["test/zz-new.test.ts"], [listed[0]!], false, "test-shards: 1 files"); | |
| 695 | expect(explicit.err).not.toContain("EXCLUDED"); | |
| 696 | }, 30_000); | |
| 697 | ||
| 698 | test("a pull that only adds test/x.test.ts is gated on it (#2037)", async () => { | |
| 699 | const got = await run(["test/zz-new.test.ts"], ["--tier", "fast", "--diff", "HEAD~1..HEAD"], [], true); | |
| 700 | expect(got.err).not.toContain("EXCLUDED"); | |
| 701 | expect(got.out).toContain("for 1 changed"); | |
| 702 | expect(got.out).toContain(" test/zz-new.test.ts: changed\n"); | |
| 703 | }, 30_000); | |
| 704 | ||
| 705 | test("a pull no test reaches is green with nothing to run, not a usage error (#3579)", async () => { | |
| 706 | const dir = mkdtempSync(join(tmpdir(), "forge-audit-")); | |
| 707 | mkdirSync(join(dir, "test")); | |
| 708 | for (const file of listed) writeFileSync(join(dir, file), ""); | |
| 709 | writeFileSync(join(dir, "impact.json"), JSON.stringify(Object.fromEntries(listed.map((f) => [f, []])))); | |
| 710 | expect(git(dir, "init", "-q")).toBe(0); | |
| 711 | expect(git(dir, "add", "-A")).toBe(0); | |
| 712 | expect(git(dir, "commit", "-qm", "base")).toBe(0); | |
| 713 | mkdirSync(join(dir, "scripts")); | |
| 714 | writeFileSync(join(dir, "scripts", "test-times.json"), "{}\n"); | |
| 715 | expect(git(dir, "add", "-A")).toBe(0); | |
| 716 | expect(git(dir, "commit", "-qm", "a table only")).toBe(0); | |
| 717 | const child = Bun.spawn(["bun", script, "--tier", "fast", "--diff", "HEAD~1..HEAD"], { | |
| 718 | cwd: dir, | |
| 719 | env: { ...process.env, FORGE_CHECK_IMPACT: "impact.json" }, | |
| 720 | stdin: "ignore", | |
| 721 | stdout: "pipe", | |
| 722 | stderr: "pipe", | |
| 723 | }); | |
| 724 | const [out, err] = await Promise.all([ | |
| 725 | new Response(child.stdout).text(), | |
| 726 | new Response(child.stderr).text(), | |
| 727 | ]); | |
| 728 | const code = await child.exited; | |
| 729 | rmSync(dir, { recursive: true, force: true }); | |
| 730 | expect(out).toContain(`0 of ${gateFiles().length} files for 1 changed`); | |
| 731 | expect(out).toContain("test-shards: the change reaches no tests; nothing to run"); | |
| 732 | expect(err).not.toContain("name the test files"); | |
| 733 | expect(code).toBe(0); | |
| 734 | }, 30_000); | |
| 735 | }); | |
| 736 | ||
| 737 | 572 | describe("the gate audit alone, first in the check", () => { |
| 738 | const script = new URL("../scripts/gate.ts", import.meta.url).pathname; | |
| 739 | const listed = presentTests(); | |
| 740 | // The check sets FORGE_CHECK_BASE; the scratch dir is no repo, so it has none. | |
| 741 | const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== "FORGE_CHECK_BASE")); | |
| 742 | const run = async (extra: string[], drop: string[] = []) => { | |
| 743 | const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); | |
| 744 | mkdirSync(join(dir, "test")); | |
| 745 | for (const file of [...listed, ...extra]) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 746 | const child = Bun.spawn(["bun", script], { cwd: dir, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); | |
| 747 | const err = await new Response(child.stderr).text(); | |
| 748 | const code = await child.exited; | |
| 749 | rmSync(dir, { recursive: true, force: true }); | |
| 750 | return { code, err }; | |
| 751 | }; | |
| 752 | ||
| 753 | test("a stale exclusion fails it by name; a new unlisted file passes", async () => { | |
| 754 | const gone = EXCLUDED[0]!.file; | |
| 755 | const red = await run([], [gone]); | |
| 756 | expect(red.code).toBe(2); | |
| 757 | expect(red.err.trim().split("\n").pop()).toBe( | |
| 758 | `gate: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`, | |
| 759 | ); | |
| 760 | const green = await run(["test/zz-new.test.ts"]); | |
| 761 | expect(green.code).toBe(0); | |
| 762 | // The scratch dir is no git repo, so the pin check says it had no base. | |
| 763 | expect(green.err).toBe("gate: no base to compare to (set FORGE_CHECK_BASE): migration pins not checked\n"); | |
| 764 | }, 30_000); | |
| 765 | ||
| 766 | 573 | test("forge.json audits before tsc, and the native tsc runs incremental from the check's cache", async () => { |
| 767 | 574 | const check = parseCheck(readFileSync(new URL("../forge.json", import.meta.url), "utf8"), undefined); |
| 768 | 575 | if (!check.ok || check.value === undefined) throw new Error("no check"); |
| @@ -897,61 +704,6 @@ describe("onlyTestFailures", () => { | ||
| 897 | 704 | }); |
| 898 | 705 | }); |
| 899 | 706 | |
| 900 | describe("a failing file is run once more alone", () => { | |
| 901 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 902 | const dir = "test/fixtures"; | |
| 903 | // Heavy by bytes, so the splitter gives it a shard of its own and the | |
| 904 | // other fixtures share the second one. Written here, not committed. | |
| 905 | const scratch = mkdtempSync(join(tmpdir(), "shard-filler-")); | |
| 906 | const filler = join(scratch, "shard-filler.test.ts"); | |
| 907 | writeFileSync(filler, `import { test } from "bun:test";\ntest("filler", () => {});\n// ${"padding ".repeat(4000)}\n`); | |
| 908 | afterAll(() => rmSync(scratch, { recursive: true, force: true })); | |
| 909 | // The split must not follow the host: pin the count and the cell's CPUs, and | |
| 910 | // drop any FORGE_CHECK_* the surrounding gate exported (#2726). | |
| 911 | const env = { ...process.env, FORGE_CHECK_SHARDS: "2", FORGE_CELL_CPUS: "4" } as Record<string, string | undefined>; | |
| 912 | for (const name of Object.keys(env)) if (name.startsWith("FORGE_CHECK_") && name !== "FORGE_CHECK_SHARDS") delete env[name]; | |
| 913 | const run = async (names: string[]) => { | |
| 914 | const child = Bun.spawn( | |
| 915 | ["bun", script, ...names.map((name) => (name === "filler" ? filler : `${dir}/shard-${name}.test.ts`))], | |
| 916 | { | |
| 917 | stdin: "ignore", | |
| 918 | stdout: "pipe", | |
| 919 | stderr: "pipe", | |
| 920 | env, | |
| 921 | }, | |
| 922 | ); | |
| 923 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 924 | return { code: await child.exited, out, err }; | |
| 925 | }; | |
| 926 | ||
| 927 | test("red only beside another file: the shard is green and the tail names it", async () => { | |
| 928 | // filler is heavy by bytes, so poison and victim share the other shard. | |
| 929 | const got = await run(["filler", "poison", "victim"]); | |
| 930 | expect(got.out).toContain("fails only beside others: test/fixtures/shard-victim.test.ts (shard-mates: none)"); | |
| 931 | expect(got.code).toBe(0); | |
| 932 | }, 30_000); | |
| 933 | ||
| 934 | test("red alone too: it stays red", async () => { | |
| 935 | const got = await run(["filler", "poison", "victim", "broken"]); | |
| 936 | expect(got.out).toContain("fails only beside others: test/fixtures/shard-victim.test.ts (shard-mates: test/fixtures/shard-broken.test.ts)"); | |
| 937 | expect(got.code).toBe(1); | |
| 938 | expect(got.out).toContain("(fail) broken"); | |
| 939 | }, 30_000); | |
| 940 | ||
| 941 | test("one file fails beside others and another errors: the shard stays red", async () => { | |
| 942 | const got = await run(["filler", "poison", "victim", "errors"]); | |
| 943 | expect(got.code).toBe(1); | |
| 944 | expect(got.out).not.toContain("fails only beside others"); | |
| 945 | expect(got.out).toContain("boom at load"); | |
| 946 | }, 30_000); | |
| 947 | ||
| 948 | test("a lone file that fails stays red with no verdict line", async () => { | |
| 949 | const got = await run(["broken"]); | |
| 950 | expect(got.code).toBe(1); | |
| 951 | expect(got.out).not.toContain("fails only beside others"); | |
| 952 | }, 30_000); | |
| 953 | }); | |
| 954 | ||
| 955 | 707 | describe("a crash outside any test", () => { |
| 956 | 708 | const ran = "Ran 3 tests across 2 files. [9ms]"; |
| 957 | 709 | test("is a red whose report is not only failed tests", () => { |
| @@ -978,163 +730,12 @@ describe("a crash outside any test", () => { | ||
| 978 | 730 | }); |
| 979 | 731 | }); |
| 980 | 732 | |
| 981 | describe("every shard runs to the end, and a crashed one once more", () => { | |
| 982 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 983 | const scratch = mkdtempSync(join(tmpdir(), "shard-crash-")); | |
| 984 | // Heavy by bytes, so it runs first and gets a shard of its own. | |
| 985 | const heavyRed = join(scratch, "heavy-red.test.ts"); | |
| 986 | writeFileSync(heavyRed, `import { expect, test } from "bun:test";\ntest("heavy red", () => { expect(1).toBe(2); });\n// ${"padding ".repeat(4000)}\n`); | |
| 987 | const filler = join(scratch, "shard-filler.test.ts"); | |
| 988 | writeFileSync(filler, `import { test } from "bun:test";\ntest("filler", () => {});\n// ${"padding ".repeat(4000)}\n`); | |
| 989 | afterAll(() => rmSync(scratch, { recursive: true, force: true })); | |
| 990 | const run = async (shards: string, files: string[], mark: string) => { | |
| 991 | const env = { ...process.env, FORGE_CHECK_SHARDS: shards, FORGE_CELL_CPUS: "4", SHARD_CRASH_MARK: mark } as Record<string, string | undefined>; | |
| 992 | for (const name of Object.keys(env)) if (name.startsWith("FORGE_CHECK_") && name !== "FORGE_CHECK_SHARDS") delete env[name]; | |
| 993 | const child = Bun.spawn(["bun", script, ...files], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env }); | |
| 994 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 995 | return { code: await child.exited, out, err }; | |
| 996 | }; | |
| 997 | 733 | |
| 998 | test("a red file does not stop the files after it", async () => { | |
| 999 | // One worker, heaviest first: the red file runs before the green one. | |
| 1000 | const got = await run("1", [heavyRed, "test/fixtures/shard-victim.test.ts"], join(scratch, "unused")); | |
| 1001 | expect(got.code).toBe(1); | |
| 1002 | expect(got.out).toContain("(fail) heavy red"); | |
| 1003 | expect(got.out).toContain("── shard 2/2: test/fixtures/shard-victim.test.ts"); | |
| 1004 | expect(got.out).not.toContain("fail-fast"); | |
| 1005 | }, 30_000); | |
| 1006 | ||
| 1007 | test("a crash between tests re-runs its shard once, and the re-run is the verdict", async () => { | |
| 1008 | const got = await run("2", [filler, "test/fixtures/shard-crash-once.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "crashed-once")); | |
| 1009 | expect(got.out).toContain("crashed outside any test (exit 1); re-running it once. The crashed run:"); | |
| 1010 | expect(got.out).toContain(" │ error: Cannot use a closed database"); | |
| 1011 | expect(got.code).toBe(0); | |
| 1012 | // Green on the re-run: the crash is named for the check to record as a flake. | |
| 1013 | expect(got.err).toMatch(/test-shards: crash flake \d+\/\d+: error: Cannot use a closed database/); | |
| 1014 | }, 30_000); | |
| 1015 | ||
| 1016 | test("a single worker quotes the crashed run too: no bare error lines, and the re-run is green", async () => { | |
| 1017 | const got = await run("1", ["test/fixtures/shard-crash-once.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "crashed-serial")); | |
| 1018 | expect(got.out).toContain("crashed outside any test (exit 1); re-running it once. The crashed run:"); | |
| 1019 | expect(got.out).toContain(" │ error: Cannot use a closed database"); | |
| 1020 | const bare = got.out.split("\n").filter((line) => !line.startsWith(" │ ")); | |
| 1021 | expect(bare.some((line) => line.includes("Cannot use a closed database"))).toBe(false); | |
| 1022 | expect(bare.some((line) => line.includes("# Unhandled error"))).toBe(false); | |
| 1023 | expect(got.code).toBe(0); | |
| 1024 | }, 30_000); | |
| 1025 | ||
| 1026 | test("only CRASH_RERUNS crashed shards are re-run; the next stays red unre-run", async () => { | |
| 1027 | const crashing = [1, 2, 3, 4].map((n) => { | |
| 1028 | const file = join(scratch, `crash-${n}.test.ts`); | |
| 1029 | writeFileSync(file, `throw new Error("boom at load ${n}");\n`); | |
| 1030 | return file; | |
| 1031 | }); | |
| 1032 | const got = await run("4", crashing, join(scratch, "unused")); | |
| 1033 | expect(got.out.split("re-running it once").length - 1).toBe(CRASH_RERUNS); | |
| 1034 | expect(got.code).toBe(1); | |
| 1035 | }, 60_000); | |
| 1036 | ||
| 1037 | test("a crash that happens again stays red", async () => { | |
| 1038 | const got = await run("2", [filler, "test/fixtures/shard-errors.test.ts", "test/fixtures/shard-victim.test.ts"], join(scratch, "unused")); | |
| 1039 | expect(got.out).toContain("re-running it once"); | |
| 1040 | expect(got.out).toContain("boom at load"); | |
| 1041 | expect(got.code).toBe(1); | |
| 1042 | }, 30_000); | |
| 1043 | }); | |
| 1044 | ||
| 1045 | describe("a fixture suite in six shards (#7617)", () => { | |
| 1046 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; | |
| 1047 | const fixture = (files: Record<string, string>): string => { | |
| 1048 | const dir = mkdtempSync(join(tmpdir(), "shards-fixture-")); | |
| 1049 | for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text); | |
| 1050 | return dir; | |
| 1051 | }; | |
| 1052 | const run = async ( | |
| 1053 | dir: string, | |
| 1054 | shards: string, | |
| 1055 | names: string[], | |
| 1056 | env: Record<string, string> = {}, | |
| 1057 | ): Promise<{ code: number; out: string; stdout: string }> => { | |
| 1058 | const child = Bun.spawn(["bun", script, ...names], { | |
| 1059 | cwd: dir, | |
| 1060 | env: { ...process.env, FORGE_CHECK_SHARDS: shards, ...env }, | |
| 1061 | stdin: "ignore", | |
| 1062 | stdout: "pipe", | |
| 1063 | stderr: "pipe", | |
| 1064 | }); | |
| 1065 | const [stdout, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 1066 | const code = await child.exited; | |
| 1067 | return { code, out: stdout + err, stdout }; | |
| 1068 | }; | |
| 1069 | const passing = (n: number): string => | |
| 1070 | `import { test } from "bun:test";\ntest("passes ${n}", () => {});\n`; | |
| 1071 | /** The files named on the `── shard N/M…: files` lines, in the order shards ran. */ | |
| 1072 | const finished = (stdout: string): string[] => | |
| 1073 | [...stdout.matchAll(/^── shard \d+\/\d+(?: \(exit \d+, [\d.]+s\))?: (.*)$/gm)].map((m) => m[1]!); | |
| 1074 | ||
| 1075 | test("twelve files in six shards: the plan has six slices and each file runs exactly once", async () => { | |
| 1076 | const names = Array.from({ length: 12 }, (_, i) => `s${i}.test.ts`); | |
| 1077 | const dir = fixture(Object.fromEntries(names.map((name, i) => [name, passing(i)]))); | |
| 1078 | const six = await run(dir, "6", names); | |
| 1079 | rmSync(dir, { recursive: true, force: true }); | |
| 1080 | expect(six.code).toBe(0); | |
| 1081 | expect(six.out).toContain("12 files in 6 shards"); | |
| 1082 | expect(six.out.match(/test-shards: start \d+\/6 /g)?.length).toBe(6); | |
| 1083 | const ran = finished(six.stdout).flatMap((line) => line.split(" ")); | |
| 1084 | for (const name of names) expect(ran.filter((file) => file === name).length).toBe(1); | |
| 1085 | expect(six.out).toMatch(/Ran 12 tests across 12 files/); | |
| 1086 | }, 60_000); | |
| 1087 | ||
| 1088 | test("a failure in any shard reds the run and names the test", async () => { | |
| 1089 | const files: Record<string, string> = {}; | |
| 1090 | for (let i = 0; i < 5; i++) files[`ok${i}.test.ts`] = passing(i); | |
| 1091 | files["bad.test.ts"] = `import { test, expect } from "bun:test";\ntest("the broken one", () => { expect(1).toBe(2); });\n`; | |
| 1092 | const dir = fixture(files); | |
| 1093 | const got = await run(dir, "3", Object.keys(files)); | |
| 1094 | rmSync(dir, { recursive: true, force: true }); | |
| 1095 | expect(got.code).not.toBe(0); | |
| 1096 | expect(got.out).toContain("(fail) the broken one"); | |
| 1097 | }, 60_000); | |
| 1098 | ||
| 1099 | test("each shard gets its own empty FORGE_APP_DATA and TMPDIR, removed after", async () => { | |
| 1100 | const probe = (n: number): string => | |
| 1101 | `import { test, expect } from "bun:test"; | |
| 1102 | import { readdirSync, writeFileSync } from "node:fs"; | |
| 1103 | import { tmpdir } from "node:os"; | |
| 1104 | test("probe ${n}", () => { | |
| 1105 | const data = process.env.FORGE_APP_DATA!; | |
| 1106 | expect(readdirSync(data)).toEqual([]); | |
| 1107 | expect(readdirSync(tmpdir())).toEqual([]); | |
| 1108 | writeFileSync(data + "/mark", "${n}"); | |
| 1109 | console.log("PROBE-DATA " + data + " PROBE-TMP " + tmpdir()); | |
| 1110 | }); | |
| 1111 | `; | |
| 1112 | const names = ["p0.test.ts", "p1.test.ts", "p2.test.ts"]; | |
| 1113 | const dir = fixture(Object.fromEntries(names.map((name, i) => [name, probe(i)]))); | |
| 1114 | const root = mkdtempSync(join(tmpdir(), "shards-root-")); | |
| 1115 | const got = await run(dir, "3", names, { TMPDIR: root, FORGE_APP_DATA: root }); | |
| 1116 | rmSync(dir, { recursive: true, force: true }); | |
| 1117 | const seen = [...got.out.matchAll(/PROBE-DATA (\S+) PROBE-TMP (\S+)/g)]; | |
| 1118 | const leaked = readdirSync(root); | |
| 1119 | rmSync(root, { recursive: true, force: true }); | |
| 1120 | expect(got.code).toBe(0); | |
| 1121 | expect(new Set(seen.map((m) => m[1])).size).toBe(3); | |
| 1122 | expect(new Set(seen.map((m) => m[2])).size).toBe(3); | |
| 1123 | for (const m of seen) expect(m[1]!.startsWith(root)).toBe(true); | |
| 1124 | expect(leaked).toEqual([]); | |
| 1125 | }, 60_000); | |
| 1126 | ||
| 1127 | test("a FORGE_APP_DATA the cell set is nested under, not replaced", async () => { | |
| 1128 | const dir = fixture({ | |
| 1129 | "d.test.ts": `import { test } from "bun:test";\ntest("d", () => { console.log("PROBE-DATA " + process.env.FORGE_APP_DATA); });\n`, | |
| 1130 | }); | |
| 1131 | const data = mkdtempSync(join(tmpdir(), "shards-data-")); | |
| 1132 | const got = await run(dir, "1", ["d.test.ts"], { FORGE_APP_DATA: data }); | |
| 1133 | rmSync(dir, { recursive: true, force: true }); | |
| 1134 | const leaked = readdirSync(data); | |
| 1135 | rmSync(data, { recursive: true, force: true }); | |
| 1136 | expect(got.code).toBe(0); | |
| 1137 | expect(got.out).toContain(`PROBE-DATA ${data}/data-0`); | |
| 1138 | expect(leaked).toEqual([]); | |
| 1139 | }, 60_000); | |
| 734 | describe("this file", () => { | |
| 735 | // Every crew run runs it as a cross file (CLAUDE.md), so it stays under a | |
| 736 | // second (#8310): a test that spawns the runner goes in shards-run.test.ts. | |
| 737 | test("spawns nothing", () => { | |
| 738 | const source = readFileSync(new URL(import.meta.url).pathname, "utf8"); | |
| 739 | expect(source.includes("Bun." + "spawn")).toBe(false); | |
| 740 | }); | |
| 1140 | 741 | }); |
new above
offline
The guard only matches Bun.spawn. Bun.spawnSync would also match by substring, but node:child_process execSync/spawn would not. Extend the check to cover child_process too. Minor; the doubled blank line above it should also go.