1import { spawn } from "node:child_process";
2import * as fs from "node:fs";
3import * as os from "node:os";
4import * as path from "node:path";
5import { forwardSignals } from "../packages/alchemy-test/src/DevCli.ts";
6
7const example = process.argv[2];
8if (example === undefined) {
9 throw new Error(
10 "Usage: bun scripts/test-example-cli.ts <example-directory> [--dev-only]",
11 );
12}
13const devOnly = process.argv.includes("--dev-only");
14
15const repositoryRoot = path.resolve(import.meta.dirname, "..");
16const exampleRoot = path.resolve(repositoryRoot, example);
17// The real launcher, not `bin/alchemy.js`: it pins bun's tsconfig to
18// alchemy's own, so the CLI's .tsx files are not transpiled with the
19// example's JSX settings (solid-js examples otherwise crash the CLI).
20const alchemyBin = path.join(
21 repositoryRoot,
22 "packages",
23 "alchemy",
24 "bin",
25 "cli.js",
26);
27const stage = "cli-example-test";
28// The summary line the CLI prints once a run converges. In non-TTY mode every
29// line carries a `[time] LEVEL (#fiber): ` prefix, so anchor on the text.
30const DONE = /Done: \d+ succeeded/;
31const timeoutMs = 4 * 60_000;
32// With a profile (`bun test:examples --profile testing`) the CLI must see the
33// real ALCHEMY_HOME (where the profile's credentials live) and must NOT run
34// as CI, which makes auth resolution skip profiles for env credentials.
35// Without a profile, run against an empty home as CI so the CLI can only
36// authenticate from env vars, the way a fresh CI runner would.
37const profile = process.env.ALCHEMY_PROFILE;
38const alchemyHome =
39 profile === undefined
40 ? fs.mkdtempSync(path.join(os.tmpdir(), "alchemy-example-cli-"))
41 : undefined;
42const childEnv = {
43 ...process.env,
44 ...(alchemyHome === undefined
45 ? {}
46 : { ALCHEMY_HOME: alchemyHome, AWS_PROFILE: undefined, CI: "true" }),
47 NO_COLOR: "1",
48};
49
50type CommandResult = {
51 readonly exitCode: number | null;
52 readonly output: string;
53};
54
55const command = (name: "dev" | "deploy" | "destroy") => [
56 "bun",
57 alchemyBin,
58 name,
59 "--stage",
60 stage,
61 "--no-input",
62 ...(name === "dev" ? [] : ["--yes"]),
63];
64
65const run = (
66 name: "deploy" | "destroy",
67 timeout = timeoutMs,
68): Promise<CommandResult> =>
69 new Promise((resolve, reject) => {
70 const child = spawn(command(name)[0]!, command(name).slice(1), {
71 cwd: exampleRoot,
72 detached: true,
73 env: childEnv,
74 stdio: ["ignore", "pipe", "pipe"],
75 });
76 forwardSignals(child);
77 let output = "";
78 const killGroup = () => {
79 if (child.pid === undefined) return;
80 try {
81 process.kill(-child.pid, "SIGKILL");
82 } catch {
83 // The CLI and its process group have already exited.
84 }
85 };
86 const append = (chunk: Buffer) => {
87 const text = chunk.toString();
88 output += text;
89 if (process.env.DEBUG) process.stderr.write(text);
90 };
91 child.stdout.on("data", append);
92 child.stderr.on("data", append);
93 const timer = setTimeout(() => {
94 output += `\nTimed out after ${timeout / 1000}s waiting for alchemy ${name}.`;
95 killGroup();
96 }, timeout);
97 child.once("error", reject);
98 child.once("exit", (exitCode) => {
99 clearTimeout(timer);
100 resolve({ exitCode, output });
101 });
102 });
103
104const runDev = (): Promise<CommandResult> =>
105 new Promise((resolve, reject) => {
106 const child = spawn(command("dev")[0]!, command("dev").slice(1), {
107 cwd: exampleRoot,
108 detached: true,
109 env: childEnv,
110 stdio: ["ignore", "pipe", "pipe"],
111 });
112 forwardSignals(child);
113 let output = "";
114 let settled = false;
115 let ready = false;
116
117 const killGroup = (signal: NodeJS.Signals) => {
118 if (child.pid === undefined) return;
119 try {
120 process.kill(-child.pid, signal);
121 } catch {
122 // The CLI and its process group have already exited.
123 }
124 };
125 const finish = (exitCode: number | null) => {
126 if (settled) return;
127 settled = true;
128 clearTimeout(timer);
129 resolve({ exitCode, output });
130 };
131 const append = (chunk: Buffer) => {
132 const text = chunk.toString();
133 output += text;
134 if (process.env.DEBUG) process.stderr.write(text);
135 if (!ready && DONE.test(output) && /https?:\/\//.test(output)) {
136 ready = true;
137 killGroup("SIGINT");
138 setTimeout(() => killGroup("SIGKILL"), 15_000).unref();
139 }
140 // A failed run leaves `dev` waiting for a file change that never
141 // comes; fail now instead of at the timeout.
142 if (!ready && output.includes("alchemy dev: run failed")) {
143 killGroup("SIGKILL");
144 }
145 };
146
147 child.stdout.on("data", append);
148 child.stderr.on("data", append);
149 child.once("error", reject);
150 child.once("exit", (exitCode) => finish(ready ? 0 : exitCode));
151 const timer = setTimeout(() => {
152 output += `\nTimed out after ${timeoutMs / 1000}s waiting for alchemy dev to become ready.`;
153 killGroup("SIGKILL");
154 }, timeoutMs);
155 });
156
157const assertSuccess = (
158 name: "dev" | "deploy" | "destroy",
159 result: CommandResult,
160) => {
161 if (result.exitCode !== 0) {
162 throw new Error(
163 `${example}: alchemy ${name} failed (exit ${result.exitCode ?? "signal"})\n${result.output}`,
164 );
165 }
166};
167
168const assertOutput = (
169 name: "dev" | "deploy" | "destroy",
170 result: CommandResult,
171 expected: readonly RegExp[],
172) => {
173 for (const pattern of expected) {
174 if (!pattern.test(result.output)) {
175 throw new Error(
176 `${example}: alchemy ${name} output did not match ${pattern}\n${result.output}`,
177 );
178 }
179 }
180};
181
182let primaryFailure: unknown;
183try {
184 const dev = await runDev();
185 assertSuccess("dev", dev);
186 assertOutput("dev", dev, [new RegExp(`Dev · ${stage}`), DONE, /https?:\/\//]);
187
188 if (!devOnly) {
189 const deployed = await run("deploy");
190 assertSuccess("deploy", deployed);
191 assertOutput("deploy", deployed, [
192 new RegExp(`Deploy · ${stage}`),
193 DONE,
194 /https?:\/\//,
195 ]);
196 }
197} catch (error) {
198 primaryFailure = error;
199} finally {
200 try {
201 const destroyed = await run("destroy");
202 if (destroyed.exitCode !== 0 && primaryFailure === undefined) {
203 primaryFailure = new Error(
204 `${example}: alchemy destroy failed (exit ${destroyed.exitCode ?? "signal"})\n${destroyed.output}`,
205 );
206 } else if (destroyed.exitCode !== 0) {
207 console.error(
208 `${example}: cleanup destroy also failed\n${destroyed.output}`,
209 );
210 } else if (primaryFailure === undefined) {
211 assertOutput("destroy", destroyed, [
212 new RegExp(`Destroy · ${stage}`),
213 DONE,
214 ]);
215 }
216 } catch (error) {
217 if (primaryFailure === undefined) primaryFailure = error;
218 else console.error(`${example}: cleanup destroy failed`, error);
219 } finally {
220 if (alchemyHome !== undefined) {
221 fs.rmSync(alchemyHome, { recursive: true, force: true });
222 }
223 }
224}
225
226if (primaryFailure !== undefined) throw primaryFailure;