@elench/testkit 0.1.40 → 0.1.42

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.
Files changed (38) hide show
  1. package/README.md +27 -13
  2. package/bin/testkit.mjs +6 -1
  3. package/lib/cli/args.mjs +0 -4
  4. package/lib/cli/args.test.mjs +0 -5
  5. package/lib/cli/index.mjs +4 -11
  6. package/lib/config/index.mjs +78 -24
  7. package/lib/database/index.mjs +19 -7
  8. package/lib/database/naming.mjs +2 -2
  9. package/lib/database/naming.test.mjs +2 -2
  10. package/lib/runner/default-runtime-runner.mjs +52 -55
  11. package/lib/runner/execution-config.mjs +31 -70
  12. package/lib/runner/execution-config.test.mjs +30 -74
  13. package/lib/runner/formatting.mjs +0 -15
  14. package/lib/runner/formatting.test.mjs +0 -18
  15. package/lib/runner/lifecycle.mjs +106 -8
  16. package/lib/runner/orchestrator.mjs +16 -10
  17. package/lib/runner/planning.mjs +66 -138
  18. package/lib/runner/planning.test.mjs +101 -167
  19. package/lib/runner/playwright-config.mjs +13 -2
  20. package/lib/runner/playwright-config.test.mjs +26 -6
  21. package/lib/runner/playwright-runner.mjs +50 -56
  22. package/lib/runner/readiness.mjs +2 -2
  23. package/lib/runner/reporting.mjs +4 -3
  24. package/lib/runner/reporting.test.mjs +2 -5
  25. package/lib/runner/results.mjs +1 -1
  26. package/lib/runner/results.test.mjs +1 -1
  27. package/lib/runner/runtime-contexts.mjs +20 -24
  28. package/lib/runner/runtime-manager.mjs +228 -0
  29. package/lib/runner/runtime-manager.test.mjs +206 -0
  30. package/lib/runner/services.mjs +8 -6
  31. package/lib/runner/state.mjs +1 -2
  32. package/lib/runner/state.test.mjs +2 -4
  33. package/lib/runner/template.mjs +90 -60
  34. package/lib/runner/template.test.mjs +59 -27
  35. package/lib/runner/worker-loop.mjs +35 -32
  36. package/lib/setup/index.d.ts +15 -10
  37. package/package.json +1 -1
  38. package/lib/runner/stack-manager.mjs +0 -146
@@ -1,11 +1,13 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
+ import { execFileSync } from "child_process";
4
5
  import { destroyRuntimeDatabase, cleanupOrphanedLocalInfrastructure } from "../database/index.mjs";
5
6
 
6
7
  const RUN_SCHEMA_VERSION = 1;
7
8
  const RUNS_DIRNAME = path.join(".testkit", "_runs");
8
9
  const TERMINATION_TIMEOUT_MS = 5_000;
10
+ const SHUTDOWN_HOLD_TIMEOUT_MS = 10_000;
9
11
 
