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,173 @@
|
|
|
1
|
+
// Docker image tests for `build` — spec/120-test-boundaries.md#31-generating-the-pre-run-image.
|
|
2
|
+
// Docker and the images are real; the agent is the fake one. Skipped by `bun test`; run with
|
|
3
|
+
// `bun run test:prepare && bun run test:docker`. Each run passes a throwaway `--image-prefix`
|
|
4
|
+
// and removes every image under it afterwards.
|
|
5
|
+
//
|
|
6
|
+
// Each runs the CLI in place in a checked-in scenario, as by hand:
|
|
7
|
+
//
|
|
8
|
+
// bun run fixture scenario_4 build two-commits
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { makeTempDir, readTextSync } from "../../../platform/fs.ts";
|
|
12
|
+
import { dirname, join } from "../../../platform/path.ts";
|
|
13
|
+
import { dockerCli } from "../../../adapters/docker/docker.ts";
|
|
14
|
+
import { scenario, thunderjar } from "../../../testing/cli.ts";
|
|
15
|
+
|
|
16
|
+
const BASE_IMAGE = `node:20-bookworm`;
|
|
17
|
+
const docker = dockerCli("docker");
|
|
18
|
+
const prefix = `thunderjar-test-${process.pid}-${Date.now()}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs `thunderjar <command> <testCase> --image-prefix <prefix>` in `project` against real Docker,
|
|
22
|
+
* with build contexts under a fresh $TMPDIR, and durations replaced by a placeholder.
|
|
23
|
+
*/
|
|
24
|
+
async function cli(project: string, command: string, testCase: string, env: Record<string, string> = {}) {
|
|
25
|
+
const tmpDir = await makeTempDir("thunderjar-build-docker-");
|
|
26
|
+
const result = await thunderjar(project, [command, testCase, "--image-prefix", prefix], {
|
|
27
|
+
THUNDERJAR_DOCKER: "docker",
|
|
28
|
+
TMPDIR: tmpDir,
|
|
29
|
+
...env,
|
|
30
|
+
});
|
|
31
|
+
const normalise = (text: string) =>
|
|
32
|
+
text.replaceAll(tmpDir, "$TMPDIR").replaceAll(prefix, "<prefix>").replace(/ built \(\d+\.\d\ds\)$/gm, " built (<t>s)");
|
|
33
|
+
return { ...result, stdout: normalise(result.stdout), stderr: normalise(result.stderr) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs `script` with sh in a throwaway container from `ref`. */
|
|
37
|
+
function inImage(ref: string, script: string) {
|
|
38
|
+
const result = Bun.spawnSync(["docker", "run", "--rm", ref, "sh", "-c", script], { stdout: "pipe", stderr: "pipe" });
|
|
39
|
+
expect(result.stderr.toString()).toBe("");
|
|
40
|
+
return result.stdout.toString().trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const prefixed = (tag: string) => `${prefix}:${tag}`;
|
|
44
|
+
|
|
45
|
+
describe.skipIf(!process.env.THUNDERJAR_TEST_DOCKER)("build, against real Docker", () => {
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
const unreachable = await docker.unreachable();
|
|
48
|
+
if (unreachable) throw new Error(`Docker can't be reached (${unreachable}).`);
|
|
49
|
+
if (!(await docker.imageExists(BASE_IMAGE))) {
|
|
50
|
+
throw new Error(`The fixture image ${BASE_IMAGE} is missing. Run \`bun run test:prepare\` first.`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
const images = Bun.spawnSync(["docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}", prefix], { stdout: "pipe" })
|
|
56
|
+
.stdout.toString()
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
if (images.length > 0) Bun.spawnSync(["docker", "image", "rm", "--force", ...images]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("scenario_1_minimal_initial_setup", () => {
|
|
63
|
+
const fixtures = scenario("scenario_1_minimal_initial_setup");
|
|
64
|
+
const REF = prefixed("prerun-h8ffdda6e");
|
|
65
|
+
|
|
66
|
+
test("build example: /workspace holds the code state's and prompt set's files, and node; a second build reuses it", async () => {
|
|
67
|
+
// Outside any git repository, too: its parameters read no commits.
|
|
68
|
+
const built = await cli(fixtures, "build", "example", { GIT_CEILING_DIRECTORIES: dirname(fixtures) });
|
|
69
|
+
|
|
70
|
+
expect(built.exitCode).toBe(0);
|
|
71
|
+
expect(built.stdout).toBe(`Building 1 prerun image for example (1 permutation)
|
|
72
|
+
Build contexts in $TMPDIR/thunderjar/builds
|
|
73
|
+
|
|
74
|
+
1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain
|
|
75
|
+
<prefix>:prerun-h8ffdda6e built (<t>s)
|
|
76
|
+
|
|
77
|
+
1 prerun image: 1 built, 0 reused
|
|
78
|
+
`);
|
|
79
|
+
expect(inImage(REF, "pwd && ls -A")).toBe("/workspace\nFAKE_INSTRUCTIONS.md\npackage-lock.json\npackage.json");
|
|
80
|
+
expect(inImage(REF, "head -1 FAKE_INSTRUCTIONS.md")).toBe("# Snerk");
|
|
81
|
+
expect(inImage(REF, "node --version")).toStartWith("v20.");
|
|
82
|
+
|
|
83
|
+
const again = await cli(fixtures, "build", "example");
|
|
84
|
+
expect(again.stdout).toBe(`Every prerun image for example exists (1 permutation)
|
|
85
|
+
|
|
86
|
+
1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain
|
|
87
|
+
<prefix>:prerun-h8ffdda6e reused
|
|
88
|
+
|
|
89
|
+
1 prerun image: 0 built, 1 reused
|
|
90
|
+
`);
|
|
91
|
+
}, 300_000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("scenario_2_typical_project", () => {
|
|
95
|
+
const fixtures = scenario("scenario_2_typical_project");
|
|
96
|
+
const ALPHA = prefixed("prerun-hefe85dda");
|
|
97
|
+
|
|
98
|
+
test("build two-pairs: builds each missing prerun image; with one removed, builds only that one, and plan shows both reused", async () => {
|
|
99
|
+
const built = await cli(fixtures, "build", "two-pairs");
|
|
100
|
+
|
|
101
|
+
expect(built.exitCode).toBe(0);
|
|
102
|
+
// The tags are those `thunderjar plan two-pairs` shows.
|
|
103
|
+
expect(built.stdout).toBe(`Building 2 prerun images for two-pairs (2 permutations)
|
|
104
|
+
Build contexts in $TMPDIR/thunderjar/builds
|
|
105
|
+
|
|
106
|
+
1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | terse
|
|
107
|
+
<prefix>:prerun-hefe85dda built (<t>s)
|
|
108
|
+
|
|
109
|
+
2 node20 | baseline | snerk | fake-harness-beta@1.0.0/fake-model-large | terse
|
|
110
|
+
<prefix>:prerun-h56ea4852 built (<t>s)
|
|
111
|
+
|
|
112
|
+
2 prerun images: 2 built, 0 reused
|
|
113
|
+
`);
|
|
114
|
+
|
|
115
|
+
Bun.spawnSync(["docker", "image", "rm", "--force", ALPHA]);
|
|
116
|
+
const again = await cli(fixtures, "build", "two-pairs");
|
|
117
|
+
expect(again.stdout).toContain(" <prefix>:prerun-hefe85dda built (<t>s)\n");
|
|
118
|
+
expect(again.stdout).toContain(" <prefix>:prerun-h56ea4852 reused\n");
|
|
119
|
+
expect(again.stdout).toEndWith("2 prerun images: 1 built, 1 reused\n");
|
|
120
|
+
|
|
121
|
+
const plan = await cli(fixtures, "plan", "two-pairs");
|
|
122
|
+
expect(plan.stdout).toContain(" prerun-hefe85dda (exists, reused)\n");
|
|
123
|
+
expect(plan.stdout).toContain(" prerun-h56ea4852 (exists, reused)\n");
|
|
124
|
+
}, 300_000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Scenario 4's parameters read real commits of this repository, so its images show what only
|
|
128
|
+
// the copied files can: a prompt file read at its commit, and a bundle with nothing after its commit.
|
|
129
|
+
describe("scenario_4_git_project", () => {
|
|
130
|
+
const fixtures = scenario("scenario_4_git_project");
|
|
131
|
+
const FIRST = "523984c7fd72f047265ab89ce5901605af6acb2b";
|
|
132
|
+
const SECOND = "952687db16b373d58f79f46690f9c5b0aa1aa141";
|
|
133
|
+
const PROJECT = "fixtureTests/scenario_4_git_project/fixtures/project";
|
|
134
|
+
const refs = new Map<string, string>();
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => {
|
|
137
|
+
const { stdout, exitCode } = await cli(fixtures, "build", "two-commits");
|
|
138
|
+
expect(exitCode).toBe(0);
|
|
139
|
+
expect(stdout).toEndWith("4 prerun images: 4 built, 0 reused\n");
|
|
140
|
+
// Each permutation's row, by code state and prompt set, and the image it built.
|
|
141
|
+
for (const [, codeState, promptSet, tag] of stdout.matchAll(/\| (\w+) +\| ([\w-]+) +\|.*\n {4}<prefix>:(\S+) {2}built/g)) {
|
|
142
|
+
refs.set(`${codeState}/${promptSet}`, prefixed(tag!));
|
|
143
|
+
}
|
|
144
|
+
expect(refs.size).toBe(4);
|
|
145
|
+
}, 300_000);
|
|
146
|
+
|
|
147
|
+
test("the first code state: this repository at the first commit, detached, with nothing after it", () => {
|
|
148
|
+
const ref = refs.get("first/house-style-second")!;
|
|
149
|
+
expect(inImage(ref, "pwd && git rev-parse HEAD && git rev-parse --abbrev-ref HEAD")).toBe(`/workspace\n${FIRST}\nHEAD`);
|
|
150
|
+
expect(inImage(ref, "git remote && git for-each-ref refs/heads")).toBe("");
|
|
151
|
+
expect(inImage(ref, `git log --all --format=%H | grep -c ${SECOND} || true`)).toBe("0");
|
|
152
|
+
expect(inImage(ref, `ls ${PROJECT}`)).toBe("greet.js\npackage-lock.json\npackage.json\nprompts");
|
|
153
|
+
// A git identity for the agent.
|
|
154
|
+
expect(inImage(ref, "git config user.email")).toBe("agent@thunderjar.invalid");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the second code state: the second commit, with the file it added", () => {
|
|
158
|
+
const ref = refs.get("second/house-style-first")!;
|
|
159
|
+
expect(inImage(ref, "git rev-parse HEAD")).toBe(SECOND);
|
|
160
|
+
expect(inImage(ref, `ls ${PROJECT}/farewell.js && cat ${PROJECT}/greet.js | tail -1`)).toBe(
|
|
161
|
+
`${PROJECT}/farewell.js\nexport const greet = (name) => \`Hello, \${name}!\`;`,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a prompt file is read at its prompt set's commit, never from the worktree", () => {
|
|
166
|
+
// The prompt set's commit, whichever the code state's is.
|
|
167
|
+
expect(inImage(refs.get("first/house-style-second")!, "head -1 HOUSE_STYLE.md")).toBe("# House style (second commit)");
|
|
168
|
+
expect(inImage(refs.get("second/house-style-first")!, "head -1 HOUSE_STYLE.md")).toBe("# House style (first commit)");
|
|
169
|
+
// The worktree's copy has moved on from both.
|
|
170
|
+
expect(readTextSync(join(fixtures, "project", "prompts", "house-style.md"))).toStartWith("# House style (worktree");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// `thunderjar build`, through the CLI, in the checked-in fixtureTests/ scenarios, in place.
|
|
2
|
+
//
|
|
3
|
+
// - `--dry-run` prints each permutation's Dockerfile, so what each image is built from is
|
|
4
|
+
// asserted on stdout, with nothing built. Any of these can be run by hand:
|
|
5
|
+
//
|
|
6
|
+
// bun run fixture scenario_2 build two-pairs --dry-run
|
|
7
|
+
//
|
|
8
|
+
// - The rest stop before an image is made, against the recording fake Docker
|
|
9
|
+
// (spec/120-test-boundaries.md#2-cli-with-recording-adapters): Docker can't be reached, a commit
|
|
10
|
+
// is missing, the project isn't in a git repository, or a build fails, which no scenario's does.
|
|
11
|
+
//
|
|
12
|
+
// What a real build makes, and which images it reuses, is in build.docker.test.ts.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { existsSync, listDir, makeTempDir } from "../../../platform/fs.ts";
|
|
16
|
+
import { dirname, join } from "../../../platform/path.ts";
|
|
17
|
+
import { fakeDocker, scenario, thunderjar } from "../../../testing/cli.ts";
|
|
18
|
+
|
|
19
|
+
type FakeDocker = Awaited<ReturnType<typeof fakeDocker>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs `thunderjar build <testCase> <flags…>` in `project`, keeping build contexts under a fresh
|
|
23
|
+
* $TMPDIR, with $TMPDIR replaced by a placeholder.
|
|
24
|
+
*/
|
|
25
|
+
async function cliBuild(
|
|
26
|
+
project: string,
|
|
27
|
+
testCase: string,
|
|
28
|
+
{ docker, flags = [], env = {} }: { docker?: FakeDocker; flags?: string[]; env?: Record<string, string> } = {},
|
|
29
|
+
) {
|
|
30
|
+
docker ??= await fakeDocker();
|
|
31
|
+
const tmpDir = await makeTempDir("thunderjar-fixture-tmp-");
|
|
32
|
+
const result = await thunderjar(project, ["build", testCase, ...flags], { ...docker.env, TMPDIR: tmpDir, ...env });
|
|
33
|
+
const normalise = (text: string) => text.replaceAll(tmpDir, "$TMPDIR");
|
|
34
|
+
return {
|
|
35
|
+
exitCode: result.exitCode,
|
|
36
|
+
stdout: normalise(result.stdout),
|
|
37
|
+
stderr: normalise(result.stderr),
|
|
38
|
+
commands: docker.commands().map(normalise),
|
|
39
|
+
builds: join(tmpDir, "thunderjar", "builds"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("scenario_1_minimal_initial_setup", () => {
|
|
44
|
+
const fixtures = scenario("scenario_1_minimal_initial_setup");
|
|
45
|
+
|
|
46
|
+
test("build example --dry-run: the Dockerfile copies the files beside the declarations", async () => {
|
|
47
|
+
const { stdout, stderr, exitCode } = await cliBuild(fixtures, "example", { flags: ["--dry-run"] });
|
|
48
|
+
|
|
49
|
+
expect(stderr).toBe("");
|
|
50
|
+
expect(exitCode).toBe(0);
|
|
51
|
+
expect(stdout).toBe(`Dockerfiles for example (1 permutation). Nothing is built.
|
|
52
|
+
|
|
53
|
+
1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | plain
|
|
54
|
+
thunderjar:prerun-h8ffdda6e
|
|
55
|
+
|
|
56
|
+
FROM node:20-bookworm
|
|
57
|
+
RUN echo "install fake-harness-alpha@1.0.0"
|
|
58
|
+
WORKDIR /workspace
|
|
59
|
+
COPY parameters/codeState/files/ /workspace/
|
|
60
|
+
RUN npm ci
|
|
61
|
+
COPY parameters/promptSet/FAKE_INSTRUCTIONS.md /workspace/FAKE_INSTRUCTIONS.md
|
|
62
|
+
`);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("scenario_2_typical_project", () => {
|
|
67
|
+
// Its parameters copy the files beside their declarations, so it builds in place, with no commits to read.
|
|
68
|
+
const fixtures = scenario("scenario_2_typical_project");
|
|
69
|
+
const ALPHA = "thunderjar:prerun-hefe85dda";
|
|
70
|
+
const BETA = "thunderjar:prerun-h56ea4852";
|
|
71
|
+
|
|
72
|
+
test("build two-pairs --dry-run: prints each permutation's Dockerfile, without Docker, and writes nothing", async () => {
|
|
73
|
+
const { stdout, stderr, exitCode, commands, builds } = await cliBuild(fixtures, "two-pairs", {
|
|
74
|
+
docker: await fakeDocker([], ["unreachable"]),
|
|
75
|
+
flags: ["--dry-run"],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(stderr).toBe("");
|
|
79
|
+
expect(exitCode).toBe(0);
|
|
80
|
+
// The tags are those `thunderjar plan two-pairs` shows. Each fragment is in order, with
|
|
81
|
+
// Thunderjar's WORKDIR before the code state: nothing else is Thunderjar's.
|
|
82
|
+
expect(stdout).toBe(`Dockerfiles for two-pairs (2 permutations). Nothing is built.
|
|
83
|
+
|
|
84
|
+
1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | terse
|
|
85
|
+
${ALPHA}
|
|
86
|
+
|
|
87
|
+
FROM node:20-bookworm
|
|
88
|
+
RUN echo "install fake-harness-alpha@1.0.0"
|
|
89
|
+
WORKDIR /workspace
|
|
90
|
+
COPY parameters/codeState/files/ /workspace/
|
|
91
|
+
RUN npm ci
|
|
92
|
+
COPY parameters/promptSet/FAKE_INSTRUCTIONS.md /workspace/FAKE_INSTRUCTIONS.md
|
|
93
|
+
|
|
94
|
+
2 node20 | baseline | snerk | fake-harness-beta@1.0.0/fake-model-large | terse
|
|
95
|
+
${BETA}
|
|
96
|
+
|
|
97
|
+
FROM node:20-bookworm
|
|
98
|
+
RUN echo "install fake-harness-beta@1.0.0"
|
|
99
|
+
WORKDIR /workspace
|
|
100
|
+
COPY parameters/codeState/files/ /workspace/
|
|
101
|
+
RUN npm ci
|
|
102
|
+
COPY parameters/promptSet/FAKE_INSTRUCTIONS.md /workspace/FAKE_INSTRUCTIONS.md
|
|
103
|
+
`);
|
|
104
|
+
expect(commands).toEqual([]);
|
|
105
|
+
expect(existsSync(builds)).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("--dry-run prints every permutation's Dockerfile, even when its image exists", async () => {
|
|
109
|
+
const { stdout, commands } = await cliBuild(fixtures, "two-pairs", {
|
|
110
|
+
docker: await fakeDocker([ALPHA, BETA]),
|
|
111
|
+
flags: ["--dry-run"],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(stdout).toContain(`RUN echo "install fake-harness-alpha@1.0.0"\n`);
|
|
115
|
+
expect(stdout).toContain(`RUN echo "install fake-harness-beta@1.0.0"\n`);
|
|
116
|
+
expect(commands).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("--dry-run tags with --image-prefix", async () => {
|
|
120
|
+
const { stdout } = await cliBuild(fixtures, "two-pairs", { flags: ["--dry-run", "--image-prefix", "throwaway"] });
|
|
121
|
+
|
|
122
|
+
expect(stdout).toContain(" throwaway:prerun-hefe85dda\n");
|
|
123
|
+
expect(stdout).toContain(" throwaway:prerun-h56ea4852\n");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("without Docker, build stops before reading anything", async () => {
|
|
127
|
+
const { stdout, stderr, exitCode, commands } = await cliBuild(fixtures, "two-pairs", {
|
|
128
|
+
docker: await fakeDocker([], ["unreachable"]),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(exitCode).toBe(1);
|
|
132
|
+
expect(stdout).toBe("");
|
|
133
|
+
expect(stderr).toBe(
|
|
134
|
+
"error: Docker can't be reached (Cannot connect to the Docker daemon at unix:///var/run/docker.sock. " +
|
|
135
|
+
"Is the docker daemon running?). Building prerun images needs Docker.\n",
|
|
136
|
+
);
|
|
137
|
+
expect(commands).toEqual(["docker version --format {{.Server.Version}}"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a failed build stops, and keeps its build context for inspection", async () => {
|
|
141
|
+
const { stdout, stderr, exitCode, commands, builds } = await cliBuild(fixtures, "two-pairs", {
|
|
142
|
+
docker: await fakeDocker([], ["fail-build"]),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(exitCode).toBe(1);
|
|
146
|
+
expect(stdout).toEndWith(" 1 node20 | baseline | snerk | fake-harness-alpha@1.0.0/fake-model-small | terse\n");
|
|
147
|
+
expect(stderr).toBe(`fake docker: the build failed
|
|
148
|
+
error: building ${ALPHA} failed (docker build exited 1).
|
|
149
|
+
Its build context is kept in $TMPDIR/thunderjar/builds/prerun-hefe85dda
|
|
150
|
+
`);
|
|
151
|
+
expect(commands.filter((c) => c.startsWith("docker build"))).toHaveLength(1);
|
|
152
|
+
expect(listDir(join(builds, "prerun-hefe85dda"))).toContain("Dockerfile");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("build with no test case, an unknown flag, or --image-prefix without a name, says what's wrong, then its usage", async () => {
|
|
156
|
+
for (const [args, error] of [
|
|
157
|
+
[["build"], "error: missing required argument 'test case'"],
|
|
158
|
+
[["build", "two-pairs", "--dry"], "error: unknown option '--dry'"],
|
|
159
|
+
[["build", "two-pairs", "--image-prefix"], "error: option '--image-prefix <name>' argument missing"],
|
|
160
|
+
[["build", "--image-prefix", "two-pairs"], "error: missing required argument 'test case'"],
|
|
161
|
+
] as const) {
|
|
162
|
+
const { stdout, stderr, exitCode } = await thunderjar(fixtures, [...args]);
|
|
163
|
+
expect(stdout).toBe("");
|
|
164
|
+
expect(stderr).toStartWith(error);
|
|
165
|
+
expect(stderr).toContain("Usage: thunderjar build [options] <test case>");
|
|
166
|
+
expect(exitCode).toBe(1);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("scenario_4_git_project", () => {
|
|
172
|
+
// Its code states and prompt sets read two real commits of this repository, the ones that added
|
|
173
|
+
// and changed fixtures/project/ (tags fixture-scenario-4-first and fixture-scenario-4-second).
|
|
174
|
+
const fixtures = scenario("scenario_4_git_project");
|
|
175
|
+
const FIRST = "523984c7fd72f047265ab89ce5901605af6acb2b";
|
|
176
|
+
const SECOND = "952687db16b373d58f79f46690f9c5b0aa1aa141";
|
|
177
|
+
const PROJECT = "fixtureTests/scenario_4_git_project/fixtures/project";
|
|
178
|
+
|
|
179
|
+
test("build two-commits --dry-run: each code state checks out its own commit, each prompt set copies its file at its own", async () => {
|
|
180
|
+
const { stdout, stderr, exitCode, commands } = await cliBuild(fixtures, "two-commits", { flags: ["--dry-run"] });
|
|
181
|
+
|
|
182
|
+
expect(stderr).toBe("");
|
|
183
|
+
expect(exitCode).toBe(0);
|
|
184
|
+
expect(commands).toEqual([]);
|
|
185
|
+
const permutation = (row: string, tag: string, commit: string, promptCommit: string) => `
|
|
186
|
+
${row} | fake-harness-alpha@1.0.0/fake-model-small | plain
|
|
187
|
+
thunderjar:${tag}
|
|
188
|
+
|
|
189
|
+
FROM node:20-bookworm
|
|
190
|
+
RUN echo "install fake-harness-alpha@1.0.0"
|
|
191
|
+
WORKDIR /workspace
|
|
192
|
+
COPY git/${commit}.bundle /tmp/code.bundle
|
|
193
|
+
RUN git clone --quiet /tmp/code.bundle /workspace \\
|
|
194
|
+
&& git -C /workspace checkout --quiet --detach ${commit} \\
|
|
195
|
+
&& git -C /workspace branch --quiet --delete --force code-state \\
|
|
196
|
+
&& git -C /workspace remote remove origin \\
|
|
197
|
+
&& git -C /workspace config user.name "Thunderjar agent" \\
|
|
198
|
+
&& git -C /workspace config user.email agent@thunderjar.invalid \\
|
|
199
|
+
&& rm /tmp/code.bundle
|
|
200
|
+
RUN cd ${PROJECT} && npm ci
|
|
201
|
+
COPY git/${promptCommit}/${PROJECT}/prompts/house-style.md /workspace/HOUSE_STYLE.md
|
|
202
|
+
`;
|
|
203
|
+
expect(stdout).toBe(
|
|
204
|
+
"Dockerfiles for two-commits (4 permutations). Nothing is built.\n" +
|
|
205
|
+
permutation(" 1 node20 | first | house-style-first ", "prerun-hf803f77d", FIRST, FIRST) +
|
|
206
|
+
permutation(" 2 node20 | first | house-style-second", "prerun-h85c50e99", FIRST, SECOND) +
|
|
207
|
+
permutation(" 3 node20 | second | house-style-first ", "prerun-hc7fcb855", SECOND, FIRST) +
|
|
208
|
+
permutation(" 4 node20 | second | house-style-second", "prerun-h51cb391f", SECOND, SECOND),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a commit that isn't in this repository stops the build before anything is built", async () => {
|
|
213
|
+
const { stdout, stderr, exitCode, commands } = await cliBuild(fixtures, "unpushed-commit");
|
|
214
|
+
|
|
215
|
+
expect(exitCode).toBe(1);
|
|
216
|
+
expect(stdout).toBe("");
|
|
217
|
+
expect(stderr).toBe(`error: commit a1b2c3 (code state "unpushed") is not in this repository.
|
|
218
|
+
Fetch it, or check that it has been pushed.
|
|
219
|
+
`);
|
|
220
|
+
expect(commands.filter((c) => c.startsWith("docker build"))).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a project whose parameters read commits must be in a git repository", async () => {
|
|
224
|
+
// Git doesn't look above the scenario's folder, so finds no repository for fixtures/.
|
|
225
|
+
const { stdout, stderr, exitCode, commands } = await cliBuild(fixtures, "two-commits", {
|
|
226
|
+
env: { GIT_CEILING_DIRECTORIES: dirname(fixtures) },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(exitCode).toBe(1);
|
|
230
|
+
expect(stdout).toBe("");
|
|
231
|
+
expect(stderr).toBe(`error: ${fixtures} is not in a git repository, but code state "first" reads commit ${FIRST} from it.\n`);
|
|
232
|
+
expect(commands.filter((c) => c.startsWith("docker build"))).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// `thunderjar build <test case>` — builds each permutation's prerun image that doesn't exist
|
|
2
|
+
// yet, then stops: nothing is run and nothing is recorded. spec/080-docker-execution.md#process,
|
|
3
|
+
// as far as "Prerun image ready".
|
|
4
|
+
//
|
|
5
|
+
// What it checks before the first build, and where it keeps each build context, are
|
|
6
|
+
// src/images/prerun-builds.ts's, which `run` builds through too.
|
|
7
|
+
//
|
|
8
|
+
// `--dry-run` prints the Dockerfile each permutation's prerun image would be built from, for
|
|
9
|
+
// every permutation, whether or not its image exists. It builds nothing, writes nothing, and
|
|
10
|
+
// doesn't ask Docker anything, nor read any commit, so what it prints doesn't depend on either.
|
|
11
|
+
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { join } from "../../../platform/path.ts";
|
|
14
|
+
import { dockerCli, type Docker } from "../../../adapters/docker/docker.ts";
|
|
15
|
+
import { findThunderjarRoot, loadTestCase } from "../../../project/config.ts";
|
|
16
|
+
import { permutationRows } from "../../../matrix/permutations.ts";
|
|
17
|
+
import { LOCAL_REPOSITORY, loadPrerunParameters, prerunImageRef, renderPrerun } from "../../../images/prerun.ts";
|
|
18
|
+
import { preparePrerunBuilds } from "../../../images/prerun-builds.ts";
|
|
19
|
+
import { resolveTestCase } from "../../../matrix/resolve.ts";
|
|
20
|
+
import { defaultRootDir } from "../../../running/runner.ts";
|
|
21
|
+
import { count, seconds } from "../format.ts";
|
|
22
|
+
import { imagePrefixOption } from "../options.ts";
|
|
23
|
+
|
|
24
|
+
export interface BuildOptions {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
/** Receives the output a block at a time, as it is produced. */
|
|
27
|
+
log?: (text: string) => void;
|
|
28
|
+
docker?: Docker;
|
|
29
|
+
/** Print each permutation's Dockerfile instead of building, `--dry-run`. */
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
/** The image repository prerun images are tagged in, `--image-prefix`. Tests use a throwaway one. */
|
|
32
|
+
repository?: string;
|
|
33
|
+
/** Build contexts are kept under `<rootDir>/builds/`. Defaults to `$TMPDIR/thunderjar`. */
|
|
34
|
+
rootDir?: string;
|
|
35
|
+
/** The clock for timing builds; stubbed in tests so output is deterministic. */
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function build(name: string, options: BuildOptions = {}): Promise<void> {
|
|
40
|
+
const {
|
|
41
|
+
cwd = process.cwd(),
|
|
42
|
+
log = (text) => console.log(text),
|
|
43
|
+
docker = dockerCli(),
|
|
44
|
+
dryRun = false,
|
|
45
|
+
repository = LOCAL_REPOSITORY,
|
|
46
|
+
rootDir = defaultRootDir(),
|
|
47
|
+
now = Date.now,
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const root = findThunderjarRoot(cwd);
|
|
51
|
+
const testCase = await loadTestCase(root, name);
|
|
52
|
+
const { permutations } = await resolveTestCase(root, testCase);
|
|
53
|
+
const rows = permutationRows(permutations);
|
|
54
|
+
const numberWidth = Math.max(2, String(permutations.length).length);
|
|
55
|
+
const pad = " ".repeat(numberWidth + 2);
|
|
56
|
+
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
log(`Dockerfiles for ${name} (${count(permutations.length, "permutation")}). Nothing is built.`);
|
|
59
|
+
for (const [n, permutation] of permutations.entries()) {
|
|
60
|
+
const { dockerfile } = renderPrerun(await loadPrerunParameters(root, testCase.task, permutation));
|
|
61
|
+
log("");
|
|
62
|
+
log(`${String(n + 1).padStart(numberWidth)} ${rows[n]}`);
|
|
63
|
+
log(`${pad}${prerunImageRef(repository, permutation.hash)}`);
|
|
64
|
+
log("");
|
|
65
|
+
log(dockerfile.trimEnd().replace(/^(?=.)/gm, pad));
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const builds = await preparePrerunBuilds({
|
|
71
|
+
root,
|
|
72
|
+
task: testCase.task,
|
|
73
|
+
permutations,
|
|
74
|
+
docker,
|
|
75
|
+
repository,
|
|
76
|
+
rootDir,
|
|
77
|
+
purpose: "Building prerun images",
|
|
78
|
+
});
|
|
79
|
+
const toBuild = builds.missing.size;
|
|
80
|
+
if (toBuild > 0) {
|
|
81
|
+
log(`Building ${count(toBuild, "prerun image")} for ${name} (${count(permutations.length, "permutation")})`);
|
|
82
|
+
log(`Build contexts in ${join(rootDir, "builds")}`);
|
|
83
|
+
} else {
|
|
84
|
+
log(`Every prerun image for ${name} exists (${count(permutations.length, "permutation")})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const [n, permutation] of permutations.entries()) {
|
|
88
|
+
const ref = prerunImageRef(repository, permutation.hash);
|
|
89
|
+
log("");
|
|
90
|
+
log(`${String(n + 1).padStart(numberWidth)} ${rows[n]}`);
|
|
91
|
+
if (!builds.missing.has(permutation.hash)) {
|
|
92
|
+
log(`${pad}${ref} reused`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const started = now();
|
|
96
|
+
await builds.build(permutation.hash);
|
|
97
|
+
log(`${pad}${ref} built (${seconds(now() - started)})`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log("");
|
|
101
|
+
log(
|
|
102
|
+
`${count(permutations.length, "prerun image")}: ${toBuild} built, ${permutations.length - toBuild} reused`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createBuildCommand(): Command {
|
|
107
|
+
return new Command("build")
|
|
108
|
+
.description("Build a test case's prerun images.")
|
|
109
|
+
.argument("<test case>", "the test case whose prerun images to build")
|
|
110
|
+
.option("--dry-run", "print each permutation's Dockerfile instead of building")
|
|
111
|
+
.addOption(imagePrefixOption("the image repository prerun images are tagged in"))
|
|
112
|
+
.action(async (name: string, options: { dryRun?: boolean; imagePrefix?: string }) => {
|
|
113
|
+
await build(name, { dryRun: options.dryRun ?? false, repository: options.imagePrefix });
|
|
114
|
+
});
|
|
115
|
+
}
|