1import {
2 Artifacts,
3 createArtifactStore,
4 makeScopedArtifacts,
5} from "@/Artifacts.ts";
6import {
7 makeSourceContext,
8 type SourceHash,
9 type SourceServices,
10} from "@/Cloudflare/Workers/Source.ts";
11import {
12 hashViteInput,
13 makeViteSource,
14} from "@/Cloudflare/Workers/Sources/Vite.ts";
15import type { WorkerProps } from "@/Cloudflare/Workers/Worker.ts";
16import { NodeServices } from "@effect/platform-node";
17import { describe, expect, it } from "alchemy-test";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Path from "effect/Path";
21import * as pathe from "pathe";
22import { cloneFixture } from "../Utils/Fixture.ts";
23
24// ─────────────────────────────────────────────────────────────────────
25// Workspace-aware input-hash memoization (no cloud, no build)
26//
27// The `fixtures/monorepo-workspace` fixture mirrors ct's
28// `packages/frontend-frameworks/fixtures/monorepo-workspace`: `app/` is the Vite root and `lib/` is a
29// sibling directory (its own package.json, NOT a package-manager
30// workspace member) that `app/src` imports by relative path. At build
31// time the toolchain discovers `lib/` from the module graph and reports
32// it as an external workspace; alchemy persists it (relative to the
33// root) in `hash.additionalWorkspaces` and folds it into `hash.input` —
34// the rebuild-deciding memo signal.
35//
36// These tests pin the memo machinery itself — `hashViteInput` and the
37// vite source's `hash()` slots — without deploying or building:
38// cross-workspace-boundary edits MUST bust the memo, while untouched
39// recomputes stay memoized. The live deploy-level behavior is covered by
40// "Vite: edits in a sibling workspace package bust the build memo" in
41// Vite.test.ts.
42// ─────────────────────────────────────────────────────────────────────
43
44const fixtureDir = pathe.resolve(
45 import.meta.dirname,
46 "fixtures/monorepo-workspace",
47);
48
49// Explicit include globs, same discipline as the live Vite tests: the
50// hash stays pinned to fixture sources. The lib workspace is hashed with
51// the default (whole-directory) scope, exactly like auto-discovered
52// workspaces in production.
53const memoInclude = ["src/**", "vite.config.ts", "package.json"];
54
55const provide = <A, E>(effect: Effect.Effect<A, E, SourceServices>) =>
56 effect.pipe(
57 Effect.provideService(
58 Artifacts,
59 makeScopedArtifacts(createArtifactStore(), "test"),
60 ),
61 Effect.provide(NodeServices.layer),
62 Effect.scoped,
63 );
64
65/** Clone the two-directory fixture into a temp dir (outside the repo, so
66 * no ancestor gitignore/lockfile leaks into the hash) and return paths. */
67const setup = Effect.gen(function* () {
68 const path = yield* Path.Path;
69 const dir = yield* cloneFixture(fixtureDir, {
70 prefix: "alchemy-ws-memo-",
71 entries: ["app", "lib"],
72 });
73 return {
74 dir,
75 appDir: path.join(dir, "app"),
76 libGreeting: path.join(dir, "lib", "src", "greeting.ts"),
77 appServer: path.join(dir, "app", "src", "server.ts"),
78 };
79});
80
81const editLib = (libGreeting: string, marker: string) =>
82 Effect.gen(function* () {
83 const fs = yield* FileSystem.FileSystem;
84 yield* fs.writeFileString(
85 libGreeting,
86 `export const LIB_VERSION = "1.0.0";\n\nexport const greeting = (name: string): string =>\n ${JSON.stringify(marker)} + name;\n`,
87 );
88 });
89
90describe(
91 "workspace-aware input-hash memo",
92 {
93 tags: [
94 "unit",
95 "provider:cloudflare",
96 "provider:cloudflare:website",
97 "provider:cloudflare:worker",
98 "local",
99 ],
100 },
101 () => {
102 it.effect(
103 "cross-workspace edits bust the memo; untouched recomputes stay memoized",
104 () =>
105 provide(
106 Effect.gen(function* () {
107 const fs = yield* FileSystem.FileSystem;
108 const { appDir, libGreeting, appServer } = yield* setup;
109 const original = yield* fs.readFileString(libGreeting);
110
111 // `additionalWorkspaces` is persisted relative to the Vite root
112 // (see the live test's "../shared") — feed it back the same way
113 // the WorkerProvider does from `output.hash.additionalWorkspaces`.
114 const hash = () =>
115 hashViteInput(
116 appDir,
117 { include: memoInclude },
118 Effect.succeed(["../lib"]),
119 );
120
121 const h1 = yield* hash();
122 expect(h1.hash).toBeDefined();
123 expect(h1.workspaces).toEqual(["../lib"]);
124
125 // Untouched recompute ⇒ identical hash ⇒ the deploy memo holds.
126 const h2 = yield* hash();
127 expect(h2.hash).toEqual(h1.hash);
128
129 // Edit ONLY the sibling workspace — nothing under the Vite root
130 // changes — and the input hash must still bust.
131 yield* editLib(libGreeting, "ws-memo-edit-1");
132 const h3 = yield* hash();
133 expect(h3.hash).not.toEqual(h1.hash);
134
135 // The hash is content-derived: restoring the original bytes
136 // restores the original hash (machine/path independence).
137 yield* fs.writeFileString(libGreeting, original);
138 const h4 = yield* hash();
139 expect(h4.hash).toEqual(h1.hash);
140
141 // Build↔diff parity: at build time workspaces are discovered as
142 // ABSOLUTE paths from the module graph; at diff time the
143 // persisted ROOT-RELATIVE spelling is fed back. Both spellings
144 // of the same directory must hash identically or the
145 // rebuild-free diff could never report "unchanged".
146 const path = yield* Path.Path;
147 const absolute = yield* hashViteInput(
148 appDir,
149 { include: memoInclude },
150 Effect.succeed([path.resolve(appDir, "../lib")]),
151 );
152 expect(absolute.hash).toEqual(h1.hash);
153 expect(absolute.workspaces).toEqual(["../lib"]);
154
155 // Root edits keep busting the memo too, independently of the
156 // workspace slot.
157 const server = yield* fs.readFileString(appServer);
158 yield* fs.writeFileString(
159 appServer,
160 server.replace("/api/greeting", "/api/greeting-v2"),
161 );
162 const h5 = yield* hash();
163 expect(h5.hash).not.toEqual(h1.hash);
164 }),
165 ),
166 );
167
168 it.effect(
169 "without the workspace in the hash set, cross-boundary edits are invisible (control)",
170 () =>
171 provide(
172 Effect.gen(function* () {
173 const { appDir, libGreeting } = yield* setup;
174
175 const hash = () =>
176 hashViteInput(
177 appDir,
178 { include: memoInclude },
179 Effect.succeed([]),
180 );
181
182 const before = yield* hash();
183 yield* editLib(libGreeting, "ws-memo-control-edit");
184 const after = yield* hash();
185
186 // The control proves the signal in the test above comes from
187 // folding `../lib` into the hash — not from anything under the
188 // Vite root.
189 expect(after.hash).toEqual(before.hash);
190 expect(after.workspaces).toEqual([]);
191 }),
192 ),
193 );
194
195 it.effect(
196 "an explicit memo.workspaces array pins the workspace set and still busts on edits",
197 () =>
198 provide(
199 Effect.gen(function* () {
200 const { appDir, libGreeting } = yield* setup;
201
202 const hash = () =>
203 hashViteInput(
204 appDir,
205 {
206 include: memoInclude,
207 workspaces: [{ cwd: "../lib" }],
208 },
209 // With an explicit workspace list the auto-discovered set is
210 // ignored entirely.
211 Effect.succeed(["../does-not-exist"]),
212 );
213
214 const before = yield* hash();
215 // Pinned workspaces don't persist an auto-discovery list.
216 expect(before.workspaces).toBeUndefined();
217
218 yield* editLib(libGreeting, "ws-memo-pinned-edit");
219 const after = yield* hash();
220 expect(after.hash).not.toEqual(before.hash);
221 }),
222 ),
223 );
224
225 it.effect(
226 "the vite source's hash() slots carry the persisted workspaces through diff",
227 () =>
228 provide(
229 Effect.gen(function* () {
230 const { appDir, libGreeting } = yield* setup;
231
232 const vite = { rootDir: appDir, memo: { include: memoInclude } };
233 const source = makeViteSource(vite);
234 const props: WorkerProps = { vite };
235 const ctx = makeSourceContext({
236 id: "MonorepoApp",
237 fqn: "MonorepoApp",
238 workerName: "stack-monorepoapp-test-abc123",
239 props,
240 compatibility: { date: "2024-09-23", flags: [] },
241 stack: { name: "stack", stage: "test" },
242 });
243
244 // `previous` is `output.hash` from state: a prior build
245 // discovered `../lib` from the module graph and persisted it.
246 const previous: SourceHash = {
247 bundle: undefined,
248 assets: undefined,
249 input: undefined,
250 additionalWorkspaces: ["../lib"],
251 };
252
253 const slots1 = yield* source.hash(ctx, previous);
254 expect(slots1.input).toBeDefined();
255 expect(slots1.additionalWorkspaces).toEqual(["../lib"]);
256
257 // Same recipe as calling the machinery directly.
258 const direct = yield* hashViteInput(
259 appDir,
260 vite.memo,
261 Effect.succeed(["../lib"]),
262 );
263 expect(slots1.input).toEqual(direct.hash);
264
265 // A cross-boundary edit changes the recomputed input slot — the
266 // WorkerProvider's `slots.input !== output.hash.input` diff then
267 // reports the update that triggers the rebuild.
268 yield* editLib(libGreeting, "ws-memo-source-edit");
269 const slots2 = yield* source.hash(ctx, previous);
270 expect(slots2.input).not.toEqual(slots1.input);
271
272 // Without persisted workspaces (fresh state) the hash set is
273 // just the root — documented by a differing input slot.
274 const slotsNoWs = yield* source.hash(ctx, undefined);
275 expect(slotsNoWs.additionalWorkspaces).toEqual([]);
276 expect(slotsNoWs.input).not.toEqual(slots2.input);
277 }),
278 ),
279 );
280 },
281);