| 1 | /** |
| 2 | * Split the live AWS suite into disjoint quota-aware lanes. |
| 3 | * |
| 4 | * The quota lane contains every binding test, every Lambda service test, and |
| 5 | * every test whose local import graph reaches a Lambda fixture. The |
| 6 | * network lanes contain every test whose local import graph creates a VPC. |
| 7 | * AWS accounts default to five VPCs per Region, so those files must not inherit |
| 8 | * the otherwise-safe high service concurrency. Tests that hold two VPCs live |
| 9 | * at once run in a separate sequential lane; never overlap it with `network`. |
| 10 | * The distributed lane contains the complement, so it can retain very high |
| 11 | * file concurrency without stampeding either shared control plane. |
| 12 | * |
| 13 | * Examples (wrap live invocations in the coordinator's hard timeout): |
| 14 | * |
| 15 | * bun scripts/aws-test-lanes.ts inventory |
| 16 | * bun scripts/aws-test-lanes.ts network-heavy --profile testing --concurrency 1 --retry 0 |
| 17 | * bun scripts/aws-test-lanes.ts network --profile testing --concurrency 3 --retry 0 |
| 18 | * bun scripts/aws-test-lanes.ts quota --profile testing --concurrency 24 --retry 0 |
| 19 | * bun scripts/aws-test-lanes.ts distributed --profile testing --concurrency 128 --retry 0 |
| 20 | */ |
| 21 | import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; |
| 22 | import { dirname, extname, relative, resolve, sep } from "node:path"; |
| 23 | |
| 24 | type Lane = "network-heavy" | "network" | "quota" | "distributed"; |
| 25 | |
| 26 | const alchemyDir = resolve(import.meta.dir, ".."); |
| 27 | const awsTestDir = resolve(alchemyDir, "test", "AWS"); |
| 28 | |
| 29 | const isTestFile = (name: string) => |
| 30 | name.endsWith(".test.ts") || name.endsWith(".test.tsx"); |
| 31 | |
| 32 | const isTypeScriptFile = (name: string) => |
| 33 | name.endsWith(".ts") || name.endsWith(".tsx"); |
| 34 | |
| 35 | const walk = (directory: string): string[] => |
| 36 | readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { |
| 37 | const path = resolve(directory, entry.name); |
| 38 | return entry.isDirectory() |
| 39 | ? walk(path) |
| 40 | : isTypeScriptFile(entry.name) |
| 41 | ? [path] |
| 42 | : []; |
| 43 | }); |
| 44 | |
| 45 | const resolveLocalImport = (from: string, specifier: string) => { |
| 46 | if (!specifier.startsWith(".")) return undefined; |
| 47 | |
| 48 | const base = resolve(dirname(from), specifier); |
| 49 | const candidates = extname(base) |
| 50 | ? [base, base.replace(/\.js$/, ".ts"), base.replace(/\.jsx$/, ".tsx")] |
| 51 | : [ |
| 52 | base, |
| 53 | `${base}.ts`, |
| 54 | `${base}.tsx`, |
| 55 | resolve(base, "index.ts"), |
| 56 | resolve(base, "index.tsx"), |
| 57 | ]; |
| 58 | |
| 59 | return candidates.find( |
| 60 | (candidate) => |
| 61 | candidate.startsWith(`${awsTestDir}${sep}`) && |
| 62 | existsSync(candidate) && |
| 63 | statSync(candidate).isFile(), |
| 64 | ); |
| 65 | }; |
| 66 | |
| 67 | const files = walk(awsTestDir).sort(); |
| 68 | const tests = files.filter((file) => isTestFile(file)); |
| 69 | const reverseImports = new Map<string, Set<string>>(); |
| 70 | const lambdaSources = new Set<string>(); |
| 71 | const networkSources = new Set<string>(); |
| 72 | |
| 73 | const importPattern = |
| 74 | /(?:from\s*|import\s*\(|new\s+URL\s*\()\s*["']([^"']+)["']/g; |
| 75 | |
| 76 | const isEc2ResourceModule = (specifier: string) => |
| 77 | specifier === "@/AWS/EC2" || |
| 78 | /^@\/AWS\/EC2\/(?:Vpc|Network)(?:\.ts)?$/.test(specifier); |
| 79 | |
| 80 | const escapeRegExp = (value: string) => |
| 81 | value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 82 | |
| 83 | // Keep executable tokens while masking comments and string/template contents. |
| 84 | // This prevents prose such as "AWS Partner Network (...)" from becoming a |
| 85 | // scarce-capacity seed. Imports are parsed separately from the original text. |
| 86 | const maskCommentsAndStrings = (source: string) => { |
| 87 | const chars = [...source]; |
| 88 | let state: "code" | "line" | "block" | "single" | "double" | "template" = |
| 89 | "code"; |
| 90 | |
| 91 | for (let index = 0; index < chars.length; index++) { |
| 92 | const char = chars[index]; |
| 93 | const next = chars[index + 1]; |
| 94 | |
| 95 | if (state === "code") { |
| 96 | if (char === "/" && next === "/") { |
| 97 | state = "line"; |
| 98 | chars[index] = chars[index + 1] = " "; |
| 99 | index++; |
| 100 | } else if (char === "/" && next === "*") { |
| 101 | state = "block"; |
| 102 | chars[index] = chars[index + 1] = " "; |
| 103 | index++; |
| 104 | } else if (char === "'") { |
| 105 | state = "single"; |
| 106 | chars[index] = " "; |
| 107 | } else if (char === '"') { |
| 108 | state = "double"; |
| 109 | chars[index] = " "; |
| 110 | } else if (char === "`") { |
| 111 | state = "template"; |
| 112 | chars[index] = " "; |
| 113 | } |
| 114 | continue; |
| 115 | } |
| 116 | |
| 117 | if (state === "line") { |
| 118 | if (char === "\n" || char === "\r") state = "code"; |
| 119 | else chars[index] = " "; |
| 120 | continue; |
| 121 | } |
| 122 | |
| 123 | if (state === "block") { |
| 124 | if (char === "*" && next === "/") { |
| 125 | chars[index] = chars[index + 1] = " "; |
| 126 | index++; |
| 127 | state = "code"; |
| 128 | } else if (char !== "\n" && char !== "\r") { |
| 129 | chars[index] = " "; |
| 130 | } |
| 131 | continue; |
| 132 | } |
| 133 | |
| 134 | const quote = state === "single" ? "'" : state === "double" ? '"' : "`"; |
| 135 | if (char === "\\") { |
| 136 | chars[index] = " "; |
| 137 | if (index + 1 < chars.length) { |
| 138 | chars[index + 1] = " "; |
| 139 | index++; |
| 140 | } |
| 141 | } else if (char === quote) { |
| 142 | chars[index] = " "; |
| 143 | state = "code"; |
| 144 | } else if (char !== "\n" && char !== "\r") { |
| 145 | chars[index] = " "; |
| 146 | } |
| 147 | } |
| 148 | return chars.join(""); |
| 149 | }; |
| 150 | |
| 151 | /** |
| 152 | * Find calls that can allocate a regional VPC without matching comments, |
| 153 | * strings, or unrelated identifiers such as `ServiceNetwork` and |
| 154 | * `listHostedZonesByVpc`. Import aliases are resolved so `Vpc as TestVpc`, |
| 155 | * EC2 namespace imports, and the top-level AWS namespace remain visible. |
| 156 | */ |
| 157 | const createsVpc = (source: string) => { |
| 158 | const resourceCalls = new Set<string>(); |
| 159 | const ec2Namespaces = new Set<string>(); |
| 160 | const awsNamespaces = new Set<string>(); |
| 161 | |
| 162 | const namedImportPattern = |
| 163 | /import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g; |
| 164 | for (const match of source.matchAll(namedImportPattern)) { |
| 165 | const [, bindings, specifier] = match; |
| 166 | if (isEc2ResourceModule(specifier)) { |
| 167 | for (const binding of bindings.split(",")) { |
| 168 | const parts = binding |
| 169 | .trim() |
| 170 | .replace(/^type\s+/, "") |
| 171 | .split(/\s+as\s+/); |
| 172 | if (parts[0] === "Vpc" || parts[0] === "Network") { |
| 173 | resourceCalls.add(parts[1] ?? parts[0]); |
| 174 | } |
| 175 | } |
| 176 | } else if (specifier === "@/AWS") { |
| 177 | for (const binding of bindings.split(",")) { |
| 178 | const parts = binding.trim().split(/\s+as\s+/); |
| 179 | if (parts[0] === "EC2") { |
| 180 | ec2Namespaces.add(parts[1] ?? parts[0]); |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | const namespaceImportPattern = |
| 187 | /import\s*\*\s*as\s*(\w+)\s*from\s*["']([^"']+)["']/g; |
| 188 | for (const match of source.matchAll(namespaceImportPattern)) { |
| 189 | const [, alias, specifier] = match; |
| 190 | if (isEc2ResourceModule(specifier)) ec2Namespaces.add(alias); |
| 191 | else if (specifier === "@/AWS") awsNamespaces.add(alias); |
| 192 | } |
| 193 | |
| 194 | const defaultImportPattern = |
| 195 | /import\s+(\w+)\s+from\s*["'](@\/AWS\/EC2\/(?:Vpc|Network)(?:\.ts)?)["']/g; |
| 196 | for (const match of source.matchAll(defaultImportPattern)) { |
| 197 | resourceCalls.add(match[1]); |
| 198 | } |
| 199 | |
| 200 | const targets = [ |
| 201 | ...[...resourceCalls].map(escapeRegExp), |
| 202 | ...[...ec2Namespaces].map( |
| 203 | (alias) => `${escapeRegExp(alias)}\\.(?:Vpc|Network)`, |
| 204 | ), |
| 205 | ...[...awsNamespaces].map( |
| 206 | (alias) => `${escapeRegExp(alias)}\\.EC2\\.(?:Vpc|Network)`, |
| 207 | ), |
| 208 | ]; |
| 209 | const code = maskCommentsAndStrings(source); |
| 210 | if (/\bcreate(?:Default)?Vpc\s*\(/.test(code)) return true; |
| 211 | if (targets.length === 0) return false; |
| 212 | return new RegExp(`\\b(?:${targets.join("|")})(?:<[^>]+>)?\\s*\\(`).test( |
| 213 | code, |
| 214 | ); |
| 215 | }; |
| 216 | |
| 217 | for (const file of files) { |
| 218 | const source = readFileSync(file, "utf8"); |
| 219 | |
| 220 | // Lambda fixtures use either the focused module or the AWS namespace. |
| 221 | if ( |
| 222 | /@\/AWS\/Lambda(?:["'/]|$)|AWS\.Lambda\.Function(?:<[^>]+>)?\s*\(/.test( |
| 223 | source, |
| 224 | ) |
| 225 | ) { |
| 226 | lambdaSources.add(file); |
| 227 | } |
| 228 | |
| 229 | // Include direct provider resources, the EC2 Network composite, distilled |
| 230 | // default-VPC helpers, and every local fixture importing one of them. This |
| 231 | // is based on the import graph rather than service names because Batch, |
| 232 | // AppRunner, DMS, EFS, RDS, and many others all consume EC2 capacity. |
| 233 | if (createsVpc(source)) { |
| 234 | networkSources.add(file); |
| 235 | } |
| 236 | |
| 237 | for (const match of source.matchAll(importPattern)) { |
| 238 | const imported = resolveLocalImport(file, match[1]); |
| 239 | if (imported === undefined) continue; |
| 240 | const importers = reverseImports.get(imported) ?? new Set<string>(); |
| 241 | importers.add(file); |
| 242 | reverseImports.set(imported, importers); |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // Mark every local importer that can reach a scarce fixture. Walking the |
| 247 | // reverse graph avoids false negatives from cycles between fixture modules. |
| 248 | const lambdaReachable = new Set(lambdaSources); |
| 249 | const networkReachable = new Set(networkSources); |
| 250 | |
| 251 | const expandImporters = (reachable: Set<string>, sources: Set<string>) => { |
| 252 | const pending = [...sources]; |
| 253 | while (pending.length > 0) { |
| 254 | const imported = pending.pop()!; |
| 255 | for (const importer of reverseImports.get(imported) ?? []) { |
| 256 | if (reachable.has(importer)) continue; |
| 257 | reachable.add(importer); |
| 258 | pending.push(importer); |
| 259 | } |
| 260 | } |
| 261 | }; |
| 262 | |
| 263 | expandImporters(lambdaReachable, lambdaSources); |
| 264 | expandImporters(networkReachable, networkSources); |
| 265 | |
| 266 | // These files hold two custom VPCs simultaneously. Run this lane sequentially |
| 267 | // and never at the same time as `network`: default VPC (1) + heavy file (2) |
| 268 | // stays below the regional default quota of 5. The ordinary network lane at |
| 269 | // concurrency 3 likewise peaks at default VPC (1) + three custom VPCs (3). |
| 270 | const networkHeavyPaths = new Set([ |
| 271 | resolve(awsTestDir, "EC2", "VpcPeeringConnection.test.ts"), |
| 272 | resolve(awsTestDir, "Route53", "ZoneVpcAssociation.test.ts"), |
| 273 | ]); |
| 274 | const networkHeavy = tests.filter( |
| 275 | (file) => networkReachable.has(file) && networkHeavyPaths.has(file), |
| 276 | ); |
| 277 | const network = tests.filter( |
| 278 | (file) => networkReachable.has(file) && !networkHeavyPaths.has(file), |
| 279 | ); |
| 280 | const networkSet = new Set([...networkHeavy, ...network]); |
| 281 | |
| 282 | const quota = tests.filter( |
| 283 | (file) => |
| 284 | !networkSet.has(file) && |
| 285 | (/Bindings\.test\.tsx?$/.test(file) || |
| 286 | file.includes(`${sep}AWS${sep}Lambda${sep}`) || |
| 287 | lambdaReachable.has(file)), |
| 288 | ); |
| 289 | const quotaSet = new Set(quota); |
| 290 | const distributed = tests.filter( |
| 291 | (file) => !networkSet.has(file) && !quotaSet.has(file), |
| 292 | ); |
| 293 | |
| 294 | const lanes: Record<Lane, string[]> = { |
| 295 | "network-heavy": networkHeavy, |
| 296 | network, |
| 297 | quota, |
| 298 | distributed, |
| 299 | }; |
| 300 | const command = process.argv[2] ?? "inventory"; |
| 301 | |
| 302 | if (command === "inventory") { |
| 303 | process.stdout.write( |
| 304 | `${JSON.stringify( |
| 305 | { |
| 306 | total: tests.length, |
| 307 | networkHeavy: networkHeavy.length, |
| 308 | network: network.length, |
| 309 | quota: quota.length, |
| 310 | distributed: distributed.length, |
| 311 | overlap: |
| 312 | [...networkHeavy, ...network, ...quota, ...distributed].length - |
| 313 | new Set([...networkHeavy, ...network, ...quota, ...distributed]).size, |
| 314 | covered: new Set([ |
| 315 | ...networkHeavy, |
| 316 | ...network, |
| 317 | ...quota, |
| 318 | ...distributed, |
| 319 | ]).size, |
| 320 | }, |
| 321 | null, |
| 322 | 2, |
| 323 | )}\n`, |
| 324 | ); |
| 325 | process.exit(0); |
| 326 | } |
| 327 | |
| 328 | if ( |
| 329 | command === "list-network-heavy" || |
| 330 | command === "list-network" || |
| 331 | command === "list-quota" || |
| 332 | command === "list-distributed" |
| 333 | ) { |
| 334 | const lane = command.slice("list-".length) as Lane; |
| 335 | process.stdout.write( |
| 336 | `${lanes[lane].map((file) => relative(alchemyDir, file)).join("\n")}\n`, |
| 337 | ); |
| 338 | process.exit(0); |
| 339 | } |
| 340 | |
| 341 | if ( |
| 342 | command !== "network-heavy" && |
| 343 | command !== "network" && |
| 344 | command !== "quota" && |
| 345 | command !== "distributed" |
| 346 | ) { |
| 347 | process.stderr.write( |
| 348 | "Usage: aws-test-lanes.ts <inventory|list-network-heavy|list-network|list-quota|list-distributed|network-heavy|network|quota|distributed> [alchemy-test options...]\n", |
| 349 | ); |
| 350 | process.exit(2); |
| 351 | } |
| 352 | |
| 353 | const lane = command as Lane; |
| 354 | const testFiles = lanes[lane].map((file) => relative(alchemyDir, file)); |
| 355 | const args = process.argv.slice(3); |
| 356 | |
| 357 | process.stderr.write( |
| 358 | `AWS ${lane} lane: ${testFiles.length}/${tests.length} files\n`, |
| 359 | ); |
| 360 | |
| 361 | const child = Bun.spawn(["bun", "alchemy-test", ...testFiles, ...args], { |
| 362 | cwd: alchemyDir, |
| 363 | env: process.env, |
| 364 | stdin: "inherit", |
| 365 | stdout: "inherit", |
| 366 | stderr: "inherit", |
| 367 | }); |
| 368 | |
| 369 | let terminating = false; |
| 370 | for (const signal of ["SIGINT", "SIGTERM"] as const) { |
| 371 | process.on(signal, () => { |
| 372 | if (terminating) return; |
| 373 | terminating = true; |
| 374 | child.kill(signal); |
| 375 | }); |
| 376 | } |
| 377 | |
| 378 | process.exit(await child.exited); |