| 1 | import { |
| 2 | Artifacts, |
| 3 | createArtifactStore, |
| 4 | makeScopedArtifacts, |
| 5 | } from "@/Artifacts.ts"; |
| 6 | import { |
| 7 | makeSourceContext, |
| 8 | type SourceHash, |
| 9 | type SourceServices, |
| 10 | } from "@/Cloudflare/Workers/Source.ts"; |
| 11 | import { |
| 12 | hashViteInput, |
| 13 | makeViteSource, |
| 14 | } from "@/Cloudflare/Workers/Sources/Vite.ts"; |
| 15 | import type { WorkerProps } from "@/Cloudflare/Workers/Worker.ts"; |
| 16 | import { NodeServices } from "@effect/platform-node"; |
| 17 | import { describe, expect, it } from "alchemy-test"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as FileSystem from "effect/FileSystem"; |
| 20 | import * as Path from "effect/Path"; |
| 21 | import * as pathe from "pathe"; |
| 22 | import { 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 | |
| 44 | const 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. |
| 53 | const memoInclude = ["src/**", "vite.config.ts", "package.json"]; |
| 54 | |
| 55 | const 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. */ |
| 67 | const 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 | |
| 81 | const 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 | |
| 90 | describe( |
| 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 | ); |