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