1/**
2 * True `alchemy dev` end-to-end for the orders app: spawns the REAL CLI
3 * (mirroring examples/aws-dev/test/dev.test.ts) and drives the WHOLE stack
4 * against the floci emulator — no cloud credentials, no cloud resources:
5 *
6 * - VPC / subnets / routing / security groups → floci EC2
7 * - shared ALB + listener + path rules → floci ELBv2 (the ALB's
8 * DNS resolves to 127.0.0.1 and is host-routed on the gateway port)
9 * - OrdersTable → floci DynamoDB
10 * - Api (bundled main:) + Web (registry image) → real containers run by
11 * floci's ECS engine, registered behind the emulated ALB
12 * - SeedTask (one-shot, launched via the RunTask binding from
13 * POST /api/seed) + HeartbeatSchedule → floci ECS + Scheduler
14 * - HOT RELOAD → editing src/Api.ts swaps the running Api container's
15 * image without a redeploy
16 */
17import { afterAll, expect, test } from "bun:test";
18import { DevCli, fetchOk } from "alchemy-test/DevCli";
19import { spawnSync } from "node:child_process";
20import * as fs from "node:fs";
21import * as path from "node:path";
22
23const root = path.resolve(import.meta.dirname, "..");
24const STAGE = "dev-cli-test";
25const cli = new DevCli({ root, stage: STAGE });
26
27// Hot-reload surface: the Api service source. The test rewrites it in
28// place with the CLI running, then restores it.
29const apiPath = path.join(root, "src", "Api.ts");
30const apiSource = fs.readFileSync(apiPath, "utf8");
31const MARKER = 'service: "orders-api"';
32const MARKER_V2 = 'service: "orders-api-v2"';
33
34// The whole suite needs docker (floci and the service containers).
35const dockerAvailable =
36 spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
37
38afterAll(async () => {
39 // Always leave the repo tree clean, even on a mid-reload failure.
40 fs.writeFileSync(apiPath, apiSource);
41
42 await cli.stop();
43 if (!process.env.NO_DESTROY && dockerAvailable) {
44 cli.destroy({ timeout: 300_000 });
45 }
46}, 400_000);
47
48test.skipIf(!dockerAvailable)(
49 "alchemy dev runs the orders app fully locally with hot reload",
50 async () => {
51 cli.start();
52
53 // The first dev deploy builds the Api/SeedTask/Report images and pulls
54 // the Web image before printing stack outputs.
55 const url = await cli.pollUntil(
56 "url in stack outputs",
57 () => cli.outputUrl("url"),
58 {
59 tries: 600,
60 delayMs: 1000,
61 },
62 );
63 const apiUrl = await cli.pollUntil("apiUrl in stack outputs", () =>
64 cli.outputUrl("apiUrl"),
65 );
66 const seedUrl = await cli.pollUntil("seedUrl in stack outputs", () =>
67 cli.outputUrl("seedUrl"),
68 );
69
70 // Dev identity: the emulated ALB's DNS resolves to 127.0.0.1 — no real
71 // AWS. The port is whatever the emulator gateway bound; only the URLs
72 // captured from the CLI's stdout are authoritative.
73 expect(url).not.toContain("amazonaws.com");
74 expect(cli.output).not.toContain("apply failed");
75
76 // Api service behind the shared ALB's `/api/*` rule.
77 const orders = (await (await fetchOk(apiUrl)).json()) as {
78 count: number;
79 orders: unknown[];
80 };
81 expect(orders.count).toBeNumber();
82
83 // Web service behind the catch-all `/*` rule (external nginx image).
84 const home = await (await fetchOk(url)).text();
85 expect(home).toContain("Server");
86
87 // POST /api/seed launches the one-shot SeedTask on the local cluster
88 // via the RunTask binding; it seeds three orders into the local table.
89 await fetchOk(seedUrl, { method: "POST" });
90 const seeded = await cli.pollUntil(
91 "seeded orders to appear",
92 async () => {
93 const res = await fetch(apiUrl);
94 if (!res.ok) return undefined;
95 const body = (await res.json()) as { count: number };
96 return body.count >= 3 ? body.count : undefined;
97 },
98 { tries: 120, delayMs: 1000 },
99 );
100 expect(seeded).toBeGreaterThanOrEqual(3);
101
102 // ── HOT RELOAD: rewrite src/Api.ts with the CLI still running — the
103 // dev watcher rebuilds the image and rolls the service's container ──
104 fs.writeFileSync(apiPath, apiSource.replace(MARKER, MARKER_V2));
105 const fallbackUrl = new URL(apiUrl);
106 fallbackUrl.pathname = "/api/ping"; // unmatched /api/* → fallback JSON
107 await cli.pollUntil(
108 "hot-swapped api (v2 marker)",
109 async () => {
110 try {
111 const res = await fetch(fallbackUrl);
112 if (!res.ok) return undefined;
113 const body = (await res.json()) as { service?: string };
114 return body.service === "orders-api-v2" ? true : undefined;
115 } catch {
116 return undefined; // mid-roll
117 }
118 },
119 { tries: 240, delayMs: 1000 },
120 );
121
122 // Restore — the swap back is itself a second hot reload and leaves
123 // the checked-in tree clean. The seeded data survives the roll.
124 fs.writeFileSync(apiPath, apiSource);
125 await cli.pollUntil(
126 "restored api (v1 marker)",
127 async () => {
128 try {
129 const res = await fetch(fallbackUrl);
130 if (!res.ok) return undefined;
131 const body = (await res.json()) as { service?: string };
132 return body.service === "orders-api" ? true : undefined;
133 } catch {
134 return undefined; // mid-roll
135 }
136 },
137 { tries: 240, delayMs: 1000 },
138 );
139 const afterReload = (await (await fetchOk(apiUrl)).json()) as {
140 count: number;
141 };
142 expect(afterReload.count).toBeGreaterThanOrEqual(3);
143 },
144 { timeout: 1_200_000 },
145);