@elench/testkit 0.1.9 → 0.1.11

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,460 +1,574 @@
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);
17
- return;
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
+
17
+ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
18
+ const configMap = new Map(allConfigs.map((config) => [config.name, config]));
19
+ let failed = false;
20
+
21
+ for (const config of configs) {
22
+ console.log(`\n══ ${config.name} ══`);
23
+ const serviceFailed = await runService(config, configMap, suiteType, suiteNames, opts);
24
+ if (serviceFailed) failed = true;
18
25
  }
19
26
 
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
- })
26
- );
27
+ if (failed) process.exit(1);
28
+ }
27
29
 
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}`;
30
+ export async function destroy(config) {
31
+ const runtimeServices = [];
32
+ if (config.testkit.dependsOn) {
33
+ for (const depName of config.testkit.dependsOn) {
34
+ runtimeServices.push({
35
+ name: depName,
36
+ stateDir: path.join(config.stateDir, "deps", depName),
37
+ testkit: null,
38
+ });
39
+ }
40
+ }
41
+ runtimeServices.push({
42
+ name: config.name,
43
+ stateDir: config.stateDir,
44
+ testkit: config.testkit,
33
45
  });
34
- console.log(`\n── Summary ──\n${summary.join("\n")}`);
35
46
 
36
- const anyFailed = results.some(
37
- (r) => r.status === "rejected" || r.value === true
38
- );
39
- if (anyFailed) process.exit(1);
47
+ for (const runtime of runtimeServices) {
48
+ if (!fs.existsSync(runtime.stateDir)) continue;
49
+ const dbFile = path.join(runtime.stateDir, "neon_branch_id");
50
+ if (fs.existsSync(dbFile)) {
51
+ const serviceConfig =
52
+ runtime.name === config.name
53
+ ? config.testkit
54
+ : { database: { projectId: readStateValue(path.join(runtime.stateDir, "neon_project_id")) } };
55
+
56
+ const projectId = serviceConfig.database?.projectId;
57
+ if (projectId) {
58
+ await runScript("neon-down.sh", {
59
+ NEON_PROJECT_ID: projectId,
60
+ STATE_DIR: runtime.stateDir,
61
+ });
62
+ }
63
+ }
64
+ }
65
+
66
+ fs.rmSync(config.stateDir, { recursive: true, force: true });
40
67
  }
41
68
 
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
- });
69
+ export function showStatus(config) {
70
+ if (!fs.existsSync(config.stateDir)) {
71
+ console.log("No state — run tests first.");
72
+ return;
73
+ }
74
+
75
+ printStateDir(config.stateDir, " ");
55
76
  }
56
77
 
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
- STATE_DIR: stateDir,
68
- });
78
+ async function runService(targetConfig, configMap, suiteType, suiteNames, opts) {
79
+ const suites = collectSuites(targetConfig, suiteType, suiteNames, opts.framework);
80
+ if (suites.length === 0) {
81
+ console.log(
82
+ `No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} skipping`
83
+ );
84
+ return false;
85
+ }
86
+
87
+ const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
88
+ fs.mkdirSync(targetConfig.stateDir, { recursive: true });
89
+
90
+ let startedServices = [];
91
+ let failed = false;
92
+
93
+ try {
94
+ await prepareDatabases(runtimeConfigs, targetConfig);
95
+ await runMigrations(runtimeConfigs, targetConfig);
96
+ await runSeeds(runtimeConfigs, targetConfig);
97
+
98
+ if (needsLocalRuntime(suites)) {
99
+ startedServices = await startLocalServices(runtimeConfigs, targetConfig);
100
+ }
101
+
102
+ for (const suite of suites) {
103
+ console.log(`\n── ${suite.type}:${suite.name} (${suite.framework}) ──`);
104
+ const result = await runSuite(targetConfig, suite, targetConfig.stateDir);
105
+ if (result.failed) failed = true;
106
+ }
107
+ } finally {
108
+ await stopLocalServices(startedServices);
109
+ }
110
+
111
+ return failed;
69
112
  }
70
113
 
71
- /**
72
- * Generate fly-env.sh and start/create the Fly machine.
73
- */
74
- export async function flyUp(config) {
75
- const { productDir, stateDir, manifest } = config;
76
- const tk = manifest.testkit;
77
-
78
- // Generate fly-env.sh from manifest (bash array for safe quoting)
79
- const envLines = [];
80
- for (const [k, v] of Object.entries(tk.fly.env || {})) {
81
- envLines.push(` --env "${k}=${v}"`);
82
- }
83
- for (const k of tk.fly.secrets || []) {
84
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
85
- }
86
- const flyEnvPath = path.join(stateDir, "fly-env.sh");
87
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
88
-
89
- await runScript("fly-up.sh", {
90
- FLY_APP: tk.fly.app,
91
- FLY_ORG: tk.fly.org,
92
- FLY_REGION: tk.fly.region || "lhr",
93
- FLY_PORT: tk.fly.port,
94
- FLY_ENV_FILE: flyEnvPath,
95
- STATE_DIR: stateDir,
96
- });
114
+ function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
115
+ const types =
116
+ suiteType === "all"
117
+ ? orderedTypes(Object.keys(config.suites))
118
+ : [suiteType === "int" ? "integration" : suiteType];
119
+
120
+ const selectedNames = new Set(suiteNames);
121
+ const suites = [];
122
+
123
+ for (const type of types) {
124
+ for (const suite of config.suites[type] || []) {
125
+ const framework = suite.framework || "k6";
126
+ if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
127
+ if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
128
+ suites.push({
129
+ ...suite,
130
+ framework,
131
+ type,
132
+ });
133
+ }
134
+ }
135
+
136
+ return suites;
97
137
  }
98
138
 
99
- /**
100
- * Build a dependent service's Docker image.
101
- */
102
- async function buildDep(config, dep) {
103
- const { productDir, stateDir } = config;
104
- const depStateDir = path.join(stateDir, "deps", dep.name);
105
- fs.mkdirSync(depStateDir, { recursive: true });
106
- await runScript("fly-build.sh", {
107
- FLY_APP: dep.fly.app,
108
- FLY_ORG: dep.fly.org,
109
- API_DIR: productDir,
110
- DOCKERFILE_DIR: path.join(productDir, dep.dockerfile),
111
- STATE_DIR: depStateDir,
112
- });
139
+ function orderedTypes(types) {
140
+ const ordered = [];
141
+ for (const known of TYPE_ORDER) {
142
+ if (types.includes(known)) ordered.push(known);
143
+ }
144
+ for (const type of types) {
145
+ if (!ordered.includes(type)) ordered.push(type);
146
+ }
147
+ return ordered;
113
148
  }
114
149
 
115
- /**
116
- * Deploy a dependent service's Fly machine.
117
- * Copies database_url from primary, generates fly-env.sh, calls fly-up.sh.
118
- */
119
- async function flyUpDep(config, dep) {
120
- const { stateDir } = config;
121
- const depStateDir = path.join(stateDir, "deps", dep.name);
122
- fs.mkdirSync(depStateDir, { recursive: true });
123
-
124
- // Copy database_url from primary state dir
125
- const primaryDbUrl = path.join(stateDir, "database_url");
126
- if (fs.existsSync(primaryDbUrl)) {
127
- fs.copyFileSync(primaryDbUrl, path.join(depStateDir, "database_url"));
128
- }
129
-
130
- // Generate fly-env.sh for dependent (bash array for safe quoting)
131
- const envLines = [];
132
- for (const [k, v] of Object.entries(dep.fly.env || {})) {
133
- envLines.push(` --env "${k}=${v}"`);
134
- }
135
- for (const k of dep.fly.secrets || []) {
136
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
137
- }
138
- const flyEnvPath = path.join(depStateDir, "fly-env.sh");
139
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
140
-
141
- await runScript("fly-up.sh", {
142
- FLY_APP: dep.fly.app,
143
- FLY_ORG: dep.fly.org,
144
- FLY_REGION: dep.fly.region || "lhr",
145
- FLY_PORT: dep.fly.port,
146
- FLY_ENV_FILE: flyEnvPath,
147
- STATE_DIR: depStateDir,
148
- });
150
+ function resolveRuntimeConfigs(targetConfig, configMap) {
151
+ const ordered = [];
152
+ const visiting = new Set();
153
+ const seen = new Set();
154
+
155
+ const visit = (config) => {
156
+ if (seen.has(config.name)) return;
157
+ if (visiting.has(config.name)) {
158
+ throw new Error(`Dependency cycle detected involving "${config.name}"`);
159
+ }
160
+
161
+ visiting.add(config.name);
162
+ for (const depName of config.testkit.dependsOn || []) {
163
+ const dep = configMap.get(depName);
164
+ if (!dep) {
165
+ throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
166
+ }
167
+ visit(dep);
168
+ }
169
+ visiting.delete(config.name);
170
+ seen.add(config.name);
171
+ ordered.push(config);
172
+ };
173
+
174
+ visit(targetConfig);
175
+ return ordered;
149
176
  }
150
177
 
151
- /**
152
- * Stop a dependent service's Fly machine.
153
- */
154
- async function flyDownDep(config, dep) {
155
- const depStateDir = path.join(config.stateDir, "deps", dep.name);
156
- await runScript("fly-down.sh", {
157
- FLY_APP: dep.fly.app,
158
- STATE_DIR: depStateDir,
159
- });
178
+ async function prepareDatabases(runtimeConfigs, targetConfig) {
179
+ for (const config of runtimeConfigs) {
180
+ const db = config.testkit.database;
181
+ if (!db) continue;
182
+
183
+ requireNeonApiKey();
184
+
185
+ const stateDir = getServiceStateDir(targetConfig, config.name);
186
+ fs.mkdirSync(stateDir, { recursive: true });
187
+
188
+ await runScript("neon-up.sh", {
189
+ NEON_PROJECT_ID: db.projectId,
190
+ NEON_DB_NAME: db.dbName,
191
+ NEON_BRANCH_NAME: db.branchName || `${targetConfig.name}-${config.name}-testkit`,
192
+ NEON_RESET: db.reset === false ? "false" : "true",
193
+ STATE_DIR: stateDir,
194
+ });
195
+
196
+ fs.writeFileSync(path.join(stateDir, "neon_project_id"), db.projectId);
197
+ }
160
198
  }
161
199
 
162
- /**
163
- * Stop the Fly machine (preserved for reuse).
164
- */
165
- export async function flyDown(config) {
166
- const { stateDir, manifest } = config;
167
- await runScript("fly-down.sh", {
168
- FLY_APP: manifest.testkit.fly.app,
169
- STATE_DIR: stateDir,
170
- });
200
+ async function runMigrations(runtimeConfigs, targetConfig) {
201
+ for (const config of runtimeConfigs) {
202
+ const migrate = config.testkit.migrate;
203
+ if (!migrate) continue;
204
+
205
+ const stateDir = getServiceStateDir(targetConfig, config.name);
206
+ const env = { ...process.env };
207
+ const dbUrl = readDatabaseUrl(stateDir);
208
+ if (dbUrl) env.DATABASE_URL = dbUrl;
209
+
210
+ console.log(`\n── migrate:${config.name} ──`);
211
+ await execaCommand(migrate.cmd, {
212
+ cwd: resolveServiceCwd(config.productDir, migrate.cwd),
213
+ env,
214
+ stdio: "inherit",
215
+ shell: true,
216
+ });
217
+ }
171
218
  }
172
219
 
173
- /**
174
- * Destroy machine + neon branch + state.
175
- * Destroys dependent machines first, then primary.
176
- */
177
- export async function destroy(config) {
178
- const { stateDir, manifest } = config;
179
- const tk = manifest.testkit;
180
-
181
- // Destroy dependents first
182
- for (const dep of tk.depends || []) {
183
- const depStateDir = path.join(stateDir, "deps", dep.name);
184
- if (fs.existsSync(depStateDir)) {
185
- await runScript("fly-destroy.sh", { FLY_APP: dep.fly.app, STATE_DIR: depStateDir });
220
+ async function runSeeds(runtimeConfigs, targetConfig) {
221
+ for (const config of runtimeConfigs) {
222
+ const seed = config.testkit.seed;
223
+ if (!seed) continue;
224
+
225
+ const stateDir = getServiceStateDir(targetConfig, config.name);
226
+ const env = { ...process.env };
227
+ const dbUrl = readDatabaseUrl(stateDir);
228
+ if (dbUrl) env.DATABASE_URL = dbUrl;
229
+
230
+ console.log(`\n── seed:${config.name} ──`);
231
+ await execaCommand(seed.cmd, {
232
+ cwd: resolveServiceCwd(config.productDir, seed.cwd),
233
+ env,
234
+ stdio: "inherit",
235
+ shell: true,
236
+ });
237
+ }
238
+ }
239
+
240
+ async function startLocalServices(runtimeConfigs, targetConfig) {
241
+ const started = [];
242
+
243
+ try {
244
+ for (const config of runtimeConfigs) {
245
+ if (!config.testkit.local) continue;
246
+ const stateDir = getServiceStateDir(targetConfig, config.name);
247
+ const proc = await startLocalService(config, stateDir);
248
+ started.push(proc);
186
249
  }
250
+ } catch (error) {
251
+ await stopLocalServices(started);
252
+ throw error;
187
253
  }
188
254
 
189
- await runScript("fly-destroy.sh", { FLY_APP: tk.fly.app, STATE_DIR: stateDir });
190
- await runScript("neon-down.sh", {
191
- NEON_PROJECT_ID: tk.neon.projectId,
192
- STATE_DIR: stateDir,
255
+ return started;
256
+ }
257
+
258
+ async function startLocalService(config, stateDir) {
259
+ const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
260
+ const env = {
261
+ ...process.env,
262
+ ...config.testkit.local.env,
263
+ };
264
+ const port = portFromUrl(config.testkit.local.baseUrl);
265
+ if (port) {
266
+ env.PORT = port;
267
+ }
268
+
269
+ const dbUrl = readDatabaseUrl(stateDir);
270
+ if (dbUrl) {
271
+ env.DATABASE_URL = dbUrl;
272
+ }
273
+
274
+ await assertLocalServicePortsAvailable(config);
275
+
276
+ console.log(`Starting ${config.name}: ${config.testkit.local.start}`);
277
+ const child = spawn(config.testkit.local.start, {
278
+ cwd,
279
+ env,
280
+ shell: true,
281
+ stdio: ["ignore", "pipe", "pipe"],
193
282
  });
194
- fs.rmSync(stateDir, { recursive: true, force: true });
283
+
284
+ pipeOutput(child.stdout, `[${config.name}]`);
285
+ pipeOutput(child.stderr, `[${config.name}]`);
286
+
287
+ const readyTimeoutMs =
288
+ config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
289
+
290
+ try {
291
+ await waitForReady({
292
+ name: config.name,
293
+ url: config.testkit.local.readyUrl,
294
+ timeoutMs: readyTimeoutMs,
295
+ process: child,
296
+ });
297
+ } catch (error) {
298
+ await stopChildProcess(child);
299
+ throw error;
300
+ }
301
+
302
+ return { name: config.name, child };
195
303
  }
196
304
 
197
- /**
198
- * Run k6 test files against the Fly machine (HTTP tests).
199
- */
200
- export async function runTests(config, files) {
201
- const { productDir, stateDir, manifest } = config;
202
- const tk = manifest.testkit;
305
+ async function runSuite(targetConfig, suite, stateDir) {
306
+ if (suite.type === "dal") {
307
+ return runDalSuite(targetConfig, suite, stateDir);
308
+ }
309
+
310
+ if (suite.framework === "playwright") {
311
+ return runPlaywrightSuite(targetConfig, suite);
312
+ }
313
+
314
+ if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
315
+ return runHttpK6Suite(targetConfig, suite);
316
+ }
203
317
 
204
- // Read state files
205
- const baseUrl = fs.readFileSync(path.join(stateDir, "fly_hostname"), "utf8").trim();
206
- const machineId = fs.readFileSync(path.join(stateDir, "fly_machine_id"), "utf8").trim();
318
+ throw new Error(
319
+ `Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
320
+ );
321
+ }
207
322
 
