| 1 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import * as Alchemy from "@/index.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as pathe from "pathe"; |
| 11 | import { cloneFixture } from "../Utils/Fixture.ts"; |
| 12 | import { expectUrlContains } from "../Utils/Http.ts"; |
| 13 | import { |
| 14 | expectWorkerExists, |
| 15 | findWorker, |
| 16 | waitForWorkerToBeDeleted, |
| 17 | } from "../Utils/Worker.ts"; |
| 18 | |
| 19 | // `dev: true` runs local providers behind the RPC sidecar proxy by default, |
| 20 | // matching the process topology of the real `alchemy dev` command. |
| 21 | const { test } = Test.make({ providers: Cloudflare.providers(), dev: true }); |
| 22 | |
| 23 | const logLevel = Effect.provideService( |
| 24 | MinimumLogLevel, |
| 25 | process.env.DEBUG ? "Debug" : "Info", |
| 26 | ); |
| 27 | |
| 28 | const fixtureDir = pathe.resolve(import.meta.dirname, "staticsite-fixture"); |
| 29 | const workerEntry = pathe.resolve(import.meta.dirname, "fixtures/worker.ts"); |
| 30 | const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); |
| 31 | |
| 32 | const staticSiteProps = (cwd: string): Cloudflare.Website.StaticSiteProps => ({ |
| 33 | command: "bash build.sh", |
| 34 | shell: true, |
| 35 | cwd, |
| 36 | outdir: "dist", |
| 37 | main: workerEntry, |
| 38 | workersDev: true, |
| 39 | compatibility: { date: "2024-01-01" }, |
| 40 | }); |
| 41 | |
| 42 | const htmlPage = (marker: string) => `<!doctype html> |
| 43 | <html> |
| 44 | <head><title>${marker}</title></head> |
| 45 | <body><h1>${marker}</h1></body> |
| 46 | </html> |
| 47 | `; |
| 48 | |
| 49 | // Tests are independent (per-test scratch stacks, private fixture clones), |
| 50 | // so run them concurrently; suites are sequential by default. |
| 51 | describe.concurrent( |
| 52 | "StaticSite dev", |
| 53 | { |
| 54 | tags: [ |
| 55 | "provider:cloudflare", |
| 56 | "provider:cloudflare:website", |
| 57 | "provider:cloudflare:worker", |
| 58 | ], |
| 59 | }, |
| 60 | () => { |
| 61 | /** |
| 62 | * The `dev.command` path: `alchemy dev` skips the build entirely and spawns |
| 63 | * the command as a long-lived child in the sidecar (`Command.Dev`). The URL |
| 64 | * the child prints to stdout becomes the site's `url`, and the Worker opts |
| 65 | * out of local emulation (`dev: { mode: "external" }`) — the dev server IS |
| 66 | * the site. |
| 67 | */ |
| 68 | test.provider( |
| 69 | "StaticSite dev: dev.command serves the site through the external dev server", |
| 70 | (stack) => |
| 71 | Effect.gen(function* () { |
| 72 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 73 | const fs = yield* FileSystem.FileSystem; |
| 74 | const path = yield* Path.Path; |
| 75 | |
| 76 | yield* stack.destroy(); |
| 77 | |
| 78 | const cwd = yield* cloneFixture(fixtureDir, { |
| 79 | prefix: "alchemy-staticsite-dev-cmd-", |
| 80 | tempRoot, |
| 81 | entries: ["src", "build.sh", "serve.mjs", ".gitignore"], |
| 82 | }); |
| 83 | |
| 84 | const marker = "staticsite-dev-command-marker"; |
| 85 | yield* fs.writeFileString( |
| 86 | path.join(cwd, "src", "index.html"), |
| 87 | htmlPage(marker), |
| 88 | ); |
| 89 | |
| 90 | const site = yield* stack.deploy( |
| 91 | Effect.gen(function* () { |
| 92 | return yield* Cloudflare.Website.StaticSite("DevCmdSite", { |
| 93 | ...staticSiteProps(cwd), |
| 94 | dev: { |
| 95 | command: "bun serve.mjs", |
| 96 | env: { DEV_MARKER: "staticsite-dev-env-marker" }, |
| 97 | }, |
| 98 | }); |
| 99 | }), |
| 100 | ); |
| 101 | |
| 102 | // The site's url is the dev server's own localhost address, |
| 103 | // extracted from the child's stdout. |
| 104 | expect(site.url).toBeDefined(); |
| 105 | expect(site.url).toMatch(/^http:\/\/localhost:\d+/); |
| 106 | |
| 107 | // Content serves through the dev server straight from `src/`. |
| 108 | yield* expectUrlContains(`${site.url!}/`, marker, { |
| 109 | timeout: "60 seconds", |
| 110 | label: "dev.command index", |
| 111 | }); |
| 112 | yield* expectUrlContains(`${site.url!}/index.html`, marker, { |
| 113 | timeout: "30 seconds", |
| 114 | label: "dev.command explicit path", |
| 115 | }); |
| 116 | |
| 117 | // `dev.env` reached the spawned child process. |
| 118 | yield* expectUrlContains( |
| 119 | `${site.url!}/__dev-env`, |
| 120 | "staticsite-dev-env-marker", |
| 121 | { |
| 122 | timeout: "30 seconds", |
| 123 | label: "dev.command env passthrough", |
| 124 | }, |
| 125 | ); |
| 126 | |
| 127 | // The build command was skipped — `dist/` was never produced. |
| 128 | expect(yield* fs.exists(path.join(cwd, "dist"))).toBe(false); |
| 129 | |
| 130 | // No cloud Worker was created (external dev mode still records the |
| 131 | // real worker name in its stub attributes). |
| 132 | expect(site.workerName).toBeDefined(); |
| 133 | expect(yield* findWorker(site.workerName, accountId)).toBeUndefined(); |
| 134 | |
| 135 | yield* stack.destroy(); |
| 136 | }).pipe(logLevel), |
| 137 | { tags: ["live"], timeout: 300_000 }, |
| 138 | ); |
| 139 | |
| 140 | /** |
| 141 | * Without `dev.command`, a dev run falls back to build mode: the build |
| 142 | * command runs (producing `dist/`) and the Worker serves the built assets |
| 143 | * from a local simulator — the `url` is a localhost dev-proxy address and |
| 144 | * no cloud Worker exists. |
| 145 | */ |
| 146 | test.provider( |
| 147 | "StaticSite dev: without dev.command the build runs and a local Worker serves the assets", |
| 148 | (stack) => |
| 149 | Effect.gen(function* () { |
| 150 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 151 | const fs = yield* FileSystem.FileSystem; |
| 152 | const path = yield* Path.Path; |
| 153 | |
| 154 | yield* stack.destroy(); |
| 155 | |
| 156 | const cwd = yield* cloneFixture(fixtureDir, { |
| 157 | prefix: "alchemy-staticsite-dev-build-", |
| 158 | tempRoot, |
| 159 | entries: ["src", "build.sh", ".gitignore"], |
| 160 | }); |
| 161 | |
| 162 | const marker = "staticsite-dev-build-marker"; |
| 163 | yield* fs.writeFileString( |
| 164 | path.join(cwd, "src", "index.html"), |
| 165 | htmlPage(marker), |
| 166 | ); |
| 167 | |
| 168 | const site = yield* stack.deploy( |
| 169 | Effect.gen(function* () { |
| 170 | return yield* Cloudflare.Website.StaticSite( |
| 171 | "DevBuildSite", |
| 172 | staticSiteProps(cwd), |
| 173 | ); |
| 174 | }), |
| 175 | ); |
| 176 | |
| 177 | // Local identity: the url points at the local dev proxy. |
| 178 | expect(site.url).toBeDefined(); |
| 179 | expect(site.url).toMatch(/^http:\/\/localhost:\d+/); |
| 180 | |
| 181 | // The build ran — `dist/` exists with the built page. |
| 182 | expect(yield* fs.exists(path.join(cwd, "dist", "index.html"))).toBe( |
| 183 | true, |
| 184 | ); |
| 185 | |
| 186 | // The built assets serve through the local Worker simulator. |
| 187 | yield* expectUrlContains(`${site.url!}/index.html`, marker, { |
| 188 | timeout: "60 seconds", |
| 189 | label: "dev build-mode assets", |
| 190 | }); |
| 191 | |
| 192 | // No cloud Worker was created. |
| 193 | expect(yield* findWorker(site.workerName, accountId)).toBeUndefined(); |
| 194 | |
| 195 | yield* stack.destroy(); |
| 196 | }).pipe(logLevel), |
| 197 | { tags: ["live"], timeout: 300_000 }, |
| 198 | ); |
| 199 | |
| 200 | /** |
| 201 | * `Alchemy.remote()` opts the whole site OUT of local emulation: even in a |
| 202 | * dev run, the build executes and the Worker deploys to real Cloudflare. |
| 203 | * Destroy deletes the cloud Worker (the state row is stamped live). |
| 204 | */ |
| 205 | test.provider( |
| 206 | "StaticSite dev: Alchemy.remote() deploys the real Worker and destroy removes it", |
| 207 | (stack) => |
| 208 | Effect.gen(function* () { |
| 209 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 210 | const fs = yield* FileSystem.FileSystem; |
| 211 | const path = yield* Path.Path; |
| 212 | |
| 213 | yield* stack.destroy(); |
| 214 | |
| 215 | const cwd = yield* cloneFixture(fixtureDir, { |
| 216 | prefix: "alchemy-staticsite-dev-remote-", |
| 217 | tempRoot, |
| 218 | entries: ["src", "build.sh", ".gitignore"], |
| 219 | }); |
| 220 | |
| 221 | const marker = "staticsite-dev-remote-marker"; |
| 222 | yield* fs.writeFileString( |
| 223 | path.join(cwd, "src", "index.html"), |
| 224 | htmlPage(marker), |
| 225 | ); |
| 226 | |
| 227 | const site = yield* stack.deploy( |
| 228 | Effect.gen(function* () { |
| 229 | return yield* Cloudflare.Website.StaticSite( |
| 230 | "RemoteSite", |
| 231 | staticSiteProps(cwd), |
| 232 | ).pipe(Alchemy.remote()); |
| 233 | }), |
| 234 | ); |
| 235 | |
| 236 | // Real identity: a non-local URL and a Worker that exists on |
| 237 | // Cloudflare. |
| 238 | expect(site.url).toBeDefined(); |
| 239 | expect(site.url).not.toMatch(/^http:\/\/localhost/); |
| 240 | yield* expectWorkerExists(site.workerName, accountId); |
| 241 | |
| 242 | // The real workers.dev URL serves the built assets. |
| 243 | yield* expectUrlContains(`${site.url!}/index.html`, marker, { |
| 244 | timeout: "120 seconds", |
| 245 | label: "remote() site marker", |
| 246 | }); |
| 247 | |
| 248 | yield* stack.destroy(); |
| 249 | |
| 250 | // The cloud Worker is gone after destroy (stamped-mode delete). |
| 251 | yield* waitForWorkerToBeDeleted(site.workerName, accountId); |
| 252 | }).pipe(logLevel), |
| 253 | { tags: ["live"], timeout: 300_000 }, |
| 254 | ); |
| 255 | }, |
| 256 | ); |