thunderjar 0.1.2 → 0.1.3-preview-eee7160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/cli.js +2161 -34
  2. package/package.json +13 -4
  3. package/src/AGENTS.md +156 -0
  4. package/src/adapters/docker/docker.ts +117 -0
  5. package/src/api/declarations.ts +279 -0
  6. package/src/api/fragments.ts +30 -0
  7. package/src/api/parameter-names.d.ts +16 -0
  8. package/src/cli/commands/build/build.docker.test.ts +173 -0
  9. package/src/cli/commands/build/build.test.ts +234 -0
  10. package/src/cli/commands/build/build.ts +115 -0
  11. package/src/cli/commands/declarations.typecheck.test.ts +173 -0
  12. package/src/cli/commands/executions/executions.test.ts +84 -0
  13. package/src/cli/commands/executions/executions.ts +31 -0
  14. package/src/cli/commands/format.ts +33 -0
  15. package/src/cli/commands/generate/generate.test.ts +101 -0
  16. package/src/cli/commands/generate/generate.ts +18 -0
  17. package/src/cli/commands/generate/parameter-names.ts +131 -0
  18. package/src/cli/commands/init/init.test.ts +123 -0
  19. package/src/cli/commands/init/init.ts +189 -0
  20. package/src/cli/commands/options.ts +17 -0
  21. package/src/cli/commands/plan/plan.test.ts +168 -0
  22. package/src/cli/commands/plan/plan.ts +72 -0
  23. package/src/cli/commands/remeasure/remeasure.test.ts +536 -0
  24. package/src/cli/commands/remeasure/remeasure.ts +194 -0
  25. package/src/cli/commands/run/run.test.ts +622 -0
  26. package/src/cli/commands/run/run.ts +203 -0
  27. package/src/cli/commands/show/show.test.ts +288 -0
  28. package/src/cli/commands/show/show.ts +252 -0
  29. package/src/cli/commands/validation.test.ts +200 -0
  30. package/src/cli/index.test.ts +47 -0
  31. package/src/cli/index.ts +9 -0
  32. package/src/cli/program.ts +29 -0
  33. package/src/images/prerun-builds.ts +108 -0
  34. package/src/images/prerun.test.ts +243 -0
  35. package/src/images/prerun.ts +272 -0
  36. package/src/images/repository.ts +72 -0
  37. package/src/index.ts +24 -0
  38. package/src/instruments/instruments.test.ts +240 -0
  39. package/src/instruments/instruments.ts +205 -0
  40. package/src/matrix/execution.ts +122 -0
  41. package/src/matrix/hashing.test.ts +298 -0
  42. package/src/matrix/hashing.ts +131 -0
  43. package/src/matrix/permutations.test.ts +58 -0
  44. package/src/matrix/permutations.ts +50 -0
  45. package/src/matrix/resolve.ts +63 -0
  46. package/src/matrix/tags.test.ts +15 -0
  47. package/src/matrix/tags.ts +22 -0
  48. package/src/matrix/ulid.test.ts +28 -0
  49. package/src/matrix/ulid.ts +22 -0
  50. package/src/measuring/aggregate.test.ts +188 -0
  51. package/src/measuring/aggregate.ts +154 -0
  52. package/src/measuring/measure.test.ts +105 -0
  53. package/src/measuring/measure.ts +62 -0
  54. package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +4 -0
  55. package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +5 -0
  56. package/src/measuring/remeasure.test.ts +352 -0
  57. package/src/measuring/remeasure.ts +113 -0
  58. package/src/platform/fs.test.ts +89 -0
  59. package/src/platform/fs.ts +123 -0
  60. package/src/platform/path.test.ts +38 -0
  61. package/src/platform/path.ts +72 -0
  62. package/src/platform/signals.test.ts +25 -0
  63. package/src/platform/signals.ts +68 -0
  64. package/src/project/config.test.ts +68 -0
  65. package/src/project/config.ts +297 -0
  66. package/src/running/runner.test.ts +73 -0
  67. package/src/running/runner.ts +252 -0
  68. package/src/store/run-data-store.test.ts +232 -0
  69. package/src/store/run-data-store.ts +254 -0
  70. package/src/testing/cli.ts +65 -0
  71. package/src/testing/fake-docker.test.ts +197 -0
  72. package/src/testing/fake-docker.ts +104 -0
  73. package/src/testing/fixture-project.ts +177 -0
  74. package/src/testing/measurement-context.ts +5 -0
  75. package/src/testing/seed-repo.test.ts +10 -0
  76. package/src/testing/seed-repo.ts +83 -0
  77. package/src/testing/test-setup.ts +10 -0