10
12
  export function createRunLifecycle(productDir) {
11
13
  const runId = buildRunId();
@@ -21,10 +23,13 @@ export function createRunLifecycle(productDir) {
21
23
  interruptReason: null,
22
24
  services: [],
23
25
  graphDirs: [],
24
- stackStateDirs: [],
26
+ runtimeDirs: [],
25
27
  runtimeStateDirs: [],
26
28
  };
27
29
  const signalListeners = [];
30
+ const managedProcesses = new Set();
31
+ let shutdownHold = null;
32
+ let shutdownHoldTimeout = null;
28
33
 
29
34
  function persist() {
30
35
  fs.mkdirSync(getRunsDir(productDir), { recursive: true });
@@ -36,6 +41,24 @@ export function createRunLifecycle(productDir) {
36
41
  persist();
37
42
  }
38
43
 
44
+ function ensureShutdownHold() {
45
+ if (shutdownHold) return;
46
+ shutdownHold = setInterval(() => {}, 1_000);
47
+ shutdownHoldTimeout = setTimeout(() => {
48
+ releaseShutdownHold();
49
+ }, SHUTDOWN_HOLD_TIMEOUT_MS);
50
+ }
51
+
52
+ function releaseShutdownHold() {
53
+ if (!shutdownHold) return;
54
+ clearInterval(shutdownHold);
55
+ shutdownHold = null;
56
+ if (shutdownHoldTimeout) {
57
+ clearTimeout(shutdownHoldTimeout);
58
+ shutdownHoldTimeout = null;
59
+ }
60
+ }
61
+
39
62
  const api = {
40
63
  runId,
41
64
  manifestPath,
@@ -55,9 +78,18 @@ export function createRunLifecycle(productDir) {
55
78
  });
56
79
  },
57
80
  requestStop(reason = "interrupted") {
81
+ ensureShutdownHold();
58
82
  if (!abortController.signal.aborted) {
59
83
  abortController.abort(new Error(`testkit run interrupted (${reason})`));
60
84
  }
85
+ for (const entry of managedProcesses) {
86
+ try {
87
+ entry.terminate?.();
88
+ } catch {
89
+ // Best-effort interruption only.
90
+ }
91
+ }
92
+ terminateDescendantProcesses(process.pid, "SIGINT");
61
93
  mutate((draft) => {
62
94
  draft.status = "interrupting";
63
95
  draft.interruptReason = reason;
@@ -66,19 +98,34 @@ export function createRunLifecycle(productDir) {
66
98
  trackGraphContext(context) {
67
99
  mutate((draft) => {
68
100
  pushUnique(draft.graphDirs, context.graphDir);
69
- pushUnique(draft.stackStateDirs, context.stackStateDir);
101
+ pushUnique(draft.runtimeDirs, context.runtimeDir);
70
102
  for (const runtimeConfig of context.runtimeConfigs || []) {
71
103
  if (runtimeConfig.stateDir) pushUnique(draft.runtimeStateDirs, runtimeConfig.stateDir);
72
104
  }
73
105
  });
74
106
  },
75
- registerService(config, child, cwd) {
107
+ registerProcess(child, terminate) {
108
+ if (!child) return;
109
+ managedProcesses.add({
110
+ child,
111
+ terminate,
112
+ });
113
+ },
114
+ unregisterProcess(childPid) {
115
+ for (const entry of managedProcesses) {
116
+ if (entry.child?.pid === childPid) {
117
+ managedProcesses.delete(entry);
118
+ }
119
+ }
120
+ },
121
+ registerService(config, child, cwd, terminate) {
122
+ api.registerProcess(child, terminate);
76
123
  const ports = collectConfigPorts(config);
77
124
  mutate((draft) => {
78
125
  draft.services = draft.services.filter((service) => service.pid !== child.pid);
79
126
  draft.services.push({
80
127
  serviceName: config.name,
81
- stackLabel: config.stackLabel,
128
+ runtimeLabel: config.runtimeLabel,
82
129
  command: config.testkit.local?.start || null,
83
130
  cwd,
84
131
  pid: child.pid,
@@ -89,6 +136,7 @@ export function createRunLifecycle(productDir) {
89
136
  });
90
137
  },
91
138
  unregisterService(childPid) {
139
+ api.unregisterProcess(childPid);
92
140
  mutate((draft) => {
93
141
  draft.services = draft.services.filter((service) => service.pid !== childPid);
94
142
  });
@@ -116,6 +164,9 @@ export function createRunLifecycle(productDir) {
116
164
  removeManifest() {
117
165
  removeManifestFile(productDir, runId);
118
166
  },
167
+ dispose() {
168
+ releaseShutdownHold();
169
+ },
119
170
  };
120
171
 
121
172
  return api;
@@ -195,8 +246,8 @@ export function findPortOwner(productDir, { host, port }) {
195
246
  }
196
247
 
197
248
  export function formatRunSummary(manifest) {
198
- const stackLabels = [...new Set((manifest.services || []).map((service) => service.stackLabel).filter(Boolean))];
199
- return `${manifest.runId} pid=${manifest.pid}${stackLabels.length > 0 ? ` stacks=${stackLabels.join(",")}` : ""}`;
249
+ const runtimeLabels = [...new Set((manifest.services || []).map((service) => service.runtimeLabel).filter(Boolean))];
250
+ return `${manifest.runId} pid=${manifest.pid}${runtimeLabels.length > 0 ? ` runtimes=${runtimeLabels.join(",")}` : ""}`;
200
251
  }
201
252
 
202
253
  export function isPidRunning(pid) {
@@ -220,8 +271,8 @@ async function cleanupRunManifest(productDir, manifest, { removeRuntimeState = f
220
271
  await destroyRuntimeDatabase({ productDir, stateDir });
221
272
  }
222
273
 
223
- for (const stackStateDir of [...new Set(manifest.stackStateDirs || [])].sort((a, b) => b.length - a.length)) {
224
- fs.rmSync(stackStateDir, { recursive: true, force: true });
274
+ for (const runtimeDir of [...new Set(manifest.runtimeDirs || [])].sort((a, b) => b.length - a.length)) {
275
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
225
276
  }
226
277
 
227
278
  for (const graphDir of [...new Set(manifest.graphDirs || [])].sort((a, b) => b.length - a.length)) {
@@ -264,6 +315,53 @@ function killProcessGroup(pid, signal) {
264
315
  }
265
316
  }
266
317
 
318
+ function terminateDescendantProcesses(rootPid, signal) {
319
+ const descendants = listDescendantPids(rootPid);
320
+ for (const pid of descendants) {
321
+ try {
322
+ process.kill(pid, signal);
323
+ } catch (error) {
324
+ if (error?.code !== "ESRCH") {
325
+ // Best-effort interruption only.
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ function listDescendantPids(rootPid) {
332
+ try {
333
+ const output = execFileSync("ps", ["-eo", "pid=,ppid="], {
334
+ encoding: "utf8",
335
+ stdio: ["ignore", "pipe", "ignore"],
336
+ });
337
+ const childrenByParent = new Map();
338
+ for (const line of output.split(/\r?\n/)) {
339
+ const trimmed = line.trim();
340
+ if (!trimmed) continue;
341
+ const [pidRaw, parentRaw] = trimmed.split(/\s+/);
342
+ const pid = Number(pidRaw);
343
+ const parentPid = Number(parentRaw);
344
+ if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
345
+ const siblings = childrenByParent.get(parentPid) || [];
346
+ siblings.push(pid);
347
+ childrenByParent.set(parentPid, siblings);
348
+ }
349
+
350
+ const descendants = [];
351
+ const stack = [...(childrenByParent.get(rootPid) || [])];
352
+ while (stack.length > 0) {
353
+ const pid = stack.pop();
354
+ descendants.push(pid);
355
+ for (const childPid of childrenByParent.get(pid) || []) {
356
+ stack.push(childPid);
357
+ }
358
+ }
359
+ return descendants;
360
+ } catch {
361
+ return [];
362
+ }
363
+ }
364
+
267
365
  async function waitForPidExit(pid, timeoutMs) {
268
366
  const startedAt = Date.now();
269
367
  while (Date.now() - startedAt < timeoutMs) {
@@ -2,7 +2,7 @@ import {
2
2
  applyShard,
3
3
  buildRuntimeGraphs,
4
4
  buildTaskQueue,
5
- claimNextBatch,
5
+ claimNextTask,
6
6
  collectSuites,
7
7
  resolveRuntimeConfigs,
8
8
  } from "./planning.mjs";
@@ -36,7 +36,7 @@ import {
36
36
  safeUsername,
37
37
  } from "./metadata.mjs";
38
38
  import { resolveExecutionConfig } from "./execution-config.mjs";
39
- import { createStackManager } from "./stack-manager.mjs";
39
+ import { createRuntimeManager } from "./runtime-manager.mjs";
40
40
  import { createWorker, runWorker } from "./worker-loop.mjs";
41
41
  import { findUnmatchedRequestedFiles, isFullRunSelection } from "./selection.mjs";
42
42
  import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
@@ -105,7 +105,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
105
105
  const trackers = buildServiceTrackers(servicePlans, startedAt);
106
106
  const executedPlans = servicePlans.filter((plan) => !plan.skipped);
107
107
  let workerCount = 0;
108
- let stackCount = 0;
108
+ let runtimeInstanceCount = 0;
109
+ let runtimeStats = [];
109
110
  let exitCode = 0;
110
111
  const lifecycle = createRunLifecycle(productDir);
111
112
  lifecycle.markRunning();
@@ -119,14 +120,13 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
119
120
  const graphs = buildRuntimeGraphs(executedPlans);
120
121
  const queue = buildTaskQueue(executedPlans, graphs, timings);
121
122
  workerCount = Math.max(1, Math.min(execution.workers, queue.length));
122
- stackCount = execution.stackMode === "shared" ? 1 : execution.stackCount;
123
+ runtimeInstanceCount = graphs.reduce((sum, graph) => sum + graph.instanceCount, 0);
123
124
  const workers = Array.from({ length: workerCount }, (_unused, index) =>
124
125
  createWorker(index + 1, productDir)
125
126
  );
126
- const stackManager = createStackManager({
127
+ const runtimeManager = createRuntimeManager({
127
128
  productDir,
128
129
  graphs,
129
- execution,
130
130
  lifecycle,
131
131
  });
132
132
  const timingUpdates = [];
@@ -137,11 +137,11 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
137
137
  runWorker(
138
138
  worker,
139
139
  queue,
140
- stackManager,
140
+ runtimeManager,
141
141
  trackers,
142
142
  timingUpdates,
143
143
  lifecycle,
144
- claimNextBatch,
144
+ claimNextTask,
145
145
  recordTaskOutcome,
146
146
  recordGraphError
147
147
  )
@@ -156,8 +156,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
156
156
  }
157
157
  }
158
158
  }
159
+ runtimeStats = runtimeManager.getStats();
159
160
  } finally {
160
- await stackManager.cleanupAll();
161
+ await runtimeManager.cleanupAll();
161
162
  }
162
163
 
163
164
  saveTimings(productDir, timings, timingUpdates);
@@ -174,7 +175,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
174
175
  finishedAt,
175
176
  execution,
176
177
  workerCount,
177
- stackCount,
178
+ runtimeInstanceCount,
179
+ runtimeStats,
178
180
  typeValues,
179
181
  suiteSelectors,
180
182
  fileNames: requestedFiles,
@@ -206,6 +208,9 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
206
208
  if (results.some((result) => result.failed)) exitCode = 1;
207
209
  if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
208
210
  } finally {
211
+ if (lifecycle.isStopRequested()) {
212
+ exitCode = Math.max(exitCode, 130);
213
+ }
209
214
  lifecycle.removeSignalHandlers();
210
215
  lifecycle.markFinished(
211
216
  exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
@@ -213,6 +218,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
213
218
  await cleanupRunById(productDir, lifecycle.runId);
214
219
  await cleanupRuns(productDir, { includeActive: false });
215
220
  lifecycle.removeManifest();
221
+ lifecycle.dispose();
216
222
  process.exitCode = exitCode;
217
223
  }
218
224
  }
@@ -4,12 +4,11 @@ import {
4
4
  matchesSuiteSelectors,
5
5
  suiteSelectionType,
6
6
  } from "./suite-selection.mjs";
7
- import { resolveBatchAccessMode, resolveBatchStackMode } from "./execution-config.mjs";
8
7
 
9
8
  const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
10
9
 
11
- export function batchNeedsLocalRuntime(batch) {
12
- return batch.tasks.some((task) => task.type !== "dal");
10
+ export function taskNeedsLocalRuntime(task) {
11
+ return task.type !== "dal";
13
12
  }
14
13
 
15
14
  export function resolveRuntimeConfigs(targetConfig, configMap) {
@@ -78,10 +77,6 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
78
77
  (framework === "playwright"
79
78
  ? Math.max(2, Math.max(1, files.length))
80
79
  : Math.max(1, files.length)),
81
- maxFileConcurrency:
82
- framework === "k6" || framework === "playwright"
83
- ? suite.testkit?.maxFileConcurrency || 1
84
- : 1,
85
80
  totalFileCount: selectedSuiteFiles.length,
86
81
  });
87
82
  orderIndex += 1;
@@ -93,7 +88,7 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
93
88
 
94
89
  export function applyShard(suites, shard) {
95
90
  if (!shard) return suites;
96
- return suites.filter((unused, index) => index % shard.total === shard.index - 1);
91
+ return suites.filter((_unused, index) => index % shard.total === shard.index - 1);
97
92
  }
98
93
 
99
94
  export function orderedTypes(types) {
@@ -109,12 +104,19 @@ export function orderedTypes(types) {
109
104
 
110
105
  export function buildRuntimeGraphs(servicePlans) {
111
106
  const executed = servicePlans.filter((plan) => !plan.skipped);
112
- const uniqueGraphs = [];
107
+ const graphs = [];
113
108
  const graphByRuntimeKey = new Map();
114
109
 
115
110
  for (const plan of executed) {
116
- if (graphByRuntimeKey.has(plan.runtimeKey)) {
117
- graphByRuntimeKey.get(plan.runtimeKey).exactTargets.push(plan.config.name);
111
+ plan.assignedGraphKey = plan.runtimeKey;
112
+ const existing = graphByRuntimeKey.get(plan.runtimeKey);
113
+ if (existing) {
114
+ existing.targetNames.push(plan.config.name);
115
+ existing.instanceCount = Math.max(existing.instanceCount, plan.config.testkit.runtime.instances);
116
+ existing.maxConcurrentTasks = Math.min(
117
+ existing.maxConcurrentTasks,
118
+ resolveGraphMaxConcurrentTasks(plan.runtimeConfigs)
119
+ );
118
120
  continue;
119
121
  }
120
122
 
@@ -122,48 +124,23 @@ export function buildRuntimeGraphs(servicePlans) {
122
124
  key: plan.runtimeKey,
123
125
  runtimeNames: plan.runtimeNames,
124
126
  runtimeConfigs: plan.runtimeConfigs,
125
- exactTargets: [plan.config.name],
126
- assignedTargets: [],
127
- dirName: null,
128
- rootConfig: null,
127
+ targetNames: [plan.config.name],
128
+ dirName: buildGraphDirName(plan.runtimeNames),
129
+ instanceCount: plan.config.testkit.runtime.instances,
130
+ maxConcurrentTasks: resolveGraphMaxConcurrentTasks(plan.runtimeConfigs),
129
131
  };
130
- uniqueGraphs.push(graph);
132
+ graphs.push(graph);
131
133
  graphByRuntimeKey.set(plan.runtimeKey, graph);
132
134
  }
133
135
 
134
- const maximalGraphs = uniqueGraphs.filter(
135
- (graph) =>
136
- !uniqueGraphs.some(
137
- (other) =>
138
- other.key !== graph.key &&
139
- isRuntimeSuperset(other.runtimeNames, graph.runtimeNames)
140
- )
141
- );
142
-
143
- for (const plan of executed) {
144
- const compatible = maximalGraphs.filter((graph) =>
145
- isRuntimeSuperset(graph.runtimeNames, plan.runtimeNames)
146
- );
147
- if (compatible.length === 0) {
148
- throw new Error(`No runtime graph found for service "${plan.config.name}"`);
149
- }
136
+ const sortedGraphs = graphs.sort((left, right) => left.dirName.localeCompare(right.dirName));
137
+ const maxInstanceCount = Math.max(1, ...sortedGraphs.map((graph) => graph.instanceCount));
150
138
 
151
- const assigned = compatible.sort(compareGraphsForAssignment)[0];
152
- plan.assignedGraphKey = assigned.key;
153
- assigned.assignedTargets.push(plan.config.name);
154
- }
155
-
156
- for (const graph of maximalGraphs) {
157
- const rootName = [...graph.exactTargets].sort()[0];
158
- const rootPlan = executed.find((plan) => plan.config.name === rootName);
159
- if (!rootPlan) {
160
- throw new Error(`Missing root plan for graph "${graph.key}"`);
161
- }
162
- graph.rootConfig = rootPlan.config;
163
- graph.dirName = buildGraphDirName(graph.runtimeNames);
164
- }
165
-
166
- return maximalGraphs.sort((a, b) => a.dirName.localeCompare(b.dirName));
139
+ return sortedGraphs.map((graph, index) => ({
140
+ ...graph,
141
+ portNamespaceIndex: index,
142
+ portNamespaceStride: maxInstanceCount,
143
+ }));
167
144
  }
168
145
 
169
146
  export function buildTaskQueue(servicePlans, graphs, timings) {
@@ -181,7 +158,6 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
181
158
 
182
159
  for (const suite of plan.suites) {
183
160
  for (const file of suite.files) {
184
- const stackMode = resolveTaskStackMode(plan.config, plan.execution, suite, file);
185
161
  const timingKey = buildTimingKey(plan.config.name, suite, file);
186
162
  tasks.push({
187
163
  id: nextId,
@@ -192,18 +168,12 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
192
168
  suiteName: suite.name,
193
169
  type: suite.type,
194
170
  framework: suite.framework,
195
- stackMode,
196
- accessMode: resolveBatchAccessMode({
197
- framework: suite.framework,
198
- type: suite.type,
199
- stackMode,
200
- }),
201
171
  orderIndex: suite.orderIndex,
202
172
  file,
173
+ locks: resolveTaskLocks(plan.config, suite, file),
174
+ resourceCost: 1,
203
175
  timingKey,
204
176
  estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
205
- maxBatchSize:
206
- suite.maxFileConcurrency || 1,
207
177
  });
208
178
  nextId += 1;
209
179
  }
@@ -219,68 +189,30 @@ export function buildTaskQueue(servicePlans, graphs, timings) {
219
189
  );
220
190
  }
221
191
 
222
- export function claimNextBatch(queue, preferredGraphKey) {
192
+ export function claimNextTask(queue, preferredGraphKey, isRunnable = () => true) {
223
193
  if (queue.length === 0) return null;
224
194
 
225
- let index = -1;
226
- if (preferredGraphKey) {
227
- index = queue.findIndex((task) => task.graphKey === preferredGraphKey);
228
- }
229
- if (index === -1) index = 0;
230
-
231
- const seed = queue.splice(index, 1)[0];
232
- const tasks = [seed];
233
-
234
- if (seed.maxBatchSize > 1) {
235
- for (let cursor = 0; cursor < queue.length; cursor += 1) {
236
- if (tasks.length >= seed.maxBatchSize) break;
237
- const candidate = queue[cursor];
238
- if (
239
- candidate.framework === seed.framework &&
240
- candidate.type === seed.type &&
241
- candidate.graphKey === seed.graphKey &&
242
- candidate.targetName === seed.targetName &&
243
- candidate.suiteKey === seed.suiteKey &&
244
- candidate.stackMode === seed.stackMode &&
245
- candidate.accessMode === seed.accessMode
246
- ) {
247
- tasks.push(candidate);
248
- queue.splice(cursor, 1);
249
- cursor -= 1;
250
- }
195
+ const preferredIndexes = [];
196
+ const fallbackIndexes = [];
197
+ for (let index = 0; index < queue.length; index += 1) {
198
+ if (preferredGraphKey && queue[index].graphKey === preferredGraphKey) {
199
+ preferredIndexes.push(index);
200
+ } else {
201
+ fallbackIndexes.push(index);
251
202
  }
252
203
  }
253
204
 
254
- tasks.sort(
255
- (a, b) =>
256
- a.orderIndex - b.orderIndex ||
257
- a.file.localeCompare(b.file)
258
- );
259
-
260
- return {
261
- graphKey: seed.graphKey,
262
- targetName: seed.targetName,
263
- framework: seed.framework,
264
- type: seed.type,
265
- stackMode: seed.stackMode,
266
- accessMode: seed.accessMode,
267
- tasks,
268
- };
269
- }
270
-
271
- export function isRuntimeSuperset(candidate, target) {
272
- return target.every((name) => candidate.includes(name));
273
- }
274
-
275
- export function compareGraphsForAssignment(left, right) {
276
- if (left.runtimeNames.length !== right.runtimeNames.length) {
277
- return left.runtimeNames.length - right.runtimeNames.length;
205
+ for (const index of [...preferredIndexes, ...fallbackIndexes]) {
206
+ if (!isRunnable(queue[index])) continue;
207
+ return queue.splice(index, 1)[0];
278
208
  }
279
- return left.key.localeCompare(right.key);
209
+
210
+ return null;
280
211
  }
281
212
 
282
- function normalizePathSeparators(filePath) {
283
- return String(filePath).split("\\").join("/");
213
+ export function buildGraphDirName(runtimeNames) {
214
+ const slug = runtimeNames.map(slugSegment).join("__");
215
+ return slug.length > 0 ? slug : "graph";
284
216
  }
285
217
 
286
218
  function applySkipRules(config, displayType, suiteName, files, opts = {}) {
@@ -324,40 +256,36 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
324
256
  };
325
257
  }
326
258
 
327
- function resolveSuiteStackMode(config, displayType, suiteName) {
328
- const defaultStackMode = config.testkit.execution.stackMode;
329
- const rules = config.testkit.serviceExecution?.suites || [];
330
- const matchedRule = rules.find((rule) =>
331
- matchesSuiteSelectors(displayType, suiteName, [rule.selector])
332
- );
333
- return resolveBatchStackMode(defaultStackMode, matchedRule?.stackMode || null);
334
- }
259
+ function resolveTaskLocks(config, suite, file) {
260
+ const locks = new Set();
261
+ const matchedSuiteRules = config.testkit.requirements?.suites || [];
262
+ for (const rule of matchedSuiteRules) {
263
+ if (matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])) {
264
+ for (const lockName of rule.locks || []) {
265
+ locks.add(lockName);
266
+ }
267
+ }
268
+ }
335
269
 
336
- function resolveTaskStackMode(config, execution, suite, file) {
337
- const effectiveExecution = execution || config.testkit.execution;
338
270
  const normalizedFile = normalizePathSeparators(file);
339
- const fileOverride = config.testkit.serviceExecution?.fileStackModeByPath?.get(normalizedFile);
340
- if (fileOverride) {
341
- return resolveBatchStackMode(effectiveExecution.stackMode, fileOverride);
271
+ for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
272
+ locks.add(lockName);
342
273
  }
343
- return resolveSuiteStackMode(
344
- {
345
- ...config,
346
- testkit: {
347
- ...config.testkit,
348
- execution: effectiveExecution,
349
- },
350
- },
351
- suite.displayType,
352
- suite.name
353
- );
274
+
275
+ return [...locks].sort();
354
276
  }
355
277
 
356
- export function buildGraphDirName(runtimeNames) {
357
- const slug = runtimeNames.map(slugSegment).join("__");
358
- return slug.length > 0 ? slug : "graph";
278
+ function normalizePathSeparators(filePath) {
279
+ return String(filePath).split("\\").join("/");
359
280
  }
360
281
 
361
282
  function slugSegment(value) {
362
283
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
363
284
  }
285
+
286
+ function resolveGraphMaxConcurrentTasks(runtimeConfigs) {
287
+ return runtimeConfigs.reduce(
288
+ (currentMin, config) => Math.min(currentMin, config.testkit.runtime.maxConcurrentTasks),
289
+ Number.POSITIVE_INFINITY
290
+ );
291
+ }