1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { isLocalId } from "@/Cloudflare/LocalRuntime";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import * as kv from "@distilled.cloud/cloudflare/kv";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as pathe from "pathe";
15import { cloneFixture } from "../Utils/Fixture.ts";
16import { 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.
20const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "astro-app");
28const staticFixtureDir = pathe.resolve(
29 import.meta.dirname,
30 "fixtures",
31 "astro-static-app",
32);
33const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
34
35class 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
44const 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 */
56const 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 */
86const 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.
115describe.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);