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