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 */
21import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22import { dirname, extname, relative, resolve, sep } from "node:path";
23
24type Lane = "network-heavy" | "network" | "quota" | "distributed";
25
26const alchemyDir = resolve(import.meta.dir, "..");
27const awsTestDir = resolve(alchemyDir, "test", "AWS");
28
29const isTestFile = (name: string) =>
30 name.endsWith(".test.ts") || name.endsWith(".test.tsx");
31
32const isTypeScriptFile = (name: string) =>
33 name.endsWith(".ts") || name.endsWith(".tsx");
34
35const 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
45const 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
67const files = walk(awsTestDir).sort();
68const tests = files.filter((file) => isTestFile(file));
69const reverseImports = new Map<string, Set<string>>();
70const lambdaSources = new Set<string>();
71const networkSources = new Set<string>();
72
73const importPattern =
74 /(?:from\s*|import\s*\(|new\s+URL\s*\()\s*["']([^"']+)["']/g;
75
76const isEc2ResourceModule = (specifier: string) =>
77 specifier === "@/AWS/EC2" ||
78 /^@\/AWS\/EC2\/(?:Vpc|Network)(?:\.ts)?$/.test(specifier);
79
80const 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.
86const 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 */
157const 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
217for (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.
248const lambdaReachable = new Set(lambdaSources);
249const networkReachable = new Set(networkSources);
250
251const 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
263expandImporters(lambdaReachable, lambdaSources);
264expandImporters(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).
270const networkHeavyPaths = new Set([
271 resolve(awsTestDir, "EC2", "VpcPeeringConnection.test.ts"),
272 resolve(awsTestDir, "Route53", "ZoneVpcAssociation.test.ts"),
273]);
274const networkHeavy = tests.filter(
275 (file) => networkReachable.has(file) && networkHeavyPaths.has(file),
276);
277const network = tests.filter(
278 (file) => networkReachable.has(file) && !networkHeavyPaths.has(file),
279);
280const networkSet = new Set([...networkHeavy, ...network]);
281
282const 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);
289const quotaSet = new Set(quota);
290const distributed = tests.filter(
291 (file) => !networkSet.has(file) && !quotaSet.has(file),
292);
293
294const lanes: Record<Lane, string[]> = {
295 "network-heavy": networkHeavy,
296 network,
297 quota,
298 distributed,
299};
300const command = process.argv[2] ?? "inventory";
301
302if (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
328if (
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
341if (
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
353const lane = command as Lane;
354const testFiles = lanes[lane].map((file) => relative(alchemyDir, file));
355const args = process.argv.slice(3);
356
357process.stderr.write(
358 `AWS ${lane} lane: ${testFiles.length}/${tests.length} files\n`,
359);
360
361const 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
369let terminating = false;
370for (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
378process.exit(await child.exited);