| 1 | import { watchBundleDirectory } from "@/Cloudflare/Workers/Sources/shared"; |
| 2 | import * as Stream from "effect/Stream"; |
| 3 | import { isPathWithin } from "@/Util/isPathWithin"; |
| 4 | import { hashDirectory } from "@/Command/Memo"; |
| 5 | import nextjsSource from "@alchemy.run/frontend-frameworks/nextjs/source"; |
| 6 | import { readPythonWorkerBundle } from "@/Cloudflare/Workers/Sources/Python"; |
| 7 | import { sha256 } from "@/Util/sha256"; |
| 8 | import { AlchemyContext, dotAlchemyDirectory } from "@/AlchemyContext"; |
| 9 | import { createTempBundleDir, getStableContextDir } from "@/Bundle/TempRoot"; |
| 10 | import { WorkerBundle } from "@/Cloudflare/Workers/Sources/Rolldown"; |
| 11 | import { createComputeArchive } from "@/Prisma/ComputeArchive"; |
| 12 | import { Stack } from "@/Stack"; |
| 13 | import { Stage } from "@/Stage"; |
| 14 | import { localState } from "@/State/LocalState"; |
| 15 | import { State } from "@/State/State"; |
| 16 | import { copyTree, hashExtraFiles } from "@/Util/extraFiles"; |
| 17 | import { PlatformServices } from "@/Util/PlatformServices"; |
| 18 | import { expect, layer } from "alchemy-test"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import { gunzipSync } from "node:zlib"; |
| 23 | |
| 24 | layer(PlatformServices)("runtime directory", (it) => { |
| 25 | it.effect( |
| 26 | "compares paths against the supplied base without using the process cwd", |
| 27 | () => |
| 28 | Effect.sync(() => { |
| 29 | expect( |
| 30 | isPathWithin( |
| 31 | ".alchemy", |
| 32 | "/workspace/app/.alchemy/bundles/worker.js", |
| 33 | "/workspace/app", |
| 34 | ), |
| 35 | ).toBe(true); |
| 36 | expect( |
| 37 | isPathWithin( |
| 38 | ".alchemy", |
| 39 | "/workspace/app/.alchemy/bundles/worker.js", |
| 40 | "/workspace/other", |
| 41 | ), |
| 42 | ).toBe(false); |
| 43 | expect(isPathWithin(".alchemy", ".alchemy", "/workspace/app")).toBe( |
| 44 | true, |
| 45 | ); |
| 46 | expect( |
| 47 | isPathWithin( |
| 48 | ".alchemy", |
| 49 | ".alchemy-backup/worker.js", |
| 50 | "/workspace/app", |
| 51 | ), |
| 52 | ).toBe(false); |
| 53 | expect( |
| 54 | isPathWithin(".alchemy", ".alchemy/../source.js", "/workspace/app"), |
| 55 | ).toBe(false); |
| 56 | expect( |
| 57 | isPathWithin("/runtime", "/runtime/worker.js", "/workspace/app"), |
| 58 | ).toBe(true); |
| 59 | }), |
| 60 | { tags: ["unit", "local"] }, |
| 61 | ); |
| 62 | |
| 63 | it.effect( |
| 64 | "preserves the relative fallback and configured roots", |
| 65 | () => |
| 66 | Effect.gen(function* () { |
| 67 | expect(yield* dotAlchemyDirectory).toBe(".alchemy"); |
| 68 | expect( |
| 69 | yield* dotAlchemyDirectory.pipe( |
| 70 | Effect.provideService(AlchemyContext, { |
| 71 | dotAlchemy: "node_modules/.cache/runtime", |
| 72 | dev: false, |
| 73 | adopt: false, |
| 74 | }), |
| 75 | ), |
| 76 | ).toBe("node_modules/.cache/runtime"); |
| 77 | expect( |
| 78 | isPathWithin( |
| 79 | "/tmp/runtime", |
| 80 | "/tmp/runtime-sibling/file", |
| 81 | process.cwd(), |
| 82 | ), |
| 83 | ).toBe(false); |
| 84 | expect( |
| 85 | isPathWithin("/tmp/runtime", "/tmp/runtime/file", process.cwd()), |
| 86 | ).toBe(true); |
| 87 | }), |
| 88 | { tags: ["unit", "local"] }, |
| 89 | ); |
| 90 | |
| 91 | it.effect( |
| 92 | "ignores runtime watch events using the caller base rather than the watched directory", |
| 93 | () => |
| 94 | Effect.gen(function* () { |
| 95 | const fs = yield* FileSystem.FileSystem; |
| 96 | const path = yield* Path.Path; |
| 97 | const root = yield* fs.makeTempDirectoryScoped(); |
| 98 | const runtime = path.join(root, "runtime"); |
| 99 | const main = path.join(root, "worker.py"); |
| 100 | yield* fs.writeFileString(main, "class Default: pass"); |
| 101 | for (const [eventPath, expected] of [ |
| 102 | ["runtime/state.json", ["Success"]], |
| 103 | ["worker.py", ["Success", "Start", "Success"]], |
| 104 | ] as const) { |
| 105 | const events = yield* watchBundleDirectory({ |
| 106 | main, |
| 107 | read: Effect.succeed({ |
| 108 | files: [ |
| 109 | { |
| 110 | path: "worker.py", |
| 111 | content: "class Default: pass", |
| 112 | hash: "test", |
| 113 | }, |
| 114 | ], |
| 115 | hash: "test", |
| 116 | }), |
| 117 | }).pipe( |
| 118 | Stream.runCollect, |
| 119 | Effect.provideService(FileSystem.FileSystem, { |
| 120 | ...fs, |
| 121 | watch: () => |
| 122 | Stream.make({ _tag: "Update" as const, path: eventPath }), |
| 123 | }), |
| 124 | Effect.provideService(AlchemyContext, { |
| 125 | dotAlchemy: path.relative(process.cwd(), runtime), |
| 126 | dev: false, |
| 127 | adopt: false, |
| 128 | }), |
| 129 | ); |
| 130 | expect(events.map((event) => event._tag)).toEqual(expected); |
| 131 | } |
| 132 | }), |
| 133 | { |
| 134 | tags: [ |
| 135 | "unit", |
| 136 | "provider:cloudflare", |
| 137 | "provider:cloudflare:worker", |
| 138 | "local", |
| 139 | ], |
| 140 | }, |
| 141 | ); |
| 142 | |
| 143 | it.effect( |
| 144 | "writes Worker bundles under the context and honors an explicit output directory", |
| 145 | () => |
| 146 | Effect.gen(function* () { |
| 147 | const fs = yield* FileSystem.FileSystem; |
| 148 | const path = yield* Path.Path; |
| 149 | const root = yield* fs.makeTempDirectoryScoped(); |
| 150 | const runtime = path.join(root, "node_modules/.cache/runtime"); |
| 151 | const main = path.join(root, "worker.mjs"); |
| 152 | yield* fs.writeFileString(path.join(root, "package.json"), "{}"); |
| 153 | yield* fs.writeFileString( |
| 154 | main, |
| 155 | 'export default { fetch: () => new Response("ok") };', |
| 156 | ); |
| 157 | const bundler = yield* WorkerBundle.pipe( |
| 158 | Effect.provideService(AlchemyContext, { |
| 159 | dotAlchemy: runtime, |
| 160 | dev: false, |
| 161 | adopt: false, |
| 162 | }), |
| 163 | ); |
| 164 | for (const override of [undefined, path.join(root, "explicit")]) { |
| 165 | const bundle = yield* bundler.build({ |
| 166 | id: "test", |
| 167 | main, |
| 168 | compatibility: { date: "2026-03-17", flags: [] }, |
| 169 | entry: { kind: "external" }, |
| 170 | stack: { name: "test", stage: "test" }, |
| 171 | extraOptions: override ? { output: { dir: override } } : undefined, |
| 172 | }); |
| 173 | const directory = override ?? path.join(runtime, "bundles/test"); |
| 174 | expect(bundle.files.length).toBeGreaterThan(0); |
| 175 | expect((yield* fs.readDirectory(directory)).length).toBeGreaterThan( |
| 176 | 0, |
| 177 | ); |
| 178 | } |
| 179 | expect(yield* fs.exists(path.join(root, ".alchemy"))).toBe(false); |
| 180 | }), |
| 181 | { |
| 182 | tags: [ |
| 183 | "unit", |
| 184 | "provider:cloudflare", |
| 185 | "provider:cloudflare:worker", |
| 186 | "local", |
| 187 | ], |
| 188 | }, |
| 189 | ); |
| 190 | |
| 191 | it.effect( |
| 192 | "keeps standalone and configured relative Worker output paths relative to the bundle cwd", |
| 193 | () => |
| 194 | Effect.gen(function* () { |
| 195 | const fs = yield* FileSystem.FileSystem; |
| 196 | const path = yield* Path.Path; |
| 197 | const root = yield* fs.makeTempDirectoryScoped(); |
| 198 | const main = path.join(root, "worker.mjs"); |
| 199 | yield* fs.writeFileString(path.join(root, "package.json"), "{}"); |
| 200 | yield* fs.writeFileString( |
| 201 | main, |
| 202 | 'export default { fetch: () => new Response("ok") };', |
| 203 | ); |
| 204 | for (const configured of [undefined, "custom/runtime"]) { |
| 205 | const bundler = yield* configured === undefined |
| 206 | ? WorkerBundle |
| 207 | : WorkerBundle.pipe( |
| 208 | Effect.provideService(AlchemyContext, { |
| 209 | dotAlchemy: configured, |
| 210 | dev: false, |
| 211 | adopt: false, |
| 212 | }), |
| 213 | ); |
| 214 | yield* bundler.build({ |
| 215 | id: "relative", |
| 216 | main, |
| 217 | compatibility: { date: "2026-03-17", flags: [] }, |
| 218 | entry: { kind: "external" }, |
| 219 | stack: { name: "test", stage: "test" }, |
| 220 | extraOptions: undefined, |
| 221 | }); |
| 222 | expect( |
| 223 | (yield* fs.readDirectory( |
| 224 | path.join(root, configured ?? ".alchemy", "bundles/relative"), |
| 225 | )).length, |
| 226 | ).toBeGreaterThan(0); |
| 227 | } |
| 228 | }), |
| 229 | { |
| 230 | tags: [ |
| 231 | "unit", |
| 232 | "provider:cloudflare", |
| 233 | "provider:cloudflare:worker", |
| 234 | "local", |
| 235 | ], |
| 236 | }, |
| 237 | ); |
| 238 | |
| 239 | it.effect( |
| 240 | "keeps temporary and stable container contexts under the full configured path", |
| 241 | () => |
| 242 | Effect.gen(function* () { |
| 243 | const fs = yield* FileSystem.FileSystem; |
| 244 | const path = yield* Path.Path; |
| 245 | const root = yield* fs.makeTempDirectoryScoped(); |
| 246 | const runtime = path.join(root, "node_modules/.cache/runtime"); |
| 247 | yield* Effect.gen(function* () { |
| 248 | const stable = yield* getStableContextDir( |
| 249 | path.join(root, "entry.ts"), |
| 250 | runtime, |
| 251 | "image", |
| 252 | ); |
| 253 | const temporary = yield* createTempBundleDir( |
| 254 | path.join(root, "entry.ts"), |
| 255 | runtime, |
| 256 | "image", |
| 257 | ); |
| 258 | expect(stable).toBe(path.join(runtime, "tmp/test-dev-image")); |
| 259 | const relative = path.relative(process.cwd(), runtime); |
| 260 | expect( |
| 261 | yield* getStableContextDir( |
| 262 | path.join(root, "entry.ts"), |
| 263 | relative, |
| 264 | "image", |
| 265 | ), |
| 266 | ).toBe(path.join(relative, "tmp/test-dev-image")); |
| 267 | expect(isPathWithin(runtime, temporary, process.cwd())).toBe(true); |
| 268 | expect(yield* fs.exists(temporary)).toBe(true); |
| 269 | }).pipe( |
| 270 | Effect.provideService(Stack, { |
| 271 | name: "test", |
| 272 | stage: "dev", |
| 273 | resources: {}, |
| 274 | bindings: {}, |
| 275 | actions: {}, |
| 276 | }), |
| 277 | Effect.provideService(Stage, "dev"), |
| 278 | ); |
| 279 | }), |
| 280 | { tags: ["unit", "local"] }, |
| 281 | ); |
| 282 | |
| 283 | it.effect( |
| 284 | "captures the configured root for a lazily initialized local state store", |
| 285 | () => |
| 286 | Effect.gen(function* () { |
| 287 | const fs = yield* FileSystem.FileSystem; |
| 288 | const path = yield* Path.Path; |
| 289 | const root = yield* fs.makeTempDirectoryScoped(); |
| 290 | const getStore = yield* State.pipe( |
| 291 | Effect.provide(localState()), |
| 292 | Effect.provideService(AlchemyContext, { |
| 293 | dotAlchemy: root, |
| 294 | dev: false, |
| 295 | adopt: false, |
| 296 | }), |
| 297 | ); |
| 298 | const store = yield* getStore; |
| 299 | yield* store.setOutput({ |
| 300 | stack: "runtime-directory", |
| 301 | stage: "test", |
| 302 | value: { value: 42 }, |
| 303 | }); |
| 304 | expect( |
| 305 | yield* store.getOutput({ stack: "runtime-directory", stage: "test" }), |
| 306 | ).toEqual({ value: 42 }); |
| 307 | expect(yield* fs.exists(path.join(root, "state"))).toBe(true); |
| 308 | }), |
| 309 | { tags: ["unit", "local"] }, |
| 310 | ); |
| 311 | |
| 312 | it.effect( |
| 313 | "excludes relocated runtime data from copies, hashes, and compute archives", |
| 314 | () => |
| 315 | Effect.gen(function* () { |
| 316 | const fs = yield* FileSystem.FileSystem; |
| 317 | const path = yield* Path.Path; |
| 318 | const root = yield* fs.makeTempDirectoryScoped(); |
| 319 | const runtime = path.join(root, "cache[private]"); |
| 320 | yield* fs.makeDirectory(runtime); |
| 321 | yield* fs.writeFileString( |
| 322 | path.join(root, "index.js"), |
| 323 | "export default 42;", |
| 324 | ); |
| 325 | yield* fs.writeFileString( |
| 326 | path.join(runtime, "secret.txt"), |
| 327 | "PRIVATE_RUNTIME_DATA", |
| 328 | ); |
| 329 | yield* Effect.gen(function* () { |
| 330 | const artifactBefore = yield* hashDirectory({ |
| 331 | cwd: runtime, |
| 332 | memo: { exclude: [], lockfile: false }, |
| 333 | }); |
| 334 | const before = yield* hashExtraFiles([{ source: root, dest: "." }]); |
| 335 | yield* fs.writeFileString( |
| 336 | path.join(runtime, "secret.txt"), |
| 337 | "CHANGED_PRIVATE_RUNTIME_DATA", |
| 338 | ); |
| 339 | expect( |
| 340 | yield* hashDirectory({ |
| 341 | cwd: runtime, |
| 342 | memo: { exclude: [], lockfile: false }, |
| 343 | }), |
| 344 | ).not.toBe(artifactBefore); |
| 345 | expect(yield* hashExtraFiles([{ source: root, dest: "." }])).toEqual( |
| 346 | before, |
| 347 | ); |
| 348 | const target = yield* fs.makeTempDirectoryScoped(); |
| 349 | yield* copyTree(root, target); |
| 350 | expect(yield* fs.exists(path.join(target, "cache[private]"))).toBe( |
| 351 | false, |
| 352 | ); |
| 353 | expect(yield* fs.exists(path.join(target, "index.js"))).toBe(true); |
| 354 | const archive = yield* createComputeArchive({ |
| 355 | directory: root, |
| 356 | entrypoint: "index.js", |
| 357 | }); |
| 358 | expect(gunzipSync(archive).toString()).not.toContain( |
| 359 | "PRIVATE_RUNTIME_DATA", |
| 360 | ); |
| 361 | }).pipe( |
| 362 | Effect.provideService(AlchemyContext, { |
| 363 | dotAlchemy: path.relative(process.cwd(), runtime), |
| 364 | dev: false, |
| 365 | adopt: false, |
| 366 | }), |
| 367 | ); |
| 368 | }), |
| 369 | { tags: ["unit", "provider:prisma", "provider:prisma:compute", "local"] }, |
| 370 | ); |
| 371 | it.effect( |
| 372 | "reads Python dependency caches from the configured root without including them as source", |
| 373 | () => |
| 374 | Effect.gen(function* () { |
| 375 | const fs = yield* FileSystem.FileSystem; |
| 376 | const path = yield* Path.Path; |
| 377 | const root = yield* fs.makeTempDirectoryScoped(); |
| 378 | const runtime = path.join(root, "runtime"); |
| 379 | const staging = path.join(runtime, "python/test"); |
| 380 | yield* fs.makeDirectory(path.join(staging, "python_modules"), { |
| 381 | recursive: true, |
| 382 | }); |
| 383 | yield* fs.writeFileString( |
| 384 | path.join(root, "worker.py"), |
| 385 | "class Default: pass", |
| 386 | ); |
| 387 | yield* fs.writeFileString( |
| 388 | path.join(staging, "python_modules/cached.py"), |
| 389 | "CACHED_DEPENDENCY = True", |
| 390 | ); |
| 391 | yield* fs.writeFileString( |
| 392 | path.join(runtime, "private.py"), |
| 393 | "PRIVATE_RUNTIME_DATA = True", |
| 394 | ); |
| 395 | yield* fs.writeFileString( |
| 396 | path.join(staging, ".synced"), |
| 397 | yield* sha256("3.13\0https://index.pyodide.org/0.28.3\0"), |
| 398 | ); |
| 399 | const bundle = yield* readPythonWorkerBundle({ |
| 400 | id: "test", |
| 401 | fqn: "test", |
| 402 | main: path.join(root, "worker.py"), |
| 403 | compatibility: { date: "2026-03-17", flags: ["python_workers"] }, |
| 404 | }).pipe( |
| 405 | Effect.provideService(AlchemyContext, { |
| 406 | dotAlchemy: runtime, |
| 407 | dev: false, |
| 408 | adopt: false, |
| 409 | }), |
| 410 | ); |
| 411 | expect( |
| 412 | bundle.files.some((file) => file.path.includes("cached.py")), |
| 413 | ).toBe(true); |
| 414 | expect( |
| 415 | bundle.files.some((file) => file.path.includes("private.py")), |
| 416 | ).toBe(false); |
| 417 | expect(yield* fs.exists(path.join(root, ".alchemy"))).toBe(false); |
| 418 | }), |
| 419 | { |
| 420 | tags: [ |
| 421 | "unit", |
| 422 | "provider:cloudflare", |
| 423 | "provider:cloudflare:worker", |
| 424 | "local", |
| 425 | ], |
| 426 | }, |
| 427 | ); |
| 428 | |
| 429 | it.effect( |
| 430 | "excludes the supplied runtime directory from Next.js source hashes", |
| 431 | () => |
| 432 | Effect.gen(function* () { |
| 433 | const fs = yield* FileSystem.FileSystem; |
| 434 | const path = yield* Path.Path; |
| 435 | const root = yield* fs.makeTempDirectoryScoped(); |
| 436 | const runtime = path.join(root, "runtime"); |
| 437 | yield* fs.makeDirectory(runtime); |
| 438 | yield* fs.writeFileString( |
| 439 | path.join(root, "app.js"), |
| 440 | "export default 42;", |
| 441 | ); |
| 442 | const provider = yield* nextjsSource.make({ root }); |
| 443 | const context = { |
| 444 | id: "test", |
| 445 | workerName: "test", |
| 446 | compatibility: { date: "2026-03-17", flags: [] }, |
| 447 | dotAlchemy: runtime, |
| 448 | }; |
| 449 | const before = yield* provider.hash(context, undefined); |
| 450 | yield* fs.writeFileString(path.join(runtime, "state.json"), "{}"); |
| 451 | expect(yield* provider.hash(context, undefined)).toEqual(before); |
| 452 | yield* fs.writeFileString( |
| 453 | path.join(root, "app.js"), |
| 454 | "export default 43;", |
| 455 | ); |
| 456 | expect(yield* provider.hash(context, undefined)).not.toEqual(before); |
| 457 | }), |
| 458 | { tags: ["unit", "local"] }, |
| 459 | ); |
| 460 | }); |