@elench/testkit 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.mjs CHANGED
@@ -1,461 +1,973 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
+ import { spawn } from "child_process";
4
+ import net from "net";
5
+ import { execa, execaCommand } from "execa";
3
6
  import { runScript } from "./exec.mjs";
4
- import { execaCommand } from "execa";
5
- import { requireFlyToken, resolveDalBinary } from "./config.mjs";
6
-
7
- const HTTP_SUITE_TYPES = new Set(["integration", "e2e", "load"]);
8
-
9
- /**
10
- * Run multiple service configs in parallel.
11
- * Single service: runs directly. Multiple: Promise.allSettled.
12
- */
13
- export async function runAll(configs, suiteType, suiteNames, opts) {
14
- if (configs.length === 1) {
15
- const failed = await runService(configs[0], suiteType, suiteNames, opts);
16
- if (failed) process.exit(1);
7
+ import {
8
+ requireNeonApiKey,
9
+ resolveDalBinary,
10
+ resolveServiceCwd,
11
+ } from "./config.mjs";
12
+
13
+ const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
14
+ const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
15
+ const DEFAULT_READY_TIMEOUT_MS = 120_000;
16
+ const PORT_STRIDE = 100;
17
+
18
+ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
19
+ const configMap = new Map(allConfigs.map((config) => [config.name, config]));
20
+ const targetSpan = Math.max(1, opts.jobs || 1);
21
+ const results = await Promise.all(
22
+ configs.map(async (config, targetSlot) => {
23
+ console.log(`\n══ ${config.name} ══`);
24
+ return runService(config, configMap, suiteType, suiteNames, opts, {
25
+ targetSlot,
26
+ targetSpan,
27
+ });
28
+ })
29
+ );
30
+
31
+ if (results.some(Boolean)) process.exit(1);
32
+ }
33
+
34
+ export async function destroy(config) {
35
+ if (!fs.existsSync(config.stateDir)) return;
36
+
37
+ const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
38
+ for (const stateDir of runtimeStateDirs) {
39
+ const projectId = readStateValue(path.join(stateDir, "neon_project_id"));
40
+ if (!projectId) continue;
41
+
42
+ await runScript("neon-down.sh", {
43
+ NEON_PROJECT_ID: projectId,
44
+ STATE_DIR: stateDir,
45
+ });
46
+ }
47
+
48
+ fs.rmSync(config.stateDir, { recursive: true, force: true });
49
+ }
50
+
51
+ export function showStatus(config) {
52
+ if (!fs.existsSync(config.stateDir)) {
53
+ console.log("No state — run tests first.");
17
54
  return;
18
55
  }
19
56
 
20
- // Multiple services — run in parallel
21
- const results = await Promise.allSettled(
22
- configs.map(async (config) => {
23
- console.log(`\n══ ${config.name} ══`);
24
- return runService(config, suiteType, suiteNames, opts);
25
- })
57
+ printStateDir(config.stateDir, " ");
58
+ }
59
+
60
+ async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
61
+ const suites = applyShard(
62
+ collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
63
+ opts.shard
26
64
  );
65
+ if (suites.length === 0) {
66
+ console.log(
67
+ `No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
68
+ );
69
+ return false;
70
+ }
27
71
 
28
- // Summarize
29
- const summary = configs.map((c, i) => {
30
- const r = results[i];
31
- const ok = r.status === "fulfilled" && !r.value;
32
- return ` ${ok ? "✓" : "✗"} ${c.name}`;
33
- });
34
- console.log(`\n── Summary ──\n${summary.join("\n")}`);
72
+ const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
73
+ fs.mkdirSync(targetConfig.stateDir, { recursive: true });
35
74
 
36
- const anyFailed = results.some(
37
- (r) => r.status === "rejected" || r.value === true
75
+ const jobs = Math.max(1, Math.min(opts.jobs || 1, suites.length));
76
+ const workerPlans = buildWorkerPlans(
77
+ targetConfig,
78
+ runtimeConfigs,
79
+ suites,
80
+ jobs,
81
+ runtimeSlot
38
82
  );
39
- if (anyFailed) process.exit(1);
40
- }
41
-
42
- /**
43
- * Build the Docker image and push to Fly registry.
44
- */
45
- export async function build(config) {
46
- const { productDir, manifest, stateDir } = config;
47
- const tk = manifest.testkit;
48
- await runScript("fly-build.sh", {
49
- FLY_APP: tk.fly.app,
50
- FLY_ORG: tk.fly.org,
51
- API_DIR: productDir,
52
- DOCKERFILE_DIR: tk.dockerfile ? path.join(productDir, tk.dockerfile) : productDir,
53
- STATE_DIR: stateDir,
54
- });
83
+
84
+ const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
85
+ let failed = false;
86
+
87
+ for (const result of results) {
88
+ if (result.status === "rejected") {
89
+ failed = true;
90
+ console.error(result.reason);
91
+ continue;
92
+ }
93
+ if (result.value) failed = true;
94
+ }
95
+
96
+ return failed;
55
97
  }
56
98
 
57
- /**
58
- * Ensure Neon branch exists and reset test data.
59
- */
60
- export async function neonUp(config) {
61
- const { stateDir, manifest } = config;
62
- const tk = manifest.testkit;
63
- await runScript("neon-up.sh", {
64
- NEON_PROJECT_ID: tk.neon.projectId,
65
- NEON_DB_NAME: tk.neon.dbName,
66
- NEON_BRANCH_NAME: tk.neon.branchName || `${config.name}-test`,
67
- NEON_RESET: tk.neon.reset === false ? "false" : "true",
68
- STATE_DIR: stateDir,
69
- });
99
+ function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
100
+ const types =
101
+ suiteType === "all"
102
+ ? orderedTypes(Object.keys(config.suites))
103
+ : [suiteType === "int" ? "integration" : suiteType];
104
+
105
+ const selectedNames = new Set(suiteNames);
106
+ const suites = [];
107
+ let orderIndex = 0;
108
+
109
+ for (const type of types) {
110
+ for (const suite of config.suites[type] || []) {
111
+ const framework = suite.framework || "k6";
112
+ if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
113
+ if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
114
+
115
+ suites.push({
116
+ ...suite,
117
+ framework,
118
+ type,
119
+ orderIndex,
120
+ sortKey: `${type}:${suite.name}`,
121
+ weight:
122
+ suite.testkit?.weight ||
123
+ (framework === "playwright"
124
+ ? Math.max(2, suite.files.length)
125
+ : Math.max(1, suite.files.length)),
126
+ maxFileConcurrency:
127
+ framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
128
+ });
129
+ orderIndex += 1;
130
+ }
131
+ }
132
+
133
+ return suites;
70
134
  }
71
135
 
72
- /**
73
- * Generate fly-env.sh and start/create the Fly machine.
74
- */
75
- export async function flyUp(config) {
76
- const { productDir, stateDir, manifest } = config;
77
- const tk = manifest.testkit;
136
+ function applyShard(suites, shard) {
137
+ if (!shard) return suites;
138
+ return suites.filter((_, index) => index % shard.total === shard.index - 1);
139
+ }
78
140
 
79
- // Generate fly-env.sh from manifest (bash array for safe quoting)
80
- const envLines = [];
81
- for (const [k, v] of Object.entries(tk.fly.env || {})) {
82
- envLines.push(` --env "${k}=${v}"`);
141
+ function orderedTypes(types) {
142
+ const ordered = [];
143
+ for (const known of TYPE_ORDER) {
144
+ if (types.includes(known)) ordered.push(known);
83
145
  }
84
- for (const k of tk.fly.secrets || []) {
85
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
146
+ for (const type of types) {
147
+ if (!ordered.includes(type)) ordered.push(type);
86
148
  }
87
- const flyEnvPath = path.join(stateDir, "fly-env.sh");
88
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
149
+ return ordered;
150
+ }
89
151
 
90
- await runScript("fly-up.sh", {
91
- FLY_APP: tk.fly.app,
92
- FLY_ORG: tk.fly.org,
93
- FLY_REGION: tk.fly.region || "lhr",
94
- FLY_PORT: tk.fly.port,
95
- FLY_ENV_FILE: flyEnvPath,
96
- STATE_DIR: stateDir,
97
- });
152
+ function resolveRuntimeConfigs(targetConfig, configMap) {
153
+ const ordered = [];
154
+ const visiting = new Set();
155
+ const seen = new Set();
156
+
157
+ const visit = (config) => {
158
+ if (seen.has(config.name)) return;
159
+ if (visiting.has(config.name)) {
160
+ throw new Error(`Dependency cycle detected involving "${config.name}"`);
161
+ }
162
+
163
+ visiting.add(config.name);
164
+ for (const depName of config.testkit.dependsOn || []) {
165
+ const dep = configMap.get(depName);
166
+ if (!dep) {
167
+ throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
168
+ }
169
+ visit(dep);
170
+ }
171
+ visiting.delete(config.name);
172
+ seen.add(config.name);
173
+ ordered.push(config);
174
+ };
175
+
176
+ visit(targetConfig);
177
+ return ordered;
98
178
  }
99
179
 
100
- /**
101
- * Build a dependent service's Docker image.
102
- */
103
- async function buildDep(config, dep) {
104
- const { productDir, stateDir } = config;
105
- const depStateDir = path.join(stateDir, "deps", dep.name);
106
- fs.mkdirSync(depStateDir, { recursive: true });
107
- await runScript("fly-build.sh", {
108
- FLY_APP: dep.fly.app,
109
- FLY_ORG: dep.fly.org,
110
- API_DIR: productDir,
111
- DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
112
- STATE_DIR: depStateDir,
113
- });
180
+ function buildWorkerPlans(targetConfig, runtimeConfigs, suites, jobs, runtimeSlot) {
181
+ const buckets = distributeSuites(suites, jobs);
182
+ return buckets
183
+ .map((bucket, index) =>
184
+ createWorkerPlan(targetConfig, runtimeConfigs, bucket.suites, index + 1, runtimeSlot)
185
+ )
186
+ .filter(Boolean);
114
187
  }
115
188
 
116
- /**
117
- * Deploy a dependent service's Fly machine.
118
- * Copies database_url from primary, generates fly-env.sh, calls fly-up.sh.
119
- */
120
- async function flyUpDep(config, dep) {
121
- const { stateDir } = config;
122
- const depStateDir = path.join(stateDir, "deps", dep.name);
123
- fs.mkdirSync(depStateDir, { recursive: true });
189
+ function distributeSuites(suites, jobs) {
190
+ const buckets = Array.from({ length: jobs }, () => ({
191
+ suites: [],
192
+ totalWeight: 0,
193
+ }));
194
+ const ordered = [...suites].sort(
195
+ (a, b) => b.weight - a.weight || a.sortKey.localeCompare(b.sortKey)
196
+ );
197
+
198
+ for (const suite of ordered) {
199
+ let bestBucket = buckets[0];
200
+ for (const bucket of buckets.slice(1)) {
201
+ if (bucket.totalWeight < bestBucket.totalWeight) {
202
+ bestBucket = bucket;
203
+ continue;
204
+ }
205
+ if (
206
+ bucket.totalWeight === bestBucket.totalWeight &&
207
+ bucket.suites.length < bestBucket.suites.length
208
+ ) {
209
+ bestBucket = bucket;
210
+ }
211
+ }
212
+
213
+ bestBucket.suites.push(suite);
214
+ bestBucket.totalWeight += suite.weight;
215
+ }
124
216
 
125
- // Copy database_url from primary state dir
126
- const primaryDbUrl = path.join(stateDir, "database_url");
127
- if (fs.existsSync(primaryDbUrl)) {
128
- fs.copyFileSync(primaryDbUrl, path.join(depStateDir, "database_url"));
217
+ for (const bucket of buckets) {
218
+ bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
129
219
  }
130
220
 
131
- // Generate fly-env.sh for dependent (bash array for safe quoting)
132
- const envLines = [];
133
- for (const [k, v] of Object.entries(dep.fly.env || {})) {
134
- envLines.push(` --env "${k}=${v}"`);
221
+ return buckets.filter((bucket) => bucket.suites.length > 0);
222
+ }
223
+
224
+ function createWorkerPlan(targetConfig, runtimeConfigs, suites, workerId, runtimeSlot) {
225
+ if (suites.length === 0) return null;
226
+
227
+ const workerStateDir = path.join(targetConfig.stateDir, "workers", `worker-${workerId}`);
228
+ const workerRuntimeConfigs = resolveWorkerRuntimeConfigs(
229
+ targetConfig,
230
+ runtimeConfigs,
231
+ workerId,
232
+ workerStateDir,
233
+ runtimeSlot
234
+ );
235
+ const workerTargetConfig = workerRuntimeConfigs.find(
236
+ (config) => config.name === targetConfig.name
237
+ );
238
+
239
+ return {
240
+ workerId,
241
+ suites,
242
+ runtimeConfigs: workerRuntimeConfigs,
243
+ targetConfig: workerTargetConfig,
244
+ };
245
+ }
246
+
247
+ function resolveWorkerRuntimeConfigs(
248
+ targetConfig,
249
+ runtimeConfigs,
250
+ workerId,
251
+ workerStateDir,
252
+ runtimeSlot
253
+ ) {
254
+ const portMap = buildPortMap(runtimeConfigs, workerId, runtimeSlot);
255
+ const baseUrlByService = new Map();
256
+ const readyUrlByService = new Map();
257
+
258
+ for (const config of runtimeConfigs) {
259
+ if (!config.testkit.local) continue;
260
+ baseUrlByService.set(
261
+ config.name,
262
+ resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
263
+ workerStateDir,
264
+ portMap,
265
+ baseUrlByService,
266
+ readyUrlByService,
267
+ })
268
+ );
269
+ readyUrlByService.set(
270
+ config.name,
271
+ resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
272
+ workerStateDir,
273
+ portMap,
274
+ baseUrlByService,
275
+ readyUrlByService,
276
+ })
277
+ );
135
278
  }
136
- for (const k of dep.fly.secrets || []) {
137
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
279
+
280
+ const urlMappings = [];
281
+ for (const config of runtimeConfigs) {
282
+ if (!config.testkit.local) continue;
283
+ const resolvedBaseUrl = baseUrlByService.get(config.name);
284
+ const resolvedReadyUrl = readyUrlByService.get(config.name);
285
+ if (resolvedBaseUrl) {
286
+ urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
287
+ }
288
+ if (resolvedReadyUrl) {
289
+ urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
290
+ }
138
291
  }
139
- const flyEnvPath = path.join(depStateDir, "fly-env.sh");
140
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
141
292
 
142
- await runScript("fly-up.sh", {
143
- FLY_APP: dep.fly.app,
144
- FLY_ORG: dep.fly.org,
145
- FLY_REGION: dep.fly.region || "lhr",
146
- FLY_PORT: dep.fly.port,
147
- FLY_ENV_FILE: flyEnvPath,
148
- STATE_DIR: depStateDir,
149
- });
293
+ return runtimeConfigs.map((config) =>
294
+ resolveWorkerConfig(
295
+ config,
296
+ targetConfig,
297
+ workerId,
298
+ workerStateDir,
299
+ portMap,
300
+ baseUrlByService,
301
+ readyUrlByService,
302
+ urlMappings
303
+ )
304
+ );
150
305
  }
151
306
 
152
- /**
153
- * Stop a dependent service's Fly machine.
154
- */
155
- async function flyDownDep(config, dep) {
156
- const depStateDir = path.join(config.stateDir, "deps", dep.name);
157
- await runScript("fly-down.sh", {
158
- FLY_APP: dep.fly.app,
159
- STATE_DIR: depStateDir,
160
- });
307
+ function buildPortMap(runtimeConfigs, workerId, runtimeSlot) {
308
+ const portMap = new Map();
309
+ const seen = new Map();
310
+ const offset = PORT_STRIDE * ((workerId - 1) + runtimeSlot.targetSlot * runtimeSlot.targetSpan);
311
+
312
+ for (const config of runtimeConfigs) {
313
+ if (!config.testkit.local) continue;
314
+
315
+ const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
316
+ if (!basePort) continue;
317
+
318
+ const actualPort = basePort + offset;
319
+ const existing = seen.get(actualPort);
320
+ if (existing) {
321
+ throw new Error(
322
+ `Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
323
+ `Assign distinct local.port/baseUrl ports in testkit.config.json.`
324
+ );
325
+ }
326
+ seen.set(actualPort, config.name);
327
+ portMap.set(config.name, actualPort);
328
+ }
329
+
330
+ return portMap;
161
331
  }
