1/**
2 * True `alchemy dev` end-to-end for the TanStack Start site: spawns the
3 * REAL CLI (mirroring examples/aws-dev/test/dev.test.ts), which runs the
4 * Vite dev server as the local `Website.Server` provider — no Railway
5 * Service, no Railway, 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 a TanStack Start server function)
12 * - HOT RELOAD → editing src/routes/index.tsx is served by
13 * Vite's HMR without a redeploy
14 */
15import { afterAll, expect, test } from "bun:test";
16import { spawn, spawnSync } from "node:child_process";
17import * as fs from "node:fs";
18import * as path from "node:path";
19
20const root = path.resolve(import.meta.dirname, "..");
21const alchemyBin = path.join(
22 root,
23 "node_modules",
24 "alchemy",
25 "bin",
26 "alchemy.js",
27);
28const STAGE = "dev-cli-test";
29
30const pagePath = path.join(root, "src", "routes", "index.tsx");
31const pageSource = fs.readFileSync(pagePath, "utf8");
32const MARKER = "Styled with Tailwind CSS";
33const MARKER_V2 = "Styled with Tailwind CSS [dev-v2]";
34
35let proc: ReturnType<typeof spawn> | undefined;
36let output = "";
37
38const pump = (stream: NodeJS.ReadableStream) => {
39 stream.on("data", (chunk: Buffer) => {
40 const text = chunk.toString();
41 output += text;
42 if (process.env.DEBUG) process.stderr.write(text);
43 });
44};
45
46/** Bounded poll for a (possibly async) producer to yield a value. */
47const pollUntil = async <T>(
48 what: string,
49 f: () => T | undefined | Promise<T | undefined>,
50 { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {},
51): Promise<T> => {
52 for (let i = 0; i < tries; i++) {
53 const value = await f();
54 if (value !== undefined) return value;
55 await Bun.sleep(delayMs);
56 }
57 throw new Error(
58 `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`,
59 );
60};
61
62/** Fetch with retries — the dev server takes a moment to start serving. */
63const fetchOk = async (
64 url: string | URL,
65 { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {},
66) => {
67 let last: Response | undefined;
68 for (let i = 0; i < tries; i++) {
69 try {
70 last = await fetch(url);
71 if (last.ok) return last;
72 } catch {
73 // dev server not listening yet
74 }
75 await Bun.sleep(delayMs);
76 }
77 throw new Error(
78 `GET ${url} never returned 2xx (last status: ${last?.status})`,
79 );
80};
81
82/** Extract the stack-output URL the CLI prints on stdout. */
83const outputUrl = () => output.match(/\burl:\s*['"]?(http[^\s'",]+)/)?.[1];
84
85afterAll(async () => {
86 fs.writeFileSync(pagePath, pageSource);
87
88 if (proc?.pid) {
89 const killGroup = (signal: NodeJS.Signals) => {
90 try {
91 process.kill(-proc!.pid!, signal);
92 } catch {
93 // group already gone
94 }
95 };
96 const exited = new Promise((resolve) => proc!.once("exit", resolve));
97 killGroup("SIGINT");
98 await Promise.race([exited, Bun.sleep(15_000)]);
99 if (proc.exitCode === null && proc.signalCode === null) {
100 killGroup("SIGKILL");
101 await Promise.race([exited, Bun.sleep(5_000)]);
102 }
103 }
104 if (!process.env.NO_DESTROY) {
105 spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], {
106 cwd: root,
107 stdio: "inherit",
108 timeout: 120_000,
109 });
110 }
111}, 180_000);
112
113test(
114 "alchemy dev serves the TanStack Start site locally with hot reload",
115 async () => {
116 proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], {
117 cwd: root,
118 detached: true,
119 stdio: ["ignore", "pipe", "pipe"],
120 });
121 pump(proc.stdout!);
122 pump(proc.stderr!);
123
124 const url = await pollUntil("url in stack outputs", outputUrl, {
125 tries: 180,
126 delayMs: 1000,
127 });
128
129 // Dev identity: the framework dev server, not Railway. The port is
130 // whatever the framework bound — only the URL captured from the CLI's
131 // stdout is authoritative.
132 expect(new URL(url).hostname).toBe("localhost");
133 expect(url).not.toContain("example.invalid");
134
135 // SSR env parity: GREETING from alchemy.run.ts reaches the dev server
136 // (read via a TanStack Start server function).
137 const home = await (await fetchOk(url)).text();
138 expect(home).toContain("Hello from TanStack Start on Railway!");
139 expect(home).toContain(MARKER);
140
141 // ── HOT RELOAD: rewrite the index page with the CLI still running —
142 // the framework dev server serves the new markup without a deploy ──
143 fs.writeFileSync(pagePath, pageSource.replace(MARKER, MARKER_V2));
144 await pollUntil(
145 "hot-reloaded page (v2 marker)",
146 async () => {
147 try {
148 const res = await fetch(url);
149 if (!res.ok) return undefined;
150 const html = await res.text();
151 return html.includes(MARKER_V2) ? true : undefined;
152 } catch {
153 return undefined; // mid-reload
154 }
155 },
156 { tries: 120, delayMs: 500 },
157 );
158
159 fs.writeFileSync(pagePath, pageSource);
160 await pollUntil(
161 "restored page (v2 marker gone)",
162 async () => {
163 try {
164 const res = await fetch(url);
165 if (!res.ok) return undefined;
166 const html = await res.text();
167 return html.includes(MARKER_V2) ? undefined : true;
168 } catch {
169 return undefined; // mid-reload
170 }
171 },
172 { tries: 120, delayMs: 500 },
173 );
174 },
175 { timeout: 600_000 },
176);