@elench/testkit 0.1.15 → 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
+ }
300
+
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
+ }
98
311
 
99
- const results = await Promise.allSettled(workerPlans.map((plan) => runWorkerPlan(plan)));
100
- let failed = false;
101
- const workers = [];
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;
@@ -435,328 +1048,25 @@ function resolveWorkerConfig(
435
1048
  Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
436
1049
  key,
437
1050
  finalizeString(String(value), context),
438
- ])
439
- ),
440
- }
441
- : undefined;
442
-
443
- return {
444
- ...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
- }
1051
+ ])
1052
+ ),
1053
+ }
1054
+ : undefined;
757
1055
 
758
- function needsLocalRuntime(suites) {
759
- return suites.some((suite) => suite.type !== "dal");
1056
+ return {
1057
+ ...config,
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) {
@@ -780,7 +1090,7 @@ function buildExecutionEnv(config, extraEnv = {}) {
780
1090
  }
781
1091
 
782
1092
  function buildPlaywrightEnv(config, baseUrl) {
783
- const env = buildExecutionEnv(config, {
1093
+ return buildExecutionEnv(config, {
784
1094
  BASE_URL: baseUrl,
785
1095
  PLAYWRIGHT_HTML_OPEN: "never",
786
1096
  PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
@@ -788,36 +1098,97 @@ function buildPlaywrightEnv(config, baseUrl) {
788
1098
  TESTKIT_MANAGED_SERVERS: "1",
789
1099
  TESTKIT_WORKER_ID: String(config.workerId),
790
1100
  });
1101
+ }
791
1102
 
792
- const browsersPath = resolvePlaywrightBrowsersPath(env.PLAYWRIGHT_BROWSERS_PATH);
793
- if (browsersPath) {
794
- env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
795
- }
1103
+ function recordTaskOutcome(trackers, task, outcome) {
1104
+ const tracker = trackers.get(task.serviceName);
1105
+ if (!tracker || tracker.skipped) return;
796
1106
 
797
- return env;
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
+ }
798
1123
  }
799
1124
 
800
- function resolvePlaywrightBrowsersPath(configuredPath) {
801
- const home = process.env.HOME;
802
- if (home) {
803
- const fallback = path.join(home, ".cache", "ms-playwright");
804
- if (fs.existsSync(fallback)) {
805
- return fallback;
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();
806
1132
  }
807
1133
  }
1134
+ }
808
1135
 
809
- return configuredPath;
1136
+ function addTrackerError(tracker, message) {
1137
+ if (tracker.errorSet.has(message)) return;
1138
+ tracker.errorSet.add(message);
1139
+ tracker.errors.push(message);
810
1140
  }
811
1141
 
812
- function buildSuiteResult(suite, failed, startedAt, failedFiles = []) {
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
+
813
1170
  return {
814
- name: suite.name,
815
- type: suite.type,
816
- framework: suite.framework,
817
- failed,
818
- fileCount: suite.files.length,
819
- failedFiles,
820
- 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,
821
1192
  };
822
1193
  }
823
1194
 
@@ -855,15 +1226,16 @@ function printRunSummary(results, durationMs) {
855
1226
  );
856
1227
 
857
1228
  if (result.failed) {
858
- const failedSuiteResults = result.workers.flatMap((worker) =>
859
- worker.suites.filter((suite) => suite.failed)
860
- );
861
- for (const suite of failedSuiteResults) {
1229
+ const failedSuitesForService = result.suites.filter((suite) => suite.failed);
1230
+ for (const suite of failedSuitesForService) {
862
1231
  const fileDetail =
863
1232
  suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
864
1233
  console.log(
865
1234
  ` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
866
1235
  );
1236
+ if (suite.error) {
1237
+ console.log(` ${suite.error}`);
1238
+ }
867
1239
  }
868
1240
  for (const error of result.errors) {
869
1241
  console.log(` - worker error: ${error}`);
@@ -891,11 +1263,6 @@ function formatDuration(durationMs) {
891
1263
  return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
892
1264
  }
893
1265
 
894
- function formatError(error) {
895
- if (error instanceof Error) return error.message;
896
- return String(error);
897
- }
898
-
899
1266
  function formatServiceSummary(result) {
900
1267
  const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
901
1268
  const notRun = result.suiteCount - result.completedSuiteCount;
@@ -906,97 +1273,216 @@ function formatServiceSummary(result) {
906
1273
  return detail;
907
1274
  }
908
1275
 
909
- function killChildProcess(child, signal) {
910
- 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
+ }
911
1289
 
912
1290
  try {
913
- process.kill(-child.pid, signal);
914
- return;
915
- } catch (error) {
916
- if (error?.code !== "ESRCH") {
917
- // Fall back to the direct child if process-group signalling is unavailable.
918
- } else {
919
- 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;
920
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
+ };
921
1333
  }
922
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;
923
1371
  try {
924
- child.kill(signal);
1372
+ parsed = JSON.parse(stdout);
925
1373
  } catch (error) {
926
- if (error?.code !== "ESRCH") throw error;
1374
+ return {
1375
+ fileResults: new Map(),
1376
+ errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
1377
+ };
927
1378
  }
928
- }
929
1379
 
930
- function readDatabaseUrl(stateDir) {
931
- 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
+ };
932
1386
  }
933
1387
 
934
- function readStateValue(filePath) {
935
- if (!fs.existsSync(filePath)) return null;
936
- 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
+ }
937
1398
  }
938
1399
 
939
- function printStateDir(dir, indent) {
940
- for (const file of fs.readdirSync(dir)) {
941
- const filePath = path.join(dir, file);
942
- if (fs.statSync(filePath).isDirectory()) {
943
- console.log(`${indent}${file}/`);
944
- printStateDir(filePath, `${indent} `);
945
- 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);
946
1425
  }
947
- const value = fs.readFileSync(filePath, "utf8").trim();
948
- console.log(`${indent}${file}: ${value}`);
949
1426
  }
1427
+
1428
+ fileResults.set(file, current);
950
1429
  }
951
1430
 
952
- function pipeOutput(stream, prefix) {
953
- if (!stream) return Promise.resolve();
1431
+ function choosePlaywrightFinalResult(results) {
1432
+ if (!results || results.length === 0) return null;
1433
+ return results[results.length - 1];
1434
+ }
954
1435
 
955
- let pending = "";
956
- return new Promise((resolve) => {
957
- let settled = false;
958
- const settle = () => {
959
- if (settled) return;
960
- settled = true;
961
- resolve();
962
- };
1436
+ function isPlaywrightPassingStatus(status) {
1437
+ return !status || ["passed", "skipped", "expected"].includes(status);
1438
+ }
963
1439
 
964
- stream.on("data", (chunk) => {
965
- pending += chunk.toString();
966
- const lines = pending.split(/\r?\n/);
967
- pending = lines.pop() || "";
968
- for (const line of lines) {
969
- if (line.length > 0) console.log(`${prefix} ${line}`);
970
- }
971
- });
972
- stream.on("end", () => {
973
- if (pending.length > 0) {
974
- console.log(`${prefix} ${pending}`);
975
- }
976
- settle();
977
- });
978
- stream.on("close", settle);
979
- stream.on("error", settle);
980
- });
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");
981
1451
  }
982
1452
 
983
- async function runWithConcurrency(items, limit, handler) {
984
- const concurrency = Math.max(1, Math.min(limit || 1, items.length || 1));
985
- 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
+ }
986
1460
 
987
- const workers = Array.from({ length: concurrency }, async () => {
988
- while (nextIndex < items.length) {
989
- const current = nextIndex;
990
- nextIndex += 1;
991
- await handler(items[current], current);
992
- }
993
- });
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
+ }
994
1469
 
995
- 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));
996
1474
  }
997
1475
 
998
- function sleep(ms) {
999
- 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
+ }
1000
1486
  }
1001
1487
 
1002
1488
  function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
@@ -1162,6 +1648,85 @@ async function isPortInUse({ host, port }) {
1162
1648
  });
1163
1649
  }
1164
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
+
1165
1730
  function findRuntimeStateDirs(rootDir) {
1166
1731
  const found = [];
1167
1732
 
@@ -1182,3 +1747,69 @@ function findRuntimeStateDirs(rootDir) {
1182
1747
  visit(rootDir);
1183
1748
  return found.sort((a, b) => b.length - a.length);
1184
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
+ }