1/**
2 * Run the live AWS suites for services that have a Floci local provider
3 * (`flociDual` / `ProviderLayer.dual` in Providers.ts) under
4 * `ALCHEMY_TEST_DEV=1`.
5 *
6 * Dedicated `*.local.test.ts` files are excluded — those already set
7 * `Test.make({ dev: true })` and some use `Alchemy.remote()` for out-of-band
8 * live checks, which this env would redirect onto the emulator.
9 *
10 * Extra alchemy-test args are forwarded (`-t`, `--retry`, paths, …).
11 */
12import { Glob } from "bun";
13import { preferLocalFlociImage } from "./floci-image.ts";
14import { existsSync, readFileSync, statSync } from "node:fs";
15import { join, relative, resolve } from "node:path";
16
17const repoRoot = resolve(import.meta.dir, "..");
18const alchemyRoot = join(repoRoot, "packages/alchemy");
19const awsTestRoot = join(alchemyRoot, "test/AWS");
20const providersFile = join(alchemyRoot, "src/AWS/Providers.ts");
21
22const extraDirs: Record<string, ReadonlyArray<string>> = {
23 SecretsManager: ["Secret"],
24};
25
26const dualizedServices = (): string[] => {
27 const source = readFileSync(providersFile, "utf8");
28 const names = new Set<string>(["Local"]);
29 const pattern = /(?:flociDual|ProviderLayer\.dual)\(\s*([A-Za-z0-9]+)\./g;
30 for (const match of source.matchAll(pattern)) {
31 const service = match[1]!;
32 names.add(service);
33 for (const extra of extraDirs[service] ?? []) names.add(extra);
34 }
35 return [...names].sort();
36};
37
38const flagsWithValue = new Set([
39 "-t",
40 "--test-name-pattern",
41 "--timeout",
42 "--retry",
43 "--concurrency",
44 "-c",
45 "--profile",
46]);
47
48const flags: string[] = [];
49const paths: string[] = [];
50const args = process.argv.slice(2);
51for (let i = 0; i < args.length; i++) {
52 const arg = args[i]!;
53 if (arg.startsWith("-")) {
54 flags.push(arg);
55 if (
56 flagsWithValue.has(arg) &&
57 args[i + 1] &&
58 !args[i + 1]!.startsWith("-")
59 ) {
60 flags.push(args[++i]!);
61 }
62 continue;
63 }
64 paths.push(arg);
65}
66
67const allowedRoots = dualizedServices()
68 .map((service) => join(awsTestRoot, service))
69 .filter((dir) => existsSync(dir));
70
71if (allowedRoots.length === 0) {
72 console.error("test:aws:floci: no dualized AWS service test dirs found");
73 process.exit(1);
74}
75
76const requestedRoots =
77 paths.length > 0 ? paths.map((p) => resolve(alchemyRoot, p)) : allowedRoots;
78
79const files: string[] = [];
80for (const root of requestedRoots) {
81 if (!existsSync(root)) {
82 files.push(relative(alchemyRoot, root));
83 continue;
84 }
85 if (statSync(root).isFile()) {
86 files.push(relative(alchemyRoot, root));
87 continue;
88 }
89 const glob = new Glob("**/*.test.ts");
90 for await (const file of glob.scan(root)) {
91 if (file.endsWith(".local.test.ts")) continue;
92 files.push(relative(alchemyRoot, join(root, file)));
93 }
94}
95
96process.env.ALCHEMY_TEST_DEV = "1";
97
98preferLocalFlociImage("test:aws:floci");
99
100if (!flags.includes("--profile")) {
101 flags.unshift("--profile", "testing");
102}
103if (!flags.includes("--concurrency") && !flags.includes("-c")) {
104 // Every concurrent file deploys real stacks against the LOCAL emulator —
105 // in-process deploys, the shared sidecar child, and a Docker container per
106 // Lambda cold start all scale with this number. 64 (tuned while the env
107 // bug made this script run against live AWS, where concurrency is free)
108 // ballooned to ~60GB RSS on a full-suite run; 12 was ~4.5GB but too slow.
109 // 32 matches the live-suite sweet spot from AGENTS.md.
110 flags.unshift("--concurrency", "32");
111}
112
113// The emulator container outlives any single run, so orphaned rows survive
114// into the next one — and a hard kill (machine death, SIGKILL) skips every
115// finalizer and `afterAll`, so orphans are routine rather than exceptional.
116// They do not just linger; they actively break later runs: a fixed-name
117// fixture collides (`DuplicateLoadBalancerName`), and a Glue table whose S3
118// location was deleted fails unrelated Athena queries, because the query
119// engine resolves the whole catalog.
120//
121// This is the emulator's counterpart to the `pnpm nuke` + `state clear` step
122// that precedes a live-cloud suite run (see AGENTS.md, "The convergence
123// loop"). Set `ALCHEMY_FLOCI_NO_RESET=1` to keep state across runs while
124// iterating on a single suite.
125if (!process.env.ALCHEMY_FLOCI_NO_RESET) {
126 const endpoint = process.env.AWS_ENDPOINT_URL ?? "http://localhost:4566";
127 try {
128 const res = await fetch(`${endpoint}/_floci/state/reset`, {
129 method: "POST",
130 signal: AbortSignal.timeout(30_000),
131 });
132 console.log(
133 res.ok
134 ? `test:aws:floci: reset emulator state (${endpoint})`
135 : `test:aws:floci: emulator state reset returned ${res.status}; continuing`,
136 );
137 } catch (error) {
138 // Not fatal: the emulator may simply not be up yet, in which case
139 // `ensureFloci` starts a fresh one during the run — which is clean anyway.
140 console.log(
141 `test:aws:floci: could not reset emulator state (${
142 error instanceof Error ? error.message : String(error)
143 }); continuing`,
144 );
145 }
146}
147
148const proc = Bun.spawn(["bun", "alchemy-test", ...files.sort(), ...flags], {
149 cwd: alchemyRoot,
150 // Bun.spawn's default env is a snapshot taken at process start, so the
151 // `process.env.ALCHEMY_TEST_DEV` mutations above never reach the child
152 // unless the env is materialized explicitly. Without this the "floci"
153 // suite silently runs live against real AWS.
154 env: { ...process.env },
155 stdout: "inherit",
156 stderr: "inherit",
157 stdin: "inherit",
158});
159process.exit(await proc.exited);