@elench/testkit 0.1.10 → 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,461 +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
- NEON_RESET: tk.neon.reset === false ? "false" : "true",
68
- STATE_DIR: stateDir,
69
- });
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;
70
112
  }
71
113
 
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;
78
-
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}"`);
83
- }
84
- for (const k of tk.fly.secrets || []) {
85
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
86
- }
87
- const flyEnvPath = path.join(stateDir, "fly-env.sh");
88
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
89
-
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
- });
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;
98
137
  }
99
138
 
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
- });
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;
114
148
  }
115
149
 
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 });
124
-
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"));
129
- }
130
-
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}"`);
135
- }
136
- for (const k of dep.fly.secrets || []) {
137
- envLines.push(` --env "${k}=\${${k}}"`); // shell substitution at source-time
138
- }
139
- const flyEnvPath = path.join(depStateDir, "fly-env.sh");
140
- fs.writeFileSync(flyEnvPath, `FLY_ENV_ARGS=(\n${envLines.join("\n")}\n)\n`);
141
-
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
- });
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;
150
176
  }
151
177
 
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
- });
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
+ }
161
198
  }
162
199
 
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
- });
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
+ }
172
218
  }
173
219
 
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 });
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);
187
249
  }
250
+ } catch (error) {
251
+ await stopLocalServices(started);
252
+ throw error;
188
253
  }
189
254
 
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,
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"],
194
282
  });
195
- 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 };
196
303
  }
197
304
 
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;
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
+ }
204
317
 
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();
318
+ throw new Error(
319
+ `Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
320
+ );
321
+ }
208
322
 
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]}`);
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`);
213
327
  }
214
- const envStr = envFlags.join(" ");
215
328
 
216
329
  let failed = false;
217
- for (const file of files) {
218
- const absFile = path.join(productDir, file);
330
+ for (const file of suite.files) {
331
+ const absFile = path.join(targetConfig.productDir, file);
219
332
  try {
220
- await execaCommand(`k6 run ${envStr} ${absFile}`, { stdio: "inherit" });
221
- } 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 {
222
339
  failed = true;
223
340
  }
224
341
  }
342
+
225
343
  return { failed };
226
344
  }
227
345
 
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();
235
-
236
- // Read DATABASE_URL from neon state
237
- const databaseUrl = fs.readFileSync(path.join(stateDir, "database_url"), "utf8").trim();
238
-
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]}`);
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`);
243
350
  }
244
- const envStr = envFlags.join(" ");
245
351
 
352
+ const k6Binary = resolveDalBinary();
246
353
  let failed = false;
247
- for (const file of files) {
248
- const absFile = path.join(productDir, file);
354
+
355
+ for (const file of suite.files) {
356
+ const absFile = path.join(targetConfig.productDir, file);
249
357
  try {
250
- await execaCommand(`${k6Binary} run ${envStr} ${absFile}`, { stdio: "inherit" });
251
- } 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 {
252
364
  failed = true;
253
365
  }
254
366
  }
255
- return { failed };
256
- }
257
367
 
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,
268
- });
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);
368
+ return { failed };
274
369
  }
275
370
 
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;
285
-
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();
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
+ );
290
377
  }
291
378
 
292
- 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)));
293
381
 
294
- console.log("\n── migrate ──");
295
382
  try {
296
- await execaCommand(migrateCmd, execOpts);
297
- return;
298
- } catch (err) {
299
- 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 };
300
396
  }
397
+ }
301
398
 
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();
399
+ async function stopLocalServices(started) {
400
+ for (const service of [...started].reverse()) {
401
+ await stopChildProcess(service.child);
309
402
  }
403
+ }
310
404
 
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}`);
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));
315
417
  }
316
418
  }
317
419
 
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
- }
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`);
349
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);
350
436
  }
437
+
438
+ throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
351
439
  }
352
440
 
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();
364
- }
365
- console.log(`\n── pre: ${suite.name} ──`);
366
- await execaCommand(suite.pre, { stdio: "inherit", cwd: productDir, env, shell: true });
441
+ function needsLocalRuntime(suites) {
442
+ return suites.some((suite) => suite.type !== "dal");
367
443
  }
368
444
 
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;
445
+ function getServiceStateDir(targetConfig, serviceName) {
446
+ if (targetConfig.name === serviceName) {
447
+ return targetConfig.stateDir;
448
+ }
449
+ return path.join(targetConfig.stateDir, "deps", serviceName);
450
+ }
375
451
 
376
- // Collect suites from manifest, partitioned by flow
377
- const types = suiteType === "all"
378
- ? Object.keys(manifest.suites)
379
- : [suiteType === "int" ? "integration" : suiteType];
452
+ function readDatabaseUrl(stateDir) {
453
+ return readStateValue(path.join(stateDir, "database_url"));
454
+ }
380
455
 
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
- }
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;
392
468
  }
469
+ const value = fs.readFileSync(filePath, "utf8").trim();
470
+ console.log(`${indent}${file}: ${value}`);
393
471
  }
472
+ }
394
473
 
395
- const httpFiles = httpSuites.flatMap((s) => s.files);
396
- const dalFiles = dalSuites.flatMap((s) => s.files);
474
+ function pipeOutput(stream, prefix) {
475
+ if (!stream) return;
397
476
 
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
- }
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
+ }
402
492
 
403
- fs.mkdirSync(stateDir, { recursive: true });
493
+ function sleep(ms) {
494
+ return new Promise((resolve) => setTimeout(resolve, ms));
495
+ }
404
496
 
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
- ]);
419
- }
497
+ function portFromUrl(rawUrl) {
498
+ try {
499
+ const url = new URL(rawUrl);
500
+ return url.port || null;
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
420
505
 
421
- // Phase 2: Database (unchanged)
422
- await neonUp(config);
423
- await migrate(config);
424
- neonReady = true;
506
+ async function assertLocalServicePortsAvailable(config) {
507
+ const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
508
+ const seen = new Set();
425
509
 
426
- // Phase 3: Deploy primary + deps in parallel
427
- await Promise.all([
428
- flyUp(config),
429
- ...deps.map(dep => flyUpDep(config, dep)),
430
- ]);
510
+ for (const endpoint of endpoints) {
511
+ const socket = socketFromUrl(endpoint);
512
+ if (!socket) continue;
431
513
 
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
- ]);
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
+ );
444
522
  }
445
523
  }
524
+ }
446
525
 
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);
452
- }
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;
457
- }
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;
458
536
  }
537
+ }
459
538
 
460
- 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
+ });
461
574
  }