@elench/testkit 0.1.30 → 0.1.32

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/cli/args.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from "path";
2
2
 
3
3
  export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
4
- export const LIFECYCLE = new Set(["status", "destroy"]);
4
+ export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
5
5
  export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
6
6
 
7
7
  export function resolveCliSelection({ first, second, serviceNames }) {
package/lib/cli/index.mjs CHANGED
@@ -56,6 +56,11 @@ export function run() {
56
56
  }
57
57
 
58
58
  // Lifecycle commands
59
+ if (type === "cleanup") {
60
+ await runner.cleanup(allConfigs[0]?.productDir || process.cwd());
61
+ return;
62
+ }
63
+
59
64
  if (type === "status" || type === "destroy") {
60
65
  for (const config of configs) {
61
66
  if (configs.length > 1) console.log(`\n── ${config.name} ──`);
@@ -90,6 +90,16 @@ import {
90
90
  writeGraphMetadata as writeGraphMetadataModel,
91
91
  } from "./state.mjs";
92
92
  import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
93
+ import {
94
+ cleanupRunById,
95
+ cleanupRuns,
96
+ cleanupStaleRuns,
97
+ createRunLifecycle,
98
+ findPortOwner,
99
+ formatRunSummary,
100
+ isPidRunning,
101
+ listRunManifests,
102
+ } from "./lifecycle.mjs";
93
103
 
94
104
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
95
105
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
@@ -100,6 +110,7 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
100
110
  const startedAt = Date.now();
101
111
  const telemetry = configs[0]?.telemetry || null;
102
112
  const productDir = configs[0]?.productDir || process.cwd();
113
+ await cleanupStaleRuns(productDir);
103
114
  const metadata = {
104
115
  git: collectGitMetadata(productDir),
105
116
  host: {
@@ -134,80 +145,97 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
134
145
  const trackers = buildServiceTrackers(servicePlans, startedAt);
135
146
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
136
147
  let workerCount = 0;
148
+ let exitCode = 0;
149
+ const lifecycle = createRunLifecycle(productDir);
150
+ lifecycle.markRunning();
151
+ lifecycle.installSignalHandlers();
152
+ let results = [];
153
+ let finishedAt = Date.now();
154
+ try {
155
+ if (executedPlans.length > 0) {
156
+ const timings = loadTimings(productDir);
157
+ const graphs = buildRuntimeGraphs(executedPlans);
158
+ const queue = buildTaskQueue(executedPlans, graphs, timings);
159
+ workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
160
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
161
+ const workers = Array.from({ length: workerCount }, (_unused, index) =>
162
+ createWorker(index + 1, productDir)
163
+ );
164
+ const timingUpdates = [];
137
165
 
138
- if (executedPlans.length > 0) {
139
- const timings = loadTimings(productDir);
140
- const graphs = buildRuntimeGraphs(executedPlans);
141
- const queue = buildTaskQueue(executedPlans, graphs, timings);
142
- workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
143
- const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
144
- const workers = Array.from({ length: workerCount }, (_unused, index) =>
145
- createWorker(index + 1, productDir)
146
- );
147
- const timingUpdates = [];
148
-
149
- const workerResults = await Promise.allSettled(
150
- workers.map((worker) =>
151
- runWorker(worker, queue, graphByKey, trackers, timingUpdates)
152
- )
153
- );
166
+ const workerResults = await Promise.allSettled(
167
+ workers.map((worker) =>
168
+ runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle)
169
+ )
170
+ );
154
171
 
155
- for (const result of workerResults) {
156
- if (result.status === "rejected") {
157
- const message = formatError(result.reason);
158
- for (const tracker of trackers.values()) {
159
- if (!tracker.skipped) addTrackerError(tracker, message);
172
+ for (const result of workerResults) {
173
+ if (result.status === "rejected") {
174
+ const message = formatError(result.reason);
175
+ for (const tracker of trackers.values()) {
176
+ if (!tracker.skipped) addTrackerError(tracker, message);
177
+ }
160
178
  }
161
179
  }
162
- }
163
-
164
- saveTimings(productDir, timings, timingUpdates);
165
- }
166
180
 
167
- const finishedAt = Date.now();
168
- const results = configs.map((config) =>
169
- finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
170
- );
171
- const artifact = buildRunArtifact({
172
- productDir,
173
- results,
174
- startedAt,
175
- finishedAt,
176
- requestedJobs: opts.jobs || 1,
177
- workerCount,
178
- suiteType,
179
- suiteNames,
180
- fileNames: requestedFiles,
181
- framework: opts.framework || "all",
182
- shard: opts.shard || null,
183
- serviceFilter: opts.serviceFilter || null,
184
- metadata,
185
- });
186
-
187
- writeRunArtifact(productDir, artifact);
188
- if (opts.writeStatus) {
189
- writeStatusArtifact(
181
+ saveTimings(productDir, timings, timingUpdates);
182
+ }
183
+ finishedAt = Date.now();
184
+ results = configs.map((config) =>
185
+ finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
186
+ );
187
+ const artifact = buildRunArtifact({
190
188
  productDir,
191
- buildStatusArtifact({
189
+ results,
190
+ startedAt,
191
+ finishedAt,
192
+ requestedJobs: opts.jobs || 1,
193
+ workerCount,
194
+ suiteType,
195
+ suiteNames,
196
+ fileNames: requestedFiles,
197
+ framework: opts.framework || "all",
198
+ shard: opts.shard || null,
199
+ serviceFilter: opts.serviceFilter || null,
200
+ metadata,
201
+ });
202
+
203
+ writeRunArtifact(productDir, artifact);
204
+ if (opts.writeStatus) {
205
+ writeStatusArtifact(
192
206
  productDir,
193
- results,
194
- suiteType,
195
- suiteNames,
196
- fileNames: requestedFiles,
197
- framework: opts.framework || "all",
198
- shard: opts.shard || null,
199
- serviceFilter: opts.serviceFilter || null,
200
- metadata,
201
- })
207
+ buildStatusArtifact({
208
+ productDir,
209
+ results,
210
+ suiteType,
211
+ suiteNames,
212
+ fileNames: requestedFiles,
213
+ framework: opts.framework || "all",
214
+ shard: opts.shard || null,
215
+ serviceFilter: opts.serviceFilter || null,
216
+ metadata,
217
+ })
218
+ );
219
+ }
220
+
221
+ printRunSummary(results, finishedAt - startedAt);
222
+ await reportTelemetry(telemetry, artifact);
223
+ if (results.some((result) => result.failed)) exitCode = 1;
224
+ if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
225
+ } finally {
226
+ lifecycle.removeSignalHandlers();
227
+ lifecycle.markFinished(
228
+ exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
202
229
  );
230
+ await cleanupRunById(productDir, lifecycle.runId);
231
+ await cleanupRuns(productDir, { includeActive: false });
232
+ lifecycle.removeManifest();
233
+ process.exitCode = exitCode;
203
234
  }
204
-
205
- printRunSummary(results, finishedAt - startedAt);
206
- await reportTelemetry(telemetry, artifact);
207
- if (results.some((result) => result.failed)) process.exit(1);
208
235
  }
209
236
 
210
237
  export async function destroy(config) {
238
+ await cleanupRuns(config.productDir, { includeActive: true });
211
239
  const roots = new Set([config.stateDir, ...findGraphDirsForService(config.productDir, config.name)]);
212
240
 
213
241
  for (const rootDir of roots) {
@@ -227,6 +255,7 @@ export async function destroy(config) {
227
255
  }
228
256
 
229
257
  export function showStatus(config) {
258
+ printRunStatus(config.productDir);
230
259
  const graphDirs = findGraphDirsForService(config.productDir, config.name);
231
260
  const hasDirectState = fs.existsSync(config.stateDir);
232
261
  const hasGraphState = graphDirs.length > 0;
@@ -247,6 +276,21 @@ export function showStatus(config) {
247
276
  showServiceDatabaseStatus(config.productDir, config.name);
248
277
  }
249
278
 
279
+ export async function cleanup(productDir) {
280
+ const summary = await cleanupRuns(productDir, { includeActive: false });
281
+ if (summary.cleaned.length === 0 && summary.skippedActive.length === 0) {
282
+ console.log("No stale runs to clean.");
283
+ return;
284
+ }
285
+
286
+ for (const manifest of summary.cleaned) {
287
+ console.log(`Cleaned stale run ${formatRunSummary(manifest)}`);
288
+ }
289
+ for (const manifest of summary.skippedActive) {
290
+ console.log(`Active run still present: ${formatRunSummary(manifest)}`);
291
+ }
292
+ }
293
+
250
294
  function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
251
295
  return configs.map((config) => {
252
296
  console.log(`\n══ ${config.name} ══`);
@@ -304,19 +348,20 @@ function createWorker(workerId, productDir) {
304
348
  };
305
349
  }
306
350
 
307
- async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
351
+ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle) {
308
352
  const startedAt = Date.now();
309
353
  console.log(`\n══ global worker ${worker.workerId} ══`);
310
354
  const errors = [];
311
355
 
312
356
  try {
313
357
  while (true) {
358
+ if (lifecycle.isStopRequested()) break;
314
359
  const batch = claimNextBatch(queue, worker.currentGraphKey);
315
360
  if (!batch) break;
316
361
 
317
362
  try {
318
- const context = await ensureWorkerGraph(worker, batch, graphByKey);
319
- const outcomes = await runBatch(context, batch);
363
+ const context = await ensureWorkerGraph(worker, batch, graphByKey, lifecycle);
364
+ const outcomes = await runBatch(context, batch, lifecycle);
320
365
  for (const outcome of outcomes) {
321
366
  recordTaskOutcome(trackers, outcome.task, outcome);
322
367
  timingUpdates.push({
@@ -329,11 +374,11 @@ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
329
374
  const message = formatError(error);
330
375
  errors.push(message);
331
376
  recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
332
- await resetCurrentGraph(worker);
377
+ await resetCurrentGraph(worker, lifecycle);
333
378
  }
334
379
  }
335
380
  } finally {
336
- await cleanupWorker(worker);
381
+ await cleanupWorker(worker, lifecycle);
337
382
  }
338
383
 
339
384
  return {
@@ -350,14 +395,14 @@ function claimNextBatch(queue, preferredGraphKey) {
350
395
  return claimNextBatchModel(queue, preferredGraphKey);
351
396
  }
352
397
 
353
- async function ensureWorkerGraph(worker, batch, graphByKey) {
398
+ async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
354
399
  const graph = graphByKey.get(batch.graphKey);
355
400
  if (!graph) {
356
401
  throw new Error(`Unknown graph "${batch.graphKey}"`);
357
402
  }
358
403
 
359
404
  if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
360
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
405
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
361
406
  worker.graphSwitches += 1;
362
407
  worker.currentGraphKey = null;
363
408
  }
@@ -366,6 +411,7 @@ async function ensureWorkerGraph(worker, batch, graphByKey) {
366
411
  if (!context) {
367
412
  context = createGraphContext(worker, graph);
368
413
  worker.graphContexts.set(batch.graphKey, context);
414
+ lifecycle.trackGraphContext(context);
369
415
  }
370
416
 
371
417
  if (!context.prepared) {
@@ -374,7 +420,7 @@ async function ensureWorkerGraph(worker, batch, graphByKey) {
374
420
  }
375
421
 
376
422
  if (batchNeedsLocalRuntime(batch) && !context.started) {
377
- context.startedServices = await startLocalServices(context.runtimeConfigs);
423
+ context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
378
424
  context.started = true;
379
425
  }
380
426
 
@@ -407,40 +453,40 @@ function createGraphContext(worker, graph) {
407
453
  };
408
454
  }
409
455
 
410
- async function deactivateGraphContext(context) {
456
+ async function deactivateGraphContext(context, lifecycle) {
411
457
  if (!context?.started) return;
412
- await stopLocalServices(context.startedServices);
458
+ await stopLocalServices(context.startedServices, lifecycle);
413
459
  context.started = false;
414
460
  context.startedServices = [];
415
461
  }
416
462
 
417
- async function resetCurrentGraph(worker) {
463
+ async function resetCurrentGraph(worker, lifecycle) {
418
464
  if (!worker.currentGraphKey) return;
419
- await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
465
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
420
466
  worker.currentGraphKey = null;
421
467
  }
422
468
 
423
- async function cleanupWorker(worker) {
469
+ async function cleanupWorker(worker, lifecycle) {
424
470
  for (const context of worker.graphContexts.values()) {
425
- await deactivateGraphContext(context);
471
+ await deactivateGraphContext(context, lifecycle);
426
472
  }
427
473
  worker.currentGraphKey = null;
428
474
  }
429
475
 
430
- async function runBatch(context, batch) {
476
+ async function runBatch(context, batch, lifecycle) {
431
477
  const targetConfig = context.configByName.get(batch.targetName);
432
478
  if (!targetConfig) {
433
479
  throw new Error(`Worker graph missing target config "${batch.targetName}"`);
434
480
  }
435
481
 
436
482
  if (batch.framework === "playwright") {
437
- return runPlaywrightBatch(targetConfig, batch);
483
+ return runPlaywrightBatch(targetConfig, batch, lifecycle);
438
484
  }
439
485
  if (batch.type === "dal") {
440
- return runDalBatch(targetConfig, batch);
486
+ return runDalBatch(targetConfig, batch, lifecycle);
441
487
  }
442
488
  if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
443
- return runHttpK6Batch(targetConfig, batch);
489
+ return runHttpK6Batch(targetConfig, batch, lifecycle);
444
490
  }
445
491
 
446
492
  throw new Error(
@@ -491,13 +537,13 @@ async function runSeed(config, databaseUrl) {
491
537
  });
492
538
  }
493
539
 
494
- async function startLocalServices(runtimeConfigs) {
540
+ async function startLocalServices(runtimeConfigs, lifecycle) {
495
541
  const started = [];
496
542
 
497
543
  try {
498
544
  for (const config of runtimeConfigs) {
499
545
  if (!config.testkit.local) continue;
500
- const proc = await startLocalService(config);
546
+ const proc = await startLocalService(config, lifecycle);
501
547
  started.push(proc);
502
548
  }
503
549
  } catch (error) {
@@ -508,7 +554,7 @@ async function startLocalServices(runtimeConfigs) {
508
554
  return started;
509
555
  }
510
556
 
511
- async function startLocalService(config) {
557
+ async function startLocalService(config, lifecycle) {
512
558
  const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
513
559
  const env = buildExecutionEnv(config, config.testkit.local.env);
514
560
  const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
@@ -524,18 +570,13 @@ async function startLocalService(config) {
524
570
  await assertLocalServicePortsAvailable(config);
525
571
 
526
572
  console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
527
- const child = spawn(config.testkit.local.start, {
528
- cwd,
529
- env,
530
- detached: true,
531
- shell: true,
532
- stdio: ["ignore", "pipe", "pipe"],
533
- });
573
+ const child = startDetachedCommand(config.testkit.local.start, cwd, env);
534
574
 
535
575
  const outputDrains = [
536
576
  pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
537
577
  pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
538
578
  ];
579
+ lifecycle.registerService(config, child, cwd);
539
580
 
540
581
  const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
541
582
 
@@ -545,16 +586,18 @@ async function startLocalService(config) {
545
586
  url: config.testkit.local.readyUrl,
546
587
  timeoutMs: readyTimeoutMs,
547
588
  process: child,
589
+ signal: lifecycle.signal,
548
590
  });
549
591
  } catch (error) {
550
592
  await stopChildProcess(child, outputDrains);
593
+ lifecycle.unregisterService(child.pid);
551
594
  throw error;
552
595
  }
553
596
 
554
597
  return { name: config.name, child, outputDrains };
555
598
  }
556
599
 
557
- async function runHttpK6Batch(targetConfig, batch) {
600
+ async function runHttpK6Batch(targetConfig, batch, lifecycle) {
558
601
  const baseUrl = targetConfig.testkit.local?.baseUrl;
559
602
  if (!baseUrl) {
560
603
  throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
@@ -565,11 +608,11 @@ async function runHttpK6Batch(targetConfig, batch) {
565
608
  );
566
609
 
567
610
  return Promise.all(
568
- batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl))
611
+ batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
569
612
  );
570
613
  }
571
614
 
572
- async function runHttpK6Task(targetConfig, task, baseUrl) {
615
+ async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
573
616
  const absFile = path.join(targetConfig.productDir, task.file);
574
617
  const k6Binary = resolveK6Binary();
575
618
  const bundledFile = await bundleK6File({
@@ -585,10 +628,10 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
585
628
  "-e",
586
629
  `BASE_URL=${baseUrl}`,
587
630
  bundledFile,
588
- ]);
631
+ ], lifecycle);
589
632
  }
590
633
 
591
- async function runDalBatch(targetConfig, batch) {
634
+ async function runDalBatch(targetConfig, batch, lifecycle) {
592
635
  const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
593
636
  if (!databaseUrl) {
594
637
  throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
@@ -599,11 +642,11 @@ async function runDalBatch(targetConfig, batch) {
599
642
  );
600
643
 
601
644
  return Promise.all(
602
- batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl))
645
+ batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
603
646
  );
604
647
  }
605
648
 
606
- async function runDalTask(targetConfig, task, databaseUrl) {
649
+ async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
607
650
  const absFile = path.join(targetConfig.productDir, task.file);
608
651
  const k6Binary = resolveK6Binary();
609
652
  const bundledFile = await bundleK6File({
@@ -619,10 +662,10 @@ async function runDalTask(targetConfig, task, databaseUrl) {
619
662
  "-e",
620
663
  `DATABASE_URL=${databaseUrl}`,
621
664
  bundledFile,
622
- ]);
665
+ ], lifecycle);
623
666
  }
624
667
 
625
- async function runPlaywrightBatch(targetConfig, batch) {
668
+ async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
626
669
  const local = targetConfig.testkit.local;
627
670
  if (!local?.baseUrl) {
628
671
  throw new Error(
@@ -631,7 +674,7 @@ async function runPlaywrightBatch(targetConfig, batch) {
631
674
  }
632
675
 
633
676
  console.log(
634
- `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
677
+ `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
635
678
  );
636
679
 
637
680
  const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
@@ -647,6 +690,8 @@ async function runPlaywrightBatch(targetConfig, batch) {
647
690
  cwd,
648
691
  env: buildPlaywrightEnv(targetConfig, local.baseUrl),
649
692
  reject: false,
693
+ cancelSignal: lifecycle.signal,
694
+ forceKillAfterDelay: 5_000,
650
695
  }
651
696
  );
652
697
 
@@ -694,9 +739,10 @@ async function runPlaywrightBatch(targetConfig, batch) {
694
739
  });
695
740
  }
696
741
 
697
- async function stopLocalServices(started) {
742
+ async function stopLocalServices(started, lifecycle) {
698
743
  for (const service of [...started].reverse()) {
699
744
  await stopChildProcess(service.child, service.outputDrains);
745
+ lifecycle?.unregisterService(service.child.pid);
700
746
  }
701
747
  }
702
748
 
@@ -721,10 +767,13 @@ async function stopChildProcess(child, outputDrains = []) {
721
767
  await Promise.all(outputDrains);
722
768
  }
723
769
 
724
- async function waitForReady({ name, url, timeoutMs, process }) {
770
+ async function waitForReady({ name, url, timeoutMs, process, signal }) {
725
771
  const start = Date.now();
726
772
 
727
773
  while (Date.now() - start < timeoutMs) {
774
+ if (signal?.aborted) {
775
+ throw signal.reason || new Error(`Service "${name}" startup aborted`);
776
+ }
728
777
  if (process.exitCode !== null) {
729
778
  throw new Error(`Service "${name}" exited before becoming ready`);
730
779
  }
@@ -917,6 +966,15 @@ function formatBatchDescriptor(batch) {
917
966
  return frameworkLabel ? ` (${frameworkLabel}, ${fileLabel})` : ` (${fileLabel})`;
918
967
  }
919
968
 
969
+ function formatPlaywrightBatchFiles(batch) {
970
+ if (!batch?.tasks?.length) return "";
971
+ const files = batch.tasks.map((task) => task.file);
972
+ if (files.length === 1) return ` · ${files[0]}`;
973
+ const preview = files.slice(0, 3).join(", ");
974
+ const suffix = files.length > 3 ? `, +${files.length - 3} more` : "";
975
+ return ` · ${preview}${suffix}`;
976
+ }
977
+
920
978
  function formatFrameworkLabel(framework) {
921
979
  if (!framework || framework === "k6") return "";
922
980
  return framework;
@@ -1076,7 +1134,7 @@ function formatError(error) {
1076
1134
  return formatErrorModel(error);
1077
1135
  }
1078
1136
 
1079
- async function runDefaultRuntimeTask(targetConfig, task, args) {
1137
+ async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle) {
1080
1138
  const k6Binary = resolveK6Binary();
1081
1139
  const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
1082
1140
  fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
@@ -1085,6 +1143,8 @@ async function runDefaultRuntimeTask(targetConfig, task, args) {
1085
1143
  cwd: targetConfig.productDir,
1086
1144
  env: buildExecutionEnv(targetConfig),
1087
1145
  reject: false,
1146
+ cancelSignal: lifecycle.signal,
1147
+ forceKillAfterDelay: 5_000,
1088
1148
  });
1089
1149
 
1090
1150
  if (result.stdout) process.stdout.write(result.stdout);
@@ -1308,14 +1368,38 @@ async function assertLocalServicePortsAvailable(config) {
1308
1368
  seen.add(key);
1309
1369
 
1310
1370
  if (await isPortInUse(socket)) {
1371
+ await cleanupStaleRuns(config.productDir);
1372
+ }
1373
+
1374
+ if (await isPortInUse(socket)) {
1375
+ const owner = findPortOwner(config.productDir, socket);
1376
+ const ownerDetail = owner
1377
+ ? owner.active
1378
+ ? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.workerLabel}:${owner.service.serviceName}.`
1379
+ : ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
1380
+ : "";
1311
1381
  throw new Error(
1312
1382
  `Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
1313
- `Stop the existing process and rerun testkit.`
1383
+ `Stop the existing process and rerun testkit.${ownerDetail}`
1314
1384
  );
1315
1385
  }
1316
1386
  }
1317
1387
  }
1318
1388
 
1389
+ function printRunStatus(productDir) {
1390
+ const manifests = listRunManifests(productDir);
1391
+ if (manifests.length === 0) return;
1392
+
1393
+ console.log(" runs/");
1394
+ for (const manifest of manifests) {
1395
+ const state = isPidRunning(manifest.pid) ? "active" : "stale";
1396
+ const ports = [...new Set((manifest.services || []).flatMap((service) => (service.ports || []).map((socket) => `${socket.host}:${socket.port}`)))];
1397
+ console.log(
1398
+ ` ${manifest.runId}: ${state} pid=${manifest.pid}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`
1399
+ );
1400
+ }
1401
+ }
1402
+
1319
1403
  function socketFromUrl(rawUrl) {
1320
1404
  return socketFromUrlModel(rawUrl);
1321
1405
  }
@@ -1376,6 +1460,26 @@ function killChildProcess(child, signal) {
1376
1460
  }
1377
1461
  }
1378
1462
 
1463
+ function startDetachedCommand(command, cwd, env) {
1464
+ if (process.platform === "win32") {
1465
+ return spawn(command, {
1466
+ cwd,
1467
+ env,
1468
+ detached: true,
1469
+ shell: true,
1470
+ stdio: ["ignore", "pipe", "pipe"],
1471
+ });
1472
+ }
1473
+
1474
+ const shell = process.env.SHELL || "/bin/sh";
1475
+ return spawn(shell, ["-lc", `exec ${command}`], {
1476
+ cwd,
1477
+ env,
1478
+ detached: true,
1479
+ stdio: ["ignore", "pipe", "pipe"],
1480
+ });
1481
+ }
1482
+
1379
1483
  function readDatabaseUrl(stateDir) {
1380
1484
  return readStateValue(path.join(stateDir, "database_url"));
1381
1485
  }
@@ -0,0 +1,349 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
5
+
6
+ const RUN_SCHEMA_VERSION = 1;
7
+ const RUNS_DIRNAME = path.join(".testkit", "_runs");
8
+ const TERMINATION_TIMEOUT_MS = 5_000;
9
+
10
+ export function createRunLifecycle(productDir) {
11
+ const runId = buildRunId();
12
+ const manifestPath = path.join(getRunsDir(productDir), `${runId}.json`);
13
+ const abortController = new AbortController();
14
+ const state = {
15
+ schemaVersion: RUN_SCHEMA_VERSION,
16
+ runId,
17
+ productDir,
18
+ pid: process.pid,
19
+ status: "starting",
20
+ startedAt: new Date().toISOString(),
21
+ interruptReason: null,
22
+ services: [],
23
+ graphDirs: [],
24
+ workerStateDirs: [],
25
+ runtimeStateDirs: [],
26
+ };
27
+ const signalListeners = [];
28
+
29
+ function persist() {
30
+ fs.mkdirSync(getRunsDir(productDir), { recursive: true });
31
+ fs.writeFileSync(manifestPath, `${JSON.stringify(state, null, 2)}\n`);
32
+ }
33
+
34
+ function mutate(mutator) {
35
+ mutator(state);
36
+ persist();
37
+ }
38
+
39
+ const api = {
40
+ runId,
41
+ manifestPath,
42
+ signal: abortController.signal,
43
+ isStopRequested() {
44
+ return abortController.signal.aborted;
45
+ },
46
+ markRunning() {
47
+ mutate((draft) => {
48
+ draft.status = "running";
49
+ });
50
+ },
51
+ markFinished(status = "finished") {
52
+ mutate((draft) => {
53
+ draft.status = status;
54
+ draft.finishedAt = new Date().toISOString();
55
+ });
56
+ },
57
+ requestStop(reason = "interrupted") {
58
+ if (!abortController.signal.aborted) {
59
+ abortController.abort(new Error(`testkit run interrupted (${reason})`));
60
+ }
61
+ mutate((draft) => {
62
+ draft.status = "interrupting";
63
+ draft.interruptReason = reason;
64
+ });
65
+ },
66
+ trackGraphContext(context) {
67
+ mutate((draft) => {
68
+ pushUnique(draft.graphDirs, context.graphDir);
69
+ pushUnique(draft.workerStateDirs, context.workerStateDir);
70
+ for (const runtimeConfig of context.runtimeConfigs || []) {
71
+ if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
72
+ }
73
+ });
74
+ },
75
+ registerService(config, child, cwd) {
76
+ const ports = collectConfigPorts(config);
77
+ mutate((draft) => {
78
+ draft.services = draft.services.filter((service) => service.pid !== child.pid);
79
+ draft.services.push({
80
+ serviceName: config.name,
81
+ workerLabel: config.workerLabel,
82
+ command: config.testkit.local?.start || null,
83
+ cwd,
84
+ pid: child.pid,
85
+ processGroupId: child.pid,
86
+ ports,
87
+ startedAt: new Date().toISOString(),
88
+ });
89
+ });
90
+ },
91
+ unregisterService(childPid) {
92
+ mutate((draft) => {
93
+ draft.services = draft.services.filter((service) => service.pid !== childPid);
94
+ });
95
+ },
96
+ installSignalHandlers() {
97
+ const install = (eventName, handler) => {
98
+ process.on(eventName, handler);
99
+ signalListeners.push([eventName, handler]);
100
+ };
101
+
102
+ const handleSignal = (signalName) => {
103
+ api.requestStop(signalName);
104
+ };
105
+
106
+ install("SIGINT", () => handleSignal("SIGINT"));
107
+ install("SIGTERM", () => handleSignal("SIGTERM"));
108
+ install("SIGHUP", () => handleSignal("SIGHUP"));
109
+ },
110
+ removeSignalHandlers() {
111
+ while (signalListeners.length > 0) {
112
+ const [eventName, handler] = signalListeners.pop();
113
+ process.off(eventName, handler);
114
+ }
115
+ },
116
+ removeManifest() {
117
+ removeManifestFile(productDir, runId);
118
+ },
119
+ };
120
+
121
+ return api;
122
+ }
123
+
124
+ export async function cleanupRuns(productDir, { includeActive = false } = {}) {
125
+ const manifests = listRunManifests(productDir);
126
+ const summary = {
127
+ cleaned: [],
128
+ skippedActive: [],
129
+ };
130
+
131
+ for (const manifest of manifests) {
132
+ const isActive = isPidRunning(manifest.pid);
133
+ if (isActive && !includeActive) {
134
+ summary.skippedActive.push(manifest);
135
+ continue;
136
+ }
137
+ await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
138
+ summary.cleaned.push(manifest);
139
+ }
140
+
141
+ await cleanupOrphanedLocalInfrastructure(productDir);
142
+ return summary;
143
+ }
144
+
145
+ export async function cleanupStaleRuns(productDir) {
146
+ const manifests = listRunManifests(productDir);
147
+ const cleaned = [];
148
+
149
+ for (const manifest of manifests) {
150
+ if (isPidRunning(manifest.pid)) continue;
151
+ await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
152
+ cleaned.push(manifest);
153
+ }
154
+
155
+ await cleanupOrphanedLocalInfrastructure(productDir);
156
+ return cleaned;
157
+ }
158
+
159
+ export async function cleanupRunById(productDir, runId) {
160
+ const manifest = listRunManifests(productDir).find((entry) => entry.runId === runId);
161
+ if (!manifest) return false;
162
+ await cleanupRunManifest(productDir, manifest, { removeRuntimeState: false });
163
+ await cleanupOrphanedLocalInfrastructure(productDir);
164
+ return true;
165
+ }
166
+
167
+ export function listRunManifests(productDir) {
168
+ const runsDir = getRunsDir(productDir);
169
+ if (!fs.existsSync(runsDir)) return [];
170
+
171
+ return fs
172
+ .readdirSync(runsDir)
173
+ .filter((entry) => entry.endsWith(".json"))
174
+ .map((entry) => readRunManifest(path.join(runsDir, entry)))
175
+ .filter(Boolean)
176
+ .sort((left, right) => String(left.startedAt || "").localeCompare(String(right.startedAt || "")));
177
+ }
178
+
179
+ export function findPortOwner(productDir, { host, port }) {
180
+ const manifests = listRunManifests(productDir);
181
+ for (const manifest of manifests) {
182
+ for (const service of manifest.services || []) {
183
+ for (const socket of service.ports || []) {
184
+ if (normalizeHost(socket.host) === normalizeHost(host) && Number(socket.port) === Number(port)) {
185
+ return {
186
+ manifest,
187
+ service,
188
+ active: isPidRunning(manifest.pid),
189
+ };
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ export function formatRunSummary(manifest) {
198
+ const workerLabels = [...new Set((manifest.services || []).map((service) => service.workerLabel).filter(Boolean))];
199
+ return `${manifest.runId} pid=${manifest.pid}${workerLabels.length > 0 ? ` workers=${workerLabels.join(",")}` : ""}`;
200
+ }
201
+
202
+ export function isPidRunning(pid) {
203
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
204
+ try {
205
+ process.kill(Number(pid), 0);
206
+ return true;
207
+ } catch (error) {
208
+ return error?.code === "EPERM";
209
+ }
210
+ }
211
+
212
+ async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = false } = {}) {
213
+ for (const service of [...(manifest.services || [])].reverse()) {
214
+ await terminateOwnedProcess(service);
215
+ }
216
+
217
+ if (removeRuntimeState) {
218
+ const runtimeStateDirs = [...new Set(manifest.runtimeStateDirs || [])].sort((a, b) => b.length - a.length);
219
+ for (const stateDir of runtimeStateDirs) {
220
+ await destroyRuntimeDatabase({ productDir, stateDir });
221
+ }
222
+
223
+ for (const workerStateDir of [...new Set(manifest.workerStateDirs || [])].sort((a, b) => b.length - a.length)) {
224
+ fs.rmSync(workerStateDir, { recursive: true, force: true });
225
+ }
226
+
227
+ for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {
228
+ pruneEmptyParents(graphDir, path.join(productDir, ".testkit", "_graphs"));
229
+ }
230
+ }
231
+
232
+ removeManifestFile(productDir, manifest.runId);
233
+ }
234
+
235
+ async function terminateOwnedProcess(service) {
236
+ const pid = Number(service.processGroupId || service.pid);
237
+ if (!Number.isInteger(pid) || pid <= 0) return;
238
+ if (!isPidRunning(pid)) return;
239
+
240
+ killProcessGroup(pid, "SIGTERM");
241
+ const exited = await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
242
+ if (!exited) {
243
+ killProcessGroup(pid, "SIGKILL");
244
+ await waitForPidExit(pid, TERMINATION_TIMEOUT_MS);
245
+ }
246
+ }
247
+
248
+ function killProcessGroup(pid, signal) {
249
+ try {
250
+ process.kill(-pid, signal);
251
+ return;
252
+ } catch (error) {
253
+ if (error?.code !== "ESRCH") {
254
+ // Fall through and try the direct pid.
255
+ } else {
256
+ return;
257
+ }
258
+ }
259
+
260
+ try {
261
+ process.kill(pid, signal);
262
+ } catch (error) {
263
+ if (error?.code !== "ESRCH") throw error;
264
+ }
265
+ }
266
+
267
+ async function waitForPidExit(pid, timeoutMs) {
268
+ const startedAt = Date.now();
269
+ while (Date.now() - startedAt < timeoutMs) {
270
+ if (!isPidRunning(pid)) return true;
271
+ await sleep(100);
272
+ }
273
+ return !isPidRunning(pid);
274
+ }
275
+
276
+ function readRunManifest(filePath) {
277
+ try {
278
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
279
+ if (!parsed?.runId) return null;
280
+ return parsed;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function collectConfigPorts(config) {
287
+ const seen = new Set();
288
+ const ports = [];
289
+ for (const rawUrl of [config.testkit.local?.baseUrl, config.testkit.local?.readyUrl]) {
290
+ if (!rawUrl) continue;
291
+ try {
292
+ const parsed = new URL(rawUrl);
293
+ const host = normalizeHost(parsed.hostname);
294
+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
295
+ const key = `${host}:${port}`;
296
+ if (seen.has(key)) continue;
297
+ seen.add(key);
298
+ ports.push({ host, port });
299
+ } catch {
300
+ // Ignore malformed URLs here; startup validation handles them elsewhere.
301
+ }
302
+ }
303
+ return ports;
304
+ }
305
+
306
+ function pushUnique(list, value) {
307
+ if (!value) return;
308
+ if (!list.includes(value)) list.push(value);
309
+ }
310
+
311
+ function getRunsDir(productDir) {
312
+ return path.join(productDir, RUNS_DIRNAME);
313
+ }
314
+
315
+ function buildRunId() {
316
+ return `${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
317
+ }
318
+
319
+ function normalizeHost(host) {
320
+ if (!host) return "127.0.0.1";
321
+ return host === "::1" ? "127.0.0.1" : host;
322
+ }
323
+
324
+ function pruneEmptyParents(startDir, stopDir) {
325
+ let current = startDir;
326
+ while (current && current.startsWith(stopDir)) {
327
+ if (!fs.existsSync(current)) {
328
+ current = path.dirname(current);
329
+ continue;
330
+ }
331
+ const entries = fs.readdirSync(current);
332
+ if (entries.length > 0) break;
333
+ fs.rmSync(current, { recursive: true, force: true });
334
+ if (current === stopDir) break;
335
+ current = path.dirname(current);
336
+ }
337
+ }
338
+
339
+ function sleep(ms) {
340
+ return new Promise((resolve) => setTimeout(resolve, ms));
341
+ }
342
+
343
+ function removeManifestFile(productDir, runId) {
344
+ const runsDir = getRunsDir(productDir);
345
+ fs.rmSync(path.join(runsDir, `${runId}.json`), { force: true });
346
+ if (fs.existsSync(runsDir) && fs.readdirSync(runsDir).length === 0) {
347
+ fs.rmSync(runsDir, { recursive: true, force: true });
348
+ }
349
+ }
@@ -69,7 +69,9 @@ export function collectSuites(config, suiteType, suiteNames, frameworkFilter, fi
69
69
  ? Math.max(2, files.length)
70
70
  : Math.max(1, files.length)),
71
71
  maxFileConcurrency:
72
- framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
72
+ framework === "k6" || framework === "playwright"
73
+ ? suite.testkit?.maxFileConcurrency || 1
74
+ : 1,
73
75
  });
74
76
  orderIndex += 1;
75
77
  }
@@ -183,9 +185,7 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
183
185
  timingKey,
184
186
  estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
185
187
  maxBatchSize:
186
- suite.framework === "playwright"
187
- ? Number.POSITIVE_INFINITY
188
- : suite.maxFileConcurrency || 1,
188
+ suite.maxFileConcurrency || 1,
189
189
  });
190
190
  nextId += 1;
191
191
  }
@@ -213,20 +213,9 @@ export function claimNextBatch(queue, preferredGraphKey) {
213
213
  const seed = queue.splice(index, 1)[0];
214
214
  const tasks = [seed];
215
215
 
216
- if (seed.framework === "playwright") {
217
- for (let cursor = queue.length - 1; cursor >= 0; cursor -= 1) {
218
- const candidate = queue[cursor];
219
- if (
220
- candidate.framework === "playwright" &&
221
- candidate.graphKey === seed.graphKey &&
222
- candidate.targetName === seed.targetName
223
- ) {
224
- tasks.push(candidate);
225
- queue.splice(cursor, 1);
226
- }
227
- }
228
- } else if (seed.maxBatchSize > 1) {
229
- for (let cursor = queue.length - 1; cursor >= 0 && tasks.length < seed.maxBatchSize; cursor -= 1) {
216
+ if (seed.maxBatchSize > 1) {
217
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
218
+ if (tasks.length >= seed.maxBatchSize) break;
230
219
  const candidate = queue[cursor];
231
220
  if (
232
221
  candidate.framework === seed.framework &&
@@ -237,6 +226,7 @@ export function claimNextBatch(queue, preferredGraphKey) {
237
226
  ) {
238
227
  tasks.push(candidate);
239
228
  queue.splice(cursor, 1);
229
+ cursor -= 1;
240
230
  }
241
231
  }
242
232
  }
@@ -137,11 +137,51 @@ describe("runner-planning", () => {
137
137
  expect(queue).toHaveLength(4);
138
138
 
139
139
  const firstBatch = claimNextBatch(queue, "api|frontend");
140
- expect(firstBatch.tasks).toHaveLength(2);
140
+ expect(firstBatch.tasks).toHaveLength(1);
141
141
  expect(firstBatch.framework).toBe("playwright");
142
142
 
143
143
  const secondBatch = claimNextBatch(queue, "api|frontend");
144
- expect(secondBatch.tasks).toHaveLength(2);
145
- expect(secondBatch.framework).toBe("k6");
144
+ expect(secondBatch.tasks).toHaveLength(1);
145
+ expect(secondBatch.framework).toBe("playwright");
146
+
147
+ const thirdBatch = claimNextBatch(queue, "api|frontend");
148
+ expect(thirdBatch.tasks).toHaveLength(2);
149
+ expect(thirdBatch.framework).toBe("k6");
150
+ });
151
+
152
+ it("allows Playwright suites to opt into bounded multi-file batches", () => {
153
+ const frontend = makeConfig("frontend");
154
+ const plans = [
155
+ {
156
+ config: frontend,
157
+ skipped: false,
158
+ runtimeConfigs: [frontend],
159
+ runtimeNames: ["frontend"],
160
+ runtimeKey: "frontend",
161
+ suites: [
162
+ {
163
+ name: "auth",
164
+ type: "e2e",
165
+ framework: "playwright",
166
+ files: ["a.spec.js", "b.spec.js", "c.spec.js"],
167
+ orderIndex: 0,
168
+ weight: 3,
169
+ maxFileConcurrency: 2,
170
+ },
171
+ ],
172
+ },
173
+ ];
174
+
175
+ const graphs = buildRuntimeGraphs(plans);
176
+ const queue = buildTaskQueue(plans, graphs, { files: {} });
177
+
178
+ const firstBatch = claimNextBatch(queue, "frontend");
179
+ expect(firstBatch.tasks.map((task) => task.file)).toEqual(["a.spec.js", "b.spec.js"]);
180
+ expect(firstBatch.tasks).toHaveLength(2);
181
+ expect(firstBatch.framework).toBe("playwright");
182
+
183
+ const secondBatch = claimNextBatch(queue, "frontend");
184
+ expect(secondBatch.tasks).toHaveLength(1);
185
+ expect(secondBatch.framework).toBe("playwright");
146
186
  });
147
187
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",