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 Hetzner Server, no
5 * Hetzner, 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 { spawn, spawnSync } from "node:child_process";
18import * as fs from "node:fs";
19import * as path from "node:path";
20
21const root = path.resolve(import.meta.dirname, "..");
22// Spawn the CLI entry directly (not through `bun run` / the cli.js
23// launcher) so signals hit the actual CLI process, whose scope teardown
24// kills the dev server and the provider sidecars.
25const alchemyBin = path.join(
26 root,
27 "node_modules",
28 "alchemy",
29 "bin",
30 "alchemy.js",
31);
32// Isolated stage so this suite never fights integ.test.ts (same stack
33// name) over state rows.
34const STAGE = "dev-cli-test";
35
36// Hot-reload surface: the SSR index page. The test rewrites it in place
37// with the CLI running, then restores it.
38const pagePath = path.join(root, "src", "pages", "index.astro");
39const pageSource = fs.readFileSync(pagePath, "utf8");
40const MARKER = "server-rendered in a Hetzner Server";
41const MARKER_V2 = "server-rendered in a Hetzner Server [dev-v2]";
42
43let proc: ReturnType<typeof spawn> | undefined;
44let output = "";
45
46const pump = (stream: NodeJS.ReadableStream) => {
47 stream.on("data", (chunk: Buffer) => {
48 const text = chunk.toString();
49 output += text;
50 if (process.env.DEBUG) process.stderr.write(text);
51 });
52};
53
54/** Bounded poll for a (possibly async) producer to yield a value. */
55const pollUntil = async <T>(
56 what: string,
57 f: () => T | undefined | Promise<T | undefined>,
58 { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {},
59): Promise<T> => {
60 for (let i = 0; i < tries; i++) {
61 const value = await f();
62 if (value !== undefined) return value;
63 await Bun.sleep(delayMs);
64 }
65 throw new Error(
66 `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`,
67 );
68};
69
70/** Fetch with retries — the dev server takes a moment to start serving. */
71const fetchOk = async (
72 url: string | URL,
73 { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {},
74) => {
75 let last: Response | undefined;
76 for (let i = 0; i < tries; i++) {
77 try {
78 last = await fetch(url);
79 if (last.ok) return last;
80 } catch {
81 // dev server not listening yet
82 }
83 await Bun.sleep(delayMs);
84 }
85 throw new Error(
86 `GET ${url} never returned 2xx (last status: ${last?.status})`,
87 );
88};
89
90/** Extract the stack-output URL the CLI prints on stdout. */
91const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1];
92
93afterAll(async () => {
94 // Always leave the repo tree clean, even on a mid-reload failure.
95 fs.writeFileSync(pagePath, pageSource);
96
97 if (proc?.pid) {
98 // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its
99 // `--watch` exec child, and the provider sidecars).
100 const killGroup = (signal: NodeJS.Signals) => {
101 try {
102 process.kill(-proc!.pid!, signal);
103 } catch {
104 // group already gone
105 }
106 };
107 const exited = new Promise((resolve) => proc!.once("exit", resolve));
108 killGroup("SIGINT");
109 await Promise.race([exited, Bun.sleep(15_000)]);
110 if (proc.exitCode === null && proc.signalCode === null) {
111 killGroup("SIGKILL");
112 await Promise.race([exited, Bun.sleep(5_000)]);
113 }
114 }
115 if (!process.env.NO_DESTROY) {
116 const destroyed = spawnSync(
117 "bun",
118 [alchemyBin, "destroy", "--stage", STAGE, "--yes"],
119 {
120 cwd: root,
121 stdio: "inherit",
122 timeout: 120_000,
123 },
124 );
125 if (destroyed.status !== 0) {
126 throw new Error(
127 `alchemy destroy exited ${destroyed.status} — local teardown must succeed`,
128 );
129 }
130 }
131}, 180_000);
132
133test(
134 "alchemy dev serves the Astro site locally with hot reload",
135 async () => {
136 proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], {
137 cwd: root,
138 // Own process group, so teardown can deliver Ctrl-C to the whole
139 // tree the way a terminal would.
140 detached: true,
141 stdio: ["ignore", "pipe", "pipe"],
142 });
143 pump(proc.stdout!);
144 pump(proc.stderr!);
145
146 const url = await pollUntil("url in stack outputs", outputUrl, {
147 tries: 180,
148 delayMs: 1000,
149 });
150
151 // Dev identity: the framework dev server, not Hetzner. The port is
152 // whatever the framework bound — only the URL captured from the CLI's
153 // stdout is authoritative.
154 expect(new URL(url).hostname).toBe("localhost");
155 expect(url).not.toContain("example.invalid");
156
157 // SSR env parity: GREETING from alchemy.run.ts reaches the dev server.
158 const home = await (await fetchOk(url)).text();
159 expect(home).toContain("Hello from Astro on Hetzner!");
160 expect(home).toContain(MARKER);
161
162 // Prerendered route serves through the dev server.
163 const about = await fetchOk(new URL("/about/", url));
164 expect(about.status).toBe(200);
165
166 // Static asset from public/.
167 const robots = await (await fetchOk(new URL("/robots.txt", url))).text();
168 expect(robots).toContain("User-agent:");
169
170 // ── HOT RELOAD: rewrite the index page with the CLI still running —
171 // the framework dev server serves the new markup without a deploy ──
172 fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2));
173 await pollUntil(
174 "hot-reloaded page (v2 marker)",
175 async () => {
176 try {
177 const res = await fetch(url);
178 if (!res.ok) return undefined;
179 const html = await res.text();
180 return html.includes(MARKER_V2) ? true : undefined;
181 } catch {
182 return undefined; // mid-reload
183 }
184 },
185 { tries: 120, delayMs: 500 },
186 );
187
188 // Restore — the swap back is itself a second hot reload and leaves
189 // the checked-in tree clean.
190 fs.writeFileSync(pagePath, pageSource);
191 await pollUntil(
192 "restored page (v2 marker gone)",
193 async () => {
194 try {
195 const res = await fetch(url);
196 if (!res.ok) return undefined;
197 const html = await res.text();
198 return html.includes(MARKER_V2) ? undefined : true;
199 } catch {
200 return undefined; // mid-reload
201 }
202 },
203 { tries: 120, delayMs: 500 },
204 );
205 },
206 { timeout: 600_000 },
207);