@elench/testkit 0.1.16 → 0.1.17

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
@@ -17,169 +17,802 @@ const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
17
17
  const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
18
18
  const DEFAULT_READY_TIMEOUT_MS = 120_000;
19
19
  const PORT_STRIDE = 100;
20
+ const TIMINGS_FILENAME = "timings.json";
21
+ const GRAPH_METADATA = "graph.json";
20
22
 
21
23
  export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
22
24
  const configMap = new Map(allConfigs.map((config) => [config.name, config]));
23
- const targetSpan = Math.max(1, opts.jobs || 1);
24
25
  const startedAt = Date.now();
25
- const results = await Promise.all(
26
- configs.map(async (config, targetSlot) => {
27
- console.log(`\n══ ${config.name} ══`);
28
- return runService(config, configMap, suiteType, suiteNames, opts, {
29
- targetSlot,
30
- targetSpan,
31
- });
32
- })
26
+ const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
27
+ const trackers = buildServiceTrackers(servicePlans, startedAt);
28
+ const executedPlans = servicePlans.filter((plan) => !plan.skipped);
29
+
30
+ if (executedPlans.length > 0) {
31
+ const productDir = executedPlans[0].config.productDir;
32
+ const timings = loadTimings(productDir);
33
+ const graphs = buildRuntimeGraphs(executedPlans);
34
+ const queue = buildTaskQueue(executedPlans, graphs, timings);
35
+ const workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
36
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
37
+ const workers = Array.from({ length: workerCount }, (_unused, index) =>
38
+ createWorker(index + 1, productDir)
39
+ );
40
+ const timingUpdates = [];
41
+
42
+ const workerResults = await Promise.allSettled(
43
+ workers.map((worker) =>
44
+ runWorker(worker, queue, graphByKey, trackers, timingUpdates)
45
+ )
46
+ );
47
+
48
+ for (const result of workerResults) {
49
+ if (result.status === "rejected") {
50
+ const message = formatError(result.reason);
51
+ for (const tracker of trackers.values()) {
52
+ if (!tracker.skipped) addTrackerError(tracker, message);
53
+ }
54
+ }
55
+ }
56
+
57
+ saveTimings(productDir, timings, timingUpdates);
58
+ }
59
+
60
+ const finishedAt = Date.now();
61
+ const results = configs.map((config) =>
62
+ finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
33
63
  );
34
64
 
35
- printRunSummary(results, Date.now() - startedAt);
65
+ printRunSummary(results, finishedAt - startedAt);
36
66
  if (results.some((result) => result.failed)) process.exit(1);
37
67
  }
38
68
 
