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