1import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2import * as path from "node:path";
3
4export type PollOptions = {
5 readonly tries?: number;
6 readonly delayMs?: number;
7};
8
9export const pollUntil = async <T>(
10 what: string,
11 effect: () => T | undefined | Promise<T | undefined>,
12 options: PollOptions & {
13 readonly diagnostics?: () => string;
14 /**
15 * Returns a reason to give up early (e.g. "the process exited"), or
16 * `undefined` to keep polling. Checked after every unsuccessful attempt so
17 * a dead subject fails in one tick instead of burning the whole budget.
18 */
19 readonly abandonIf?: () => string | undefined;
20 } = {},
21): Promise<T> => {
22 const { tries = 30, delayMs = 1_000, diagnostics, abandonIf } = options;
23 for (let attempt = 0; attempt < tries; attempt++) {
24 const value = await effect();
25 if (value !== undefined) return value;
26 const reason = abandonIf?.();
27 if (reason !== undefined) {
28 const detail = diagnostics?.();
29 throw new Error(
30 `Gave up waiting for ${what}: ${reason}.${detail ? `\n${detail}` : ""}`,
31 );
32 }
33 await Bun.sleep(delayMs);
34 }
35 const detail = diagnostics?.();
36 throw new Error(
37 `Timed out waiting for ${what}.${detail ? `\n${detail}` : ""}`,
38 );
39};
40
41export function fetchOk(
42 url: string | URL,
43 options?: PollOptions,
44): Promise<Response>;
45export function fetchOk(
46 url: string | URL,
47 init?: RequestInit,
48 options?: PollOptions,
49): Promise<Response>;
50export async function fetchOk(
51 url: string | URL,
52 initOrOptions: RequestInit | PollOptions = {},
53 explicitOptions?: PollOptions,
54): Promise<Response> {
55 const optionsOnly =
56 explicitOptions === undefined &&
57 ("tries" in initOrOptions || "delayMs" in initOrOptions);
58 const init = optionsOnly ? undefined : (initOrOptions as RequestInit);
59 const options = optionsOnly
60 ? (initOrOptions as PollOptions)
61 : (explicitOptions ?? {});
62 const { tries = 20, delayMs = 500 } = options;
63 let last: Response | undefined;
64 for (let attempt = 0; attempt < tries; attempt++) {
65 try {
66 last = await fetch(url, init);
67 if (last.ok) return last;
68 } catch {
69 // The dev endpoint may not be listening yet.
70 }
71 await Bun.sleep(delayMs);
72 }
73 throw new Error(
74 `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`,
75 );
76}
77
78export type DevCliOptions = {
79 readonly root: string;
80 readonly stage?: string;
81 readonly alchemyBin?: string;
82 readonly env?: NodeJS.ProcessEnv;
83};
84
85export const parseOutputUrl = (
86 output: string,
87 key: string,
88): string | undefined => {
89 const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
90 return output.match(
91 new RegExp(
92 `\\b${escaped}(?:\\s*:\\s*|\\s+)['"]?(http[^\\s'",]+)['"]?(?=[\\s,}])`,
93 ),
94 )?.[1];
95};
96
97export const parseOutputValue = (
98 output: string,
99 key: string,
100): string | undefined => {
101 const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102 return output.match(
103 new RegExp(`\\b${escaped}(?:\\s*:\\s*|\\s+)['"]?([^\\s'",]+)`),
104 )?.[1];
105};
106
107/** Owns a real `alchemy dev` child and its complete detached process group. */
108export class DevCli {
109 readonly root: string;
110 readonly stage: string;
111 readonly alchemyBin: string;
112 readonly env: NodeJS.ProcessEnv;
113 #process: ReturnType<typeof spawn> | undefined;
114 #output = "";
115
116 constructor(options: DevCliOptions) {
117 this.root = options.root;
118 this.stage = options.stage ?? "dev-cli-test";
119 this.alchemyBin =
120 options.alchemyBin ??
121 path.join(this.root, "node_modules", "alchemy", "bin", "alchemy.js");
122 this.env = { ...process.env, ...options.env };
123 }
124
125 get output(): string {
126 return this.#output;
127 }
128
129 get outputTail(): string {
130 return `--- alchemy dev output (tail) ---\n${this.#output.slice(-4_000)}`;
131 }
132
133 start(): void {
134 if (this.#process !== undefined) throw new Error("alchemy dev is running");
135 const child = spawn(
136 "bun",
137 [this.alchemyBin, "dev", "--stage", this.stage],
138 {
139 cwd: this.root,
140 detached: true,
141 env: this.env,
142 stdio: ["ignore", "pipe", "pipe"],
143 },
144 );
145 this.#process = child;
146 forwardSignals(child);
147 const capture = (chunk: Buffer) => {
148 const text = chunk.toString();
149 this.#output += text;
150 if (process.env.DEBUG) process.stderr.write(text);
151 };
152 child.stdout.on("data", capture);
153 child.stderr.on("data", capture);
154 }
155
156 outputUrl(key: string): string | undefined {
157 return parseOutputUrl(this.#output, key);
158 }
159
160 outputValue(key: string): string | undefined {
161 return parseOutputValue(this.#output, key);
162 }
163
164 pollUntil<T>(
165 what: string,
166 effect: () => T | undefined | Promise<T | undefined>,
167 options: PollOptions = {},
168 ): Promise<T> {
169 return pollUntil(what, effect, {
170 ...options,
171 diagnostics: () => this.outputTail,
172 // A dev child that died (crash, failed apply) never produces what we're
173 // waiting for — fail immediately with its output instead of sitting
174 // through the full poll budget.
175 abandonIf: () => {
176 const child = this.#process;
177 if (child === undefined) return "alchemy dev is not running";
178 if (child.exitCode !== null) {
179 return `alchemy dev exited with code ${child.exitCode}`;
180 }
181 if (child.signalCode !== null) {
182 return `alchemy dev was killed by ${child.signalCode}`;
183 }
184 return undefined;
185 },
186 });
187 }
188
189 outputUrlWhenReady(key: string, options?: PollOptions): Promise<string> {
190 return this.pollUntil(
191 `${key} url in stack outputs`,
192 () => this.outputUrl(key),
193 options,
194 );
195 }
196
197 async stop(): Promise<void> {
198 const child = this.#process;
199 if (child?.pid === undefined) return;
200 if (child.exitCode !== null || child.signalCode !== null) {
201 this.#process = undefined;
202 return;
203 }
204 const killGroup = (signal: NodeJS.Signals) => {
205 try {
206 process.kill(-child.pid!, signal);
207 } catch {
208 // The group has already exited.
209 }
210 };
211 const exited = new Promise<void>((resolve) =>
212 child.once("exit", () => resolve()),
213 );
214 killGroup("SIGINT");
215 await Promise.race([exited, Bun.sleep(15_000)]);
216 if (child.exitCode === null && child.signalCode === null) {
217 killGroup("SIGKILL");
218 await Promise.race([exited, Bun.sleep(5_000)]);
219 }
220 this.#process = undefined;
221 }
222
223 destroy(options: { readonly timeout?: number } = {}): void {
224 const result = spawnSync(
225 "bun",
226 [this.alchemyBin, "destroy", "--stage", this.stage, "--yes"],
227 {
228 cwd: this.root,
229 env: this.env,
230 stdio: "inherit",
231 timeout: options.timeout ?? 120_000,
232 },
233 );
234 if (result.error !== undefined) throw result.error;
235 if (result.status !== 0) {
236 throw new Error(`alchemy destroy exited ${result.status ?? "by signal"}`);
237 }
238 }
239}
240
241/** Forward Ctrl-C to a detached CLI and let it finish its own cleanup. */
242export const forwardSignals = (child: ChildProcess): void => {
243 let interrupted: NodeJS.Signals | undefined;
244 const forward = (signal: NodeJS.Signals) => {
245 if (interrupted !== undefined) return;
246 interrupted = signal;
247 child.kill(signal);
248 };
249 const removeListeners = () => {
250 process.off("SIGINT", forward);
251 process.off("SIGTERM", forward);
252 };
253 process.on("SIGINT", forward);
254 process.on("SIGTERM", forward);
255 child.once("error", removeListeners);
256 child.once("exit", () => {
257 removeListeners();
258 // Bun skips afterAll on Ctrl-C. Exit only after the CLI has shut down,
259 // without resuming the interrupted tests or starting the next command.
260 if (interrupted !== undefined) {
261 process.exit(interrupted === "SIGINT" ? 130 : 143);
262 }
263 });
264};