@@ -0,0 +1,622 @@
1
+ // `thunderjar run --dry-run`, through the CLI, in the checked-in fixtureTests/ scenarios, in place.
2
+ // Each asserts on stdout, with the new execution ID replaced by ID. Any of these can be run by hand:
3
+ //
4
+ // bun run fixture scenario_2 run two-pairs --dry-run
5
+ //
6
+ // Below them, the real-run tests still work on generated projects, in process.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { existsSync, listDir, makeTempDir, readTextSync } from "../../../platform/fs.ts";
10
+ import { join } from "../../../platform/path.ts";
11
+ import { hashMeasurement } from "../../../matrix/hashing.ts";
12
+ import type { AggregationResult, ContainerRunRecord } from "../../../store/run-data-store.ts";
13
+ import { createHostMeasurementOpener, createHostRunner, type ContainerRunner } from "../../../running/runner.ts";
14
+ import { fakeDocker, scenario, thunderjar } from "../../../testing/cli.ts";
15
+ import { memoryDocker } from "../../../testing/fake-docker.ts";
16
+ import { createFixtureProject, linkThunderjar, stub } from "../../../testing/fixture-project.ts";
17
+ import { run, type RunOptions } from "./run.ts";
18
+
19
+ const tmp = () => makeTempDir("thunderjar-run-");
20
+
21
+ const ID = "01k4x9j2e8mqz3abcdefghjkmn";
22
+ const newExecutionId = () => ID;
23
+
24
+ const ULID = /^[0-9a-hjkmnp-tv-z]{26}$/;
25
+
26
+ const idOf = (stdout: string) => /^Execution (\S+) /.exec(stdout)?.[1];
27
+
28
+ /** `thunderjar run <args…>` in `fixtures`, with the execution ID it printed, and stdout with that ID replaced by ID. */
29
+ async function runCli(fixtures: string, args: string[], env: Record<string, string> = {}) {
30
+ const result = await thunderjar(fixtures, ["run", ...args], env);
31
+ const id = idOf(result.stdout);
32
+ return { ...result, id, stdout: id ? result.stdout.replaceAll(id, ID) : result.stdout };
33
+ }
34
+
35
+ /** Each fake harness's cli, as the dry run prints it. */
36
+ const ALPHA_CLI = (prompt: string, model: string) =>
37
+ `printf '%s\\n' '// ${prompt}' '// @ts-ignore' 'export const isPrime = (n: number) => n > 1 && n % 2 !== 0;' > isPrime.ts && echo '{"model":"${model}"}' > /thunderjar/result.json`;
38
+ const BETA_CLI = (prompt: string, model: string) =>
39
+ `printf '%s\\n' '// ${prompt}' 'export const isPrime = (n: number) => n > 1 && Array.from({ length: n - 2 }, (_, i) => i + 2).every((d) => n % d !== 0);' > isPrime.ts && echo '{"model":"${model}"}' > /thunderjar/result.json`;
40
+
41
+ const TERSE = "isPrime.ts: export a primality test.";
42
+ const PLAIN = "Create a file isPrime.ts exporting a function that tests for primality.";
43
+
44
+ /** The top level of `dir` and everything under `.thunderjar/`, so a test can show nothing was written. */
45
+ const listTree = (dir: string) => [
46
+ ...listDir(dir),
47
+ ...listDir(join(dir, ".thunderjar"), { recursive: true }),
48
+ ].toSorted();
49
+
50
+ describe("scenario_2_typical_project", () => {
51
+ const fixtures = scenario("scenario_2_typical_project");
52
+
53
+ test("run two-pairs --dry-run prints each permutation's prerun image, then each iteration's postrun image and harness command, and writes nothing", async () => {
54
+ const before = listTree(fixtures);
55
+
56
+ const { stdout, stderr, exitCode, id } = await runCli(fixtures, ["two-pairs", "--dry-run"]);
57
+
58
+ expect(stderr).toBe("");
59
+ expect(exitCode).toBe(0);
60
+ expect(id).toMatch(ULID);
61
+ // The prerun hashes are those `plan` shows for the same permutations (src/cli/commands/plan/plan.test.ts).
62
+ expect(stdout).toBe(`Execution ${ID} (dry run, 2 permutations × 2 iterations)
63
+
64
+ Permutation 1/2 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | terse
65
+ prerun-hefe85dda (will build)
66
+ iteration 1/2 postrun-e${ID}-hefe85dda-i00
67
+ ${ALPHA_CLI(TERSE, "fake-model-small-001")}
68
+ iteration 2/2 postrun-e${ID}-hefe85dda-i01
69
+ ${ALPHA_CLI(TERSE, "fake-model-small-001")}
70
+
71
+ Permutation 2/2 node20 | baseline | snerk | fake-harness-beta@1.0.0/fake-model-large | terse
72
+ prerun-h56ea4852 (will build)
73
+ iteration 1/2 postrun-e${ID}-h56ea4852-i00
74
+ ${BETA_CLI(TERSE, "fake-model-large-001")}
75
+ iteration 2/2 postrun-e${ID}-h56ea4852-i01
76
+ ${BETA_CLI(TERSE, "fake-model-large-001")}
77
+ `);
78
+ expect(listTree(fixtures)).toEqual(before);
79
+ });
80
+
81
+ test("each invocation gets its own execution ID", async () => {
82
+ const first = await runCli(fixtures, ["two-pairs", "--dry-run"]);
83
+ const second = await runCli(fixtures, ["two-pairs", "--dry-run"]);
84
+ expect(first.id).toMatch(ULID);
85
+ expect(second.id).toMatch(ULID);
86
+ expect(first.id).not.toBe(second.id);
87
+ });
88
+
89
+ test("run is-prime-baseline --dry-run walks permutations in plan's order, with every iteration inside each", async () => {
90
+ const { stdout, exitCode } = await runCli(fixtures, ["is-prime-baseline", "--dry-run"]);
91
+ const lines = stdout.split("\n");
92
+
93
+ expect(exitCode).toBe(0);
94
+ expect(lines[0]).toBe(`Execution ${ID} (dry run, 8 permutations × 5 iterations)`);
95
+ // The prerun images, in the order `plan is-prime-baseline` lists them (src/cli/commands/plan/plan.test.ts).
96
+ expect(stdout.match(/prerun-h[0-9a-f]{8}/g)).toEqual([
97
+ "prerun-h8ffdda6e",
98
+ "prerun-hefe85dda",
99
+ "prerun-h8baa2cff",
100
+ "prerun-h56ea4852",
101
+ "prerun-hfaa55eaf",
102
+ "prerun-h3e2038b8",
103
+ "prerun-h09fa95b9",
104
+ "prerun-he7379c79",
105
+ ]);
106
+
107
+ // Each permutation header is followed by its prerun image and five iterations, i00 to i04.
108
+ const permutationLines = lines.filter((line) => line.startsWith("Permutation "));
109
+ expect(permutationLines.map((line) => line.split(" ")[0])).toEqual(
110
+ [1, 2, 3, 4, 5, 6, 7, 8].map((n) => `Permutation ${n}/8`),
111
+ );
112
+ const iterationLines = lines.filter((line) => line.startsWith(" iteration "));
113
+ expect(iterationLines).toHaveLength(40);
114
+ iterationLines.forEach((line, i) => {
115
+ expect(line).toContain(`iteration ${(i % 5) + 1}/5 postrun-e${ID}-h`);
116
+ expect(line).toEndWith(`-i0${i % 5}`);
117
+ });
118
+
119
+ expect(lines.slice(2, 6).join("\n")).toBe(`Permutation 1/8 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain
120
+ prerun-h8ffdda6e (will build)
121
+ iteration 1/5 postrun-e${ID}-h8ffdda6e-i00
122
+ ${ALPHA_CLI(PLAIN, "fake-model-small-001")}`);
123
+ });
124
+
125
+ test("run version-cli-override --dry-run uses the selected version's cli override, and indents every line of a multi-line command", async () => {
126
+ const { stdout, stderr, exitCode } = await runCli(fixtures, ["version-cli-override", "--dry-run"]);
127
+
128
+ expect(stderr).toBe("");
129
+ expect(exitCode).toBe(0);
130
+ expect(stdout).toBe(`Execution ${ID} (dry run, 1 permutation × 1 iteration)
131
+
132
+ Permutation 1/1 node20 | baseline | snerk | fake-harness-alpha@1.1.0/fake-model-large | terse
133
+ prerun-h1fef9cf7 (will build)
134
+ iteration 1/1 postrun-e${ID}-h1fef9cf7-i00
135
+ echo "fake-harness-alpha@1.1.0" > version.txt
136
+ ${ALPHA_CLI(TERSE, "fake-model-large-001")}
137
+ `);
138
+ });
139
+
140
+ test("says what's wrong, then its usage, for a missing test case name, an unknown flag, or a bad option", async () => {
141
+ for (const [args, error] of [
142
+ [[], "error: missing required argument 'test case'"],
143
+ [["--dry-run"], "error: missing required argument 'test case'"],
144
+ [["is-prime-baseline", "--dry-run", "--json"], "error: unknown option '--json'"],
145
+ [["is-prime-baseline", "--dry-run", "--image-prefix"], "error: option '--image-prefix <name>' argument missing"],
146
+ [["is-prime-baseline", "--dry-run", "--data-dir"], "error: option '--data-dir <dir>' argument missing"],
147
+ [["is-prime-baseline", "--data-dir", "a", "--data-dir", "b"], "It may only be given once."],
148
+ [["is-prime-baseline", "--image-prefix", "--dry-run"], "It needs a value, not another flag."],
149
+ ] as const) {
150
+ const { stdout, stderr, exitCode } = await runCli(fixtures, [...args]);
151
+ expect(stdout).toBe("");
152
+ expect(stderr).toContain(error);
153
+ expect(stderr).toContain("Usage: thunderjar run [options] <test case>");
154
+ expect(exitCode).toBe(1);
155
+ }
156
+ });
157
+
158
+ test("run two-pairs --dry-run --image-prefix looks up each prerun image under the prefix", async () => {
159
+ const docker = await fakeDocker(["throwaway:prerun-hefe85dda"]);
160
+
161
+ const { stdout, stderr, exitCode } = await runCli(fixtures, ["two-pairs", "--dry-run", "--image-prefix", "throwaway"], docker.env);
162
+
163
+ expect(stderr).toBe("");
164
+ expect(exitCode).toBe(0);
165
+ expect(stdout).toContain(" prerun-hefe85dda (exists, reused)\n");
166
+ expect(stdout).toContain(" prerun-h56ea4852 (will build)\n");
167
+ expect(docker.commands()).toEqual([
168
+ "docker version --format {{.Server.Version}}",
169
+ "docker image inspect --format {{.Id}} throwaway:prerun-hefe85dda",
170
+ "docker image inspect --format {{.Id}} throwaway:prerun-h56ea4852",
171
+ ]);
172
+ });
173
+
174
+ test("run two-pairs with a data dir writes the run data store only under it, leaving .thunderjar/.data/ untouched", async () => {
175
+ const before = listTree(fixtures);
176
+ const dataDir = join(await tmp(), "data");
177
+
178
+ const stdout = await printed("two-pairs", { dryRun: false, cwd: fixtures, newExecutionId, dataDir, ...onHost(await tmp()) });
179
+
180
+ expect(stdout).toContain(`Execution ${ID} (2 permutations × 2 iterations)`);
181
+ expect(listDir(dataDir)).toEqual(["runs.json"]);
182
+ const { containerRuns } = JSON.parse(readTextSync(join(dataDir, "runs.json"))) as { containerRuns: ContainerRunRecord[] };
183
+ expect(containerRuns.map((r) => r.executionId)).toEqual([ID, ID, ID, ID]);
184
+ expect(listTree(fixtures)).toEqual(before);
185
+ });
186
+
187
+ test("reports an unknown test case as a configuration error", async () => {
188
+ const { stderr, exitCode } = await runCli(fixtures, ["no-such-test-case", "--dry-run"]);
189
+ expect(stderr).toContain(`no test case named "no-such-test-case"`);
190
+ expect(exitCode).toBe(1);
191
+ });
192
+ });
193
+
194
+ /** Everything `run` prints, as one string. */
195
+ async function printed(name: string, options: RunOptions) {
196
+ const blocks: string[] = [];
197
+ await run(name, { ...options, log: (text) => blocks.push(text) });
198
+ return blocks.join("\n");
199
+ }
200
+
201
+ async function fixture(changes: Record<string, string | null> = {}) {
202
+ const dir = await tmp();
203
+ await createFixtureProject(dir, changes);
204
+ await linkThunderjar(dir);
205
+ return dir;
206
+ }
207
+
208
+ // --- Running for real, with the fake agent (fake-harness-alpha@1.0.0/fake-model-small) ---
209
+ // In a container on the host in place of Docker, with prerun images "built" by an in-memory
210
+ // Docker: what `run` does around each container run, not Docker itself. Real Docker runs are in
211
+ // fixtureTests/*/run.docker.test.ts.
212
+
213
+ /** A clock that moves 40 ms each time it is read, so every container run takes 0.04s. */
214
+ const stubClock = () => {
215
+ let t = 1790692255104;
216
+ return () => (t += 40);
217
+ };
218
+
219
+ /** Runs each container run on the host under `rootDir`, timed by `now`, and builds prerun images in memory. */
220
+ const onHost = (rootDir: string, now: () => number = Date.now) => ({
221
+ rootDir,
222
+ now,
223
+ docker: memoryDocker(),
224
+ runner: createHostRunner(rootDir, now),
225
+ measurementOpener: createHostMeasurementOpener(rootDir),
226
+ });
227
+
228
+ /** Runs `name` for real with the ID and clock stubbed, keeping container runs under a fresh root. */
229
+ async function runForReal(dir: string, name: string) {
230
+ const rootDir = await tmp();
231
+ const stdout = await printed(name, { dryRun: false, cwd: dir, newExecutionId, ...onHost(rootDir, stubClock()) });
232
+ return { stdout, rootDir, executionDir: join(rootDir, ID) };
233
+ }
234
+
235
+ /** The host's stand-in for one postrun image: what the fake agent left, and run.json. */
236
+ const containerRun = (executionDir: string, tag: string) => {
237
+ const dir = join(executionDir, tag);
238
+ const read = (rel: string) => (existsSync(join(dir, rel)) ? readTextSync(join(dir, rel)) : undefined);
239
+ return {
240
+ workspace: listDir(join(dir, "workspace")).toSorted(),
241
+ fakeAgentFile: read("workspace/isPrime.ts"),
242
+ result: read("thunderjar/result.json"),
243
+ run: JSON.parse(read("thunderjar/run.json")!) as { startedAt: number; finishedAt: number; exitCode: number },
244
+ };
245
+ };
246
+
247
+ const FAKE = `postrun-e${ID}-h`;
248
+
249
+ test("builds the prerun image, then runs each container run, leaving the agent's files, its result and run.json, and measures it", async () => {
250
+ const dir = await fixture();
251
+ const { stdout, rootDir, executionDir } = await runForReal(dir, "fake-is-prime");
252
+
253
+ const tag = /iteration 1\/2 (\S+)/.exec(stdout)![1]!;
254
+ const hash = tag.slice(FAKE.length, -"-i00".length);
255
+ expect(stdout).toBe(`Execution ${ID} (1 permutation × 2 iterations)
256
+ Images in thunderjar
257
+ Build contexts in ${join(rootDir, "builds")}
258
+
259
+ Permutation 1/1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain
260
+ prerun-h${hash} built (0.04s)
261
+ iteration 1/2 ${FAKE}${hash}-i00 exit 0 0.04s
262
+ isPrimeTemplateTest {passed: 3, failed: 2}
263
+ isPrimeTsExists true
264
+ readmeExists false
265
+ tsIgnoreCount 1
266
+ iteration 2/2 ${FAKE}${hash}-i01 exit 0 0.04s
267
+ isPrimeTemplateTest {passed: 3, failed: 2}
268
+ isPrimeTsExists true
269
+ readmeExists false
270
+ tsIgnoreCount 1
271
+
272
+ 2 container runs: 2 exited 0
273
+ Recorded in .thunderjar/.data/runs.json`);
274
+
275
+ expect(listDir(executionDir).toSorted()).toEqual([`${FAKE}${hash}-i00`, `${FAKE}${hash}-i01`]);
276
+ for (const tag of listDir(executionDir)) {
277
+ const { workspace, fakeAgentFile, result, run } = containerRun(executionDir, tag);
278
+ // Only what the agent left: no test rendered while measuring.
279
+ expect(workspace).toEqual(["isPrime.ts"]);
280
+ expect(fakeAgentFile).toBe(`// ${PLAIN}
281
+ // @ts-ignore
282
+ export const isPrime = (n: number) => n > 1 && n % 2 !== 0;
283
+ `);
284
+ expect(result).toBe(`{"model":"fake-model-small-001"}\n`);
285
+ expect(run.exitCode).toBe(0);
286
+ expect(run.finishedAt - run.startedAt).toBe(40);
287
+ }
288
+ });
289
+
290
+ test("records a failing cli's exit code, and still runs the remaining iterations", async () => {
291
+ const dir = await fixture({
292
+ // 1.0.1 writes its file, then fails.
293
+ "test-case-parameters/harnessModels/fake-harness-alpha/versions.ts": stub("declareHarnessVersions", `{
294
+ "1.0.0": { version: "1.0.0" },
295
+ "1.0.1": { version: "1.0.1", cli: () => \`touch isPrime.ts && exit 3\` },
296
+ }`),
297
+ "test-cases/fake-fails.ts": stub("declareTestCase", `{
298
+ baseImage: ["node20"],
299
+ codeState: ["baseline"],
300
+ promptSet: ["snerk"],
301
+ harnessModel: ["fake-harness-alpha@1.0.1/fake-model-small", "fake-harness-alpha@1.0.0/fake-model-small"],
302
+ task: "is-prime",
303
+ initialPrompt: ["plain"],
304
+ iterations: 2,
305
+ }`),
306
+ });
307
+ const { stdout, executionDir } = await runForReal(dir, "fake-fails");
308
+
309
+ const lines = stdout.split("\n");
310
+ const exits = lines.filter((line) => line.startsWith(" iteration ")).map((line) => / exit (\d+) /.exec(line)![1]);
311
+ const failing = lines.findIndex((line) => line.includes("fake-harness-alpha@1.0.1/fake-model-small"));
312
+ expect(exits).toEqual(failing === 4 ? ["3", "3", "0", "0"] : ["0", "0", "3", "3"]);
313
+ expect(lines.at(-2)).toBe("4 container runs: 2 exited 0, 2 exited 3");
314
+
315
+ const runs = listDir(executionDir).map((tag) => containerRun(executionDir, tag));
316
+ expect(runs).toHaveLength(4);
317
+ expect(runs.map(({ run }) => run.exitCode).toSorted()).toEqual([0, 0, 3, 3]);
318
+ // Every run left its file, including those after the failures.
319
+ for (const { workspace } of runs) expect(workspace).toEqual(["isPrime.ts"]);
320
+
321
+ // Every run is recorded too, the failures with exit code 3.
322
+ const recorded = recordedRuns(dir);
323
+ expect(recorded).toHaveLength(4);
324
+ for (const { harnessModel, execution } of recorded) {
325
+ expect(execution.exitCode).toBe(harnessModel.version === "1.0.1" ? 3 : 0);
326
+ }
327
+ });
328
+
329
+ /** Every container run recorded in the fixture project's run data store. */
330
+ const recordedRuns = (dir: string) =>
331
+ (JSON.parse(readTextSync(join(dir, ".thunderjar", ".data", "runs.json"))) as {
332
+ containerRuns: ContainerRunRecord[];
333
+ }).containerRuns;
334
+
335
+ const HASH = /^[0-9a-f]{64}$/;
336
+
337
+ const TEMPLATE_TEST_CONFIG = { subject: "**/isPrime.ts", template: "./isPrime.test.template.ts" };
338
+
339
+ const measurementsDir = (dir: string) => join(dir, ".thunderjar", "test-case-parameters", "tasks", "is-prime", "measurements");
340
+
341
+ test("records each container run, with its execution as in its run.json and every measure as its result", async () => {
342
+ const dir = await fixture();
343
+ const { executionDir } = await runForReal(dir, "fake-is-prime");
344
+
345
+ const recorded = recordedRuns(dir);
346
+ expect(recorded.map((r) => r.postrunImage)).toEqual(listDir(executionDir).toSorted());
347
+ recorded.forEach((record, iteration) => {
348
+ const { permutationHash, parameters } = record;
349
+ expect(permutationHash).toMatch(HASH);
350
+ for (const { parameterHash } of Object.values(parameters)) expect(parameterHash).toMatch(HASH);
351
+ const { run } = containerRun(executionDir, record.postrunImage);
352
+ expect(record).toEqual({
353
+ executionId: ID,
354
+ testCase: "fake-is-prime",
355
+ task: "is-prime",
356
+ permutationHash,
357
+ iteration,
358
+ postrunImage: `${FAKE}${permutationHash.slice(0, 8)}-i0${iteration}`,
359
+ ranAt: new Date(run.startedAt).toISOString(),
360
+ parameters: {
361
+ baseImage: { name: "node20", parameterHash: parameters.baseImage.parameterHash },
362
+ codeState: { name: "baseline", parameterHash: parameters.codeState.parameterHash },
363
+ promptSet: { name: "snerk", parameterHash: parameters.promptSet.parameterHash },
364
+ harnessModel: { name: "fake-harness-alpha@1.0.0/fake-model-small", parameterHash: parameters.harnessModel.parameterHash },
365
+ initialPrompt: { name: "plain", parameterHash: parameters.initialPrompt.parameterHash },
366
+ },
367
+ harnessModel: { family: "fake-harness-alpha", version: "1.0.0", modelId: "fake-model-small-001" },
368
+ result: {
369
+ isPrimeTemplateTest: {
370
+ instrument: "templateTest",
371
+ contentHash: hashMeasurement({ instrument: "templateTest", config: TEMPLATE_TEST_CONFIG, dir: measurementsDir(dir) }),
372
+ measuredAt: record.result.isPrimeTemplateTest!.measuredAt,
373
+ outcome: { outcome: "measured", data: { passed: 3, failed: 2 } },
374
+ },
375
+ isPrimeTsExists: {
376
+ instrument: "fileCreated",
377
+ contentHash: hashMeasurement({ instrument: "fileCreated", config: { glob: "**/isPrime.ts" } }),
378
+ measuredAt: record.result.isPrimeTsExists!.measuredAt,
379
+ outcome: { outcome: "measured", data: true },
380
+ },
381
+ readmeExists: {
382
+ instrument: "fileCreated",
383
+ contentHash: hashMeasurement({ instrument: "fileCreated", config: { glob: "**/README.md" } }),
384
+ measuredAt: record.result.readmeExists!.measuredAt,
385
+ outcome: { outcome: "measured", data: false },
386
+ },
387
+ tsIgnoreCount: {
388
+ instrument: "grep",
389
+ contentHash: hashMeasurement({ instrument: "grep", config: { glob: "**/*.ts", pattern: "@ts-ignore" } }),
390
+ measuredAt: record.result.tsIgnoreCount!.measuredAt,
391
+ outcome: { outcome: "measured", data: 1 },
392
+ },
393
+ },
394
+ execution: run,
395
+ });
396
+ // Measured after the agent finished, by the same clock.
397
+ for (const { measuredAt } of Object.values(record.result)) {
398
+ expect(Date.parse(measuredAt)).toBeGreaterThan(run.finishedAt);
399
+ }
400
+ });
401
+ // The second iteration started after the first finished.
402
+ expect(recorded[1]!.execution.startedAt).toBeGreaterThan(recorded[0]!.execution.finishedAt);
403
+ });
404
+
405
+ test("keeps the runs recorded before a crash", async () => {
406
+ const dir = await fixture();
407
+ let calls = 0;
408
+ const rootDir = await tmp();
409
+ const host = createHostRunner(rootDir, stubClock());
410
+ const runner: ContainerRunner = {
411
+ runContainer: (planned) => (++calls === 2 ? Promise.reject(new Error("docker went away")) : host.runContainer(planned)),
412
+ };
413
+
414
+ const options = { dryRun: false, cwd: dir, newExecutionId, log: () => {}, ...onHost(rootDir), runner };
415
+ await expect(run("fake-is-prime", options)).rejects.toThrow("docker went away");
416
+ expect(recordedRuns(dir).map((r) => r.iteration)).toEqual([0]);
417
+ expect(Object.keys(recordedRuns(dir)[0]!.result)).toEqual(["isPrimeTemplateTest", "isPrimeTsExists", "readmeExists", "tsIgnoreCount"]);
418
+ });
419
+
420
+ test("a crash while measuring leaves the container run recorded with an empty result", async () => {
421
+ const dir = await fixture();
422
+ const rootDir = await tmp();
423
+ const options = {
424
+ dryRun: false,
425
+ cwd: dir,
426
+ newExecutionId,
427
+ log: () => {},
428
+ ...onHost(rootDir),
429
+ measurementOpener: { openMeasurementContext: () => Promise.reject(new Error("measurement container went away")) },
430
+ };
431
+ await expect(run("fake-is-prime", options)).rejects.toThrow("measurement container went away");
432
+ expect(recordedRuns(dir).map((r) => [r.iteration, r.result])).toEqual([[0, {}]]);
433
+ });
434
+
435
+ test("an instrument that throws is recorded as erroredWhileMeasuring, and every other measure is still taken", async () => {
436
+ const dir = await fixture();
437
+ const rootDir = await tmp();
438
+ const host = createHostMeasurementOpener(rootDir);
439
+ // A context whose glob throws for one pattern only.
440
+ const measurementOpener = {
441
+ async openMeasurementContext(tag: string) {
442
+ const ctx = await host.openMeasurementContext(tag);
443
+ return {
444
+ ...ctx,
445
+ glob: (pattern: string) => (pattern.includes("README") ? Promise.reject(new Error("disk on fire")) : ctx.glob(pattern)),
446
+ };
447
+ },
448
+ };
449
+ const stdout = await printed("fake-is-prime", { dryRun: false, cwd: dir, newExecutionId, ...onHost(rootDir, stubClock()), measurementOpener });
450
+
451
+ expect(stdout.split("\n").filter((line) => line.startsWith(" "))).toEqual([
452
+ " isPrimeTemplateTest {passed: 3, failed: 2}",
453
+ " isPrimeTsExists true",
454
+ " readmeExists erroredWhileMeasuring: disk on fire",
455
+ " tsIgnoreCount 1",
456
+ " isPrimeTemplateTest {passed: 3, failed: 2}",
457
+ " isPrimeTsExists true",
458
+ " readmeExists erroredWhileMeasuring: disk on fire",
459
+ " tsIgnoreCount 1",
460
+ ]);
461
+ expect(stdout.split("\n").at(-2)).toBe("2 container runs: 2 exited 0");
462
+ for (const { result } of recordedRuns(dir)) {
463
+ expect(result.isPrimeTsExists!.outcome).toEqual({ outcome: "measured", data: true });
464
+ expect(result.readmeExists!.outcome).toEqual({ outcome: "erroredWhileMeasuring", error: { message: "disk on fire" } });
465
+ expect(result.readmeExists!.instrument).toBe("fileCreated");
466
+ expect(result.readmeExists!.contentHash).toMatch(HASH);
467
+ }
468
+ });
469
+
470
+ test("runs that exit non-zero are still measured", async () => {
471
+ const dir = await fixture({
472
+ "test-case-parameters/harnessModels/fake-harness-alpha/index.ts": stub("declareHarness", `{
473
+ applyParameter: () => \`\`,
474
+ cli: () => \`touch README.md && exit 3\`,
475
+ }`),
476
+ });
477
+ const { stdout } = await runForReal(dir, "fake-is-prime");
478
+ // With no isPrime.ts and no .ts file at all, there is nothing to test or count.
479
+ expect(stdout.split("\n").filter((line) => line.startsWith(" "))).toEqual([
480
+ " isPrimeTemplateTest skipped",
481
+ " isPrimeTsExists false",
482
+ " readmeExists true",
483
+ " tsIgnoreCount skipped",
484
+ " isPrimeTemplateTest skipped",
485
+ " isPrimeTsExists false",
486
+ " readmeExists true",
487
+ " tsIgnoreCount skipped",
488
+ ]);
489
+ });
490
+
491
+ test("a task without measurements prints and records none", async () => {
492
+ const dir = await fixture({
493
+ "test-case-parameters/tasks/is-prime/measurements/isPrimeTsExists.ts": null,
494
+ "test-case-parameters/tasks/is-prime/measurements/readmeExists.ts": null,
495
+ "test-case-parameters/tasks/is-prime/measurements/tsIgnoreCount.ts": null,
496
+ "test-case-parameters/tasks/is-prime/measurements/isPrimeTemplateTest.ts": null,
497
+ });
498
+ const { stdout } = await runForReal(dir, "fake-is-prime");
499
+ expect(stdout.split("\n").filter((line) => line.startsWith(" "))).toEqual([]);
500
+ for (const { result } of recordedRuns(dir)) expect(result).toEqual({});
501
+ });
502
+
503
+ test("kills a cli that runs past the initial prompt's timeoutSeconds, recording exit code 124", async () => {
504
+ const dir = await fixture({
505
+ "test-case-parameters/tasks/is-prime/initial-prompts/plain.ts": stub("declareInitialPrompt", `{
506
+ prompt: "Take your time.",
507
+ timeoutSeconds: 1,
508
+ applyParameter: () => \`\`,
509
+ }`),
510
+ "test-case-parameters/harnessModels/fake-harness-alpha/index.ts": stub("declareHarness", `{
511
+ applyParameter: () => \`\`,
512
+ cli: () => \`touch started.txt && sleep 2 && touch finished.txt\`,
513
+ }`),
514
+ });
515
+ const rootDir = await tmp();
516
+ const startedAt = Date.now();
517
+ const stdout = await printed("fake-is-prime", { dryRun: false, cwd: dir, newExecutionId, ...onHost(rootDir) });
518
+ const elapsed = Date.now() - startedAt;
519
+
520
+ // Each of the two iterations is killed at about a second, well before `sleep 2` ends.
521
+ expect(elapsed).toBeLessThan(3500);
522
+ expect(stdout.split("\n").at(-2)).toBe("2 container runs: 2 exited 124");
523
+ await Bun.sleep(1500); // past when `sleep 2` would have finished
524
+ const executionDir = join(rootDir, ID);
525
+ for (const tag of listDir(executionDir)) {
526
+ const { workspace, run } = containerRun(executionDir, tag);
527
+ expect(workspace).toEqual(["started.txt"]);
528
+ expect(run.exitCode).toBe(124);
529
+ expect(run.finishedAt - run.startedAt).toBeGreaterThanOrEqual(1000);
530
+ expect(run.finishedAt - run.startedAt).toBeLessThan(1900);
531
+ }
532
+ });
533
+
534
+ /** Every aggregation result stored in the fixture project's run data store. */
535
+ const storedAggregations = (dir: string) =>
536
+ (JSON.parse(readTextSync(join(dir, ".thunderjar", ".data", "runs.json"))) as {
537
+ aggregationResults?: AggregationResult[];
538
+ }).aggregationResults;
539
+
540
+ test("stores each permutation's aggregation result beside its runs, by each measurement's aggregate", async () => {
541
+ const dir = await fixture();
542
+ await runForReal(dir, "fake-is-prime");
543
+ const [{ permutationHash }] = recordedRuns(dir) as [ContainerRunRecord];
544
+
545
+ expect(storedAggregations(dir)).toEqual([
546
+ {
547
+ executionId: ID,
548
+ permutationHash,
549
+ iterations: 2,
550
+ measures: {
551
+ isPrimeTemplateTest: { measured: 2, sum: { passed: 6, failed: 4 } },
552
+ isPrimeTsExists: { measured: 2, rate: 1 },
553
+ readmeExists: { measured: 2, rate: 0 },
554
+ tsIgnoreCount: { measured: 2, mean: 1, max: 1 },
555
+ },
556
+ duration: { mean: 40, max: 40 },
557
+ },
558
+ ]);
559
+ });
560
+
561
+ test("recomputes the aggregation result after each container run, so a crash leaves it up to date", async () => {
562
+ const dir = await fixture();
563
+ let calls = 0;
564
+ const rootDir = await tmp();
565
+ const host = createHostRunner(rootDir, stubClock());
566
+ const runner: ContainerRunner = {
567
+ runContainer: (planned) => (++calls === 2 ? Promise.reject(new Error("docker went away")) : host.runContainer(planned)),
568
+ };
569
+
570
+ await expect(run("fake-is-prime", { dryRun: false, cwd: dir, newExecutionId, log: () => {}, ...onHost(rootDir), runner })).rejects.toThrow(
571
+ "docker went away",
572
+ );
573
+ expect(storedAggregations(dir)!.map(({ iterations, measures }) => [iterations, measures.isPrimeTsExists])).toEqual([
574
+ [1, { measured: 1, rate: 1 }],
575
+ ]);
576
+ });
577
+
578
+ test("a task without measurements still stores an aggregation result, of its durations", async () => {
579
+ const dir = await fixture({
580
+ "test-case-parameters/tasks/is-prime/measurements/isPrimeTsExists.ts": null,
581
+ "test-case-parameters/tasks/is-prime/measurements/readmeExists.ts": null,
582
+ "test-case-parameters/tasks/is-prime/measurements/tsIgnoreCount.ts": null,
583
+ "test-case-parameters/tasks/is-prime/measurements/isPrimeTemplateTest.ts": null,
584
+ });
585
+ await runForReal(dir, "fake-is-prime");
586
+ expect(storedAggregations(dir)!.map(({ iterations, measures, duration }) => ({ iterations, measures, duration }))).toEqual([
587
+ { iterations: 2, measures: {}, duration: { mean: 40, max: 40 } },
588
+ ]);
589
+ });
590
+
591
+ test("builds each missing prerun image once, under --image-prefix, and reuses one that exists", async () => {
592
+ const dir = await fixture();
593
+ const rootDir = await tmp();
594
+ const host = onHost(rootDir, stubClock());
595
+ const options = { dryRun: false, cwd: dir, newExecutionId, ...host, repository: "thunderjar-test-prefix" };
596
+
597
+ const first = await printed("fake-is-prime", options);
598
+ expect(host.docker.builds).toEqual([expect.stringMatching(/^thunderjar-test-prefix:prerun-h[0-9a-f]{8}$/)]);
599
+ expect(first).toContain("Images in thunderjar-test-prefix\n");
600
+
601
+ const second = await printed("fake-is-prime", options);
602
+ expect(host.docker.builds).toHaveLength(1);
603
+ const tag = host.docker.builds[0]!.split(":")[1]!;
604
+ expect(second.split("\n").slice(0, 5)).toEqual([
605
+ `Execution ${ID} (1 permutation × 2 iterations)`,
606
+ "Images in thunderjar-test-prefix",
607
+ "",
608
+ "Permutation 1/1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain",
609
+ ` ${tag} reused`,
610
+ ]);
611
+ });
612
+
613
+ test("fails before running anything when Docker can't be reached", async () => {
614
+ const dir = await fixture();
615
+ const rootDir = await tmp();
616
+ const docker = { ...memoryDocker(), unreachable: async () => "no daemon" };
617
+ const options = { dryRun: false, cwd: dir, newExecutionId, log: () => {}, ...onHost(rootDir), docker };
618
+ await expect(run("fake-is-prime", options)).rejects.toThrow(
619
+ "error: Docker can't be reached (no daemon). Running a test case needs Docker.",
620
+ );
621
+ expect(existsSync(join(dir, ".thunderjar", ".data"))).toBe(false);
622
+ });