1import { preferLocalFlociImage } from "./floci-image.ts";
2
3// `bun test:examples --profile testing` — the example suites read the
4// profile from `ALCHEMY_PROFILE` (`Test.make({ profile: process.env.ALCHEMY_PROFILE })`),
5// so translate the flag into the env every spawned `bun test` inherits.
6// Without this the flag was silently ignored and every suite ran against
7// the `default` profile, which only works when the shell already exports
8// `ALCHEMY_PROFILE`.
9{
10 const argv = process.argv.slice(2);
11 const index = argv.findIndex(
12 (arg) => arg === "--profile" || arg.startsWith("--profile="),
13 );
14 if (index !== -1) {
15 const arg = argv[index]!;
16 const profile = arg.includes("=")
17 ? arg.slice("--profile=".length)
18 : argv[index + 1];
19 if (profile === undefined || profile.startsWith("-")) {
20 console.error("--profile requires a value, e.g. --profile testing");
21 process.exit(2);
22 }
23 process.env.ALCHEMY_PROFILE = profile;
24 }
25}
26
27// The AWS examples run against the floci emulator: use the locally built
28// image when there is one so an emulator fix is testable before its
29// release image lands on GHCR.
30preferLocalFlociImage("test:examples");
31
32const examples = [
33 "./examples/cloudflare-dev",
34 "./examples/cloudflare-worker",
35 "./examples/cloudflare-worker-async",
36 "./examples/cloudflare-website-tanstack-start",
37 "./examples/cloudflare-tanstack-start-solid",
38 "./examples/cloudflare-neon-drizzle",
39 "./examples/cloudflare-vue",
40 "./examples/cloudflare-website-solidstart",
41 "./examples/cloudflare-foldkit",
42 "./examples/cloudflare-foldkit-ssr",
43 "./examples/cloudflare-octane",
44 "./examples/cloudflare-website-astro",
45 "./examples/cloudflare-website-foldkit",
46 "./examples/cloudflare-website-nextjs",
47 "./examples/cloudflare-website-nuxt",
48 "./examples/cloudflare-website-react-router",
49 "./examples/cloudflare-website-sveltekit",
50 "./examples/cloudflare-website-service-bindings",
51 "./examples/cloudflare-website-vite",
52 "./examples/cloudflare-website-vinext",
53 "./examples/cloudflare-website-waku",
54 "./examples/cloudflare-website-vocs",
55 "./examples/aws-dev",
56 // "./examples/aws-ecs",
57 "./examples/aws-lambda",
58 // Cloudfront is too slow
59 // "./examples/aws-website-astro",
60 // "./examples/aws-website-foldkit",
61 // "./examples/aws-website-nextjs",
62 // "./examples/aws-website-nuxt",
63 // "./examples/aws-website-react-router",
64 // "./examples/aws-website-solidstart",
65 // "./examples/aws-website-sveltekit",
66 // "./examples/aws-website-tanstack-start",
67 // "./examples/aws-website-vite",
68 // "./examples/aws-website-vinext",
69 // "./examples/aws-website-waku",
70 "./examples/fly-app",
71 "./examples/fly-service",
72 "./examples/fly-website-vite",
73 "./examples/hetzner-website-vite",
74 // Railway examples are gated out of test:examples for now: repeated
75 // Railway platform outages (deployment queue backlogs, e.g. the
76 // 2026-08-28 "Deployments slow to start" incident) and general
77 // flakiness working with Railway's API make these suites too
78 // unreliable for CI. Run them from the example directory with
79 // `bun test` when Railway is healthy.
80 // "./examples/railway-project",
81 // "./examples/railway-service",
82 // "./examples/railway-website-vite",
83 // Card-app Website composites (same apps as the AWS/Cloudflare
84 // *-website-* examples). Commented so CI does not provision extra
85 // sites; run them from the example directory with `bun test`.
86 // "./examples/fly-website-astro",
87 // "./examples/fly-website-foldkit",
88 // "./examples/fly-website-nextjs",
89 // "./examples/fly-website-nuxt",
90 // "./examples/fly-website-react-router",
91 // "./examples/fly-website-solidstart",
92 // "./examples/fly-website-sveltekit",
93 // "./examples/fly-website-tanstack-start",
94 // "./examples/fly-website-vinext",
95 // "./examples/fly-website-waku",
96 // "./examples/fly-website-vocs",
97 // "./examples/hetzner-website-astro",
98 // "./examples/hetzner-website-foldkit",
99 // "./examples/hetzner-website-nextjs",
100 // "./examples/hetzner-website-nuxt",
101 // "./examples/hetzner-website-react-router",
102 // "./examples/hetzner-website-solidstart",
103 // "./examples/hetzner-website-sveltekit",
104 // "./examples/hetzner-website-tanstack-start",
105 // "./examples/hetzner-website-vinext",
106 // "./examples/hetzner-website-waku",
107 // "./examples/hetzner-website-vocs",
108 // "./examples/railway-website-astro",
109 // "./examples/railway-website-foldkit",
110 // "./examples/railway-website-nextjs",
111 // "./examples/railway-website-nuxt",
112 // "./examples/railway-website-react-router",
113 // "./examples/railway-website-solidstart",
114 // "./examples/railway-website-sveltekit",
115 // "./examples/railway-website-tanstack-start",
116 // "./examples/railway-website-vinext",
117 // "./examples/railway-website-waku",
118 // "./examples/railway-website-vocs",
119 "./examples/fly-sprite",
120 "./examples/fly-redis",
121 "./examples/fly-bucket",
122 "./examples/fly-postgres",
123] as const;
124
125// The AWS examples share one floci emulator container (`alchemy-floci`):
126// two `alchemy dev` sessions ensuring it at the same time race to recreate
127// it on an image bump and hot-swap each other's Lambda code, so they run
128// one at a time. Everything else stays concurrent.
129const serialGroup = (example: string): string | undefined =>
130 example.startsWith("./examples/aws-") ? "floci" : undefined;
131
132type CommandResult = {
133 label: string;
134 command: readonly string[];
135 exitCode: number | null;
136 stdout: string;
137 stderr: string;
138};
139
140type Task = {
141 label: string;
142 command: readonly string[];
143 cwd?: string;
144 /** Tasks sharing a key run one at a time, in list order. */
145 serial?: string;
146};
147
148type TaskState = Task & {
149 status: "pending" | "running" | "ok" | "failed";
150 startedAt?: number;
151 endedAt?: number;
152 exitCode?: number | null;
153};
154
155const readStream = async (
156 stream: ReadableStream<Uint8Array>,
157): Promise<string> => new Response(stream).text();
158
159const elapsedSeconds = (state: TaskState): string => {
160 if (state.startedAt === undefined) {
161 return "0s";
162 }
163 const endedAt = state.endedAt ?? performance.now();
164 return `${Math.round((endedAt - state.startedAt) / 1000)}s`;
165};
166
167const makeStatusRenderer = (title: string, states: readonly TaskState[]) => {
168 const interactive = process.stdout.isTTY === true;
169 let renderedRows = 0;
170
171 const taskLine = (state: TaskState) => {
172 const icon =
173 state.status === "ok"
174 ? "ok"
175 : state.status === "failed"
176 ? "failed"
177 : state.status;
178 const exit =
179 state.exitCode === undefined || state.exitCode === 0
180 ? ""
181 : ` exit ${state.exitCode ?? "signal"}`;
182 return ` ${icon.padEnd(7)} ${state.label} ${elapsedSeconds(state)}${exit}`;
183 };
184
185 // Physical rows a line occupies once the terminal wraps it.
186 const rowsFor = (line: string) => {
187 const columns = process.stdout.columns || 80;
188 return Math.max(1, Math.ceil(line.length / columns));
189 };
190 const rowsForAll = (output: readonly string[]) =>
191 output.reduce((total, line) => total + rowsFor(line), 0);
192
193 const fullLines = () => [title, ...states.map(taskLine)];
194
195 // The in-place repaint moves the cursor up with `\x1b[NF`, which cannot
196 // climb above the top of the viewport: if a paint is taller than the
197 // terminal (or lines wrap), the write scrolls and every repaint leaks its
198 // topmost rows into scrollback. Keep each paint strictly shorter than the
199 // viewport by collapsing finished tasks into the header once space runs
200 // out (`+ 1` accounts for the cursor parking on the row below the paint).
201 const lines = () => {
202 const rows = process.stdout.rows || 24;
203 const full = fullLines();
204 if (rowsForAll(full) + 1 <= rows) {
205 return full;
206 }
207 const done = states.filter((state) => state.status === "ok").length;
208 const active = states.filter((state) => state.status !== "ok");
209 const header = `${title} (${done}/${states.length} ok)`;
210 const activeLines = active.map(taskLine);
211 let shown = activeLines.length;
212 const fits = (count: number) => {
213 const overflow = count < activeLines.length ? 1 : 0;
214 return (
215 rowsForAll([header, ...activeLines.slice(0, count)]) + overflow + 1 <=
216 rows
217 );
218 };
219 while (shown > 0 && !fits(shown)) {
220 shown--;
221 }
222 return shown === activeLines.length
223 ? [header, ...activeLines]
224 : [
225 header,
226 ...activeLines.slice(0, shown),
227 ` … ${activeLines.length - shown} more`,
228 ];
229 };
230
231 return {
232 failure(result: CommandResult) {
233 if (interactive && renderedRows > 0) {
234 process.stdout.write(`\x1b[${renderedRows}F\x1b[J`);
235 renderedRows = 0;
236 }
237 const output = [
238 `\nFailed: ${result.label} (exit ${result.exitCode ?? "signal"}): ${result.command.join(" ")}`,
239 `--- ${result.label} stdout ---`,
240 result.stdout.trimEnd() || "(empty)",
241 `--- ${result.label} stderr ---`,
242 result.stderr.trimEnd() || "(empty)",
243 "",
244 ].join("\n");
245 // Use the TUI's stream so its next repaint starts below the logs.
246 (interactive ? process.stdout : process.stderr).write(output);
247 this.render();
248 },
249 render() {
250 if (!interactive) {
251 return;
252 }
253 if (renderedRows > 0) {
254 process.stdout.write(`\x1b[${renderedRows}F\x1b[J`);
255 }
256 const output = lines();
257 process.stdout.write(`${output.join("\n")}\n`);
258 renderedRows = rowsForAll(output);
259 },
260 finish() {
261 if (interactive && renderedRows > 0) {
262 process.stdout.write(`\x1b[${renderedRows}F\x1b[J`);
263 renderedRows = 0;
264 }
265 // Always end with the full table (the live view may be compacted).
266 for (const line of fullLines()) {
267 console.log(line);
268 }
269 },
270 };
271};
272
273const run = async (
274 state: TaskState,
275 render: () => void,
276): Promise<CommandResult> => {
277 state.status = "running";
278 state.startedAt = performance.now();
279 render();
280
281 const child = Bun.spawn([...state.command], {
282 cwd: state.cwd,
283 // Snapshot env at spawn time — `Bun.spawn` does not pick up later
284 // `process.env` mutations, and we want `ALCHEMY_PROFILE` (and friends)
285 // from the parent invocation.
286 env: { ...process.env },
287 stdout: "pipe",
288 stderr: "pipe",
289 stdin: "inherit",
290 });
291 const [exitCode, stdout, stderr] = await Promise.all([
292 child.exited,
293 readStream(child.stdout),
294 readStream(child.stderr),
295 ]);
296
297 state.status = exitCode === 0 ? "ok" : "failed";
298 state.exitCode = exitCode;
299 state.endedAt = performance.now();
300 render();
301
302 return {
303 label: state.label,
304 command: state.command,
305 exitCode,
306 stdout,
307 stderr,
308 };
309};
310
311const runParallel = async (
312 title: string,
313 tasks: readonly Task[],
314 options?: { readonly concurrency?: number },
315): Promise<readonly CommandResult[]> => {
316 const states = tasks.map((task): TaskState => ({
317 ...task,
318 status: "pending",
319 }));
320 const renderer = makeStatusRenderer(title, states);
321 renderer.render();
322 const interval = setInterval(() => renderer.render(), 1000);
323 const chains = new Map<string, Promise<unknown>>();
324
325 // At most `concurrency` tasks run at once; the rest wait for a slot.
326 const limit = options?.concurrency ?? Infinity;
327 let active = 0;
328 const waiting: Array<() => void> = [];
329 const acquire = () =>
330 new Promise<void>((resolve) => {
331 if (active < limit) {
332 active++;
333 resolve();
334 } else {
335 waiting.push(() => {
336 active++;
337 resolve();
338 });
339 }
340 });
341 const release = () => {
342 active--;
343 waiting.shift()?.();
344 };
345
346 try {
347 return await Promise.all(
348 states.map((state) => {
349 const start = async () => {
350 await acquire();
351 try {
352 const result = await run(state, () => renderer.render());
353 if (result.exitCode !== 0) renderer.failure(result);
354 return result;
355 } finally {
356 release();
357 }
358 };
359 if (state.serial === undefined) return start();
360 const next = (chains.get(state.serial) ?? Promise.resolve()).then(
361 start,
362 start,
363 );
364 chains.set(state.serial, next);
365 return next;
366 }),
367 );
368 } finally {
369 clearInterval(interval);
370 renderer.finish();
371 }
372};
373
374const testResults = await runParallel(
375 "Example tests",
376 examples.map((example) => ({
377 label: example,
378 // Run `bun test` IN the example directory. Concurrent
379 // `bun run --filter <workspace> test` all contend on bun's workspace
380 // graph (each sits in `openat` walking the monorepo) and none of them
381 // ever spawn the actual test process.
382 command: ["bun", "test"] as const,
383 cwd: example,
384 serial: serialGroup(example),
385 })),
386);
387const failedTests = testResults.filter((result) => result.exitCode !== 0);
388
389if (failedTests.length > 0) {
390 console.error("\nFailed example tests:");
391 for (const failure of failedTests) {
392 const exit = failure.exitCode === null ? "signal" : failure.exitCode;
393 console.error(
394 `- ${failure.label} (exit ${exit}): ${failure.command.join(" ")}`,
395 );
396 }
397
398 process.exit(1);
399}
400
401const cliResults = await runParallel(
402 "Example CLI lifecycle",
403 examples.map((example) => ({
404 label: `${example} CLI lifecycle`,
405 command: ["bun", "scripts/test-example-cli.ts", example],
406 serial: serialGroup(example),
407 })),
408 // Each `alchemy dev` session is a CLI, an exec child, sidecars, workerd
409 // and a bundler watch loop; 30 at once starve each other past the
410 // 4-minute readiness timeout.
411 { concurrency: 8 },
412);
413const failedCliTests = cliResults.filter((result) => result.exitCode !== 0);
414
415if (failedCliTests.length > 0) {
416 console.error("\nFailed example CLI lifecycle tests:");
417 for (const failure of failedCliTests) {
418 const exit = failure.exitCode === null ? "signal" : failure.exitCode;
419 console.error(`- ${failure.label} (exit ${exit})`);
420 }
421 process.exit(1);
422}
423
424const [formatFailure] = await runParallel("Format", [
425 { label: "format", command: ["bun", "run", "format"] },
426]);
427if (formatFailure.exitCode !== 0) {
428 console.error("\nFormat failed:");
429 if (formatFailure.stdout.length > 0) {
430 console.error(formatFailure.stdout.trimEnd());
431 }
432 if (formatFailure.stderr.length > 0) {
433 console.error(formatFailure.stderr.trimEnd());
434 }
435 process.exit(formatFailure.exitCode ?? 1);
436}