208
- // Build -e flags
209
- const envFlags = [`-e BASE_URL=${baseUrl}`, `-e MACHINE_ID=${machineId}`];
210
- for (const key of tk.k6?.secrets || []) {
211
- envFlags.push(`-e ${key}=${process.env[key]}`);
323
+ async function runHttpK6Suite(targetConfig, suite) {
324
+ const baseUrl = targetConfig.testkit.local?.baseUrl;
325
+ if (!baseUrl) {
326
+ throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
212
327
  }
213
- const envStr = envFlags.join(" ");
214
328
 
215
329
  let failed = false;
216
- for (const file of files) {
217
- const absFile = path.join(productDir, file);
330
+ for (const file of suite.files) {
331
+ const absFile = path.join(targetConfig.productDir, file);
218
332
  try {
219
- await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
220
- } catch (err) {
333
+ await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
334
+ cwd: targetConfig.productDir,
335
+ env: process.env,
336
+ stdio: "inherit",
337
+ });
338
+ } catch {
221
339
  failed = true;
222
340
  }
223
341
  }
342
+
224
343
  return { failed };
225
344
  }
226
345
 
227
- /**
228
- * Run k6 DAL test files directly against Neon (no Fly machine).
229
- */
230
- export async function runDalTests(config, files) {
231
- const { productDir, stateDir, manifest } = config;
232
- const tk = manifest.testkit;
233
- const k6Binary = resolveDalBinary();
234
-
235
- // Read DATABASE_URL from neon state
236
- const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
237
-
238
- // Build -e flags
239
- const envFlags = [`-e DATABASE_URL=${databaseUrl}`];
240
- for (const key of tk.dal?.secrets || []) {
241
- envFlags.push(`-e ${key}=${process.env[key]}`);
346
+ async function runDalSuite(targetConfig, suite, stateDir) {
347
+ const databaseUrl = readDatabaseUrl(stateDir);
348
+ if (!databaseUrl) {
349
+ throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
242
350
  }
243
- const envStr = envFlags.join(" ");
244
351
 
352
+ const k6Binary = resolveDalBinary();
245
353
  let failed = false;
246
- for (const file of files) {
247
- const absFile = path.join(productDir, file);
354
+
355
+ for (const file of suite.files) {
356
+ const absFile = path.join(targetConfig.productDir, file);
248
357
  try {
249
- await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
250
- } catch (err) {
358
+ await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
359
+ cwd: targetConfig.productDir,
360
+ env: process.env,
361
+ stdio: "inherit",
362
+ });
363
+ } catch {
251
364
  failed = true;
252
365
  }
253
366
  }
254
- return { failed };
255
- }
256
367
 
257
- /**
258
- * Delete the Neon branch (so neonUp creates a fresh fork from prod).
259
- */
260
- async function neonReset(config) {
261
- const { stateDir, manifest } = config;
262
- const tk = manifest.testkit;
263
- console.log("Deleting Neon branch for fresh fork...");
264
- await runScript("neon-down.sh", {
265
- NEON_PROJECT_ID: tk.neon.projectId,
266
- STATE_DIR: stateDir,
267
- });
268
- // Clear branch state so neonUp creates a new one
269
- const branchFile = path.join(stateDir, "neon_branch_id");
270
- if (fs.existsSync(branchFile)) fs.unlinkSync(branchFile);
271
- const dbUrlFile = path.join(stateDir, "database_url");
272
- if (fs.existsSync(dbUrlFile)) fs.unlinkSync(dbUrlFile);
368
+ return { failed };
273
369
  }
274
370
 
275
- /**
276
- * Run database migrations from local source against the test branch.
277
- * If migrations fail, nukes the Neon branch and retries on a fresh fork.
278
- * A second failure means a real migration bug — throws.
279
- */
280
- async function migrate(config) {
281
- const { productDir, stateDir, manifest } = config;
282
- const migrateCmd = manifest.testkit.migrate?.cmd;
283
- if (!migrateCmd) return;
284
-
285
- const dbUrlPath = path.join(stateDir, "database_url");
286
- const env = { ...process.env };
287
- if (fs.existsSync(dbUrlPath)) {
288
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
371
+ async function runPlaywrightSuite(targetConfig, suite) {
372
+ const local = targetConfig.testkit.local;
373
+ if (!local?.baseUrl) {
374
+ throw new Error(
375
+ `Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
376
+ );
289
377
  }
290
378
 
291
- const execOpts = { stdio: "inherit", cwd: productDir, env, shell: true };
379
+ const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
380
+ const files = suite.files.map((file) => path.relative(cwd, path.join(targetConfig.productDir, file)));
292
381
 
293
- console.log("\n── migrate ──");
294
382
  try {
295
- await execaCommand(migrateCmd, execOpts);
296
- return;
297
- } catch (err) {
298
- console.log("\nMigration failed — resetting Neon branch and retrying...\n");
383
+ await execa("npx", ["playwright", "test", ...files], {
384
+ cwd,
385
+ env: {
386
+ ...process.env,
387
+ BASE_URL: local.baseUrl,
388
+ PLAYWRIGHT_HTML_OPEN: "never",
389
+ TESTKIT_MANAGED_SERVERS: "1",
390
+ },
391
+ stdio: "inherit",
392
+ });
393
+ return { failed: false };
394
+ } catch {
395
+ return { failed: true };
299
396
  }
397
+ }
300
398
 
301
- // Nuke and retry: fresh fork from prod, clean goose state
302
- await neonReset(config);
303
- await neonUp(config);
304
-
305
- // Re-read DATABASE_URL from fresh branch
306
- if (fs.existsSync(dbUrlPath)) {
307
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
399
+ async function stopLocalServices(started) {
400
+ for (const service of [...started].reverse()) {
401
+ await stopChildProcess(service.child);
308
402
  }
403
+ }
309
404
 
310
- try {
311
- await execaCommand(migrateCmd, execOpts);
312
- } catch (retryErr) {
313
- throw new Error(`Migration failed on fresh branch — likely a bug in your migration SQL:\n${retryErr.message}`);
405
+ async function stopChildProcess(child) {
406
+ if (!child || child.exitCode !== null) return;
407
+
408
+ child.kill("SIGTERM");
409
+ const exited = await Promise.race([
410
+ new Promise((resolve) => child.once("exit", () => resolve(true))),
411
+ sleep(5_000).then(() => false),
412
+ ]);
413
+
414
+ if (!exited && child.exitCode === null) {
415
+ child.kill("SIGKILL");
416
+ await new Promise((resolve) => child.once("exit", resolve));
314
417
  }
315
418
  }
316
419
 
317
- /**
318
- * Show state directory contents.
319
- */
320
- export function showStatus(config) {
321
- const { stateDir } = config;
322
- if (!fs.existsSync(stateDir)) {
323
- console.log("No state — run tests first.");
324
- return;
325
- }
326
- for (const file of fs.readdirSync(stateDir)) {
327
- if (file === "fly-env.sh") continue;
328
- const filePath = path.join(stateDir, file);
329
- if (fs.statSync(filePath).isDirectory()) continue;
330
- const val = fs.readFileSync(filePath, "utf8").trim();
331
- console.log(` ${file}: ${val}`);
332
- }
333
-
334
- // Show dependent state dirs
335
- const depsDir = path.join(stateDir, "deps");
336
- if (fs.existsSync(depsDir)) {
337
- for (const depName of fs.readdirSync(depsDir)) {
338
- const depDir = path.join(depsDir, depName);
339
- if (!fs.statSync(depDir).isDirectory()) continue;
340
- console.log(` ── dep: ${depName} ──`);
341
- for (const file of fs.readdirSync(depDir)) {
342
- if (file === "fly-env.sh") continue;
343
- const filePath = path.join(depDir, file);
344
- if (fs.statSync(filePath).isDirectory()) continue;
345
- const val = fs.readFileSync(filePath, "utf8").trim();
346
- console.log(` ${file}: ${val}`);
347
- }
420
+ async function waitForReady({ name, url, timeoutMs, process }) {
421
+ const start = Date.now();
422
+
423
+ while (Date.now() - start < timeoutMs) {
424
+ if (process.exitCode !== null) {
425
+ throw new Error(`Service "${name}" exited before becoming ready`);
348
426
  }
427
+
428
+ try {
429
+ const response = await fetch(url);
430
+ if (response.ok) return;
431
+ } catch {
432
+ // Service still warming up.
433
+ }
434
+
435
+ await sleep(1_000);
349
436
  }
437
+
438
+ throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
350
439
  }
351
440
 
352
- /**
353
- * Run a suite's `pre` command if defined.
354
- * Executes in the product directory with DATABASE_URL available.
355
- */
356
- async function runSuitePre(config, suite) {
357
- if (!suite.pre) return;
358
- const { productDir, stateDir } = config;
359
- const dbUrlPath = path.join(stateDir, "database_url");
360
- const env = { ...process.env };
361
- if (fs.existsSync(dbUrlPath)) {
362
- env.DATABASE_URL = fs.readFileSync(dbUrlPath, "utf8").trim();
363
- }
364
- console.log(`\n── pre: ${suite.name} ──`);
365
- await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
441
+ function needsLocalRuntime(suites) {
442
+ return suites.some((suite) => suite.type !== "dal");
366
443
  }
367
444
 
368
- /**
369
- * Run a single service: orchestrate HTTP and/or DAL test flows.
370
- * Returns true if any tests failed.
371
- */
372
- async function runService(config, suiteType, suiteNames, opts) {
373
- const { manifest, stateDir } = config;
445
+ function getServiceStateDir(targetConfig, serviceName) {
446
+ if (targetConfig.name === serviceName) {
447
+ return targetConfig.stateDir;
448
+ }
449
+ return path.join(targetConfig.stateDir, "deps", serviceName);
450
+ }
374
451
 
375
- // Collect suites from manifest, partitioned by flow
376
- const types = suiteType === "all"
377
- ? Object.keys(manifest.suites)
378
- : [suiteType === "int" ? "integration" : suiteType];
452
+ function readDatabaseUrl(stateDir) {
453
+ return readStateValue(path.join(stateDir, "database_url"));
454
+ }
379
455
 
380
- let httpSuites = [];
381
- let dalSuites = [];
382
- for (const type of types) {
383
- const suites = manifest.suites[type] || [];
384
- for (const suite of suites) {
385
- if (suiteNames.length && !suiteNames.includes(suite.name)) continue;
386
- if (type === "dal") {
387
- dalSuites.push(suite);
388
- } else {
389
- httpSuites.push(suite);
390
- }
456
+ function readStateValue(filePath) {
457
+ if (!fs.existsSync(filePath)) return null;
458
+ return fs.readFileSync(filePath, "utf8").trim();
459
+ }
460
+
461
+ function printStateDir(dir, indent) {
462
+ for (const file of fs.readdirSync(dir)) {
463
+ const filePath = path.join(dir, file);
464
+ if (fs.statSync(filePath).isDirectory()) {
465
+ console.log(`${indent}${file}/`);
466
+ printStateDir(filePath, `${indent} `);
467
+ continue;
391
468
  }
469
+ const value = fs.readFileSync(filePath, "utf8").trim();
470
+ console.log(`${indent}${file}: ${value}`);
392
471
  }
472
+ }
393
473
 
394
- const httpFiles = httpSuites.flatMap((s) => s.files);
395
- const dalFiles = dalSuites.flatMap((s) => s.files);
474
+ function pipeOutput(stream, prefix) {
475
+ if (!stream) return;
396
476
 
397
- if (!httpFiles.length && !dalFiles.length) {
398
- console.log(`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`);
399
- return false;
400
- }
477
+ let pending = "";
478
+ stream.on("data", (chunk) => {
479
+ pending += chunk.toString();
480
+ const lines = pending.split(/\r?\n/);
481
+ pending = lines.pop() || "";
482
+ for (const line of lines) {
483
+ if (line.length > 0) console.log(`${prefix} ${line}`);
484
+ }
485
+ });
486
+ stream.on("end", () => {
487
+ if (pending.length > 0) {
488
+ console.log(`${prefix} ${pending}`);
489
+ }
490
+ });
491
+ }
401
492
 
402
- fs.mkdirSync(stateDir, { recursive: true });
493
+ function sleep(ms) {
494
+ return new Promise((resolve) => setTimeout(resolve, ms));
495
+ }
403
496
 
404
- let failed = false;
405
- let neonReady = false;
406
-
407
- // HTTP flow: build → neon → migrate → fly → run → fly down
408
- if (httpFiles.length) {
409
- requireFlyToken(config.name);
410
- const deps = manifest.testkit.depends || [];
411
-
412
- // Phase 1: Build primary + deps in parallel
413
- if (opts.build) {
414
- await Promise.all([
415
- build(config),
416
- ...deps.map(dep => buildDep(config, dep)),
417
- ]);
418
- }
497
+ function portFromUrl(rawUrl) {
498
+ try {
499
+ const url = new URL(rawUrl);
500
+ return url.port || null;
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
419
505
 
420
- // Phase 2: Database (unchanged)
421
- await neonUp(config);
422
- await migrate(config);
423
- neonReady = true;
506
+ async function assertLocalServicePortsAvailable(config) {
507
+ const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
508
+ const seen = new Set();
424
509
 
425
- // Phase 3: Deploy primary + deps in parallel
426
- await Promise.all([
427
- flyUp(config),
428
- ...deps.map(dep => flyUpDep(config, dep)),
429
- ]);
510
+ for (const endpoint of endpoints) {
511
+ const socket = socketFromUrl(endpoint);
512
+ if (!socket) continue;
430
513
 
431
- try {
432
- for (const suite of httpSuites) {
433
- await runSuitePre(config, suite);
434
- }
435
- const result = await runTests(config, httpFiles);
436
- if (result?.failed) failed = true;
437
- } finally {
438
- // Phase 5: Teardown primary + deps in parallel
439
- await Promise.allSettled([
440
- flyDown(config),
441
- ...deps.map(dep => flyDownDep(config, dep)),
442
- ]);
514
+ const key = `${socket.host}:${socket.port}`;
515
+ if (seen.has(key)) continue;
516
+ seen.add(key);
517
+
518
+ if (await isPortInUse(socket)) {
519
+ throw new Error(
520
+ `Cannot start "${config.name}" because ${key} is already in use. Stop the existing process and rerun testkit.`
521
+ );
443
522
  }
444
523
  }
524
+ }
445
525
 
446
- // DAL flow: neon → migrate → run suite pre hooks → run DAL tests
447
- if (dalFiles.length) {
448
- if (!neonReady) {
449
- await neonUp(config);
450
- await migrate(config);
451
- }
452
- for (const suite of dalSuites) {
453
- await runSuitePre(config, suite);
454
- const result = await runDalTests(config, suite.files);
455
- if (result?.failed) failed = true;
456
- }
526
+ function socketFromUrl(rawUrl) {
527
+ try {
528
+ const url = new URL(rawUrl);
529
+ const port = Number(url.port);
530
+ if (!Number.isInteger(port) || port <= 0) return null;
531
+
532
+ const host = normalizeSocketHost(url.hostname);
533
+ return host ? { host, port } : null;
534
+ } catch {
535
+ return null;
457
536
  }
537
+ }
458
538
 
459
- return failed;
539
+ function normalizeSocketHost(hostname) {
540
+ if (!hostname || hostname === "localhost") return "127.0.0.1";
541
+ if (hostname === "[::1]") return "::1";
542
+ return hostname;
543
+ }
544
+
545
+ async function isPortInUse({ host, port }) {
546
+ return new Promise((resolve, reject) => {
547
+ const socket = new net.Socket();
548
+ let settled = false;
549
+
550
+ const finish = (value, error = null) => {
551
+ if (settled) return;
552
+ settled = true;
553
+ socket.destroy();
554
+ if (error) {
555
+ reject(error);
556
+ return;
557
+ }
558
+ resolve(value);
559
+ };
560
+
561
+ socket.setTimeout(1_000);
562
+ socket.once("connect", () => finish(true));
563
+ socket.once("timeout", () => finish(false));
564
+ socket.once("error", (error) => {
565
+ if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
566
+ finish(false);
567
+ return;
568
+ }
569
+ finish(false, error);
570
+ });
571
+
572
+ socket.connect(port, host);
573
+ });
460
574
  }