1/**
2 * True `alchemy dev` end-to-end for AWS: spawns the REAL CLI and drives
3 * every local binding over HTTP against the floci emulator.
4 *
5 * This is a different code path from the `Test.make({ dev: true })` harness
6 * suites under packages/alchemy/test/AWS — 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 (the #1007 bug class, mirrored from
11 * examples/cloudflare-dev/test/dev.test.ts).
12 *
13 * Coverage, all against floci (no cloud credentials, no cloud resources):
14 * - Function URL → `/` serves through the emulator's URL proxy
15 * - env vars → `/` echoes MY_VARIABLE via effect/Config
16 * - S3 binding → `/s3` PutObject/GetObject roundtrip
17 * - DynamoDB binding → `/dynamo` PutItem/GetItem roundtrip
18 * - SQS + consumer → `/queue/send` produces; floci's ESM poller
19 * delivers to `consumeQueueMessages`, which records
20 * into the table read back by `/queue/messages`
21 * - SNS + consumer → `/topic/send` publishes; the lambda-protocol
22 * Subscription + invoke Permission (glue that must
23 * be created ON the emulator, not real AWS) deliver
24 * to `consumeTopicNotifications`, read back by
25 * `/topic/messages`
26 * - DynamoDB Streams → `/items` writes; the table stream + its
27 * EventSourceMapping deliver the change record to
28 * `consumeTableChanges`, read back by `/changes`
29 * - Action data plane → the deploy-time SeedObject Action put/gets an
30 * object through the S3 bindings inside the CLI
31 * process; the stack outputs carry the dummy
32 * account id (000000000000) proving the calls hit
33 * the emulator, not real AWS
34 * - HOT RELOAD → rewriting src/marker.ts (no redeploy) must serve
35 * the new marker; restoring it must swap back
36 */
37import { afterAll, expect, test } from "bun:test";
38import { DevCli, fetchOk } from "alchemy-test/DevCli";
39import { spawnSync } from "node:child_process";
40import * as fs from "node:fs";
41import * as path from "node:path";
42
43const root = path.resolve(import.meta.dirname, "..");
44const STAGE = "dev-cli-test";
45// Credential-free dev, deterministically: an isolated (unconfigured)
46// alchemy profile plus stripped AWS env credentials keep this suite
47// hermetic — it must pass on a machine with zero AWS configuration, and
48// must never touch a developer's real profile. (Action data-plane calls
49// are routed to the emulator per bound resource by the engine regardless
50// of ambient credentials — see Binding.Service's data-plane routing.)
51const cli = new DevCli({
52 root,
53 stage: STAGE,
54 env: {
55 ALCHEMY_PROFILE: "aws-dev-cli-test",
56 AWS_ACCESS_KEY_ID: undefined,
57 AWS_SECRET_ACCESS_KEY: undefined,
58 AWS_SESSION_TOKEN: undefined,
59 AWS_PROFILE: undefined,
60 },
61});
62
63const markerPath = path.join(root, "src", "marker.ts");
64const markerSource = fs.readFileSync(markerPath, "utf8");
65
66// The whole suite needs docker (floci runs as a container).
67const dockerAvailable =
68 spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
69afterAll(async () => {
70 // Always leave the repo tree clean, even on a mid-reload failure.
71 fs.writeFileSync(markerPath, markerSource);
72
73 await cli.stop();
74 if (!process.env.NO_DESTROY && dockerAvailable) {
75 cli.destroy();
76 }
77}, 180_000);
78
79test.skipIf(!dockerAvailable)(
80 "alchemy dev serves every local AWS binding end-to-end with hot reload",
81 async () => {
82 cli.start();
83
84 // The first dev deploy may pull the floci image, provision the local
85 // data plane, and package the function before printing stack outputs.
86 const api = await cli.pollUntil(
87 "api url in stack outputs",
88 () => cli.outputUrl("api"),
89 { tries: 300, delayMs: 1000 },
90 );
91
92 // Dev identity: the function URL is served locally, not by AWS
93 // (e.g. http://<id>.lambda-url.us-east-1.localhost:<port>/). The port
94 // is whatever the emulator bound — never hard-code it, only the URL
95 // captured from the CLI's stdout is authoritative.
96 expect(new URL(api).hostname).toEndWith("localhost");
97 expect(api).not.toContain("amazonaws.com");
98
99 // Action data plane: the deploy-time SeedObject Action ran inside the
100 // CLI's exec process and put/get an object through the S3 bindings.
101 // The dummy account id proves the calls hit the emulator, not real
102 // AWS; the roundtripped body proves the put actually landed.
103 const seedAccount = await cli.pollUntil(
104 "seedAccount in stack outputs",
105 () => cli.outputValue("seedAccount"),
106 );
107 expect(seedAccount).toBe("000000000000");
108 const seedText = await cli.pollUntil("seedText in stack outputs", () =>
109 cli.outputValue("seedText"),
110 );
111 expect(seedText).toBe("seed-object-body-v1");
112
113 // Marker + env: the function reads MY_VARIABLE through effect/Config.
114 const home = (await (await fetchOk(api)).json()) as {
115 marker: string;
116 variable: string;
117 };
118 expect(home.marker).toBe("aws-dev-marker-v1");
119 expect(home.variable).toBe("my-variable-abc123");
120
121 // S3 binding: put/get roundtrip against the emulator.
122 const s3 = (await (await fetchOk(new URL("/s3", api))).json()) as {
123 text: string;
124 };
125 expect(s3.text).toBe("hello from s3");
126
127 // DynamoDB binding: put/get roundtrip against the emulator.
128 const dynamo = (await (await fetchOk(new URL("/dynamo", api))).json()) as {
129 text: string | null;
130 };
131 expect(dynamo.text).toBe("hello from dynamo");
132
133 // SQS: produce over the binding; floci's event-source-mapping poller
134 // delivers to the consumer, which records into the table.
135 const message = { id: crypto.randomUUID(), text: "hello from alchemy dev" };
136 await fetchOk(new URL("/queue/send", api), {
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(
145 new URL(`/queue/messages?id=${message.id}`, api),
146 );
147 if (!res.ok) return undefined;
148 const { body } = (await res.json()) as { body: string | null };
149 return body ?? undefined;
150 },
151 { tries: 60, delayMs: 500 },
152 );
153 expect(JSON.parse(received)).toEqual(message);
154
155 // SNS: publish over the binding. Delivery runs through the
156 // lambda-protocol Subscription and the invoke Permission — the glue
157 // resources that regressed by being created against real AWS with
158 // emulator ARNs (InvalidParameterException: TopicArn / Function not
159 // found). Consumption proves both were created on the emulator.
160 const notification = { id: crypto.randomUUID(), text: "hello from sns" };
161 await fetchOk(new URL("/topic/send", api), {
162 method: "POST",
163 headers: { "content-type": "application/json" },
164 body: JSON.stringify(notification),
165 });
166 const delivered = await cli.pollUntil(
167 "topic notification to be consumed",
168 async () => {
169 const res = await fetch(
170 new URL(`/topic/messages?id=${notification.id}`, api),
171 );
172 if (!res.ok) return undefined;
173 const { body } = (await res.json()) as { body: string | null };
174 return body ?? undefined;
175 },
176 { tries: 60, delayMs: 500 },
177 );
178 expect(JSON.parse(delivered)).toEqual(notification);
179
180 // DynamoDB Streams: write a plain item; the table stream's
181 // EventSourceMapping delivers the change record to
182 // `consumeTableChanges`, which records it under `change:<id>`.
183 const itemId = crypto.randomUUID();
184 await fetchOk(new URL("/items", api), {
185 method: "POST",
186 headers: { "content-type": "application/json" },
187 body: JSON.stringify({ id: itemId }),
188 });
189 const change = await cli.pollUntil(
190 "table change record to be consumed",
191 async () => {
192 const res = await fetch(new URL(`/changes?id=${itemId}`, api));
193 if (!res.ok) return undefined;
194 const { body } = (await res.json()) as { body: string | null };
195 return body ?? undefined;
196 },
197 { tries: 60, delayMs: 500 },
198 );
199 expect(change).toBe("INSERT");
200
201 // ── HOT RELOAD: rewrite src/marker.ts with the CLI still running. The
202 // `--watch` exec child re-runs, the dev provider hot-swaps the function
203 // code in floci, and the SAME url serves the new marker — no deploy ──
204 fs.writeFileSync(
205 markerPath,
206 markerSource.replace("aws-dev-marker-v1", "aws-dev-marker-v2"),
207 );
208 await cli.pollUntil(
209 "hot-swapped marker v2",
210 async () => {
211 try {
212 const res = await fetch(api);
213 if (!res.ok) return undefined;
214 const { marker } = (await res.json()) as { marker: string };
215 return marker === "aws-dev-marker-v2" ? marker : undefined;
216 } catch {
217 return undefined; // mid-swap
218 }
219 },
220 { tries: 240, delayMs: 500 },
221 );
222
223 // Bindings survived the reload: the table still holds the consumed
224 // message and the S3 roundtrip still works.
225 const afterReload = (await (
226 await fetchOk(new URL(`/queue/messages?id=${message.id}`, api))
227 ).json()) as { body: string | null };
228 expect(afterReload.body).toBe(JSON.stringify(message));
229 const s3After = (await (await fetchOk(new URL("/s3", api))).json()) as {
230 text: string;
231 };
232 expect(s3After.text).toBe("hello from s3");
233
234 // Restore the marker — the swap back is itself a second hot reload,
235 // and leaves the checked-in tree clean.
236 fs.writeFileSync(markerPath, markerSource);
237 await cli.pollUntil(
238 "restored marker v1",
239 async () => {
240 try {
241 const res = await fetch(api);
242 if (!res.ok) return undefined;
243 const { marker } = (await res.json()) as { marker: string };
244 return marker === "aws-dev-marker-v1" ? marker : undefined;
245 } catch {
246 return undefined; // mid-swap
247 }
248 },
249 { tries: 240, delayMs: 500 },
250 );
251 },
252 { timeout: 600_000 },
253);