1import * as Command from "@/Command";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as pathe from "pathe";
7
8const { test } = Test.make({ providers: Command.providers() });
9
10const FIXTURE_DIR = pathe.resolve(import.meta.dirname, "fixture");
11
12const makeTemporaryFixture = Effect.fn(function* () {
13 const fs = yield* FileSystem.FileSystem;
14 const tempDir = yield* fs.makeTempDirectoryScoped();
15 yield* fs.copy(FIXTURE_DIR, tempDir);
16 return {
17 cwd: tempDir,
18 outdir: pathe.join(tempDir, "dist"),
19 };
20});
21
22test.provider(
23 "create, skip, update, delete build with memoization",
24 (stack) =>
25 Effect.gen(function* () {
26 const fs = yield* FileSystem.FileSystem;
27
28 yield* stack.destroy();
29
30 const fixture = yield* makeTemporaryFixture();
31
32 const deploy = () =>
33 stack.deploy(
34 Command.Build("test-build", {
35 command: "bash build.sh",
36 cwd: fixture.cwd,
37 outdir: "dist",
38 }),
39 );
40
41 const build1 = yield* deploy();
42
43 // `outdir` is persisted relative to `process.cwd()` for portability;
44 // resolve it back to an absolute path before comparing.
45 expect(pathe.resolve(build1.outdir)).toBe(fixture.outdir);
46 expect(build1.hash).toMatchObject({
47 input: expect.any(String),
48 output: expect.any(String),
49 });
50
51 const distExists = yield* fs.exists(fixture.outdir);
52 expect(distExists).toBe(true);
53
54 const outputExists = yield* fs.exists(
55 pathe.join(fixture.outdir, "output.txt"),
56 );
57 expect(outputExists).toBe(true);
58
59 const firstBuildOutput = yield* fs.readFileString(
60 pathe.join(fixture.outdir, "output.txt"),
61 );
62
63 yield* Effect.sleep(1100);
64
65 const build2 = yield* deploy();
66
67 expect(build2.hash).toMatchObject(build1.hash);
68
69 const secondBuildOutput = yield* fs.readFileString(
70 pathe.join(fixture.outdir, "output.txt"),
71 );
72 expect(secondBuildOutput).toBe(firstBuildOutput);
73
74 yield* fs.writeFileString(
75 pathe.join(fixture.cwd, "src", "main.ts"),
76 'export const message = "Updated!";\n',
77 );
78
79 const build3 = yield* deploy();
80
81 expect(build3.hash).not.toMatchObject(build1.hash);
82
83 const thirdBuildOutput = yield* fs.readFileString(
84 pathe.join(fixture.outdir, "output.txt"),
85 );
86 expect(thirdBuildOutput).not.toBe(firstBuildOutput);
87
88 yield* fs.writeFileString(
89 pathe.join(fixture.cwd, "src", "main.ts"),
90 'export const message = "Hello, World!";\n',
91 );
92
93 yield* stack.destroy();
94
95 const distExistsAfterDestroy = yield* fs.exists(fixture.outdir);
96 expect(distExistsAfterDestroy).toBe(false);
97 }),
98 { tags: ["unit", "local"], timeout: 60000 },
99);
100
101test.provider(
102 "input hash folds in the build command and env",
103 (stack) =>
104 Effect.gen(function* () {
105 yield* stack.destroy();
106
107 const fixture = yield* makeTemporaryFixture();
108
109 const deploy = (props: Partial<Command.BuildProps>) =>
110 stack.deploy(
111 Command.Build("test-build", {
112 command: "bash build.sh",
113 cwd: fixture.cwd,
114 outdir: "dist",
115 ...props,
116 }),
117 );
118
119 const withEnvA = yield* deploy({ env: { API_URL: "https://a.example" } });
120
121 // Same source tree + outdir, only the env differs: the build must not be
122 // judged reusable, so the input hash must change.
123 const withEnvB = yield* deploy({ env: { API_URL: "https://b.example" } });
124 expect(withEnvB.hash.input).not.toBe(withEnvA.hash.input);
125
126 // Likewise a change to the command string busts the input hash.
127 const withCommand = yield* deploy({
128 command: "bash build.sh dummy",
129 env: { API_URL: "https://b.example" },
130 });
131 expect(withCommand.hash.input).not.toBe(withEnvB.hash.input);
132
133 yield* stack.destroy();
134 }),
135 { tags: ["unit", "local"], timeout: 60000 },
136);
137
138test.provider(
139 "rebuilds memoized output if outdir is missing",
140 (stack) =>
141 Effect.gen(function* () {
142 const fs = yield* FileSystem.FileSystem;
143
144 yield* stack.destroy();
145
146 const fixture = yield* makeTemporaryFixture();
147 expect(yield* fs.exists(fixture.outdir)).toBe(false);
148
149 const deploy = () =>
150 stack.deploy(
151 Command.Build("test-build", {
152 command: "bash build.sh",
153 cwd: fixture.cwd,
154 outdir: "dist",
155 }),
156 );
157
158 yield* deploy();
159 expect(yield* fs.exists(fixture.outdir)).toBe(true);
160
161 yield* fs.remove(fixture.outdir, { recursive: true });
162 expect(yield* fs.exists(fixture.outdir)).toBe(false);
163
164 yield* deploy();
165 expect(yield* fs.exists(fixture.outdir)).toBe(true);
166
167 yield* stack.destroy();
168 }),
169 { tags: ["unit", "local"] },
170);
171
172test.provider(
173 "memo include globs can reach outside cwd (monorepo workspace deps)",
174 (stack) =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177
178 yield* stack.destroy();
179
180 // A monorepo shape: the app builds from `app/` and imports a sibling
181 // workspace package at `packages/env/` that the default memo scope
182 // (files under `cwd`) does not cover.
183 const tempDir = yield* fs.makeTempDirectoryScoped();
184 const appDir = pathe.join(tempDir, "app");
185 const siblingDir = pathe.join(tempDir, "packages", "env");
186 yield* fs.copy(FIXTURE_DIR, appDir);
187 yield* fs.makeDirectory(siblingDir, { recursive: true });
188 const siblingFile = pathe.join(siblingDir, "value.ts");
189 yield* fs.writeFileString(siblingFile, 'export const value = "a";\n');
190
191 const outputFile = pathe.join(appDir, "dist", "output.txt");
192 // Exclude `dist` from the memo hash: build.sh stamps `dist/output.txt`
193 // with $(date) (the test's rebuild detector), and the temp fixture has
194 // no .gitignore to filter it. With dist hashed, two rebuilds straddling
195 // a wall-clock second boundary would produce different "input" hashes.
196 const deploy = () =>
197 stack.deploy(
198 Command.Build("test-build", {
199 command: "bash build.sh",
200 cwd: appDir,
201 outdir: "dist",
202 memo: {
203 include: ["**/*", "../packages/env/**"],
204 exclude: ["dist/**"],
205 },
206 }),
207 );
208
209 const build1 = yield* deploy();
210 const firstOutput = yield* fs.readFileString(outputFile);
211
212 // Nothing changed (including the sibling): the build memoizes.
213 yield* Effect.sleep(1100);
214 const build2 = yield* deploy();
215 expect(build2.hash).toMatchObject(build1.hash);
216 expect(yield* fs.readFileString(outputFile)).toBe(firstOutput);
217
218 // Editing only the sibling package busts the input hash and rebuilds.
219 yield* fs.writeFileString(siblingFile, 'export const value = "b";\n');
220 const build3 = yield* deploy();
221 expect(build3.hash.input).not.toBe(build1.hash.input);
222 expect(yield* fs.readFileString(outputFile)).not.toBe(firstOutput);
223
224 // An *absolute* include glob matches the same files: its matches are
225 // normalized back to cwd-relative keys, so the input hash is identical
226 // to the `../` form — machine-specific path prefixes never leak into
227 // the hash (and the build memoizes instead of rerunning).
228 const build4 = yield* stack.deploy(
229 Command.Build("test-build", {
230 command: "bash build.sh",
231 cwd: appDir,
232 outdir: "dist",
233 memo: {
234 include: ["**/*", pathe.join(siblingDir, "**")],
235 exclude: ["dist/**"],
236 },
237 }),
238 );
239 expect(build4.hash.input).toBe(build3.hash.input);
240
241 yield* stack.destroy();
242 }),
243 { tags: ["unit", "local"], timeout: 60000 },
244);