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.
- package/dist/cli.js +2161 -34
- package/package.json +13 -4
- package/src/AGENTS.md +156 -0
- package/src/adapters/docker/docker.ts +117 -0
- package/src/api/declarations.ts +279 -0
- package/src/api/fragments.ts +30 -0
- package/src/api/parameter-names.d.ts +16 -0
- package/src/cli/commands/build/build.docker.test.ts +173 -0
- package/src/cli/commands/build/build.test.ts +234 -0
- package/src/cli/commands/build/build.ts +115 -0
- package/src/cli/commands/declarations.typecheck.test.ts +173 -0
- package/src/cli/commands/executions/executions.test.ts +84 -0
- package/src/cli/commands/executions/executions.ts +31 -0
- package/src/cli/commands/format.ts +33 -0
- package/src/cli/commands/generate/generate.test.ts +101 -0
- package/src/cli/commands/generate/generate.ts +18 -0
- package/src/cli/commands/generate/parameter-names.ts +131 -0
- package/src/cli/commands/init/init.test.ts +123 -0
- package/src/cli/commands/init/init.ts +189 -0
- package/src/cli/commands/options.ts +17 -0
- package/src/cli/commands/plan/plan.test.ts +168 -0
- package/src/cli/commands/plan/plan.ts +72 -0
- package/src/cli/commands/remeasure/remeasure.test.ts +536 -0
- package/src/cli/commands/remeasure/remeasure.ts +194 -0
- package/src/cli/commands/run/run.test.ts +622 -0
- package/src/cli/commands/run/run.ts +203 -0
- package/src/cli/commands/show/show.test.ts +288 -0
- package/src/cli/commands/show/show.ts +252 -0
- package/src/cli/commands/validation.test.ts +200 -0
- package/src/cli/index.test.ts +47 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/program.ts +29 -0
- package/src/images/prerun-builds.ts +108 -0
- package/src/images/prerun.test.ts +243 -0
- package/src/images/prerun.ts +272 -0
- package/src/images/repository.ts +72 -0
- package/src/index.ts +24 -0
- package/src/instruments/instruments.test.ts +240 -0
- package/src/instruments/instruments.ts +205 -0
- package/src/matrix/execution.ts +122 -0
- package/src/matrix/hashing.test.ts +298 -0
- package/src/matrix/hashing.ts +131 -0
- package/src/matrix/permutations.test.ts +58 -0
- package/src/matrix/permutations.ts +50 -0
- package/src/matrix/resolve.ts +63 -0
- package/src/matrix/tags.test.ts +15 -0
- package/src/matrix/tags.ts +22 -0
- package/src/matrix/ulid.test.ts +28 -0
- package/src/matrix/ulid.ts +22 -0
- package/src/measuring/aggregate.test.ts +188 -0
- package/src/measuring/aggregate.ts +154 -0
- package/src/measuring/measure.test.ts +105 -0
- package/src/measuring/measure.ts +62 -0
- package/src/measuring/remeasure-templates/v1/isPrime.test.template.txt +4 -0
- package/src/measuring/remeasure-templates/v2/isPrime.test.template.txt +5 -0
- package/src/measuring/remeasure.test.ts +352 -0
- package/src/measuring/remeasure.ts +113 -0
- package/src/platform/fs.test.ts +89 -0
- package/src/platform/fs.ts +123 -0
- package/src/platform/path.test.ts +38 -0
- package/src/platform/path.ts +72 -0
- package/src/platform/signals.test.ts +25 -0
- package/src/platform/signals.ts +68 -0
- package/src/project/config.test.ts +68 -0
- package/src/project/config.ts +297 -0
- package/src/running/runner.test.ts +73 -0
- package/src/running/runner.ts +252 -0
- package/src/store/run-data-store.test.ts +232 -0
- package/src/store/run-data-store.ts +254 -0
- package/src/testing/cli.ts +65 -0
- package/src/testing/fake-docker.test.ts +197 -0
- package/src/testing/fake-docker.ts +104 -0
- package/src/testing/fixture-project.ts +177 -0
- package/src/testing/measurement-context.ts +5 -0
- package/src/testing/seed-repo.test.ts +10 -0
- package/src/testing/seed-repo.ts +83 -0
- 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
|
+
});
|