@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/README.md +9 -5
- package/lib/cli.mjs +1 -1
- package/lib/runner.mjs +1223 -592
- package/package.json +1 -1
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
taskCount: worker.taskCount,
|
|
349
|
+
graphSwitches: worker.graphSwitches,
|
|
128
350
|
errors,
|
|
129
351
|
};
|
|
130
352
|
}
|
|
131
353
|
|
|
132
|
-
function
|
|
133
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
880
|
+
return suites;
|
|
255
881
|
}
|
|
256
882
|
|
|
257
|
-
function
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
953
|
+
function buildPortMap(runtimeConfigs, workerId) {
|
|
341
954
|
const portMap = new Map();
|
|
342
955
|
const seen = new Map();
|
|
343
|
-
const offset = PORT_STRIDE * (
|
|
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
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
1103
|
+
function recordTaskOutcome(trackers, task, outcome) {
|
|
1104
|
+
const tracker = trackers.get(task.serviceName);
|
|
1105
|
+
if (!tracker || tracker.skipped) return;
|
|
796
1106
|
|
|
797
|
-
|
|
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
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
805
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
859
|
-
|
|
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
|
|
910
|
-
if (
|
|
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
|
-
|
|
914
|
-
return
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
1372
|
+
parsed = JSON.parse(stdout);
|
|
925
1373
|
} catch (error) {
|
|
926
|
-
|
|
1374
|
+
return {
|
|
1375
|
+
fileResults: new Map(),
|
|
1376
|
+
errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
|
|
1377
|
+
};
|
|
927
1378
|
}
|
|
928
|
-
}
|
|
929
1379
|
|
|
930
|
-
|
|
931
|
-
|
|
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
|
|
935
|
-
|
|
936
|
-
|
|
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
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
|
953
|
-
if (!
|
|
1431
|
+
function choosePlaywrightFinalResult(results) {
|
|
1432
|
+
if (!results || results.length === 0) return null;
|
|
1433
|
+
return results[results.length - 1];
|
|
1434
|
+
}
|
|
954
1435
|
|
|
955
|
-
|
|
956
|
-
return
|
|
957
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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
|
|
999
|
-
return
|
|
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
|
+
}
|