| 1 | import { spawn, spawnSync, type ChildProcess } from "node:child_process"; |
| 2 | import * as path from "node:path"; |
| 3 | |
| 4 | export type PollOptions = { |
| 5 | readonly tries?: number; |
| 6 | readonly delayMs?: number; |
| 7 | }; |
| 8 | |
| 9 | export 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 | |
| 41 | export function fetchOk( |
| 42 | url: string | URL, |
| 43 | options?: PollOptions, |
| 44 | ): Promise<Response>; |
| 45 | export function fetchOk( |
| 46 | url: string | URL, |
| 47 | init?: RequestInit, |
| 48 | options?: PollOptions, |
| 49 | ): Promise<Response>; |
| 50 | export 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 | |
| 78 | export type DevCliOptions = { |
| 79 | readonly root: string; |
| 80 | readonly stage?: string; |
| 81 | readonly alchemyBin?: string; |
| 82 | readonly env?: NodeJS.ProcessEnv; |
| 83 | }; |
| 84 | |
| 85 | export 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 | |
| 97 | export 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. */ |
| 108 | export 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. */ |
| 242 | export 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 | }; |