162
332
 
163
- /**
164
- * Stop the Fly machine (preserved for reuse).
165
- */
166
- export async function flyDown(config) {
167
- const { stateDir, manifest } = config;
168
- await runScript("fly-down.sh", {
169
- FLY_APP: manifest.testkit.fly.app,
170
- STATE_DIR: stateDir,
171
- });
333
+ function resolveWorkerConfig(
334
+ config,
335
+ targetConfig,
336
+ workerId,
337
+ workerStateDir,
338
+ portMap,
339
+ baseUrlByService,
340
+ readyUrlByService,
341
+ urlMappings
342
+ ) {
343
+ const stateDir = getWorkerServiceStateDir(workerStateDir, targetConfig.name, config.name);
344
+ const context = {
345
+ workerId,
346
+ serviceName: config.name,
347
+ targetName: targetConfig.name,
348
+ serviceStateDir: stateDir,
349
+ portMap,
350
+ baseUrlByService,
351
+ readyUrlByService,
352
+ urlMappings,
353
+ };
354
+
355
+ const database = config.testkit.database
356
+ ? {
357
+ ...config.testkit.database,
358
+ branchName:
359
+ config.testkit.database.branchName !== undefined
360
+ ? finalizeString(config.testkit.database.branchName, context)
361
+ : `${targetConfig.name}-${config.name}-w${workerId}-testkit`,
362
+ }
363
+ : undefined;
364
+
365
+ const migrate = config.testkit.migrate
366
+ ? {
367
+ ...config.testkit.migrate,
368
+ cmd: finalizeString(config.testkit.migrate.cmd, context),
369
+ cwd:
370
+ config.testkit.migrate.cwd !== undefined
371
+ ? finalizeString(config.testkit.migrate.cwd, context)
372
+ : config.testkit.migrate.cwd,
373
+ }
374
+ : undefined;
375
+
376
+ const seed = config.testkit.seed
377
+ ? {
378
+ ...config.testkit.seed,
379
+ cmd: finalizeString(config.testkit.seed.cmd, context),
380
+ cwd:
381
+ config.testkit.seed.cwd !== undefined
382
+ ? finalizeString(config.testkit.seed.cwd, context)
383
+ : config.testkit.seed.cwd,
384
+ }
385
+ : undefined;
386
+
387
+ const local = config.testkit.local
388
+ ? {
389
+ ...config.testkit.local,
390
+ start: finalizeString(config.testkit.local.start, context),
391
+ cwd:
392
+ config.testkit.local.cwd !== undefined
393
+ ? finalizeString(config.testkit.local.cwd, context)
394
+ : config.testkit.local.cwd,
395
+ port: portMap.get(config.name) || config.testkit.local.port,
396
+ baseUrl: baseUrlByService.get(config.name),
397
+ readyUrl: readyUrlByService.get(config.name),
398
+ env: Object.fromEntries(
399
+ Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
400
+ key,
401
+ finalizeString(String(value), context),
402
+ ])
403
+ ),
404
+ }
405
+ : undefined;
406
+
407
+ return {
408
+ ...config,
409
+ stateDir,
410
+ workerId,
411
+ workerLabel: `w${workerId}`,
412
+ targetName: targetConfig.name,
413
+ testkit: {
414
+ ...config.testkit,
415
+ database,
416
+ migrate,
417
+ seed,
418
+ local,
419
+ },
420
+ };
172
421
  }
