1/**
2 * True `alchemy dev` end-to-end for the microvm shell (mirroring
3 * examples/aws-ecs/test/dev.test.ts): spawns the REAL CLI and drives the
4 * whole cross-cloud stack locally — no cloud credentials, no cloud
5 * resources:
6 *
7 * - Worker + ShellSession Durable Object → local workerd
8 * - IAM User/AccessKey/Role + STS chain → floci
9 * - ShellMicrovm image build + MicroVMs → floci (real Docker containers)
10 *
11 * The test opens ONE session WebSocket and runs several commands over it,
12 * asserting the streamed output renders like a real shell (no exit-status
13 * chatter, no blank-line padding) and that all commands hit the SAME
14 * MicroVM (state persists across commands — no re-boot per command).
15 */
16import { afterAll, expect, test } from "bun:test";
17import { spawn, spawnSync } from "node:child_process";
18import * as path from "node:path";
19
20const root = path.resolve(import.meta.dirname, "..");
21// Spawn the CLI entry directly (not through `bun run` / the cli.js
22// launcher) so signals hit the actual CLI process, whose scope teardown
23// kills the exec child and the provider sidecars.
24const alchemyBin = path.join(
25 root,
26 "node_modules",
27 "alchemy",
28 "bin",
29 "alchemy.js",
30);
31// Isolated stage so this suite never fights a developer's own `alchemy dev`
32// session (default stage) over state rows.
33const STAGE = "dev-cli-test";
34
35// The whole suite needs docker (floci and the MicroVM containers).
36const dockerAvailable =
37 spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
38
39let proc: ReturnType<typeof spawn> | undefined;
40let ws: WebSocket | undefined;
41let output = "";
42
43const pump = (stream: NodeJS.ReadableStream) => {
44 stream.on("data", (chunk: Buffer) => {
45 const text = chunk.toString();
46 output += text;
47 if (process.env.DEBUG) process.stderr.write(text);
48 });
49};
50
51/** Bounded poll for a (possibly async) producer to yield a value. */
52const pollUntil = async <T>(
53 what: string,
54 f: () => T | undefined | Promise<T | undefined>,
55 { tries = 30, delayMs = 1000 }: { tries?: number; delayMs?: number } = {},
56): Promise<T> => {
57 for (let i = 0; i < tries; i++) {
58 const value = await f();
59 if (value !== undefined) return value;
60 await Bun.sleep(delayMs);
61 }
62 throw new Error(
63 `Timed out waiting for ${what}.\n--- alchemy dev output (tail) ---\n${output.slice(-4000)}`,
64 );
65};
66
67/** Extract a stack-output URL the CLI prints on stdout. */
68const outputUrl = (key: string) =>
69 output.match(new RegExp(`\\b${key}:\\s*['"]?(http[^\\s'",]+)`))?.[1];
70
71/**
72 * Send a command over the session socket and collect streamed output until
73 * `expected` shows up — the shell renders like a real terminal (no exit
74 * trailer to key on).
75 */
76const run = (socket: WebSocket, command: string, expected: string) =>
77 new Promise<string>((resolve, reject) => {
78 let out = "";
79 const onMessage = (e: MessageEvent) => {
80 out += typeof e.data === "string" ? e.data : "";
81 if (out.includes(expected)) {
82 socket.removeEventListener("message", onMessage);
83 clearTimeout(timer);
84 resolve(out);
85 }
86 };
87 // The first command waits through the session's MicroVM boot.
88 const timer = setTimeout(() => {
89 socket.removeEventListener("message", onMessage);
90 reject(new Error(`timeout for '${command}' (got: ${JSON.stringify(out)})`));
91 }, 120_000);
92 socket.addEventListener("message", onMessage);
93 socket.send(command);
94 });
95
96afterAll(async () => {
97 ws?.close();
98 if (proc?.pid) {
99 // Ctrl-C semantics: signal the whole PROCESS GROUP (the CLI, its
100 // `--watch` exec child, and the provider sidecars).
101 const killGroup = (signal: NodeJS.Signals) => {
102 try {
103 process.kill(-proc!.pid!, signal);
104 } catch {
105 // group already gone
106 }
107 };
108 const exited = new Promise((resolve) => proc!.once("exit", resolve));
109 killGroup("SIGINT");
110 await Promise.race([exited, Bun.sleep(15_000)]);
111 if (proc.exitCode === null && proc.signalCode === null) {
112 killGroup("SIGKILL");
113 await Promise.race([exited, Bun.sleep(5_000)]);
114 }
115 }
116 if (!process.env.NO_DESTROY && dockerAvailable) {
117 spawnSync("bun", [alchemyBin, "destroy", "--stage", STAGE, "--yes"], {
118 cwd: root,
119 stdio: "inherit",
120 timeout: 300_000,
121 });
122 // Session MicroVMs are provisioned at RUNTIME (per WebSocket), not as
123 // stack resources, so `destroy` does not reap them — remove the
124 // emulator's VM containers this run booted.
125 const ids = spawnSync(
126 "docker",
127 ["ps", "-q", "--filter", "name=floci-microvm-"],
128 { encoding: "utf8" },
129 )
130 .stdout.trim()
131 .split("\n")
132 .filter(Boolean);
133 if (ids.length > 0) {
134 spawnSync("docker", ["rm", "-f", ...ids], { stdio: "ignore" });
135 }
136 }
137}, 400_000);
138
139test.skipIf(!dockerAvailable)(
140 "alchemy dev serves a browser shell backed by a local MicroVM",
141 async () => {
142 proc = spawn("bun", [alchemyBin, "dev", "--stage", STAGE], {
143 cwd: root,
144 // Own process group, so teardown can deliver Ctrl-C to the whole
145 // tree the way a terminal would.
146 detached: true,
147 stdio: ["ignore", "pipe", "pipe"],
148 });
149 pump(proc.stdout!);
150 pump(proc.stderr!);
151
152 // First dev deploy builds the ShellMicrovm image before printing
153 // stack outputs.
154 const url = await pollUntil("url in stack outputs", () => outputUrl("url"), {
155 tries: 600,
156 delayMs: 1000,
157 });
158
159 // Dev identity: the Worker is local workerd; no real cloud.
160 expect(url).toContain("localhost");
161 expect(output).not.toContain("apply failed");
162
163 // The Worker serves the terminal SPA.
164 const html = await fetch(url).then((r) => r.text());
165 expect(html).toContain("microvm@shell");
166
167 // One session socket: the Worker provisions the session's MicroVM
168 // (assume-role control plane against floci) and hands it to the DO.
169 const wsUrl = new URL(url);
170 wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
171 wsUrl.pathname = `/session/dev-test-${Math.random().toString(36).slice(2, 8)}/ws`;
172 const socket = new WebSocket(wsUrl);
173 ws = socket;
174 await new Promise<void>((resolve, reject) => {
175 socket.addEventListener("open", () => resolve());
176 socket.addEventListener("error", () => reject(new Error("socket error")));
177 });
178
179 // Command round-trip, streamed back through DO → Worker → browser.
180 const marker = "hello-from-microvm";
181 const first = await run(socket, `echo ${marker}`, marker);
182 expect(first).toContain(`${marker}\n`);
183
184 // Real-shell rendering: no exit-status chatter, no blank-line padding.
185 expect(first).not.toContain("[exit");
186 expect(first).not.toMatch(/\n\n/);
187
188 // Two more commands on the SAME socket → same cached VM: a file written
189 // by one command is read back by the next (state persists, no re-boot).
190 await run(socket, "echo persisted-$$ > /tmp/marker.txt && echo written", "written");
191 const readBack = await run(socket, "cat /tmp/marker.txt", "persisted-");
192 expect(readBack).toMatch(/persisted-\d+\n/);
193 expect(readBack).not.toMatch(/\n\n/);
194 },
195 600_000,
196);