1/**
2 * True `alchemy dev` end-to-end for the Astro site: spawns the REAL CLI
3 * (mirroring examples/aws-dev/test/dev.test.ts), which runs Astro's own
4 * dev server as the local `Website.Server` provider — no Lambda, no
5 * CloudFront, no S3; the only cloud touch is the state store.
6 *
7 * Coverage:
8 * - stack output → `url` is a local dev-server address (port is
9 * whatever the framework bound — never hard-coded)
10 * - SSR env parity → `/` renders the GREETING declared in alchemy.run.ts
11 * - routing → `/about/` (prerendered route) serves
12 * - static assets → `/robots.txt` from public/
13 * - HOT RELOAD → editing src/pages/index.astro is served by the
14 * framework's HMR without a redeploy
15 */
16import { afterAll, expect, test } from "bun:test";
17import { DevCli, fetchOk } from "alchemy-test/DevCli";
18import * as fs from "node:fs";
19import * as path from "node:path";
20
21const root = path.resolve(import.meta.dirname, "..");
22// Isolated stage so this suite never fights integ.test.ts (same stack
23// name) over state rows.
24const STAGE = "dev-cli-test";
25const cli = new DevCli({ root, stage: STAGE });
26
27// Hot-reload surface: the SSR index page. The test rewrites it in place
28// with the CLI running, then restores it.
29const pagePath = path.join(root, "src", "pages", "index.astro");
30const pageSource = fs.readFileSync(pagePath, "utf8");
31const MARKER = "server-rendered in an AWS Lambda";
32const MARKER_V2 = "server-rendered in an AWS Lambda [dev-v2]";
33
34afterAll(async () => {
35 // Always leave the repo tree clean, even on a mid-reload failure.
36 fs.writeFileSync(pagePath, pageSource);
37
38 await cli.stop();
39 if (!process.env.NO_DESTROY) cli.destroy();
40}, 180_000);
41
42test(
43 "alchemy dev serves the Astro site locally with hot reload",
44 async () => {
45 cli.start();
46
47 const url = await cli.outputUrlWhenReady("url", {
48 tries: 180,
49 delayMs: 1000,
50 });
51
52 // Dev identity: the framework dev server, not CloudFront. The port is
53 // whatever the framework bound — only the URL captured from the CLI's
54 // stdout is authoritative.
55 expect(new URL(url).hostname).toBe("localhost");
56 expect(url).not.toContain("cloudfront.net");
57
58 // SSR env parity: GREETING from alchemy.run.ts reaches the dev server.
59 const home = await (await fetchOk(url)).text();
60 expect(home).toContain("Hello from Astro on AWS!");
61 expect(home).toContain(MARKER);
62
63 // Prerendered route serves through the dev server.
64 const about = await fetchOk(new URL("/about/", url));
65 expect(about.status).toBe(200);
66
67 // Static asset from public/.
68 const robots = await (await fetchOk(new URL("/robots.txt", url))).text();
69 expect(robots).toContain("User-agent:");
70
71 // ── HOT RELOAD: rewrite the index page with the CLI still running —
72 // the framework dev server serves the new markup without a deploy ──
73 fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2));
74 await cli.pollUntil(
75 "hot-reloaded page (v2 marker)",
76 async () => {
77 try {
78 const res = await fetch(url);
79 if (!res.ok) return undefined;
80 const html = await res.text();
81 return html.includes(MARKER_V2) ? true : undefined;
82 } catch {
83 return undefined; // mid-reload
84 }
85 },
86 { tries: 120, delayMs: 500 },
87 );
88
89 // Restore — the swap back is itself a second hot reload and leaves
90 // the checked-in tree clean.
91 fs.writeFileSync(pagePath, pageSource);
92 await cli.pollUntil(
93 "restored page (v2 marker gone)",
94 async () => {
95 try {
96 const res = await fetch(url);
97 if (!res.ok) return undefined;
98 const html = await res.text();
99 return html.includes(MARKER_V2) ? undefined : true;
100 } catch {
101 return undefined; // mid-reload
102 }
103 },
104 { tries: 120, delayMs: 500 },
105 );
106 },
107 { timeout: 600_000 },
108);