173
422
 
174
- /**
175
- * Destroy machine + neon branch + state.
176
- * Destroys dependent machines first, then primary.
177
- */
178
- export async function destroy(config) {
179
- const { stateDir, manifest } = config;
180
- const tk = manifest.testkit;
181
-
182
- // Destroy dependents first
183
- for (const dep of tk.depends || []) {
184
- const depStateDir = path.join(stateDir, "deps", dep.name);
185
- if (fs.existsSync(depStateDir)) {
186
- await runScript("fly-destroy.sh", { FLY_APP: dep.fly.app, STATE_DIR: depStateDir });
423
+ async function runWorkerPlan(plan) {
424
+ console.log(
425
+ `\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
426
+ );
427
+
428
+ let startedServices = [];
429
+ let failed = false;
430
+
431
+ try {
432
+ await prepareDatabases(plan.runtimeConfigs);
433
+ await runMigrations(plan.runtimeConfigs);
434
+ await runSeeds(plan.runtimeConfigs);
435
+
436
+ if (needsLocalRuntime(plan.suites)) {
437
+ startedServices = await startLocalServices(plan.runtimeConfigs);
187
438
  }
439
+
440
+ for (const suite of plan.suites) {
441
+ console.log(
442
+ `\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
443
+ );
444
+ const result = await runSuite(plan.targetConfig, suite);
445
+ if (result.failed) failed = true;
446
+ }
447
+ } finally {
448
+ await stopLocalServices(startedServices);
188
449
  }
189
450
 
190
- await runScript("fly-destroy.sh", { FLY_APP: tk.fly.app, STATE_DIR: stateDir });
191
- await runScript("neon-down.sh", {
192
- NEON_PROJECT_ID: tk.neon.projectId,
193
- STATE_DIR: stateDir,
194
- });
195
- fs.rmSync(stateDir, { recursive: true, force: true });
451
+ return failed;
196
452
  }
197
453
 
198
- /**
199
- * Run k6 test files against the Fly machine (HTTP tests).
200
- */
201
- export async function runTests(config, files) {
202
- const { productDir, stateDir, manifest } = config;
203
- const tk = manifest.testkit;
454
+ async function prepareDatabases(runtimeConfigs) {
455
+ for (const config of runtimeConfigs) {
456
+ const db = config.testkit.database;
457
+ if (!db) continue;
458
+
459
+ requireNeonApiKey();
460
+ fs.mkdirSync(config.stateDir, { recursive: true });
204
461
 
205
- // Read state files
206
- const baseUrl = fs.readFileSync(path.join(stateDir, "fly_hostname"), "utf8").trim();
207
- const machineId = fs.readFileSync(path.join(stateDir, "fly_machine_id"), "utf8").trim();
462
+ await runScript("neon-up.sh", {
463
+ NEON_PROJECT_ID: db.projectId,
464
+ NEON_DB_NAME: db.dbName,
465
+ NEON_BRANCH_NAME: db.branchName,
466
+ NEON_RESET: db.reset === false ? "false" : "true",
467
+ STATE_DIR: config.stateDir,
468
+ });
208
469
 
209
- // Build -e flags
210
- const envFlags = [`-e BASE_URL=${baseUrl}`, `-e MACHINE_ID=${machineId}`];
211
- for (const key of tk.k6?.secrets || []) {
212
- envFlags.push(`-e ${key}=${process.env[key]}`);
470
+ fs.writeFileSync(path.join(config.stateDir, "neon_project_id"), db.projectId);
213
471
  }
214
- const envStr = envFlags.join(" ");
472
+ }
215
473
 
216
- let failed = false;
217
- for (const file of files) {
218
- const absFile = path.join(productDir, file);
219
- try {
220
- await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
221
- } catch (err) {
222
- failed = true;
474
+ async function runMigrations(runtimeConfigs) {
475
+ for (const config of runtimeConfigs) {
476
+ const migrate = config.testkit.migrate;
477
+ if (!migrate) continue;
478
+
479
+ const env = { ...process.env };
480
+ const dbUrl = readDatabaseUrl(config.stateDir);
481
+ if (dbUrl) env.DATABASE_URL = dbUrl;
482
+
483
+ console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
484
+ await execaCommand(migrate.cmd, {
485
+ cwd: resolveServiceCwd(config.productDir, migrate.cwd),
486
+ env,
487
+ stdio: "inherit",
488
+ shell: true,
489
+ });
490
+ }
491
+ }
492
+
493
+ async function runSeeds(runtimeConfigs) {
494
+ for (const config of runtimeConfigs) {
495
+ const seed = config.testkit.seed;
496
+ if (!seed) continue;
497
+
498
+ const env = { ...process.env };
499
+ const dbUrl = readDatabaseUrl(config.stateDir);
500
+ if (dbUrl) env.DATABASE_URL = dbUrl;
501
+
502
+ console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
503
+ await execaCommand(seed.cmd, {
504
+ cwd: resolveServiceCwd(config.productDir, seed.cwd),
505
+ env,
506
+ stdio: "inherit",
507
+ shell: true,
508
+ });
509
+ }
510
+ }
511
+
512
+ async function startLocalServices(runtimeConfigs) {
513
+ const started = [];
514
+
515
+ try {
516
+ for (const config of runtimeConfigs) {
517
+ if (!config.testkit.local) continue;
518
+ const proc = await startLocalService(config);
519
+ started.push(proc);
223
520
  }
521
+ } catch (error) {
522
+ await stopLocalServices(started);
523
+ throw error;
224
524
  }
225
- return { failed };
525
+
526
+ return started;
226
527
  }
227
528
 
228
- /**
229
- * Run k6 DAL test files directly against Neon (no Fly machine).
230
- */
231
- export async function runDalTests(config, files) {
232
- const { productDir, stateDir, manifest } = config;
233
- const tk = manifest.testkit;
234
- const k6Binary = resolveDalBinary();
529
+ async function startLocalService(config) {
530
+ const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
531
+ const env = {
532
+ ...process.env,
533
+ ...config.testkit.local.env,
534
+ };
535
+ const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
536
+ if (port) {
537
+ env.PORT = String(port);
538
+ }
539
+
540
+ const dbUrl = readDatabaseUrl(config.stateDir);
541
+ if (dbUrl) {
542
+ env.DATABASE_URL = dbUrl;
543
+ }
544
+
545
+ await assertLocalServicePortsAvailable(config);
235
546
 
236
- // Read DATABASE_URL from neon state
237
- const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
547
+ console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
548
+ const child = spawn(config.testkit.local.start, {
549
+ cwd,
550
+ env,
551
+ shell: true,
552
+ stdio: ["ignore", "pipe", "pipe"],
553
+ });
554
+
555
+ pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`);
556
+ pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`);
557
+
558
+ const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
559
+
560
+ try {
561
+ await waitForReady({
562
+ name: `${config.workerLabel}:${config.name}`,
563
+ url: config.testkit.local.readyUrl,
564
+ timeoutMs: readyTimeoutMs,
565
+ process: child,
566
+ });
567
+ } catch (error) {
568
+ await stopChildProcess(child);
569
+ throw error;
570
+ }
571
+
572
+ return { name: config.name, child };
573
+ }
574
+
575
+ async function runSuite(targetConfig, suite) {
576
+ if (suite.type === "dal") {
577
+ return runDalSuite(targetConfig, suite);
578
+ }
579
+
580
+ if (suite.framework === "playwright") {
581
+ return runPlaywrightSuite(targetConfig, suite);
582
+ }
583
+
584
+ if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
585
+ return runHttpK6Suite(targetConfig, suite);
586
+ }
238
587
 
239
- // Build -e flags
240
- const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
241
- for (const key of tk.dal?.secrets || []) {
242
- envFlags.push(`-e ${key}=${process.env[key]}`);
588
+ throw new Error(
589
+ `Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
590
+ );
591
+ }
592
+
593
+ async function runHttpK6Suite(targetConfig, suite) {
594
+ const baseUrl = targetConfig.testkit.local?.baseUrl;
595
+ if (!baseUrl) {
596
+ throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
243
597
  }
244
- const envStr = envFlags.join(" ");
245
598
 
246
599
  let failed = false;
247
- for (const file of files) {
248
- const absFile = path.join(productDir, file);
600
+ await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
601
+ const absFile = path.join(targetConfig.productDir, file);
602
+ console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
249
603
  try {
250
- await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
251
- } catch (err) {
604
+ await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
605
+ cwd: targetConfig.productDir,
606
+ env: process.env,
607
+ stdio: "inherit",
608
+ });
609
+ } catch {
252
610
  failed = true;
253
611
  }
254
- }
612
+ });
613
+
255
614
  return { failed };
256
615
  }
257
616
 
258
- /**
259
- * Delete the Neon branch (so neonUp creates a fresh fork from prod).
260
- */
261
- async function neonReset(config) {
262
- const { stateDir, manifest } = config;
263
- const tk = manifest.testkit;
264
- console.log("Deleting Neon branch for fresh fork...");
265
- await runScript("neon-down.sh", {
266
- NEON_PROJECT_ID: tk.neon.projectId,
267
- STATE_DIR: stateDir,
617
+ async function runDalSuite(targetConfig, suite) {
618
+ const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
619
+ if (!databaseUrl) {
620
+ throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
621
+ }
622
+
623
+ const k6Binary = resolveDalBinary();
624
+ let failed = false;
625
+ await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
626
+ const absFile = path.join(targetConfig.productDir, file);
627
+ console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
628
+ try {
629
+ await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
630
+ cwd: targetConfig.productDir,
631
+ env: process.env,
632
+ stdio: "inherit",
633
+ });
634
+ } catch {
635
+ failed = true;
636
+ }
268
637
  });
269
- // Clear branch state so neonUp creates a new one
270
- const branchFile = path.join(stateDir, "neon_branch_id");
271
- if (fs.existsSync(branchFile)) fs.unlinkSync(branchFile);
272
- const dbUrlFile = path.join(stateDir, "database_url");
273
- if (fs.existsSync(dbUrlFile)) fs.unlinkSync(dbUrlFile);
274
- }
275
638
 
276
- /**
277
- * Run database migrations from local source against the test branch.
278
- * If migrations fail, nukes the Neon branch and retries on a fresh fork.
279
- * A second failure means a real migration bug — throws.
280
- */
281
- async function migrate(config) {
282
- const { productDir, stateDir, manifest } = config;
283
- const migrateCmd = manifest.testkit.migrate?.cmd;
284
- if (!migrateCmd) return;
639
+ return { failed };
640
+ }
285
641
 
286
- const dbUrlPath = path.join(stateDir, "database_url");
287
- const env = { ...process.env };
288
- if (fs.existsSync(dbUrlPath)) {
289
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
642
+ async function runPlaywrightSuite(targetConfig, suite) {
643
+ const local = targetConfig.testkit.local;
644
+ if (!local?.baseUrl) {
645
+ throw new Error(
646
+ `Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
647
+ );
290
648
  }
291
649
 
292
- const execOpts = { stdio: "inherit", cwd: productDir, env, shell: true };
650
+ const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
651
+ const files = suite.files.map((file) =>
652
+ path.relative(cwd, path.join(targetConfig.productDir, file))
653
+ );
293
654
 
294
- console.log("\n── migrate ──");
295
655
  try {
296
- await execaCommand(migrateCmd, execOpts);
297
- return;
298
- } catch (err) {
299
- console.log("\nMigration failed — resetting Neon branch and retrying...\n");
656
+ await execa("npx", ["playwright", "test", ...files], {
657
+ cwd,
658
+ env: {
659
+ ...process.env,
660
+ BASE_URL: local.baseUrl,
661
+ PLAYWRIGHT_HTML_OPEN: "never",
662
+ TESTKIT_MANAGED_SERVERS: "1",
663
+ TESTKIT_WORKER_ID: String(targetConfig.workerId),
664
+ },
665
+ stdio: "inherit",
666
+ });
667
+ return { failed: false };
668
+ } catch {
669
+ return { failed: true };
300
670
  }
671
+ }
301
672
 
302
- // Nuke and retry: fresh fork from prod, clean goose state
303
- await neonReset(config);
304
- await neonUp(config);
305
-
306
- // Re-read DATABASE_URL from fresh branch
307
- if (fs.existsSync(dbUrlPath)) {
308
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
673
+ async function stopLocalServices(started) {
674
+ for (const service of [...started].reverse()) {
675
+ await stopChildProcess(service.child);
309
676
  }
677
+ }
310
678
 
311
- try {
312
- await execaCommand(migrateCmd, execOpts);
313
- } catch (retryErr) {
314
- throw new Error(`Migration failed on fresh branch — likely a bug in your migration SQL:\n${retryErr.message}`);
679
+ async function stopChildProcess(child) {
680
+ if (!child || child.exitCode !== null) return;
681
+
682
+ child.kill("SIGTERM");
683
+ const exited = await Promise.race([
684
+ new Promise((resolve) => child.once("exit", () => resolve(true))),
685
+ sleep(5_000).then(() => false),
686
+ ]);
687
+
688
+ if (!exited && child.exitCode === null) {
689
+ child.kill("SIGKILL");
690
+ await new Promise((resolve) => child.once("exit", resolve));
315
691
  }
316
692
  }
317
693
 
318
- /**
319
- * Show state directory contents.
320
- */
321
- export function showStatus(config) {
322
- const { stateDir } = config;
323
- if (!fs.existsSync(stateDir)) {
324
- console.log("No state — run tests first.");
325
- return;
326
- }
327
- for (const file of fs.readdirSync(stateDir)) {
328
- if (file === "fly-env.sh") continue;
329
- const filePath = path.join(stateDir, file);
330
- if (fs.statSync(filePath).isDirectory()) continue;
331
- const val = fs.readFileSync(filePath, "utf8").trim();
332
- console.log(` ${file}: ${val}`);
333
- }
334
-
335
- // Show dependent state dirs
336
- const depsDir = path.join(stateDir, "deps");
337
- if (fs.existsSync(depsDir)) {
338
- for (const depName of fs.readdirSync(depsDir)) {
339
- const depDir = path.join(depsDir, depName);
340
- if (!fs.statSync(depDir).isDirectory()) continue;
341
- console.log(` ── dep: ${depName} ──`);
342
- for (const file of fs.readdirSync(depDir)) {
343
- if (file === "fly-env.sh") continue;
344
- const filePath = path.join(depDir, file);
345
- if (fs.statSync(filePath).isDirectory()) continue;
346
- const val = fs.readFileSync(filePath, "utf8").trim();
347
- console.log(` ${file}: ${val}`);
348
- }
694
+ async function waitForReady({ name, url, timeoutMs, process }) {
695
+ const start = Date.now();
696
+
697
+ while (Date.now() - start < timeoutMs) {
698
+ if (process.exitCode !== null) {
699
+ throw new Error(`Service "${name}" exited before becoming ready`);
349
700
  }
701
+
702
+ try {
703
+ const response = await fetch(url);
704
+ if (response.ok) return;
705
+ } catch {
706
+ // Service still warming up.
707
+ }
708
+
709
+ await sleep(1_000);
350
710
  }
711
+
712
+ throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
351
713
  }
352
714
 
353
- /**
354
- * Run a suite's `pre` command if defined.
355
- * Executes in the product directory with DATABASE_URL available.
356
- */
357
- async function runSuitePre(config, suite) {
358
- if (!suite.pre) return;
359
- const { productDir, stateDir } = config;
360
- const dbUrlPath = path.join(stateDir, "database_url");
361
- const env = { ...process.env };
362
- if (fs.existsSync(dbUrlPath)) {
363
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
715
+ function needsLocalRuntime(suites) {
716
+ return suites.some((suite) => suite.type !== "dal");
717
+ }
718
+
719
+ function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
720
+ if (targetName === serviceName) {
721
+ return workerStateDir;
364
722
  }
365
- console.log(`\n── pre: ${suite.name} ──`);
366
- await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
723
+ return path.join(workerStateDir, "deps", serviceName);
367
724
  }
368
725
 
369
- /**
370
- * Run a single service: orchestrate HTTP and/or DAL test flows.
371
- * Returns true if any tests failed.
372
- */
373
- async function runService(config, suiteType, suiteNames, opts) {
374
- const { manifest, stateDir } = config;
726
+ function readDatabaseUrl(stateDir) {
727
+ return readStateValue(path.join(stateDir, "database_url"));
728
+ }
375
729
 
376
- // Collect suites from manifest, partitioned by flow
377
- const types = suiteType === "all"
378
- ? Object.keys(manifest.suites)
379
- : [suiteType === "int" ? "integration" : suiteType];
730
+ function readStateValue(filePath) {
731
+ if (!fs.existsSync(filePath)) return null;
732
+ return fs.readFileSync(filePath, "utf8").trim();
733
+ }
380
734
 
381
- let httpSuites = [];
382
- let dalSuites = [];
383
- for (const type of types) {
384
- const suites = manifest.suites[type] || [];
385
- for (const suite of suites) {
386
- if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
387
- if (type === "dal") {
388
- dalSuites.push(suite);
389
- } else {
390
- httpSuites.push(suite);
391
- }
735
+ function printStateDir(dir, indent) {
736
+ for (const file of fs.readdirSync(dir)) {
737
+ const filePath = path.join(dir, file);
738
+ if (fs.statSync(filePath).isDirectory()) {
739
+ console.log(`${indent}${file}/`);
740
+ printStateDir(filePath, `${indent} `);
741
+ continue;
392
742
  }
743
+ const value = fs.readFileSync(filePath, "utf8").trim();
744
+ console.log(`${indent}${file}: ${value}`);
393
745
  }
746
+ }
394
747
 
395
- const httpFiles = httpSuites.flatMap((s) => s.files);
396
- const dalFiles = dalSuites.flatMap((s) => s.files);
748
+ function pipeOutput(stream, prefix) {
749
+ if (!stream) return;
397
750
 
398
- if (!httpFiles.length && !dalFiles.length) {
399
- console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
400
- return false;
401
- }
751
+ let pending = "";
752
+ stream.on("data", (chunk) => {
753
+ pending += chunk.toString();
754
+ const lines = pending.split(/\r?\n/);
755
+ pending = lines.pop() || "";
756
+ for (const line of lines) {
757
+ if (line.length > 0) console.log(`${prefix} ${line}`);
758
+ }
759
+ });
760
+ stream.on("end", () => {
761
+ if (pending.length > 0) {
762
+ console.log(`${prefix} ${pending}`);
763
+ }
764
+ });
765
+ }
402
766
 
403
- fs.mkdirSync(stateDir, { recursive: true });
767
+ async function runWithConcurrency(items, limit, handler) {
768
+ const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
769
+ let nextIndex = 0;
404
770
 
405
- let failed = false;
406
- let neonReady = false;
407
-
408
- // HTTP flow: build → neon → migrate → fly → run → fly down
409
- if (httpFiles.length) {
410
- requireFlyToken(config.name);
411
- const deps = manifest.testkit.depends || [];
412
-
413
- // Phase 1: Build primary + deps in parallel
414
- if (opts.build) {
415
- await Promise.all([
416
- build(config),
417
- ...deps.map(dep => buildDep(config, dep)),
418
- ]);
771
+ const workers = Array.from({ length: concurrency }, async () => {
772
+ while (nextIndex < items.length) {
773
+ const current = nextIndex;
774
+ nextIndex += 1;
775
+ await handler(items[current], current);
419
776
  }
777
+ });
420
778
 
421
- // Phase 2: Database (unchanged)
422
- await neonUp(config);
423
- await migrate(config);
424
- neonReady = true;
779
+ await Promise.all(workers);
780
+ }
425
781
 
426
- // Phase 3: Deploy primary + deps in parallel
427
- await Promise.all([
428
- flyUp(config),
429
- ...deps.map(dep => flyUpDep(config, dep)),
430
- ]);
782
+ function sleep(ms) {
783
+ return new Promise((resolve) => setTimeout(resolve, ms));
784
+ }
431
785
 
432
- try {
433
- for (const suite of httpSuites) {
434
- await runSuitePre(config, suite);
435
- }
436
- const result = await runTests(config, httpFiles);
437
- if (result?.failed) failed = true;
438
- } finally {
439
- // Phase 5: Teardown primary + deps in parallel
440
- await Promise.allSettled([
441
- flyDown(config),
442
- ...deps.map(dep => flyDownDep(config, dep)),
443
- ]);
786
+ function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
787
+ const resolved = resolveTemplateString(rawUrl, {
788
+ ...context,
789
+ targetName: targetConfig.name,
790
+ workerId,
791
+ serviceName,
792
+ });
793
+ const actualPort = context.portMap.get(serviceName);
794
+ return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
795
+ }
796
+
797
+ function finalizeString(value, context) {
798
+ let resolved = resolveTemplateString(value, context);
799
+ for (const [source, destination] of context.urlMappings || []) {
800
+ if (source && destination && source !== destination) {
801
+ resolved = resolved.split(source).join(destination);
444
802
  }
445
803
  }
804
+ return resolved;
805
+ }
446
806
 
447
- // DAL flow: neon → migrate → run suite pre hooks → run DAL tests
448
- if (dalFiles.length) {
449
- if (!neonReady) {
450
- await neonUp(config);
451
- await migrate(config);
807
+ function resolveTemplateString(value, context) {
808
+ if (typeof value !== "string") return value;
809
+
810
+ return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
811
+ switch (token) {
812
+ case "worker":
813
+ return String(context.workerId);
814
+ case "target":
815
+ return context.targetName;
816
+ case "service":
817
+ return context.serviceName;
818
+ case "stateDir":
819
+ return context.serviceStateDir;
820
+ case "port": {
821
+ const serviceName = arg || context.serviceName;
822
+ const port = context.portMap.get(serviceName);
823
+ if (!port) {
824
+ throw new Error(`Unknown port placeholder for service "${serviceName}"`);
825
+ }
826
+ return String(port);
827
+ }
828
+ case "baseUrl": {
829
+ const serviceName = arg || context.serviceName;
830
+ const baseUrl = context.baseUrlByService.get(serviceName);
831
+ if (!baseUrl) {
832
+ throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
833
+ }
834
+ return baseUrl;
835
+ }
836
+ case "readyUrl": {
837
+ const serviceName = arg || context.serviceName;
838
+ const readyUrl = context.readyUrlByService.get(serviceName);
839
+ if (!readyUrl) {
840
+ throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
841
+ }
842
+ return readyUrl;
843
+ }
844
+ default:
845
+ throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
452
846
  }
453
- for (const suite of dalSuites) {
454
- await runSuitePre(config, suite);
455
- const result = await runDalTests(config, suite.files);
456
- if (result?.failed) failed = true;
847
+ });
848
+ }
849
+
850
+ function rewriteUrlPort(rawUrl, port) {
851
+ try {
852
+ const original = new URL(rawUrl);
853
+ if (!original.port) return rawUrl;
854
+
855
+ const rewritten = new URL(rawUrl);
856
+ rewritten.port = String(port);
857
+
858
+ let next = rewritten.toString();
859
+ if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
860
+ next = next.slice(0, -1);
457
861
  }
862
+ return next;
863
+ } catch {
864
+ return rawUrl;
458
865
  }
866
+ }
459
867
 
460
- return failed;
868
+ function numericPortFromUrl(rawUrl) {
869
+ try {
870
+ const url = new URL(rawUrl);
871
+ const port = Number(url.port);
872
+ return Number.isInteger(port) && port > 0 ? port : null;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+
878
+ async function assertLocalServicePortsAvailable(config) {
879
+ const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
880
+ const seen = new Set();
881
+
882
+ for (const endpoint of endpoints) {
883
+ const socket = socketFromUrl(endpoint);
884
+ if (!socket) continue;
885
+
886
+ const key = `${socket.host}:${socket.port}`;
887
+ if (seen.has(key)) continue;
888
+ seen.add(key);
889
+
890
+ if (await isPortInUse(socket)) {
891
+ throw new Error(
892
+ `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
893
+ `Stop the existing process and rerun testkit.`
894
+ );
895
+ }
896
+ }
897
+ }
898
+
899
+ function socketFromUrl(rawUrl) {
900
+ try {
901
+ const url = new URL(rawUrl);
902
+ const port = Number(url.port);
903
+ if (!Number.isInteger(port) || port <= 0) return null;
904
+
905
+ const host = normalizeSocketHost(url.hostname);
906
+ return host ? { host, port } : null;
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
911
+
912
+ function normalizeSocketHost(hostname) {
913
+ if (!hostname || hostname === "localhost") return "127.0.0.1";
914
+ if (hostname === "[::1]") return "::1";
915
+ return hostname;
916
+ }
917
+
918
+ async function isPortInUse({ host, port }) {
919
+ return new Promise((resolve, reject) => {
920
+ const socket = new net.Socket();
921
+ let settled = false;
922
+
923
+ const finish = (value, error = null) => {
924
+ if (settled) return;
925
+ settled = true;
926
+ socket.destroy();
927
+ if (error) {
928
+ reject(error);
929
+ return;
930
+ }
931
+ resolve(value);
932
+ };
933
+
934
+ socket.setTimeout(1_000);
935
+ socket.once("connect", () => finish(true));
936
+ socket.once("timeout", () => finish(false));
937
+ socket.once("error", (error) => {
938
+ if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
939
+ finish(false);
940
+ return;
941
+ }
942
+ finish(false, error);
943
+ });
944
+
945
+ socket.connect(port, host);
946
+ });
947
+ }
948
+
949
+ function findRuntimeStateDirs(rootDir) {
950
+ const found = [];
951
+
952
+ const visit = (dir) => {
953
+ if (!fs.existsSync(dir)) return;
954
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
955
+ const hasRuntimeFiles = entries.some(
956
+ (entry) =>
957
+ entry.isFile() &&
958
+ (entry.name === "neon_project_id" || entry.name === "neon_branch_id")
959
+ );
960
+ if (hasRuntimeFiles) {
961
+ found.push(dir);
962
+ }
963
+
964
+ for (const entry of entries) {
965
+ if (entry.isDirectory()) {
966
+ visit(path.join(dir, entry.name));
967
+ }
968
+ }
969
+ };
970
+
971
+ visit(rootDir);
972
+ return found.sort((a, b) => b.length - a.length);
461
973
  }