1/**
2 * True `alchemy dev` end-to-end: spawns the REAL CLI and drives every local
3 * binding over HTTP.
4 *
5 * This is a different code path from `integ.test.ts`, which deploys through
6 * the test harness (`Test.make({ dev: true })`). The harness mirrors the RPC
7 * sidecar topology, but only this test covers the CLI itself: arg parsing,
8 * the `bin/exec` child under `--watch`, the `ALCHEMY_RPC_SPAWNER_URL`
9 * handshake, and provider sidecars whose lifetime is tied to the CLI
10 * process. #1007 (local D1 migrations crashing under `alchemy dev` but not
11 * under the harness) is the class of bug this suite exists to catch.
12 *
13 * Coverage per binding, all against the local simulators (no cloud
14 * resources beyond the state store):
15 * - assets → `/` serves the static index
16 * - vars + secrets → `/env` echoes MY_VARIABLE / MY_SECRET
17 * - self_url → `/env`.PUBLIC_URL and EffectWorker `/url`
18 * - Durable Object → `/counter` increments across requests
19 * - R2 → `/r2` put/get/list roundtrip
20 * - D1 + migrations → `/d1` inserts into the `greetings` table, which
21 * exists ONLY if `./migrations` applied through the
22 * real dev path
23 * - Queue + Consumer → `/queue/send` produces, the local broker delivers
24 * to the `queue()` handler, `/queue/messages` reads
25 * - wasm modules → `/wasm` on both entrypoint styles
26 * - KV (Effect-style) → EffectWorker `/` lists the namespace
27 * - Workflow → `/workflow/start` + status poll to completion
28 * - Container → `/sandbox` fetches through the local container
29 * - Cache API → `/cache` misses then hits
30 * - Rate limit → `/ratelimit` throttles the third call
31 * - versionMetadata → `/version` returns a stubbed id
32 * - Service binding → `/service` calls EffectWorker worker-to-worker
33 * - Secrets Store → MediaWorker `/secret` echoes the seeded value
34 *
35 * The heavier local simulators (Browser, Images, Stream, email, tail
36 * consumers, cron, secret_key, Analytics Engine) are covered end-to-end by
37 * integ.test.ts — the binding lowering is identical under both topologies,
38 * so this suite pins only the cheap ones on the CLI path.
39 */
40import { afterAll, expect, test } from "bun:test";
41import { DevCli, fetchOk } from "alchemy-test/DevCli";
42import * as path from "node:path";
43import { WORKFLOW_SECRET_VALUE } from "../src/NotifyWorkflow.ts";
44
45const root = path.resolve(import.meta.dirname, "..");
46const STAGE = "dev-cli-test";
47const cli = new DevCli({ root, stage: STAGE });
48
49afterAll(async () => {
50 await cli.stop();
51 if (!process.env.NO_DESTROY) {
52 cli.destroy();
53 }
54}, 180_000);
55
56test(
57 "alchemy dev serves every local binding end-to-end",
58 async () => {
59 // Start from an empty stage. The local D1 provider trusts its state row
60 // for "migrations applied", so a stage left deployed by an interrupted
61 // run (remote state store) combined with a wiped `.alchemy/local` (e.g.
62 // `git clean -fdx`) would skip the migrations and fail `/d1` with
63 // `no such table: greetings` — a stale-environment failure, not the
64 // #1007 regression this route exists to pin.
65 cli.destroy();
66 cli.start();
67
68 // The first dev deploy applies D1 migrations through the sidecar,
69 // prepares the sandbox image, boots workerd, and then prints the stack
70 // outputs.
71 const asyncWorker = await cli.pollUntil(
72 "asyncWorker url in stack outputs",
73 () => cli.outputUrl("asyncWorker"),
74 { tries: 300, delayMs: 1000 },
75 );
76 const effectWorker = await cli.pollUntil(
77 "effectWorker url in stack outputs",
78 () => cli.outputUrl("effectWorker"),
79 { tries: 30, delayMs: 1000 },
80 );
81 const mediaWorker = await cli.pollUntil(
82 "mediaWorker url in stack outputs",
83 () => cli.outputUrl("mediaWorker"),
84 { tries: 30, delayMs: 1000 },
85 );
86
87 // Assets: the fallthrough route serves ./assets/index.html.
88 const index = await (await fetchOk(asyncWorker)).text();
89 expect(index).toContain("<h1>Hello, world!</h1>");
90
91 // Plain-text vars, secrets, and the self_url binding.
92 const env = (await (
93 await fetchOk(new URL("/env", asyncWorker))
94 ).json()) as Record<string, unknown>;
95 expect(env.MY_VARIABLE).toBe("my-variable-abc123");
96 expect(env.MY_SECRET).toBe("my-secret-abc123");
97 expect(env.PUBLIC_URL).toBe(asyncWorker.replace(/\/$/, ""));
98
99 // Durable Object: state persists across requests.
100 const count = (body: string) =>
101 Number(body.match(/^Hello, world! (\d+)$/)?.[1]);
102 const first = await (
103 await fetchOk(new URL("/counter", asyncWorker))
104 ).text();
105 const second = await (
106 await fetchOk(new URL("/counter", asyncWorker))
107 ).text();
108 expect(count(second)).toBe(count(first) + 1);
109
110 // R2: put/get/list against the local simulator.
111 const r2 = (await (await fetchOk(new URL("/r2", asyncWorker))).json()) as {
112 text: string;
113 keys: string[];
114 };
115 expect(r2.text).toBe("hello from r2");
116 expect(r2.keys).toContain("hello.txt");
117
118 // D1: the `greetings` table exists ONLY if ./migrations applied through
119 // the real `alchemy dev` path — the #1007 repro.
120 const d1 = (await (await fetchOk(new URL("/d1", asyncWorker))).json()) as {
121 text: string | null;
122 };
123 expect(d1.text).toBe("hello from d1");
124
125 // wasm modules ship through the bundler for both entrypoint styles.
126 for (const worker of [asyncWorker, effectWorker]) {
127 const wasm = (await (await fetchOk(new URL("/wasm", worker))).json()) as {
128 result: number;
129 };
130 expect(wasm.result).toBe(7);
131 }
132
133 // Queue: produce over the binding; the local broker delivers to the
134 // `queue()` handler, which records into the QueueMessages DO.
135 const message = { text: "hello from alchemy dev", sentAt: Date.now() };
136 await fetchOk(new URL("/queue/send", asyncWorker), {
137 method: "POST",
138 headers: { "content-type": "application/json" },
139 body: JSON.stringify(message),
140 });
141 const received = await cli.pollUntil(
142 "queue message to be consumed",
143 async () => {
144 const res = await fetch(new URL("/queue/messages", asyncWorker));
145 if (!res.ok) return undefined;
146 const messages = (await res.json()) as Array<{
147 body: { sentAt: number };
148 }>;
149 return messages.find((m) => m.body.sentAt === message.sentAt);
150 },
151 { tries: 50, delayMs: 500 },
152 );
153 expect(received).toMatchObject({ body: message });
154
155 // Cache API: per-run key — first miss, second hit.
156 const cacheKey = crypto.randomUUID();
157 const cacheOnce = async () =>
158 (await (
159 await fetchOk(new URL(`/cache?key=${cacheKey}`, asyncWorker))
160 ).json()) as { hit: boolean };
161 expect((await cacheOnce()).hit).toBe(false);
162 expect((await cacheOnce()).hit).toBe(true);
163
164 // Rate limit: 2 per 10s per key — the third call is throttled.
165 const rlKey = crypto.randomUUID();
166 const limitOnce = async () =>
167 (await (
168 await fetchOk(new URL(`/ratelimit?key=${rlKey}`, asyncWorker))
169 ).json()) as { success: boolean };
170 expect((await limitOnce()).success).toBe(true);
171 expect((await limitOnce()).success).toBe(true);
172 expect((await limitOnce()).success).toBe(false);
173
174 // Version metadata: locally stubbed with a random id.
175 const version = (await (
176 await fetchOk(new URL("/version", asyncWorker))
177 ).json()) as { id: string };
178 expect(typeof version.id).toBe("string");
179 expect(version.id.length).toBeGreaterThan(0);
180
181 // Service binding: AsyncWorker calls EffectWorker worker-to-worker. The
182 // dev registry may pick the peer up asynchronously, so allow retries.
183 const service = (await (
184 await fetchOk(new URL("/service", asyncWorker), undefined, {
185 tries: 30,
186 delayMs: 1000,
187 })
188 ).json()) as { url: string };
189 expect(service.url).toBe(effectWorker.replace(/\/$/, ""));
190
191 // Secrets Store: the seeded value round-trips through the binding.
192 const secret = (await (
193 await fetchOk(new URL("/secret", mediaWorker))
194 ).json()) as { value: string };
195 expect(secret.value).toBe("store-secret-abc123");
196
197 // KV via the Effect-style binding: EffectWorker's root route lists the
198 // namespace.
199 const kv = (await (await fetchOk(effectWorker)).json()) as {
200 keys: unknown[];
201 list_complete: boolean;
202 };
203 expect(Array.isArray(kv.keys)).toBe(true);
204 expect(typeof kv.list_complete).toBe("boolean");
205
206 // self_url, Effect-style: `yield* Worker.URL` at init.
207 const self = (await (
208 await fetchOk(new URL("/url", effectWorker))
209 ).json()) as { url: string };
210 expect(self.url).toBe(effectWorker.replace(/\/$/, ""));
211
212 // Workflow: start an instance and poll it to completion — pins the
213 // workflows binding, the KV roundtrip task inside it, and plantime
214 // secret resolution.
215 const roomId = `dev-cli-${Math.random().toString(36).slice(2, 10)}`;
216 const started = (await (
217 await fetchOk(new URL(`/workflow/start/${roomId}`, effectWorker), {
218 method: "POST",
219 })
220 ).json()) as { instanceId: string };
221 const status = await cli.pollUntil(
222 "workflow to settle",
223 async () => {
224 const res = await fetch(
225 new URL(`/workflow/status/${started.instanceId}`, effectWorker),
226 );
227 if (!res.ok) return undefined;
228 const s = (await res.json()) as {
229 status: string;
230 output?: { text?: string; secret?: string };
231 error?: unknown;
232 };
233 return s.status === "complete" || s.status === "errored"
234 ? s
235 : undefined;
236 },
237 { tries: 60, delayMs: 2000 },
238 );
239 expect(status.error).toBeFalsy();
240 expect(status.status).toBe("complete");
241 expect(status.output?.text).toBe("Processed: hello from workflow");
242 expect(status.output?.secret).toBe(WORKFLOW_SECRET_VALUE);
243
244 // Container: the sandbox route proxies into the locally-running
245 // container (docker), env vars included. Generous retry budget — the
246 // container may still be pulling/starting on first request.
247 const sandbox = await (
248 await fetchOk(new URL("/sandbox", effectWorker), undefined, {
249 tries: 120,
250 delayMs: 1000,
251 })
252 ).text();
253 expect(sandbox).toBe(
254 "Hello from Sandbox container! GREETING=hello-from-env",
255 );
256 },
257 { timeout: 600_000 },
258);