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