| 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 | */ |
| 12 | import { Glob } from "bun"; |
| 13 | import { preferLocalFlociImage } from "./floci-image.ts"; |
| 14 | import { existsSync, readFileSync, statSync } from "node:fs"; |
| 15 | import { join, relative, resolve } from "node:path"; |
| 16 | |
| 17 | const repoRoot = resolve(import.meta.dir, ".."); |
| 18 | const alchemyRoot = join(repoRoot, "packages/alchemy"); |
| 19 | const awsTestRoot = join(alchemyRoot, "test/AWS"); |
| 20 | const providersFile = join(alchemyRoot, "src/AWS/Providers.ts"); |
| 21 | |
| 22 | const extraDirs: Record<string, ReadonlyArray<string>> = { |
| 23 | SecretsManager: ["Secret"], |
| 24 | }; |
| 25 | |
| 26 | const 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 | |
| 38 | const flagsWithValue = new Set([ |
| 39 | "-t", |
| 40 | "--test-name-pattern", |
| 41 | "--timeout", |
| 42 | "--retry", |
| 43 | "--concurrency", |
| 44 | "-c", |
| 45 | "--profile", |
| 46 | ]); |
| 47 | |
| 48 | const flags: string[] = []; |
| 49 | const paths: string[] = []; |
| 50 | const args = process.argv.slice(2); |
| 51 | for (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 | |
| 67 | const allowedRoots = dualizedServices() |
| 68 | .map((service) => join(awsTestRoot, service)) |
| 69 | .filter((dir) => existsSync(dir)); |
| 70 | |
| 71 | if (allowedRoots.length === 0) { |
| 72 | console.error("test:aws:floci: no dualized AWS service test dirs found"); |
| 73 | process.exit(1); |
| 74 | } |
| 75 | |
| 76 | const requestedRoots = |
| 77 | paths.length > 0 ? paths.map((p) => resolve(alchemyRoot, p)) : allowedRoots; |
| 78 | |
| 79 | const files: string[] = []; |
| 80 | for (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 | |
| 96 | process.env.ALCHEMY_TEST_DEV = "1"; |
| 97 | |
| 98 | preferLocalFlociImage("test:aws:floci"); |
| 99 | |
| 100 | if (!flags.includes("--profile")) { |
| 101 | flags.unshift("--profile", "testing"); |
| 102 | } |
| 103 | if (!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. |
| 125 | if (!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 | |
| 148 | const 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 | }); |
| 159 | process.exit(await proc.exited); |