| 1 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import { isLocalId } from "@/Cloudflare/LocalRuntime"; |
| 4 | import * as Alchemy from "@/index.ts"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as kv from "@distilled.cloud/cloudflare/kv"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as pathe from "pathe"; |
| 15 | import { cloneFixture } from "../Utils/Fixture.ts"; |
| 16 | import { expectUrlContains } from "../Utils/Http.ts"; |
| 17 | |
| 18 | // `dev: true` runs local providers behind the RPC sidecar proxy by default, |
| 19 | // matching the process topology of the real `alchemy dev` command. |
| 20 | const { test } = Test.make({ providers: Cloudflare.providers(), dev: true }); |
| 21 | |
| 22 | const logLevel = Effect.provideService( |
| 23 | MinimumLogLevel, |
| 24 | process.env.DEBUG ? "Debug" : "Info", |
| 25 | ); |
| 26 | |
| 27 | const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "astro-app"); |
| 28 | const staticFixtureDir = pathe.resolve( |
| 29 | import.meta.dirname, |
| 30 | "fixtures", |
| 31 | "astro-static-app", |
| 32 | ); |
| 33 | const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); |
| 34 | |
| 35 | class DevResponseMismatch extends Data.TaggedError("DevResponseMismatch")<{ |
| 36 | url: string; |
| 37 | detail: string; |
| 38 | }> { |
| 39 | override get message() { |
| 40 | return `${this.url} :: ${this.detail}`; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | const devRetry = Effect.retry({ |
| 45 | schedule: Schedule.min([ |
| 46 | Schedule.exponential("500 millis", 1.5), |
| 47 | Schedule.spaced("4 seconds"), |
| 48 | ]), |
| 49 | times: 10, |
| 50 | }); |
| 51 | |
| 52 | /** |
| 53 | * Cookie-aware fetch for the dev session round-trip: returns the body plus |
| 54 | * the `astro-session` cookie pair from `set-cookie` (if any). |
| 55 | */ |
| 56 | const fetchSession = (url: string, cookie?: string) => |
| 57 | Effect.tryPromise({ |
| 58 | try: async (signal) => { |
| 59 | const res = await fetch(url, { |
| 60 | signal, |
| 61 | cache: "no-store", |
| 62 | headers: { |
| 63 | "cache-control": "no-cache", |
| 64 | accept: "*/*", |
| 65 | ...(cookie ? { cookie } : {}), |
| 66 | }, |
| 67 | }); |
| 68 | const body = await res.text(); |
| 69 | const sessionCookie = res.headers |
| 70 | .get("set-cookie") |
| 71 | ?.match(/astro-session=[^;,\s]+/)?.[0]; |
| 72 | return { status: res.status, body, sessionCookie }; |
| 73 | }, |
| 74 | catch: (e) => |
| 75 | new DevResponseMismatch({ |
| 76 | url, |
| 77 | detail: e instanceof Error ? e.message : String(e), |
| 78 | }), |
| 79 | }); |
| 80 | |
| 81 | /** |
| 82 | * Fetch `url` and assert the exact `status` with a body containing |
| 83 | * `marker` — used for the dev 404 page, which `expectUrlContains` can't |
| 84 | * assert (it requires `res.ok`). |
| 85 | */ |
| 86 | const expectStatusBody = (url: string, status: number, marker: string) => |
| 87 | Effect.tryPromise({ |
| 88 | try: async (signal) => { |
| 89 | const res = await fetch(url, { |
| 90 | signal, |
| 91 | cache: "no-store", |
| 92 | headers: { "cache-control": "no-cache", accept: "*/*" }, |
| 93 | }); |
| 94 | return { status: res.status, body: await res.text() }; |
| 95 | }, |
| 96 | catch: (e) => |
| 97 | new DevResponseMismatch({ |
| 98 | url, |
| 99 | detail: e instanceof Error ? e.message : String(e), |
| 100 | }), |
| 101 | }).pipe( |
| 102 | Effect.filterOrFail( |
| 103 | (r) => r.status === status && r.body.includes(marker), |
| 104 | (r) => |
| 105 | new DevResponseMismatch({ |
| 106 | url, |
| 107 | detail: `expected ${status} with "${marker}", got ${r.status} ${r.body.slice(0, 240)}`, |
| 108 | }), |
| 109 | ), |
| 110 | devRetry, |
| 111 | ); |
| 112 | |
| 113 | // Tests are independent (per-test scratch stacks, private fixture clones), |
| 114 | // so run them concurrently; suites are sequential by default. |
| 115 | describe.concurrent( |
| 116 | "Astro dev", |
| 117 | { tags: ["provider:cloudflare", "provider:cloudflare:website"] }, |
| 118 | () => { |
| 119 | test.provider( |
| 120 | "Astro dev: local dev server renders SSR with bindings and local sessions", |
| 121 | (stack) => |
| 122 | Effect.gen(function* () { |
| 123 | yield* stack.destroy(); |
| 124 | |
| 125 | const rootDir = yield* cloneFixture(fixtureDir, { |
| 126 | prefix: "alchemy-astro-dev-", |
| 127 | tempRoot, |
| 128 | entries: ["astro.config.mjs", "package.json", "public", "src"], |
| 129 | }); |
| 130 | |
| 131 | const marker = "astro-dev-marker"; |
| 132 | |
| 133 | const { site, sessions } = yield* stack.deploy( |
| 134 | Effect.gen(function* () { |
| 135 | const site = yield* Cloudflare.Website.Astro("AstroLocal", { |
| 136 | rootDir, |
| 137 | dev: { port: 0 }, |
| 138 | memo: { |
| 139 | include: [ |
| 140 | "src/**", |
| 141 | "public/**", |
| 142 | "astro.config.mjs", |
| 143 | "package.json", |
| 144 | ], |
| 145 | }, |
| 146 | env: { TEST_MARKER: marker }, |
| 147 | }); |
| 148 | // Dedupes by logical id — a handle on the session namespace the |
| 149 | // Astro resource auto-provisioned, for the dev-identity assertion. |
| 150 | const sessions = |
| 151 | yield* Cloudflare.KV.Namespace("AstroLocalSession"); |
| 152 | return { site, sessions }; |
| 153 | }), |
| 154 | ); |
| 155 | |
| 156 | // Local identity: the url points at the alchemy dev proxy — no |
| 157 | // cloud Worker exists. |
| 158 | expect(site.url).toBeDefined(); |
| 159 | expect(site.url).toMatch(/^http:\/\/localhost:\d+/); |
| 160 | |
| 161 | // The auto-provisioned session namespace is emulated locally — a |
| 162 | // `dev:` id is proof no cloud call ran. |
| 163 | expect(isLocalId(sessions.namespaceId)).toBe(true); |
| 164 | |
| 165 | // SSR page rendered by astro's dev server (ssr environment runs |
| 166 | // in workerd behind the proxy) — reads the TEST_MARKER binding. |
| 167 | yield* expectUrlContains(`${site.url!}/`, marker, { |
| 168 | timeout: "120 seconds", |
| 169 | label: "astro dev SSR home with env binding", |
| 170 | }); |
| 171 | |
| 172 | // Prerendered route renders on demand in dev. |
| 173 | yield* expectUrlContains(`${site.url!}/about/`, "prerendered-page", { |
| 174 | timeout: "60 seconds", |
| 175 | label: "astro dev prerender route", |
| 176 | }); |
| 177 | |
| 178 | // Static asset from `public/` through the dev server. |
| 179 | yield* expectUrlContains( |
| 180 | `${site.url!}/static.txt`, |
| 181 | "astro-static-asset", |
| 182 | { |
| 183 | timeout: "60 seconds", |
| 184 | label: "astro dev static asset", |
| 185 | }, |
| 186 | ); |
| 187 | |
| 188 | // ── sessions round-trip against the LOCAL simulator ──────────────── |
| 189 | const first = yield* fetchSession(`${site.url!}/session`).pipe( |
| 190 | Effect.filterOrFail( |
| 191 | (r) => r.status === 200 && r.body.includes("session-count=1"), |
| 192 | (r) => |
| 193 | new DevResponseMismatch({ |
| 194 | url: `${site.url!}/session`, |
| 195 | detail: `${r.status} ${r.body.slice(0, 240)}`, |
| 196 | }), |
| 197 | ), |
| 198 | devRetry, |
| 199 | ); |
| 200 | expect(first.sessionCookie).toBeDefined(); |
| 201 | |
| 202 | const second = yield* fetchSession( |
| 203 | `${site.url!}/session`, |
| 204 | first.sessionCookie, |
| 205 | ).pipe( |
| 206 | Effect.filterOrFail( |
| 207 | (r) => r.body.includes("session-count=2"), |
| 208 | (r) => |
| 209 | new DevResponseMismatch({ |
| 210 | url: `${site.url!}/session`, |
| 211 | detail: `expected session-count=2, got ${r.body.slice(0, 240)}`, |
| 212 | }), |
| 213 | ), |
| 214 | devRetry, |
| 215 | ); |
| 216 | expect(second.body).toContain("session-count=2"); |
| 217 | |
| 218 | // Out-of-band: NO real cloud namespace was created for the |
| 219 | // auto-provisioned session KV. (Physical titles embed the logical id |
| 220 | // verbatim: `{stack}-AstroLocalSession-{stage}-{suffix}`.) |
| 221 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 222 | const cloudNamespace = yield* kv.listNamespaces |
| 223 | .items({ accountId }) |
| 224 | .pipe( |
| 225 | Stream.filter((ns) => ns.title.includes("AstroLocalSession")), |
| 226 | Stream.runHead, |
| 227 | Effect.map(Option.getOrUndefined), |
| 228 | ); |
| 229 | expect(cloudNamespace).toBeUndefined(); |
| 230 | |
| 231 | yield* stack.destroy(); |
| 232 | }).pipe(logLevel), |
| 233 | { tags: ["provider:cloudflare:kv", "live"], timeout: 300_000 }, |
| 234 | ); |
| 235 | |
| 236 | // ───────────────────────────────────────────────────────────────────── |
| 237 | // Declared-static app in dev: `output: "static"` produces no |
| 238 | // server modules — the dev server must still answer every route from the |
| 239 | // assets-only dispatch, including the custom 404 page. |
| 240 | // ───────────────────────────────────────────────────────────────────── |
| 241 | |
| 242 | test.provider( |
| 243 | "Astro dev: declared-static app serves pages and the 404 page assets-only", |
| 244 | (stack) => |
| 245 | Effect.gen(function* () { |
| 246 | yield* stack.destroy(); |
| 247 | |
| 248 | const rootDir = yield* cloneFixture(staticFixtureDir, { |
| 249 | prefix: "alchemy-astro-static-dev-", |
| 250 | tempRoot, |
| 251 | entries: ["package.json", "public", "src"], |
| 252 | }); |
| 253 | |
| 254 | const site = yield* stack.deploy( |
| 255 | Effect.gen(function* () { |
| 256 | return yield* Cloudflare.Website.Astro("AstroStaticLocal", { |
| 257 | rootDir, |
| 258 | dev: { port: 0 }, |
| 259 | memo: { |
| 260 | include: ["src/**", "public/**", "package.json"], |
| 261 | }, |
| 262 | astro: { output: "static" }, |
| 263 | assets: { notFoundHandling: "404-page" }, |
| 264 | }); |
| 265 | }), |
| 266 | ); |
| 267 | |
| 268 | expect(site.url).toBeDefined(); |
| 269 | expect(site.url).toMatch(/^http:\/\/localhost:\d+/); |
| 270 | |
| 271 | yield* expectUrlContains(`${site.url!}/`, "static-home", { |
| 272 | timeout: "120 seconds", |
| 273 | label: "astro dev static home", |
| 274 | }); |
| 275 | yield* expectUrlContains(`${site.url!}/about/`, "static-about", { |
| 276 | timeout: "60 seconds", |
| 277 | label: "astro dev static about", |
| 278 | }); |
| 279 | // Unknown routes serve the custom 404 page with a real 404 status. |
| 280 | yield* expectStatusBody( |
| 281 | `${site.url!}/definitely-not-a-page`, |
| 282 | 404, |
| 283 | "static-404", |
| 284 | ); |
| 285 | |
| 286 | yield* stack.destroy(); |
| 287 | }).pipe(logLevel), |
| 288 | { tags: ["local"], timeout: 300_000 }, |
| 289 | ); |
| 290 | |
| 291 | // ───────────────────────────────────────────────────────────────────── |
| 292 | // `Alchemy.remote()` opt-out: the SITE_KV namespace runs LIVE even in a |
| 293 | // dev run — the local dev server's binding proxies to the real cloud |
| 294 | // namespace, and destroy deletes it from the cloud (stamped-mode delete). |
| 295 | // ───────────────────────────────────────────────────────────────────── |
| 296 | |
| 297 | test.provider( |
| 298 | "Astro dev: Alchemy.remote() SITE_KV binds the real namespace from the local server", |
| 299 | (stack) => |
| 300 | Effect.gen(function* () { |
| 301 | yield* stack.destroy(); |
| 302 | |
| 303 | const rootDir = yield* cloneFixture(fixtureDir, { |
| 304 | prefix: "alchemy-astro-remote-", |
| 305 | tempRoot, |
| 306 | entries: ["astro.config.mjs", "package.json", "public", "src"], |
| 307 | }); |
| 308 | |
| 309 | const marker = "astro-remote-marker"; |
| 310 | |
| 311 | const { site, liveKv } = yield* stack.deploy( |
| 312 | Effect.gen(function* () { |
| 313 | const liveKv = yield* Cloudflare.KV.Namespace( |
| 314 | "AstroRemoteSiteKV", |
| 315 | ).pipe(Alchemy.remote()); |
| 316 | const site = yield* Cloudflare.Website.Astro("AstroRemoteLocal", { |
| 317 | rootDir, |
| 318 | dev: { port: 0 }, |
| 319 | memo: { |
| 320 | include: [ |
| 321 | "src/**", |
| 322 | "public/**", |
| 323 | "astro.config.mjs", |
| 324 | "package.json", |
| 325 | ], |
| 326 | }, |
| 327 | env: { TEST_MARKER: marker, SITE_KV: liveKv }, |
| 328 | }); |
| 329 | return { site, liveKv }; |
| 330 | }), |
| 331 | ); |
| 332 | |
| 333 | // The site itself is local; the remote() namespace is REAL. |
| 334 | expect(site.url).toMatch(/^http:\/\/localhost:\d+/); |
| 335 | expect(isLocalId(liveKv.namespaceId)).toBe(false); |
| 336 | |
| 337 | // Wait for the dev server before driving the binding. |
| 338 | yield* expectUrlContains(`${site.url!}/`, marker, { |
| 339 | timeout: "120 seconds", |
| 340 | label: "astro dev home (remote kv stack)", |
| 341 | }); |
| 342 | |
| 343 | // Write through the remote-proxied binding via the fixture's |
| 344 | // /api/kv route, then read it back through the same binding. |
| 345 | yield* Effect.tryPromise({ |
| 346 | try: async (signal) => { |
| 347 | const res = await fetch( |
| 348 | `${site.url!}/api/kv?key=remote-key&value=${marker}`, |
| 349 | { signal, method: "PUT", cache: "no-store" }, |
| 350 | ); |
| 351 | return { status: res.status, body: await res.text() }; |
| 352 | }, |
| 353 | catch: (e) => |
| 354 | new DevResponseMismatch({ |
| 355 | url: `${site.url!}/api/kv`, |
| 356 | detail: e instanceof Error ? e.message : String(e), |
| 357 | }), |
| 358 | }).pipe( |
| 359 | Effect.filterOrFail( |
| 360 | (r) => r.status === 200, |
| 361 | (r) => |
| 362 | new DevResponseMismatch({ |
| 363 | url: `${site.url!}/api/kv`, |
| 364 | detail: `PUT ${r.status} ${r.body.slice(0, 240)}`, |
| 365 | }), |
| 366 | ), |
| 367 | devRetry, |
| 368 | ); |
| 369 | |
| 370 | yield* expectStatusBody( |
| 371 | `${site.url!}/api/kv?key=remote-key`, |
| 372 | 200, |
| 373 | `"value":"${marker}"`, |
| 374 | ); |
| 375 | |
| 376 | // Out-of-band: the write landed in the REAL cloud namespace. |
| 377 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 378 | const observed = yield* kv |
| 379 | .getNamespaceValue({ |
| 380 | accountId, |
| 381 | namespaceId: liveKv.namespaceId, |
| 382 | keyName: "remote-key", |
| 383 | }) |
| 384 | .pipe( |
| 385 | Effect.flatMap((res) => |
| 386 | Effect.tryPromise(() => |
| 387 | new Response( |
| 388 | Stream.toReadableStream(res.body) as BodyInit, |
| 389 | ).text(), |
| 390 | ), |
| 391 | ), |
| 392 | Effect.retry({ |
| 393 | schedule: Schedule.exponential("1 second", 1.5), |
| 394 | times: 8, |
| 395 | }), |
| 396 | ); |
| 397 | expect(observed).toBe(marker); |
| 398 | |
| 399 | yield* stack.destroy(); |
| 400 | |
| 401 | // The live namespace was deleted from the cloud on destroy (its |
| 402 | // state row is stamped live, so the live provider handles the |
| 403 | // delete even in a dev run). |
| 404 | const gone = yield* kv |
| 405 | .getNamespace({ accountId, namespaceId: liveKv.namespaceId }) |
| 406 | .pipe( |
| 407 | Effect.as(false), |
| 408 | Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)), |
| 409 | ); |
| 410 | expect(gone).toBe(true); |
| 411 | }).pipe(logLevel), |
| 412 | { tags: ["provider:cloudflare:kv", "live"], timeout: 300_000 }, |
| 413 | ); |
| 414 | }, |
| 415 | ); |