39
69
  export async function destroy(config) {
40
- if (!fs.existsSync(config.stateDir)) return;
41
-
42
- const runtimeStateDirs = findRuntimeStateDirs(config.stateDir);
43
- for (const stateDir of runtimeStateDirs) {
44
- await destroyRuntimeDatabase({
45
- productDir: config.productDir,
46
- stateDir,
47
- });
70
+ const roots = new Set([config.stateDir, ...findGraphDirsForService(config.productDir, config.name)]);
71
+
72
+ for (const rootDir of roots) {
73
+ if (!fs.existsSync(rootDir)) continue;
74
+ const runtimeStateDirs = findRuntimeStateDirs(rootDir);
75
+ for (const stateDir of runtimeStateDirs) {
76
+ await destroyRuntimeDatabase({
77
+ productDir: config.productDir,
78
+ stateDir,
79
+ });
80
+ }
81
+ fs.rmSync(rootDir, { recursive: true, force: true });
48
82
  }
49
83
 
50
84
  await destroyServiceDatabaseCache(config.productDir, config.name);
51
- fs.rmSync(config.stateDir, { recursive: true, force: true });
52
85
  await cleanupOrphanedLocalInfrastructure(config.productDir);
53
86
  }
54
87
 
55
88
  export function showStatus(config) {
56
- if (!fs.existsSync(config.stateDir)) {
89
+ const graphDirs = findGraphDirsForService(config.productDir, config.name);
90
+ const hasDirectState = fs.existsSync(config.stateDir);
91
+ const hasGraphState = graphDirs.length > 0;
92
+
93
+ if (!hasDirectState && !hasGraphState) {
57
94
  console.log("No state — run tests first.");
58
95
  } else {
59
- printStateDir(config.stateDir, " ");
96
+ if (hasDirectState) {
97
+ console.log(" service-state/");
98
+ printStateDir(config.stateDir, " ");
99
+ }
100
+ for (const graphDir of graphDirs) {
101
+ console.log(` graph-state/${path.basename(graphDir)}/`);
102
+ printStateDir(graphDir, " ");
103
+ }
60
104
  }
105
+
61
106
  showServiceDatabaseStatus(config.productDir, config.name);
62
107
  }
63
108
 
64
- async function runService(targetConfig, configMap, suiteType, suiteNames, opts, runtimeSlot) {
65
- const startedAt = Date.now();
66
- const suites = applyShard(
67
- collectSuites(targetConfig, suiteType, suiteNames, opts.framework),
68
- opts.shard
69
- );
70
- if (suites.length === 0) {
71
- console.log(
72
- `No test files for ${targetConfig.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
109
+ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
110
+ return configs.map((config) => {
111
+ console.log(`\n══ ${config.name} ══`);
112
+ const suites = applyShard(
113
+ collectSuites(config, suiteType, suiteNames, opts.framework),
114
+ opts.shard
73
115
  );
116
+
117
+ if (suites.length === 0) {
118
+ console.log(
119
+ `No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
120
+ );
121
+ return {
122
+ config,
123
+ skipped: true,
124
+ suites: [],
125
+ runtimeConfigs: [],
126
+ runtimeNames: [],
127
+ runtimeKey: null,
128
+ };
129
+ }
130
+
131
+ const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
74
132
  return {
75
- name: targetConfig.name,
76
- failed: false,
77
- skipped: true,
78
- suiteCount: 0,
79
- completedSuiteCount: 0,
80
- failedSuiteCount: 0,
81
- durationMs: Date.now() - startedAt,
82
- workers: [],
133
+ config,
134
+ skipped: false,
135
+ suites,
136
+ runtimeConfigs,
137
+ runtimeNames: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort(),
138
+ runtimeKey: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort().join("|"),
139
+ };
140
+ });
141
+ }
142
+
143
+ function buildServiceTrackers(servicePlans, startedAt) {
144
+ const trackers = new Map();
145
+
146
+ for (const plan of servicePlans) {
147
+ if (plan.skipped) {
148
+ trackers.set(plan.config.name, {
149
+ name: plan.config.name,
150
+ skipped: true,
151
+ suiteCount: 0,
152
+ suites: [],
153
+ suitesByKey: new Map(),
154
+ errors: [],
155
+ errorSet: new Set(),
156
+ startedAt,
157
+ firstTaskAt: null,
158
+ lastTaskAt: null,
159
+ });
160
+ continue;
161
+ }
162
+
163
+ const suites = plan.suites.map((suite) => ({
164
+ key: `${suite.type}:${suite.name}`,
165
+ name: suite.name,
166
+ type: suite.type,
167
+ framework: suite.framework,
168
+ orderIndex: suite.orderIndex,
169
+ fileCount: suite.files.length,
170
+ completedFileCount: 0,
171
+ failedFiles: [],
172
+ failedFileSet: new Set(),
173
+ durationMs: 0,
174
+ error: null,
175
+ }));
176
+
177
+ trackers.set(plan.config.name, {
178
+ name: plan.config.name,
179
+ skipped: false,
180
+ suiteCount: suites.length,
181
+ suites,
182
+ suitesByKey: new Map(suites.map((suite) => [suite.key, suite])),
83
183
  errors: [],
184
+ errorSet: new Set(),
185
+ startedAt,
186
+ firstTaskAt: null,
187
+ lastTaskAt: null,
188
+ });
189
+ }
190
+
191
+ return trackers;
192
+ }
193
+
194
+ function buildRuntimeGraphs(servicePlans) {
195
+ const executed = servicePlans.filter((plan) => !plan.skipped);
196
+ const uniqueGraphs = [];
197
+ const graphByRuntimeKey = new Map();
198
+
199
+ for (const plan of executed) {
200
+ if (graphByRuntimeKey.has(plan.runtimeKey)) {
201
+ graphByRuntimeKey.get(plan.runtimeKey).exactTargets.push(plan.config.name);
202
+ continue;
203
+ }
204
+
205
+ const graph = {
206
+ key: plan.runtimeKey,
207
+ runtimeNames: plan.runtimeNames,
208
+ runtimeConfigs: plan.runtimeConfigs,
209
+ exactTargets: [plan.config.name],
210
+ assignedTargets: [],
211
+ dirName: null,
212
+ rootConfig: null,
84
213
  };
214
+ uniqueGraphs.push(graph);
215
+ graphByRuntimeKey.set(plan.runtimeKey, graph);
85
216
  }
86
217
 
87
- const runtimeConfigs = resolveRuntimeConfigs(targetConfig, configMap);
88
- fs.mkdirSync(targetConfig.stateDir, { recursive: true });
218
+ const maximalGraphs = uniqueGraphs.filter(
219
+ (graph) =>
220
+ !uniqueGraphs.some(
221
+ (other) =>
222
+ other.key !== graph.key &&
223
+ isRuntimeSuperset(other.runtimeNames, graph.runtimeNames)
224
+ )
225
+ );
226
+
227
+ for (const plan of executed) {
228
+ const compatible = maximalGraphs.filter((graph) =>
229
+ isRuntimeSuperset(graph.runtimeNames, plan.runtimeNames)
230
+ );
231
+ if (compatible.length === 0) {
232
+ throw new Error(`No runtime graph found for service "${plan.config.name}"`);
233
+ }
89
234
 
90
- const jobs = Math.max(1, Math.min(opts.jobs || 1, suites.length));
91
- const workerPlans = buildWorkerPlans(
92
- targetConfig,
93
- runtimeConfigs,
94
- suites,
95
- jobs,
96
- runtimeSlot
235
+ const assigned = compatible.sort(compareGraphsForAssignment)[0];
236
+ plan.assignedGraphKey = assigned.key;
237
+ assigned.assignedTargets.push(plan.config.name);
238
+ }
239
+
240
+ for (const graph of maximalGraphs) {
241
+ const rootName = [...graph.exactTargets].sort()[0];
242
+ const rootPlan = executed.find((plan) => plan.config.name === rootName);
243
+ if (!rootPlan) {
244
+ throw new Error(`Missing root plan for graph "${graph.key}"`);
245
+ }
246
+ graph.rootConfig = rootPlan.config;
247
+ graph.dirName = buildGraphDirName(graph.runtimeNames);
248
+ }
249
+
250
+ return maximalGraphs.sort((a, b) => a.dirName.localeCompare(b.dirName));
251
+ }
252
+
253
+ function buildTaskQueue(servicePlans, graphs, timings) {
254
+ const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
255
+ const tasks = [];
256
+ let nextId = 1;
257
+
258
+ for (const plan of servicePlans) {
259
+ if (plan.skipped) continue;
260
+
261
+ const graph = graphByKey.get(plan.assignedGraphKey);
262
+ if (!graph) {
263
+ throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
264
+ }
265
+
266
+ for (const suite of plan.suites) {
267
+ for (const file of suite.files) {
268
+ const timingKey = buildTimingKey(plan.config.name, suite, file);
269
+ tasks.push({
270
+ id: nextId,
271
+ graphKey: graph.key,
272
+ targetName: plan.config.name,
273
+ serviceName: plan.config.name,
274
+ suiteKey: `${suite.type}:${suite.name}`,
275
+ suiteName: suite.name,
276
+ type: suite.type,
277
+ framework: suite.framework,
278
+ orderIndex: suite.orderIndex,
279
+ file,
280
+ timingKey,
281
+ estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
282
+ maxBatchSize:
283
+ suite.framework === "playwright"
284
+ ? Number.POSITIVE_INFINITY
285
+ : suite.maxFileConcurrency || 1,
286
+ });
287
+ nextId += 1;
288
+ }
289
+ }
290
+ }
291
+
292
+ return tasks.sort(
293
+ (a, b) =>
294
+ b.estimatedDurationMs - a.estimatedDurationMs ||
295
+ a.serviceName.localeCompare(b.serviceName) ||
296
+ a.suiteKey.localeCompare(b.suiteKey) ||
297
+ a.file.localeCompare(b.file)
97
298
  );
299
+ }
98
300
 
99
- const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
100
- let failed = false;
101
- const workers = [];
301
+ function createWorker(workerId, productDir) {
302
+ return {
303
+ workerId,
304
+ productDir,
305
+ currentGraphKey: null,
306
+ graphContexts: new Map(),
307
+ graphSwitches: 0,
308
+ taskCount: 0,
309
+ };
310
+ }
311
+
312
+ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
313
+ const startedAt = Date.now();
314
+ console.log(`\n══ global worker ${worker.workerId} ══`);
102
315
  const errors = [];
103
- let failedSuiteCount = 0;
104
- let completedSuiteCount = 0;
105
316
 
106
- for (const result of results) {
107
- if (result.status === "rejected") {
108
- failed = true;
109
- console.error(result.reason);
110
- errors.push(formatError(result.reason));
111
- continue;
317
+ try {
318
+ while (true) {
319
+ const batch = claimNextBatch(queue, worker.currentGraphKey);
320
+ if (!batch) break;
321
+
322
+ try {
323
+ const context = await ensureWorkerGraph(worker, batch, graphByKey);
324
+ const outcomes = await runBatch(context, batch);
325
+ for (const outcome of outcomes) {
326
+ recordTaskOutcome(trackers, outcome.task, outcome);
327
+ timingUpdates.push({
328
+ key: outcome.task.timingKey,
329
+ durationMs: outcome.durationMs,
330
+ });
331
+ worker.taskCount += 1;
332
+ }
333
+ } catch (error) {
334
+ const message = formatError(error);
335
+ errors.push(message);
336
+ recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
337
+ await resetCurrentGraph(worker);
338
+ }
112
339
  }
113
- workers.push(result.value);
114
- completedSuiteCount += result.value.completedSuiteCount;
115
- failedSuiteCount += result.value.failedSuiteCount;
116
- if (result.value.failed) failed = true;
340
+ } finally {
341
+ await cleanupWorker(worker);
117
342
  }
118
343
 
119
344
  return {
120
- name: targetConfig.name,
121
- failed,
122
- skipped: false,
123
- suiteCount: suites.length,
124
- completedSuiteCount,
125
- failedSuiteCount,
345
+ workerId: worker.workerId,
346
+ failed: errors.length > 0,
126
347
  durationMs: Date.now() - startedAt,
127
- workers,
348
+ taskCount: worker.taskCount,
349
+ graphSwitches: worker.graphSwitches,
128
350
  errors,
129
351
  };
130
352
  }
131
353
 
132
- function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
133
- const types =
134
- suiteType === "all"
135
- ? orderedTypes(Object.keys(config.suites))
136
- : [suiteType === "int" ? "integration" : suiteType];
354
+ function claimNextBatch(queue, preferredGraphKey) {
355
+ if (queue.length === 0) return null;
137
356
 
138
- const selectedNames = new Set(suiteNames);
139
- const suites = [];
140
- let orderIndex = 0;
357
+ let index = -1;
358
+ if (preferredGraphKey) {
359
+ index = queue.findIndex((task) => task.graphKey === preferredGraphKey);
360
+ }
361
+ if (index === -1) index = 0;
141
362
 
142
- for (const type of types) {
143
- for (const suite of config.suites[type] || []) {
144
- const framework = suite.framework || "k6";
145
- if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
146
- if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
363
+ const seed = queue.splice(index, 1)[0];
364
+ const tasks = [seed];
147
365
 
148
- suites.push({
149
- ...suite,
150
- framework,
151
- type,
152
- orderIndex,
153
- sortKey: `${type}:${suite.name}`,
154
- weight:
155
- suite.testkit?.weight ||
156
- (framework === "playwright"
157
- ? Math.max(2, suite.files.length)
158
- : Math.max(1, suite.files.length)),
159
- maxFileConcurrency:
160
- framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
161
- });
162
- orderIndex += 1;
366
+ if (seed.framework === "playwright") {
367
+ for (let cursor = queue.length - 1; cursor >= 0; cursor -= 1) {
368
+ const candidate = queue[cursor];
369
+ if (
370
+ candidate.framework === "playwright" &&
371
+ candidate.graphKey === seed.graphKey &&
372
+ candidate.targetName === seed.targetName
373
+ ) {
374
+ tasks.push(candidate);
375
+ queue.splice(cursor, 1);
376
+ }
377
+ }
378
+ } else if (seed.maxBatchSize > 1) {
379
+ for (let cursor = queue.length - 1; cursor >= 0 && tasks.length < seed.maxBatchSize; cursor -= 1) {
380
+ const candidate = queue[cursor];
381
+ if (
382
+ candidate.framework === seed.framework &&
383
+ candidate.type === seed.type &&
384
+ candidate.graphKey === seed.graphKey &&
385
+ candidate.targetName === seed.targetName &&
386
+ candidate.suiteKey === seed.suiteKey
387
+ ) {
388
+ tasks.push(candidate);
389
+ queue.splice(cursor, 1);
390
+ }
163
391
  }
164
392
  }
165
393
 
166
- return suites;
394
+ tasks.sort(
395
+ (a, b) =>
396
+ a.orderIndex - b.orderIndex ||
397
+ a.file.localeCompare(b.file)
398
+ );
399
+
400
+ return {
401
+ graphKey: seed.graphKey,
402
+ targetName: seed.targetName,
403
+ framework: seed.framework,
404
+ type: seed.type,
405
+ tasks,
406
+ };
167
407
  }
168
408
 
169
- function applyShard(suites, shard) {
170
- if (!shard) return suites;
171
- return suites.filter((_, index) => index % shard.total === shard.index - 1);
409
+ async function ensureWorkerGraph(worker, batch, graphByKey) {
410
+ const graph = graphByKey.get(batch.graphKey);
411
+ if (!graph) {
412
+ throw new Error(`Unknown graph "${batch.graphKey}"`);
413
+ }
414
+
415
+ if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
416
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
417
+ worker.graphSwitches += 1;
418
+ worker.currentGraphKey = null;
419
+ }
420
+
421
+ let context = worker.graphContexts.get(batch.graphKey);
422
+ if (!context) {
423
+ context = createGraphContext(worker, graph);
424
+ worker.graphContexts.set(batch.graphKey, context);
425
+ }
426
+
427
+ if (!context.prepared) {
428
+ await prepareDatabases(context.runtimeConfigs);
429
+ context.prepared = true;
430
+ }
431
+
432
+ if (batchNeedsLocalRuntime(batch) && !context.started) {
433
+ context.startedServices = await startLocalServices(context.runtimeConfigs);
434
+ context.started = true;
435
+ }
436
+
437
+ worker.currentGraphKey = batch.graphKey;
438
+ return context;
172
439
  }
173
440
 
174
- function orderedTypes(types) {
175
- const ordered = [];
176
- for (const known of TYPE_ORDER) {
177
- if (types.includes(known)) ordered.push(known);
441
+ function createGraphContext(worker, graph) {
442
+ const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
443
+ const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
444
+ fs.mkdirSync(workerStateDir, { recursive: true });
445
+ writeGraphMetadata(graphDir, graph);
446
+
447
+ const runtimeConfigs = resolveWorkerRuntimeConfigs(
448
+ graph.rootConfig,
449
+ graph.runtimeConfigs,
450
+ worker.workerId,
451
+ workerStateDir
452
+ );
453
+
454
+ return {
455
+ graphKey: graph.key,
456
+ graphDir,
457
+ workerStateDir,
458
+ runtimeConfigs,
459
+ configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
460
+ prepared: false,
461
+ started: false,
462
+ startedServices: [],
463
+ };
464
+ }
465
+
466
+ async function deactivateGraphContext(context) {
467
+ if (!context?.started) return;
468
+ await stopLocalServices(context.startedServices);
469
+ context.started = false;
470
+ context.startedServices = [];
471
+ }
472
+
473
+ async function resetCurrentGraph(worker) {
474
+ if (!worker.currentGraphKey) return;
475
+ await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
476
+ worker.currentGraphKey = null;
477
+ }
478
+
479
+ async function cleanupWorker(worker) {
480
+ for (const context of worker.graphContexts.values()) {
481
+ await deactivateGraphContext(context);
178
482
  }
179
- for (const type of types) {
180
- if (!ordered.includes(type)) ordered.push(type);
483
+ worker.currentGraphKey = null;
484
+ }
485
+
486
+ async function runBatch(context, batch) {
487
+ const targetConfig = context.configByName.get(batch.targetName);
488
+ if (!targetConfig) {
489
+ throw new Error(`Worker graph missing target config "${batch.targetName}"`);
181
490
  }
182
- return ordered;
491
+
492
+ if (batch.framework === "playwright") {
493
+ return runPlaywrightBatch(targetConfig, batch);
494
+ }
495
+ if (batch.type === "dal") {
496
+ return runDalBatch(targetConfig, batch);
497
+ }
498
+ if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
499
+ return runHttpK6Batch(targetConfig, batch);
500
+ }
501
+
502
+ throw new Error(
503
+ `Unsupported task combination for ${batch.targetName}: type=${batch.type} framework=${batch.framework}`
504
+ );
505
+ }
506
+
507
+ async function prepareDatabases(runtimeConfigs) {
508
+ for (const config of runtimeConfigs) {
509
+ await prepareDatabaseRuntime(config, {
510
+ runMigrate: config.testkit.migrate
511
+ ? (databaseUrl) => runMigrate(config, databaseUrl)
512
+ : null,
513
+ runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
514
+ });
515
+ }
516
+ }
517
+
518
+ async function runMigrate(config, databaseUrl) {
519
+ const migrate = config.testkit.migrate;
520
+ if (!migrate) return;
521
+
522
+ const env = buildExecutionEnv(config);
523
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
524
+
525
+ console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
526
+ await execaCommand(migrate.cmd, {
527
+ cwd: resolveServiceCwd(config.productDir, migrate.cwd),
528
+ env,
529
+ stdio: "inherit",
530
+ shell: true,
531
+ });
532
+ }
533
+
534
+ async function runSeed(config, databaseUrl) {
535
+ const seed = config.testkit.seed;
536
+ if (!seed) return;
537
+
538
+ const env = buildExecutionEnv(config);
539
+ if (databaseUrl) env.DATABASE_URL = databaseUrl;
540
+
541
+ console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
542
+ await execaCommand(seed.cmd, {
543
+ cwd: resolveServiceCwd(config.productDir, seed.cwd),
544
+ env,
545
+ stdio: "inherit",
546
+ shell: true,
547
+ });
548
+ }
549
+
550
+ async function startLocalServices(runtimeConfigs) {
551
+ const started = [];
552
+
553
+ try {
554
+ for (const config of runtimeConfigs) {
555
+ if (!config.testkit.local) continue;
556
+ const proc = await startLocalService(config);
557
+ started.push(proc);
558
+ }
559
+ } catch (error) {
560
+ await stopLocalServices(started);
561
+ throw error;
562
+ }
563
+
564
+ return started;
565
+ }
566
+
567
+ async function startLocalService(config) {
568
+ const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
569
+ const env = buildExecutionEnv(config, config.testkit.local.env);
570
+ const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
571
+ if (port) {
572
+ env.PORT = String(port);
573
+ }
574
+
575
+ const dbUrl = readDatabaseUrl(config.stateDir);
576
+ if (dbUrl) {
577
+ env.DATABASE_URL = dbUrl;
578
+ }
579
+
580
+ await assertLocalServicePortsAvailable(config);
581
+
582
+ console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
583
+ const child = spawn(config.testkit.local.start, {
584
+ cwd,
585
+ env,
586
+ detached: true,
587
+ shell: true,
588
+ stdio: ["ignore", "pipe", "pipe"],
589
+ });
590
+
591
+ const outputDrains = [
592
+ pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
593
+ pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
594
+ ];
595
+
596
+ const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
597
+
598
+ try {
599
+ await waitForReady({
600
+ name: `${config.workerLabel}:${config.name}`,
601
+ url: config.testkit.local.readyUrl,
602
+ timeoutMs: readyTimeoutMs,
603
+ process: child,
604
+ });
605
+ } catch (error) {
606
+ await stopChildProcess(child, outputDrains);
607
+ throw error;
608
+ }
609
+
610
+ return { name: config.name, child, outputDrains };
611
+ }
612
+
613
+ async function runHttpK6Batch(targetConfig, batch) {
614
+ const baseUrl = targetConfig.testkit.local?.baseUrl;
615
+ if (!baseUrl) {
616
+ throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
617
+ }
618
+
619
+ console.log(
620
+ `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName} (${batch.framework}, ${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
621
+ );
622
+
623
+ return Promise.all(
624
+ batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl))
625
+ );
626
+ }
627
+
628
+ async function runHttpK6Task(targetConfig, task, baseUrl) {
629
+ const absFile = path.join(targetConfig.productDir, task.file);
630
+ console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
631
+ const startedAt = Date.now();
632
+ try {
633
+ await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, absFile], {
634
+ cwd: targetConfig.productDir,
635
+ env: buildExecutionEnv(targetConfig),
636
+ stdio: "inherit",
637
+ });
638
+ return {
639
+ task,
640
+ failed: false,
641
+ error: null,
642
+ durationMs: Date.now() - startedAt,
643
+ };
644
+ } catch (error) {
645
+ return {
646
+ task,
647
+ failed: true,
648
+ error: formatError(error),
649
+ durationMs: Date.now() - startedAt,
650
+ };
651
+ }
652
+ }
653
+
654
+ async function runDalBatch(targetConfig, batch) {
655
+ const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
656
+ if (!databaseUrl) {
657
+ throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
658
+ }
659
+
660
+ console.log(
661
+ `\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName} (${batch.framework}, ${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
662
+ );
663
+
664
+ return Promise.all(
665
+ batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl))
666
+ );
667
+ }
668
+
669
+ async function runDalTask(targetConfig, task, databaseUrl) {
670
+ const absFile = path.join(targetConfig.productDir, task.file);
671
+ const k6Binary = resolveDalBinary();
672
+ console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
673
+ const startedAt = Date.now();
674
+ try {
675
+ await execa(
676
+ k6Binary,
677
+ ["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, absFile],
678
+ {
679
+ cwd: targetConfig.productDir,
680
+ env: buildExecutionEnv(targetConfig),
681
+ stdio: "inherit",
682
+ }
683
+ );
684
+ return {
685
+ task,
686
+ failed: false,
687
+ error: null,
688
+ durationMs: Date.now() - startedAt,
689
+ };
690
+ } catch (error) {
691
+ return {
692
+ task,
693
+ failed: true,
694
+ error: formatError(error),
695
+ durationMs: Date.now() - startedAt,
696
+ };
697
+ }
698
+ }
699
+
700
+ async function runPlaywrightBatch(targetConfig, batch) {
701
+ const local = targetConfig.testkit.local;
702
+ if (!local?.baseUrl) {
703
+ throw new Error(
704
+ `Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
705
+ );
706
+ }
707
+
708
+ console.log(
709
+ `\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
710
+ );
711
+
712
+ const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
713
+ const requestedFiles = batch.tasks.map((task) =>
714
+ path.relative(cwd, path.join(targetConfig.productDir, task.file))
715
+ );
716
+ const startedAt = Date.now();
717
+ const result = await execa(
718
+ "npx",
719
+ ["playwright", "test", "--reporter=json", ...requestedFiles],
720
+ {
721
+ cwd,
722
+ env: buildPlaywrightEnv(targetConfig, local.baseUrl),
723
+ reject: false,
724
+ }
725
+ );
726
+
727
+ if (result.stderr) {
728
+ printBufferedOutput(result.stderr, `[${targetConfig.workerLabel}:${targetConfig.name}:playwright]`);
729
+ }
730
+
731
+ const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
732
+ const batchDurationMs = Date.now() - startedAt;
733
+ const genericError =
734
+ result.exitCode === 0
735
+ ? parsed.errors[0] || null
736
+ : parsed.errors[0] ||
737
+ result.stderr.trim() ||
738
+ `Playwright exited with code ${result.exitCode}`;
739
+
740
+ return batch.tasks.map((task) => {
741
+ const relativeFile = normalizePathSeparators(
742
+ path.relative(cwd, path.join(targetConfig.productDir, task.file))
743
+ );
744
+ const fileResult = parsed.fileResults.get(relativeFile);
745
+ if (fileResult) {
746
+ return {
747
+ task,
748
+ failed: fileResult.failed,
749
+ error: fileResult.error,
750
+ durationMs:
751
+ fileResult.durationMs > 0
752
+ ? fileResult.durationMs
753
+ : Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
754
+ };
755
+ }
756
+
757
+ return {
758
+ task,
759
+ failed: result.exitCode !== 0,
760
+ error: result.exitCode !== 0 ? genericError : null,
761
+ durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
762
+ };
763
+ });
764
+ }
765
+
766
+ async function stopLocalServices(started) {
767
+ for (const service of [...started].reverse()) {
768
+ await stopChildProcess(service.child, service.outputDrains);
769
+ }
770
+ }
771
+
772
+ async function stopChildProcess(child, outputDrains = []) {
773
+ if (!child) return;
774
+ if (child.exitCode !== null) {
775
+ await Promise.all(outputDrains);
776
+ return;
777
+ }
778
+
779
+ killChildProcess(child, "SIGTERM");
780
+ const exited = await Promise.race([
781
+ new Promise((resolve) => child.once("exit", () => resolve(true))),
782
+ sleep(5_000).then(() => false),
783
+ ]);
784
+
785
+ if (!exited && child.exitCode === null) {
786
+ killChildProcess(child, "SIGKILL");
787
+ await new Promise((resolve) => child.once("exit", resolve));
788
+ }
789
+
790
+ await Promise.all(outputDrains);
791
+ }
792
+
793
+ async function waitForReady({ name, url, timeoutMs, process }) {
794
+ const start = Date.now();
795
+
796
+ while (Date.now() - start < timeoutMs) {
797
+ if (process.exitCode !== null) {
798
+ throw new Error(`Service "${name}" exited before becoming ready`);
799
+ }
800
+
801
+ try {
802
+ const response = await fetch(url);
803
+ if (response.ok) return;
804
+ } catch {
805
+ // Service still warming up.
806
+ }
807
+
808
+ await sleep(1_000);
809
+ }
810
+
811
+ throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
812
+ }
813
+
814
+ function batchNeedsLocalRuntime(batch) {
815
+ return batch.tasks.some((task) => task.type !== "dal");
183
816
  }
184
817
 
185
818
  function resolveRuntimeConfigs(targetConfig, configMap) {
@@ -210,81 +843,61 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
210
843
  return ordered;
211
844
  }
212
845
 
213
- function buildWorkerPlans(targetConfig, runtimeConfigs, suites, jobs, runtimeSlot) {
214
- const buckets = distributeSuites(suites, jobs);
215
- return buckets
216
- .map((bucket, index) =>
217
- createWorkerPlan(targetConfig, runtimeConfigs, bucket.suites, index + 1, runtimeSlot)
218
- )
219
- .filter(Boolean);
220
- }
221
-
222
- function distributeSuites(suites, jobs) {
223
- const buckets = Array.from({ length: jobs }, () => ({
224
- suites: [],
225
- totalWeight: 0,
226
- }));
227
- const ordered = [...suites].sort(
228
- (a, b) => b.weight - a.weight || a.sortKey.localeCompare(b.sortKey)
229
- );
846
+ function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
847
+ const types =
848
+ suiteType === "all"
849
+ ? orderedTypes(Object.keys(config.suites))
850
+ : [suiteType === "int" ? "integration" : suiteType];
230
851
 
231
- for (const suite of ordered) {
232
- let bestBucket = buckets[0];
233
- for (const bucket of buckets.slice(1)) {
234
- if (bucket.totalWeight < bestBucket.totalWeight) {
235
- bestBucket = bucket;
236
- continue;
237
- }
238
- if (
239
- bucket.totalWeight === bestBucket.totalWeight &&
240
- bucket.suites.length < bestBucket.suites.length
241
- ) {
242
- bestBucket = bucket;
243
- }
244
- }
852
+ const selectedNames = new Set(suiteNames);
853
+ const suites = [];
854
+ let orderIndex = 0;
245
855
 
246
- bestBucket.suites.push(suite);
247
- bestBucket.totalWeight += suite.weight;
248
- }
856
+ for (const type of types) {
857
+ for (const suite of config.suites[type] || []) {
858
+ const framework = suite.framework || "k6";
859
+ if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
860
+ if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
249
861
 
250
- for (const bucket of buckets) {
251
- bucket.suites.sort((a, b) => a.orderIndex - b.orderIndex);
862
+ suites.push({
863
+ ...suite,
864
+ framework,
865
+ type,
866
+ orderIndex,
867
+ sortKey: `${type}:${suite.name}`,
868
+ weight:
869
+ suite.testkit?.weight ||
870
+ (framework === "playwright"
871
+ ? Math.max(2, suite.files.length)
872
+ : Math.max(1, suite.files.length)),
873
+ maxFileConcurrency:
874
+ framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
875
+ });
876
+ orderIndex += 1;
877
+ }
252
878
  }
253
879
 
254
- return buckets.filter((bucket) => bucket.suites.length > 0);
880
+ return suites;
255
881
  }
256
882
 
257
- function createWorkerPlan(targetConfig, runtimeConfigs, suites, workerId, runtimeSlot) {
258
- if (suites.length === 0) return null;
259
-
260
- const workerStateDir = path.join(targetConfig.stateDir, "workers", `worker-${workerId}`);
261
- const workerRuntimeConfigs = resolveWorkerRuntimeConfigs(
262
- targetConfig,
263
- runtimeConfigs,
264
- workerId,
265
- workerStateDir,
266
- runtimeSlot
267
- );
268
- const workerTargetConfig = workerRuntimeConfigs.find(
269
- (config) => config.name === targetConfig.name
270
- );
883
+ function applyShard(suites, shard) {
884
+ if (!shard) return suites;
885
+ return suites.filter((unused, index) => index % shard.total === shard.index - 1);
886
+ }
271
887
 
272
- return {
273
- workerId,
274
- suites,
275
- runtimeConfigs: workerRuntimeConfigs,
276
- targetConfig: workerTargetConfig,
277
- };
888
+ function orderedTypes(types) {
889
+ const ordered = [];
890
+ for (const known of TYPE_ORDER) {
891
+ if (types.includes(known)) ordered.push(known);
892
+ }
893
+ for (const type of types) {
894
+ if (!ordered.includes(type)) ordered.push(type);
895
+ }
896
+ return ordered;
278
897
  }
279
898
 
280
- function resolveWorkerRuntimeConfigs(
281
- targetConfig,
282
- runtimeConfigs,
283
- workerId,
284
- workerStateDir,
285
- runtimeSlot
286
- ) {
287
- const portMap = buildPortMap(runtimeConfigs, workerId, runtimeSlot);
899
+ function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
900
+ const portMap = buildPortMap(runtimeConfigs, workerId);
288
901
  const baseUrlByService = new Map();
289
902
  const readyUrlByService = new Map();
290
903
 
@@ -337,10 +950,10 @@ function resolveWorkerRuntimeConfigs(
337
950
  );
338
951
  }
339
952
 
340
- function buildPortMap(runtimeConfigs, workerId, runtimeSlot) {
953
+ function buildPortMap(runtimeConfigs, workerId) {
341
954
  const portMap = new Map();
342
955
  const seen = new Map();
343
- const offset = PORT_STRIDE * ((workerId - 1) + runtimeSlot.targetSlot * runtimeSlot.targetSpan);
956
+ const offset = PORT_STRIDE * (workerId - 1);
344
957
 
345
958
  for (const config of runtimeConfigs) {
346
959
  if (!config.testkit.local) continue;
@@ -442,321 +1055,18 @@ function resolveWorkerConfig(
442
1055
 
443
1056
  return {
444
1057
  ...config,
445
- stateDir,
446
- workerId,
447
- workerLabel: `w${workerId}`,
448
- targetName: targetConfig.name,
449
- testkit: {
450
- ...config.testkit,
451
- database,
452
- migrate,
453
- seed,
454
- local,
455
- },
456
- };
457
- }
458
-
459
- async function runWorkerPlan(plan) {
460
- const startedAt = Date.now();
461
- console.log(
462
- `\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
463
- );
464
-
465
- let startedServices = [];
466
- let failed = false;
467
- const suiteResults = [];
468
- let fatalError = null;
469
-
470
- try {
471
- await prepareDatabases(plan.runtimeConfigs);
472
-
473
- if (needsLocalRuntime(plan.suites)) {
474
- startedServices = await startLocalServices(plan.runtimeConfigs);
475
- }
476
-
477
- for (const suite of plan.suites) {
478
- console.log(
479
- `\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
480
- );
481
- const result = await runSuite(plan.targetConfig, suite);
482
- suiteResults.push(result);
483
- if (result.failed) failed = true;
484
- }
485
- } catch (error) {
486
- fatalError = error;
487
- failed = true;
488
- throw error;
489
- } finally {
490
- await stopLocalServices(startedServices);
491
- }
492
-
493
- return {
494
- workerId: plan.workerId,
495
- failed,
496
- fatalError: fatalError ? formatError(fatalError) : null,
497
- durationMs: Date.now() - startedAt,
498
- suiteCount: plan.suites.length,
499
- completedSuiteCount: suiteResults.length,
500
- failedSuiteCount: suiteResults.filter((result) => result.failed).length,
501
- suites: suiteResults,
502
- };
503
- }
504
-
505
- async function prepareDatabases(runtimeConfigs) {
506
- for (const config of runtimeConfigs) {
507
- await prepareDatabaseRuntime(config, {
508
- runMigrate: config.testkit.migrate
509
- ? (databaseUrl) => runMigrate(config, databaseUrl)
510
- : null,
511
- runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
512
- });
513
- }
514
- }
515
-
516
- async function runMigrate(config, databaseUrl) {
517
- const migrate = config.testkit.migrate;
518
- if (!migrate) return;
519
-
520
- const env = buildExecutionEnv(config);
521
- if (databaseUrl) env.DATABASE_URL = databaseUrl;
522
-
523
- console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
524
- await execaCommand(migrate.cmd, {
525
- cwd: resolveServiceCwd(config.productDir, migrate.cwd),
526
- env,
527
- stdio: "inherit",
528
- shell: true,
529
- });
530
- }
531
-
532
- async function runSeed(config, databaseUrl) {
533
- const seed = config.testkit.seed;
534
- if (!seed) return;
535
-
536
- const env = buildExecutionEnv(config);
537
- if (databaseUrl) env.DATABASE_URL = databaseUrl;
538
-
539
- console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
540
- await execaCommand(seed.cmd, {
541
- cwd: resolveServiceCwd(config.productDir, seed.cwd),
542
- env,
543
- stdio: "inherit",
544
- shell: true,
545
- });
546
- }
547
-
548
- async function startLocalServices(runtimeConfigs) {
549
- const started = [];
550
-
551
- try {
552
- for (const config of runtimeConfigs) {
553
- if (!config.testkit.local) continue;
554
- const proc = await startLocalService(config);
555
- started.push(proc);
556
- }
557
- } catch (error) {
558
- await stopLocalServices(started);
559
- throw error;
560
- }
561
-
562
- return started;
563
- }
564
-
565
- async function startLocalService(config) {
566
- const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
567
- const env = buildExecutionEnv(config, config.testkit.local.env);
568
- const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
569
- if (port) {
570
- env.PORT = String(port);
571
- }
572
-
573
- const dbUrl = readDatabaseUrl(config.stateDir);
574
- if (dbUrl) {
575
- env.DATABASE_URL = dbUrl;
576
- }
577
-
578
- await assertLocalServicePortsAvailable(config);
579
-
580
- console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
581
- const child = spawn(config.testkit.local.start, {
582
- cwd,
583
- env,
584
- detached: true,
585
- shell: true,
586
- stdio: ["ignore", "pipe", "pipe"],
587
- });
588
-
589
- const outputDrains = [
590
- pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
591
- pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
592
- ];
593
-
594
- const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
595
-
596
- try {
597
- await waitForReady({
598
- name: `${config.workerLabel}:${config.name}`,
599
- url: config.testkit.local.readyUrl,
600
- timeoutMs: readyTimeoutMs,
601
- process: child,
602
- });
603
- } catch (error) {
604
- await stopChildProcess(child, outputDrains);
605
- throw error;
606
- }
607
-
608
- return { name: config.name, child, outputDrains };
609
- }
610
-
611
- async function runSuite(targetConfig, suite) {
612
- if (suite.type === "dal") {
613
- return runDalSuite(targetConfig, suite);
614
- }
615
-
616
- if (suite.framework === "playwright") {
617
- return runPlaywrightSuite(targetConfig, suite);
618
- }
619
-
620
- if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
621
- return runHttpK6Suite(targetConfig, suite);
622
- }
623
-
624
- throw new Error(
625
- `Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
626
- );
627
- }
628
-
629
- async function runHttpK6Suite(targetConfig, suite) {
630
- const baseUrl = targetConfig.testkit.local?.baseUrl;
631
- if (!baseUrl) {
632
- throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
633
- }
634
-
635
- const startedAt = Date.now();
636
- let failed = false;
637
- const failedFiles = [];
638
- await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
639
- const absFile = path.join(targetConfig.productDir, file);
640
- console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
641
- try {
642
- await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
643
- cwd: targetConfig.productDir,
644
- env: buildExecutionEnv(targetConfig),
645
- stdio: "inherit",
646
- });
647
- } catch {
648
- failed = true;
649
- failedFiles.push(file);
650
- }
651
- });
652
-
653
- return buildSuiteResult(suite, failed, startedAt, failedFiles);
654
- }
655
-
656
- async function runDalSuite(targetConfig, suite) {
657
- const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
658
- if (!databaseUrl) {
659
- throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
660
- }
661
-
662
- const k6Binary = resolveDalBinary();
663
- const startedAt = Date.now();
664
- let failed = false;
665
- const failedFiles = [];
666
- await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
667
- const absFile = path.join(targetConfig.productDir, file);
668
- console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
669
- try {
670
- await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
671
- cwd: targetConfig.productDir,
672
- env: buildExecutionEnv(targetConfig),
673
- stdio: "inherit",
674
- });
675
- } catch {
676
- failed = true;
677
- failedFiles.push(file);
678
- }
679
- });
680
-
681
- return buildSuiteResult(suite, failed, startedAt, failedFiles);
682
- }
683
-
684
- async function runPlaywrightSuite(targetConfig, suite) {
685
- const local = targetConfig.testkit.local;
686
- if (!local?.baseUrl) {
687
- throw new Error(
688
- `Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
689
- );
690
- }
691
-
692
- const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
693
- const files = suite.files.map((file) =>
694
- path.relative(cwd, path.join(targetConfig.productDir, file))
695
- );
696
- const startedAt = Date.now();
697
-
698
- try {
699
- await execa("npx", ["playwright", "test", ...files], {
700
- cwd,
701
- env: buildPlaywrightEnv(targetConfig, local.baseUrl),
702
- stdio: "inherit",
703
- });
704
- return buildSuiteResult(suite, false, startedAt);
705
- } catch {
706
- return buildSuiteResult(suite, true, startedAt);
707
- }
708
- }
709
-
710
- async function stopLocalServices(started) {
711
- for (const service of [...started].reverse()) {
712
- await stopChildProcess(service.child, service.outputDrains);
713
- }
714
- }
715
-
716
- async function stopChildProcess(child, outputDrains = []) {
717
- if (!child) return;
718
- if (child.exitCode !== null) {
719
- await Promise.all(outputDrains);
720
- return;
721
- }
722
-
723
- killChildProcess(child, "SIGTERM");
724
- const exited = await Promise.race([
725
- new Promise((resolve) => child.once("exit", () => resolve(true))),
726
- sleep(5_000).then(() => false),
727
- ]);
728
-
729
- if (!exited && child.exitCode === null) {
730
- killChildProcess(child, "SIGKILL");
731
- await new Promise((resolve) => child.once("exit", resolve));
732
- }
733
-
734
- await Promise.all(outputDrains);
735
- }
736
-
737
- async function waitForReady({ name, url, timeoutMs, process }) {
738
- const start = Date.now();
739
-
740
- while (Date.now() - start < timeoutMs) {
741
- if (process.exitCode !== null) {
742
- throw new Error(`Service "${name}" exited before becoming ready`);
743
- }
744
-
745
- try {
746
- const response = await fetch(url);
747
- if (response.ok) return;
748
- } catch {
749
- // Service still warming up.
750
- }
751
-
752
- await sleep(1_000);
753
- }
754
-
755
- throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
756
- }
757
-
758
- function needsLocalRuntime(suites) {
759
- return suites.some((suite) => suite.type !== "dal");
1058
+ stateDir,
1059
+ workerId,
1060
+ workerLabel: `w${workerId}`,
1061
+ targetName: targetConfig.name,
1062
+ testkit: {
1063
+ ...config.testkit,
1064
+ database,
1065
+ migrate,
1066
+ seed,
1067
+ local,
1068
+ },
1069
+ };
760
1070
  }
761
1071
 
762
1072
  function resolveServiceStateDir(workerStateDir, targetName, config) {
@@ -790,15 +1100,95 @@ function buildPlaywrightEnv(config, baseUrl) {
790
1100
  });
791
1101
  }
792
1102
 
793
- function buildSuiteResult(suite, failed, startedAt, failedFiles = []) {
1103
+ function recordTaskOutcome(trackers, task, outcome) {
1104
+ const tracker = trackers.get(task.serviceName);
1105
+ if (!tracker || tracker.skipped) return;
1106
+
1107
+ const finishedAt = Date.now();
1108
+ if (!tracker.firstTaskAt) tracker.firstTaskAt = finishedAt;
1109
+ tracker.lastTaskAt = finishedAt;
1110
+
1111
+ const suite = tracker.suitesByKey.get(task.suiteKey);
1112
+ if (!suite) return;
1113
+
1114
+ suite.completedFileCount += 1;
1115
+ suite.durationMs += outcome.durationMs;
1116
+ if (outcome.failed && !suite.failedFileSet.has(task.file)) {
1117
+ suite.failedFileSet.add(task.file);
1118
+ suite.failedFiles.push(task.file);
1119
+ }
1120
+ if (outcome.error && !suite.error) {
1121
+ suite.error = outcome.error;
1122
+ }
1123
+ }
1124
+
1125
+ function recordGraphError(trackers, graph, message) {
1126
+ const targetNames = graph?.assignedTargets || [];
1127
+ for (const targetName of targetNames) {
1128
+ const tracker = trackers.get(targetName);
1129
+ if (tracker && !tracker.skipped) {
1130
+ addTrackerError(tracker, message);
1131
+ tracker.lastTaskAt = Date.now();
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ function addTrackerError(tracker, message) {
1137
+ if (tracker.errorSet.has(message)) return;
1138
+ tracker.errorSet.add(message);
1139
+ tracker.errors.push(message);
1140
+ }
1141
+
1142
+ function finalizeServiceResult(tracker, startedAt, finishedAt) {
1143
+ if (!tracker || tracker.skipped) {
1144
+ return {
1145
+ name: tracker?.name || "unknown",
1146
+ failed: false,
1147
+ skipped: true,
1148
+ suiteCount: 0,
1149
+ completedSuiteCount: 0,
1150
+ failedSuiteCount: 0,
1151
+ durationMs: 0,
1152
+ suites: [],
1153
+ errors: [],
1154
+ };
1155
+ }
1156
+
1157
+ const suites = [...tracker.suites].sort(
1158
+ (a, b) => a.orderIndex - b.orderIndex || a.name.localeCompare(b.name)
1159
+ );
1160
+ const completedSuiteCount = suites.filter(
1161
+ (suite) => suite.completedFileCount === suite.fileCount
1162
+ ).length;
1163
+ const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
1164
+ const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
1165
+ const durationMs =
1166
+ tracker.firstTaskAt && tracker.lastTaskAt
1167
+ ? Math.max(tracker.lastTaskAt - tracker.firstTaskAt, accumulatedDurationMs)
1168
+ : Math.max(finishedAt - startedAt, accumulatedDurationMs);
1169
+
794
1170
  return {
795
- name: suite.name,
796
- type: suite.type,
797
- framework: suite.framework,
798
- failed,
799
- fileCount: suite.files.length,
800
- failedFiles,
801
- durationMs: Date.now() - startedAt,
1171
+ name: tracker.name,
1172
+ failed:
1173
+ failedSuiteCount > 0 ||
1174
+ tracker.errors.length > 0 ||
1175
+ completedSuiteCount < tracker.suiteCount,
1176
+ skipped: false,
1177
+ suiteCount: tracker.suiteCount,
1178
+ completedSuiteCount,
1179
+ failedSuiteCount,
1180
+ durationMs,
1181
+ suites: suites.map((suite) => ({
1182
+ name: suite.name,
1183
+ type: suite.type,
1184
+ framework: suite.framework,
1185
+ failed: suite.failedFiles.length > 0,
1186
+ fileCount: suite.fileCount,
1187
+ failedFiles: suite.failedFiles,
1188
+ durationMs: suite.durationMs,
1189
+ error: suite.error,
1190
+ })),
1191
+ errors: tracker.errors,
802
1192
  };
803
1193
  }
804
1194
 
@@ -836,15 +1226,16 @@ function printRunSummary(results, durationMs) {
836
1226
  );
837
1227
 
838
1228
  if (result.failed) {
839
- const failedSuiteResults = result.workers.flatMap((worker) =>
840
- worker.suites.filter((suite) => suite.failed)
841
- );
842
- for (const suite of failedSuiteResults) {
1229
+ const failedSuitesForService = result.suites.filter((suite) => suite.failed);
1230
+ for (const suite of failedSuitesForService) {
843
1231
  const fileDetail =
844
1232
  suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
845
1233
  console.log(
846
1234
  ` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
847
1235
  );
1236
+ if (suite.error) {
1237
+ console.log(` ${suite.error}`);
1238
+ }
848
1239
  }
849
1240
  for (const error of result.errors) {
850
1241
  console.log(` - worker error: ${error}`);
@@ -872,11 +1263,6 @@ function formatDuration(durationMs) {
872
1263
  return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
873
1264
  }
874
1265
 
875
- function formatError(error) {
876
- if (error instanceof Error) return error.message;
877
- return String(error);
878
- }
879
-
880
1266
  function formatServiceSummary(result) {
881
1267
  const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
882
1268
  const notRun = result.suiteCount - result.completedSuiteCount;
@@ -887,97 +1273,216 @@ function formatServiceSummary(result) {
887
1273
  return detail;
888
1274
  }
889
1275
 
890
- function killChildProcess(child, signal) {
891
- if (!child?.pid) return;
1276
+ function formatError(error) {
1277
+ if (error instanceof Error) return error.message;
1278
+ return String(error);
1279
+ }
1280
+
1281
+ function loadTimings(productDir) {
1282
+ const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
1283
+ if (!fs.existsSync(filePath)) {
1284
+ return {
1285
+ version: 1,
1286
+ files: {},
1287
+ };
1288
+ }
892
1289
 
893
1290
  try {
894
- process.kill(-child.pid, signal);
895
- return;
896
- } catch (error) {
897
- if (error?.code !== "ESRCH") {
898
- // Fall back to the direct child if process-group signalling is unavailable.
899
- } else {
900
- return;
1291
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
1292
+ return {
1293
+ version: 1,
1294
+ files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
1295
+ };
1296
+ } catch {
1297
+ return {
1298
+ version: 1,
1299
+ files: {},
1300
+ };
1301
+ }
1302
+ }
1303
+
1304
+ function saveTimings(productDir, timings, updates) {
1305
+ if (updates.length === 0) return;
1306
+
1307
+ const next = {
1308
+ version: 1,
1309
+ files: { ...timings.files },
1310
+ };
1311
+
1312
+ for (const update of updates) {
1313
+ const existing = next.files[update.key];
1314
+ if (!existing) {
1315
+ next.files[update.key] = {
1316
+ durationMs: Math.max(1, Math.round(update.durationMs)),
1317
+ runs: 1,
1318
+ updatedAt: new Date().toISOString(),
1319
+ };
1320
+ continue;
901
1321
  }
1322
+
1323
+ const runs = Number(existing.runs || 0) + 1;
1324
+ const durationMs = Math.max(
1325
+ 1,
1326
+ Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
1327
+ );
1328
+ next.files[update.key] = {
1329
+ durationMs,
1330
+ runs,
1331
+ updatedAt: new Date().toISOString(),
1332
+ };
902
1333
  }
903
1334
 
1335
+ const rootDir = path.join(productDir, ".testkit");
1336
+ fs.mkdirSync(rootDir, { recursive: true });
1337
+ fs.writeFileSync(
1338
+ path.join(rootDir, TIMINGS_FILENAME),
1339
+ JSON.stringify(next, null, 2)
1340
+ );
1341
+ }
1342
+
1343
+ function estimateTaskDuration(timings, timingKey, suite) {
1344
+ const cached = timings.files[timingKey];
1345
+ if (cached?.durationMs) return cached.durationMs;
1346
+
1347
+ const base =
1348
+ suite.framework === "playwright"
1349
+ ? 20_000
1350
+ : suite.type === "dal"
1351
+ ? 4_000
1352
+ : 8_000;
1353
+ return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
1354
+ }
1355
+
1356
+ function buildTimingKey(serviceName, suite, file) {
1357
+ return [
1358
+ serviceName,
1359
+ suite.framework,
1360
+ suite.type,
1361
+ normalizePathSeparators(file),
1362
+ ].join("|");
1363
+ }
1364
+
1365
+ function parsePlaywrightJsonResults(stdout, cwd) {
1366
+ if (!stdout.trim()) {
1367
+ return { fileResults: new Map(), errors: [] };
1368
+ }
1369
+
1370
+ let parsed;
904
1371
  try {
905
- child.kill(signal);
1372
+ parsed = JSON.parse(stdout);
906
1373
  } catch (error) {
907
- if (error?.code !== "ESRCH") throw error;
1374
+ return {
1375
+ fileResults: new Map(),
1376
+ errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
1377
+ };
908
1378
  }
909
- }
910
1379
 
911
- function readDatabaseUrl(stateDir) {
912
- return readStateValue(path.join(stateDir, "database_url"));
1380
+ const fileResults = new Map();
1381
+ visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
1382
+ return {
1383
+ fileResults,
1384
+ errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
1385
+ };
913
1386
  }
914
1387
 
915
- function readStateValue(filePath) {
916
- if (!fs.existsSync(filePath)) return null;
917
- return fs.readFileSync(filePath, "utf8").trim();
1388
+ function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
1389
+ for (const suite of suites || []) {
1390
+ const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
1391
+ for (const child of suite.suites || []) {
1392
+ visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
1393
+ }
1394
+ for (const spec of suite.specs || []) {
1395
+ collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
1396
+ }
1397
+ }
918
1398
  }
919
1399
 
920
- function printStateDir(dir, indent) {
921
- for (const file of fs.readdirSync(dir)) {
922
- const filePath = path.join(dir, file);
923
- if (fs.statSync(filePath).isDirectory()) {
924
- console.log(`${indent}${file}/`);
925
- printStateDir(filePath, `${indent} `);
926
- continue;
1400
+ function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
1401
+ const file = normalizeReportedFile(extractReporterFile(spec) || inheritedFile, cwd);
1402
+ if (!file) return;
1403
+
1404
+ const current = fileResults.get(file) || {
1405
+ failed: false,
1406
+ error: null,
1407
+ durationMs: 0,
1408
+ };
1409
+
1410
+ for (const test of spec.tests || []) {
1411
+ const results = Array.isArray(test.results) ? test.results : [];
1412
+ current.durationMs += results.reduce(
1413
+ (sum, result) => sum + Number(result?.duration || 0),
1414
+ 0
1415
+ );
1416
+
1417
+ const final = choosePlaywrightFinalResult(results);
1418
+ const failed =
1419
+ test.outcome === "unexpected" ||
1420
+ !isPlaywrightPassingStatus(final?.status);
1421
+
1422
+ if (failed) {
1423
+ current.failed = true;
1424
+ current.error ||= extractPlaywrightFailure(final, spec, test);
927
1425
  }
928
- const value = fs.readFileSync(filePath, "utf8").trim();
929
- console.log(`${indent}${file}: ${value}`);
930
1426
  }
1427
+
1428
+ fileResults.set(file, current);
931
1429
  }
932
1430
 
933
- function pipeOutput(stream, prefix) {
934
- if (!stream) return Promise.resolve();
1431
+ function choosePlaywrightFinalResult(results) {
1432
+ if (!results || results.length === 0) return null;
1433
+ return results[results.length - 1];
1434
+ }
935
1435
 
936
- let pending = "";
937
- return new Promise((resolve) => {
938
- let settled = false;
939
- const settle = () => {
940
- if (settled) return;
941
- settled = true;
942
- resolve();
943
- };
1436
+ function isPlaywrightPassingStatus(status) {
1437
+ return !status || ["passed", "skipped", "expected"].includes(status);
1438
+ }
944
1439
 
945
- stream.on("data", (chunk) => {
946
- pending += chunk.toString();
947
- const lines = pending.split(/\r?\n/);
948
- pending = lines.pop() || "";
949
- for (const line of lines) {
950
- if (line.length > 0) console.log(`${prefix} ${line}`);
951
- }
952
- });
953
- stream.on("end", () => {
954
- if (pending.length > 0) {
955
- console.log(`${prefix} ${pending}`);
956
- }
957
- settle();
958
- });
959
- stream.on("close", settle);
960
- stream.on("error", settle);
961
- });
1440
+ function extractPlaywrightFailure(finalResult, spec, test) {
1441
+ const fromResult =
1442
+ finalResult?.error?.message ||
1443
+ finalResult?.error?.value ||
1444
+ finalResult?.error?.stack;
1445
+ if (fromResult) return firstLine(fromResult);
1446
+
1447
+ const fromTest = test?.errors?.[0]?.message;
1448
+ if (fromTest) return firstLine(fromTest);
1449
+
1450
+ return firstLine(spec?.title || "Playwright test failed");
962
1451
  }
963
1452
 
964
- async function runWithConcurrency(items, limit, handler) {
965
- const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
966
- let nextIndex = 0;
1453
+ function formatPlaywrightReporterError(error) {
1454
+ if (!error) return null;
1455
+ if (typeof error === "string") return firstLine(error);
1456
+ if (typeof error.message === "string") return firstLine(error.message);
1457
+ if (typeof error.value === "string") return firstLine(error.value);
1458
+ return null;
1459
+ }
967
1460
 
968
- const workers = Array.from({ length: concurrency }, async () => {
969
- while (nextIndex < items.length) {
970
- const current = nextIndex;
971
- nextIndex += 1;
972
- await handler(items[current], current);
973
- }
974
- });
1461
+ function extractReporterFile(node) {
1462
+ if (!node || typeof node !== "object") return null;
1463
+ if (typeof node.file === "string" && node.file.length > 0) return node.file;
1464
+ if (node.location && typeof node.location.file === "string" && node.location.file.length > 0) {
1465
+ return node.location.file;
1466
+ }
1467
+ return null;
1468
+ }
975
1469
 
976
- await Promise.all(workers);
1470
+ function normalizeReportedFile(filePath, cwd) {
1471
+ if (!filePath) return null;
1472
+ const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
1473
+ return normalizePathSeparators(path.relative(cwd, absolute));
977
1474
  }
978
1475
 
979
- function sleep(ms) {
980
- return new Promise((resolve) => setTimeout(resolve, ms));
1476
+ function firstLine(value) {
1477
+ return String(value).split(/\r?\n/).find((line) => line.trim().length > 0)?.trim() || null;
1478
+ }
1479
+
1480
+ function printBufferedOutput(output, prefix) {
1481
+ for (const line of output.split(/\r?\n/)) {
1482
+ if (line.trim().length > 0) {
1483
+ console.log(`${prefix} ${line}`);
1484
+ }
1485
+ }
981
1486
  }
982
1487
 
983
1488
  function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
@@ -1143,6 +1648,85 @@ async function isPortInUse({ host, port }) {
1143
1648
  });
1144
1649
  }
1145
1650
 
1651
+ function killChildProcess(child, signal) {
1652
+ if (!child?.pid) return;
1653
+
1654
+ try {
1655
+ process.kill(-child.pid, signal);
1656
+ return;
1657
+ } catch (error) {
1658
+ if (error?.code !== "ESRCH") {
1659
+ // Fall back to the direct child if process-group signalling is unavailable.
1660
+ } else {
1661
+ return;
1662
+ }
1663
+ }
1664
+
1665
+ try {
1666
+ child.kill(signal);
1667
+ } catch (error) {
1668
+ if (error?.code !== "ESRCH") throw error;
1669
+ }
1670
+ }
1671
+
1672
+ function readDatabaseUrl(stateDir) {
1673
+ return readStateValue(path.join(stateDir, "database_url"));
1674
+ }
1675
+
1676
+ function readStateValue(filePath) {
1677
+ if (!fs.existsSync(filePath)) return null;
1678
+ return fs.readFileSync(filePath, "utf8").trim();
1679
+ }
1680
+
1681
+ function printStateDir(dir, indent) {
1682
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1683
+ const filePath = path.join(dir, entry.name);
1684
+ if (entry.isDirectory()) {
1685
+ console.log(`${indent}${entry.name}/`);
1686
+ printStateDir(filePath, `${indent} `);
1687
+ continue;
1688
+ }
1689
+ const value =
1690
+ entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
1691
+ console.log(`${indent}${entry.name}: ${value}`);
1692
+ }
1693
+ }
1694
+
1695
+ function pipeOutput(stream, prefix) {
1696
+ if (!stream) return Promise.resolve();
1697
+
1698
+ let pending = "";
1699
+ return new Promise((resolve) => {
1700
+ let settled = false;
1701
+ const settle = () => {
1702
+ if (settled) return;
1703
+ settled = true;
1704
+ resolve();
1705
+ };
1706
+
1707
+ stream.on("data", (chunk) => {
1708
+ pending += chunk.toString();
1709
+ const lines = pending.split(/\r?\n/);
1710
+ pending = lines.pop() || "";
1711
+ for (const line of lines) {
1712
+ if (line.length > 0) console.log(`${prefix} ${line}`);
1713
+ }
1714
+ });
1715
+ stream.on("end", () => {
1716
+ if (pending.length > 0) {
1717
+ console.log(`${prefix} ${pending}`);
1718
+ }
1719
+ settle();
1720
+ });
1721
+ stream.on("close", settle);
1722
+ stream.on("error", settle);
1723
+ });
1724
+ }
1725
+
1726
+ function sleep(ms) {
1727
+ return new Promise((resolve) => setTimeout(resolve, ms));
1728
+ }
1729
+
1146
1730
  function findRuntimeStateDirs(rootDir) {
1147
1731
  const found = [];
1148
1732
 
@@ -1163,3 +1747,69 @@ function findRuntimeStateDirs(rootDir) {
1163
1747
  visit(rootDir);
1164
1748
  return found.sort((a, b) => b.length - a.length);
1165
1749
  }
1750
+
1751
+ function findGraphDirsForService(productDir, serviceName) {
1752
+ const graphsRoot = path.join(productDir, ".testkit", "_graphs");
1753
+ if (!fs.existsSync(graphsRoot)) return [];
1754
+
1755
+ const matches = [];
1756
+ for (const entry of fs.readdirSync(graphsRoot, { withFileTypes: true })) {
1757
+ if (!entry.isDirectory()) continue;
1758
+ const graphDir = path.join(graphsRoot, entry.name);
1759
+ const metadata = readGraphMetadata(graphDir);
1760
+ if (!metadata) continue;
1761
+ if ((metadata.runtimeServices || []).includes(serviceName)) {
1762
+ matches.push(graphDir);
1763
+ }
1764
+ }
1765
+
1766
+ return matches.sort();
1767
+ }
1768
+
1769
+ function writeGraphMetadata(graphDir, graph) {
1770
+ fs.mkdirSync(graphDir, { recursive: true });
1771
+ const metadata = {
1772
+ runtimeServices: graph.runtimeNames,
1773
+ assignedTargets: [...graph.assignedTargets].sort(),
1774
+ rootService: graph.rootConfig.name,
1775
+ };
1776
+ fs.writeFileSync(
1777
+ path.join(graphDir, GRAPH_METADATA),
1778
+ JSON.stringify(metadata, null, 2)
1779
+ );
1780
+ }
1781
+
1782
+ function readGraphMetadata(graphDir) {
1783
+ const metadataPath = path.join(graphDir, GRAPH_METADATA);
1784
+ if (!fs.existsSync(metadataPath)) return null;
1785
+
1786
+ try {
1787
+ return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
1788
+ } catch {
1789
+ return null;
1790
+ }
1791
+ }
1792
+
1793
+ function isRuntimeSuperset(candidate, target) {
1794
+ return target.every((name) => candidate.includes(name));
1795
+ }
1796
+
1797
+ function compareGraphsForAssignment(left, right) {
1798
+ if (left.runtimeNames.length !== right.runtimeNames.length) {
1799
+ return left.runtimeNames.length - right.runtimeNames.length;
1800
+ }
1801
+ return left.key.localeCompare(right.key);
1802
+ }
1803
+
1804
+ function buildGraphDirName(runtimeNames) {
1805
+ const slug = runtimeNames.map(slugSegment).join("__");
1806
+ return slug.length > 0 ? slug : "graph";
1807
+ }
1808
+
1809
+ function slugSegment(value) {
1810
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
1811
+ }
1812
+
1813
+ function normalizePathSeparators(filePath) {
1814
+ return filePath.split(path.sep).join("/");
1815
+ }