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