@elench/testkit 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/lib/cli.mjs +1 -1
- package/lib/runner.mjs +1223 -573
- 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
|
+
}
|
|
98
300
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
301
|
+
function createWorker(workerId, productDir) {
|
|
302
|
+
return {
|
|
303
|
+
workerId,
|
|
304
|
+
productDir,
|
|
305
|
+
currentGraphKey: null,
|
|
306
|
+
graphContexts: new Map(),
|
|
307
|
+
graphSwitches: 0,
|
|
308
|
+
taskCount: 0,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
|
|
313
|
+
const startedAt = Date.now();
|
|
314
|
+
console.log(`\n══ global worker ${worker.workerId} ══`);
|
|
102
315
|
const errors = [];
|
|
103
|
-
let failedSuiteCount = 0;
|
|
104
|
-
let completedSuiteCount = 0;
|
|
105
316
|
|
|
106
|
-
|
|
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;
|
|
@@ -442,321 +1055,18 @@ function resolveWorkerConfig(
|
|
|
442
1055
|
|
|
443
1056
|
return {
|
|
444
1057
|
...config,
|
|
445
|
-
stateDir,
|
|
446
|
-
workerId,
|
|
447
|
-
workerLabel: `w${workerId}`,
|
|
448
|
-
targetName: targetConfig.name,
|
|
449
|
-
testkit: {
|
|
450
|
-
...config.testkit,
|
|
451
|
-
database,
|
|
452
|
-
migrate,
|
|
453
|
-
seed,
|
|
454
|
-
local,
|
|
455
|
-
},
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function runWorkerPlan(plan) {
|
|
460
|
-
const startedAt = Date.now();
|
|
461
|
-
console.log(
|
|
462
|
-
`\n══ ${plan.targetConfig.name} worker ${plan.workerId} (${plan.suites.length} suite${plan.suites.length === 1 ? "" : "s"}) ══`
|
|
463
|
-
);
|
|
464
|
-
|
|
465
|
-
let startedServices = [];
|
|
466
|
-
let failed = false;
|
|
467
|
-
const suiteResults = [];
|
|
468
|
-
let fatalError = null;
|
|
469
|
-
|
|
470
|
-
try {
|
|
471
|
-
await prepareDatabases(plan.runtimeConfigs);
|
|
472
|
-
|
|
473
|
-
if (needsLocalRuntime(plan.suites)) {
|
|
474
|
-
startedServices = await startLocalServices(plan.runtimeConfigs);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
for (const suite of plan.suites) {
|
|
478
|
-
console.log(
|
|
479
|
-
`\n── ${plan.targetConfig.workerLabel} ${suite.type}:${suite.name} (${suite.framework}) ──`
|
|
480
|
-
);
|
|
481
|
-
const result = await runSuite(plan.targetConfig, suite);
|
|
482
|
-
suiteResults.push(result);
|
|
483
|
-
if (result.failed) failed = true;
|
|
484
|
-
}
|
|
485
|
-
} catch (error) {
|
|
486
|
-
fatalError = error;
|
|
487
|
-
failed = true;
|
|
488
|
-
throw error;
|
|
489
|
-
} finally {
|
|
490
|
-
await stopLocalServices(startedServices);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return {
|
|
494
|
-
workerId: plan.workerId,
|
|
495
|
-
failed,
|
|
496
|
-
fatalError: fatalError ? formatError(fatalError) : null,
|
|
497
|
-
durationMs: Date.now() - startedAt,
|
|
498
|
-
suiteCount: plan.suites.length,
|
|
499
|
-
completedSuiteCount: suiteResults.length,
|
|
500
|
-
failedSuiteCount: suiteResults.filter((result) => result.failed).length,
|
|
501
|
-
suites: suiteResults,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
async function prepareDatabases(runtimeConfigs) {
|
|
506
|
-
for (const config of runtimeConfigs) {
|
|
507
|
-
await prepareDatabaseRuntime(config, {
|
|
508
|
-
runMigrate: config.testkit.migrate
|
|
509
|
-
? (databaseUrl) => runMigrate(config, databaseUrl)
|
|
510
|
-
: null,
|
|
511
|
-
runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async function runMigrate(config, databaseUrl) {
|
|
517
|
-
const migrate = config.testkit.migrate;
|
|
518
|
-
if (!migrate) return;
|
|
519
|
-
|
|
520
|
-
const env = buildExecutionEnv(config);
|
|
521
|
-
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
522
|
-
|
|
523
|
-
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
524
|
-
await execaCommand(migrate.cmd, {
|
|
525
|
-
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
526
|
-
env,
|
|
527
|
-
stdio: "inherit",
|
|
528
|
-
shell: true,
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function runSeed(config, databaseUrl) {
|
|
533
|
-
const seed = config.testkit.seed;
|
|
534
|
-
if (!seed) return;
|
|
535
|
-
|
|
536
|
-
const env = buildExecutionEnv(config);
|
|
537
|
-
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
538
|
-
|
|
539
|
-
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
540
|
-
await execaCommand(seed.cmd, {
|
|
541
|
-
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
542
|
-
env,
|
|
543
|
-
stdio: "inherit",
|
|
544
|
-
shell: true,
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
async function startLocalServices(runtimeConfigs) {
|
|
549
|
-
const started = [];
|
|
550
|
-
|
|
551
|
-
try {
|
|
552
|
-
for (const config of runtimeConfigs) {
|
|
553
|
-
if (!config.testkit.local) continue;
|
|
554
|
-
const proc = await startLocalService(config);
|
|
555
|
-
started.push(proc);
|
|
556
|
-
}
|
|
557
|
-
} catch (error) {
|
|
558
|
-
await stopLocalServices(started);
|
|
559
|
-
throw error;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
return started;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async function startLocalService(config) {
|
|
566
|
-
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
567
|
-
const env = buildExecutionEnv(config, config.testkit.local.env);
|
|
568
|
-
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
569
|
-
if (port) {
|
|
570
|
-
env.PORT = String(port);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
574
|
-
if (dbUrl) {
|
|
575
|
-
env.DATABASE_URL = dbUrl;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
await assertLocalServicePortsAvailable(config);
|
|
579
|
-
|
|
580
|
-
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
581
|
-
const child = spawn(config.testkit.local.start, {
|
|
582
|
-
cwd,
|
|
583
|
-
env,
|
|
584
|
-
detached: true,
|
|
585
|
-
shell: true,
|
|
586
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
const outputDrains = [
|
|
590
|
-
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
|
|
591
|
-
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
|
|
592
|
-
];
|
|
593
|
-
|
|
594
|
-
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
595
|
-
|
|
596
|
-
try {
|
|
597
|
-
await waitForReady({
|
|
598
|
-
name: `${config.workerLabel}:${config.name}`,
|
|
599
|
-
url: config.testkit.local.readyUrl,
|
|
600
|
-
timeoutMs: readyTimeoutMs,
|
|
601
|
-
process: child,
|
|
602
|
-
});
|
|
603
|
-
} catch (error) {
|
|
604
|
-
await stopChildProcess(child, outputDrains);
|
|
605
|
-
throw error;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
return { name: config.name, child, outputDrains };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function runSuite(targetConfig, suite) {
|
|
612
|
-
if (suite.type === "dal") {
|
|
613
|
-
return runDalSuite(targetConfig, suite);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (suite.framework === "playwright") {
|
|
617
|
-
return runPlaywrightSuite(targetConfig, suite);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
if (suite.framework === "k6" && HTTP_K6_TYPES.has(suite.type)) {
|
|
621
|
-
return runHttpK6Suite(targetConfig, suite);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
throw new Error(
|
|
625
|
-
`Unsupported suite combination for ${targetConfig.name}: type=${suite.type} framework=${suite.framework}`
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
async function runHttpK6Suite(targetConfig, suite) {
|
|
630
|
-
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
631
|
-
if (!baseUrl) {
|
|
632
|
-
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const startedAt = Date.now();
|
|
636
|
-
let failed = false;
|
|
637
|
-
const failedFiles = [];
|
|
638
|
-
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
639
|
-
const absFile = path.join(targetConfig.productDir, file);
|
|
640
|
-
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
641
|
-
try {
|
|
642
|
-
await execa("k6", ["run", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
643
|
-
cwd: targetConfig.productDir,
|
|
644
|
-
env: buildExecutionEnv(targetConfig),
|
|
645
|
-
stdio: "inherit",
|
|
646
|
-
});
|
|
647
|
-
} catch {
|
|
648
|
-
failed = true;
|
|
649
|
-
failedFiles.push(file);
|
|
650
|
-
}
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
return buildSuiteResult(suite, failed, startedAt, failedFiles);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async function runDalSuite(targetConfig, suite) {
|
|
657
|
-
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
658
|
-
if (!databaseUrl) {
|
|
659
|
-
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const k6Binary = resolveDalBinary();
|
|
663
|
-
const startedAt = Date.now();
|
|
664
|
-
let failed = false;
|
|
665
|
-
const failedFiles = [];
|
|
666
|
-
await runWithConcurrency(suite.files, suite.maxFileConcurrency, async (file) => {
|
|
667
|
-
const absFile = path.join(targetConfig.productDir, file);
|
|
668
|
-
console.log(`·· ${targetConfig.workerLabel}:${suite.name} → ${file}`);
|
|
669
|
-
try {
|
|
670
|
-
await execa(k6Binary, ["run", "-e", `DATABASE_URL=${databaseUrl}`, absFile], {
|
|
671
|
-
cwd: targetConfig.productDir,
|
|
672
|
-
env: buildExecutionEnv(targetConfig),
|
|
673
|
-
stdio: "inherit",
|
|
674
|
-
});
|
|
675
|
-
} catch {
|
|
676
|
-
failed = true;
|
|
677
|
-
failedFiles.push(file);
|
|
678
|
-
}
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
return buildSuiteResult(suite, failed, startedAt, failedFiles);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async function runPlaywrightSuite(targetConfig, suite) {
|
|
685
|
-
const local = targetConfig.testkit.local;
|
|
686
|
-
if (!local?.baseUrl) {
|
|
687
|
-
throw new Error(
|
|
688
|
-
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
689
|
-
);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
693
|
-
const files = suite.files.map((file) =>
|
|
694
|
-
path.relative(cwd, path.join(targetConfig.productDir, file))
|
|
695
|
-
);
|
|
696
|
-
const startedAt = Date.now();
|
|
697
|
-
|
|
698
|
-
try {
|
|
699
|
-
await execa("npx", ["playwright", "test", ...files], {
|
|
700
|
-
cwd,
|
|
701
|
-
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
702
|
-
stdio: "inherit",
|
|
703
|
-
});
|
|
704
|
-
return buildSuiteResult(suite, false, startedAt);
|
|
705
|
-
} catch {
|
|
706
|
-
return buildSuiteResult(suite, true, startedAt);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
async function stopLocalServices(started) {
|
|
711
|
-
for (const service of [...started].reverse()) {
|
|
712
|
-
await stopChildProcess(service.child, service.outputDrains);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
async function stopChildProcess(child, outputDrains = []) {
|
|
717
|
-
if (!child) return;
|
|
718
|
-
if (child.exitCode !== null) {
|
|
719
|
-
await Promise.all(outputDrains);
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
killChildProcess(child, "SIGTERM");
|
|
724
|
-
const exited = await Promise.race([
|
|
725
|
-
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
726
|
-
sleep(5_000).then(() => false),
|
|
727
|
-
]);
|
|
728
|
-
|
|
729
|
-
if (!exited && child.exitCode === null) {
|
|
730
|
-
killChildProcess(child, "SIGKILL");
|
|
731
|
-
await new Promise((resolve) => child.once("exit", resolve));
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
await Promise.all(outputDrains);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
738
|
-
const start = Date.now();
|
|
739
|
-
|
|
740
|
-
while (Date.now() - start < timeoutMs) {
|
|
741
|
-
if (process.exitCode !== null) {
|
|
742
|
-
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
try {
|
|
746
|
-
const response = await fetch(url);
|
|
747
|
-
if (response.ok) return;
|
|
748
|
-
} catch {
|
|
749
|
-
// Service still warming up.
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
await sleep(1_000);
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function needsLocalRuntime(suites) {
|
|
759
|
-
return suites.some((suite) => suite.type !== "dal");
|
|
1058
|
+
stateDir,
|
|
1059
|
+
workerId,
|
|
1060
|
+
workerLabel: `w${workerId}`,
|
|
1061
|
+
targetName: targetConfig.name,
|
|
1062
|
+
testkit: {
|
|
1063
|
+
...config.testkit,
|
|
1064
|
+
database,
|
|
1065
|
+
migrate,
|
|
1066
|
+
seed,
|
|
1067
|
+
local,
|
|
1068
|
+
},
|
|
1069
|
+
};
|
|
760
1070
|
}
|
|
761
1071
|
|
|
762
1072
|
function resolveServiceStateDir(workerStateDir, targetName, config) {
|
|
@@ -790,15 +1100,95 @@ function buildPlaywrightEnv(config, baseUrl) {
|
|
|
790
1100
|
});
|
|
791
1101
|
}
|
|
792
1102
|
|
|
793
|
-
function
|
|
1103
|
+
function recordTaskOutcome(trackers, task, outcome) {
|
|
1104
|
+
const tracker = trackers.get(task.serviceName);
|
|
1105
|
+
if (!tracker || tracker.skipped) return;
|
|
1106
|
+
|
|
1107
|
+
const finishedAt = Date.now();
|
|
1108
|
+
if (!tracker.firstTaskAt) tracker.firstTaskAt = finishedAt;
|
|
1109
|
+
tracker.lastTaskAt = finishedAt;
|
|
1110
|
+
|
|
1111
|
+
const suite = tracker.suitesByKey.get(task.suiteKey);
|
|
1112
|
+
if (!suite) return;
|
|
1113
|
+
|
|
1114
|
+
suite.completedFileCount += 1;
|
|
1115
|
+
suite.durationMs += outcome.durationMs;
|
|
1116
|
+
if (outcome.failed && !suite.failedFileSet.has(task.file)) {
|
|
1117
|
+
suite.failedFileSet.add(task.file);
|
|
1118
|
+
suite.failedFiles.push(task.file);
|
|
1119
|
+
}
|
|
1120
|
+
if (outcome.error && !suite.error) {
|
|
1121
|
+
suite.error = outcome.error;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function recordGraphError(trackers, graph, message) {
|
|
1126
|
+
const targetNames = graph?.assignedTargets || [];
|
|
1127
|
+
for (const targetName of targetNames) {
|
|
1128
|
+
const tracker = trackers.get(targetName);
|
|
1129
|
+
if (tracker && !tracker.skipped) {
|
|
1130
|
+
addTrackerError(tracker, message);
|
|
1131
|
+
tracker.lastTaskAt = Date.now();
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function addTrackerError(tracker, message) {
|
|
1137
|
+
if (tracker.errorSet.has(message)) return;
|
|
1138
|
+
tracker.errorSet.add(message);
|
|
1139
|
+
tracker.errors.push(message);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
1143
|
+
if (!tracker || tracker.skipped) {
|
|
1144
|
+
return {
|
|
1145
|
+
name: tracker?.name || "unknown",
|
|
1146
|
+
failed: false,
|
|
1147
|
+
skipped: true,
|
|
1148
|
+
suiteCount: 0,
|
|
1149
|
+
completedSuiteCount: 0,
|
|
1150
|
+
failedSuiteCount: 0,
|
|
1151
|
+
durationMs: 0,
|
|
1152
|
+
suites: [],
|
|
1153
|
+
errors: [],
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const suites = [...tracker.suites].sort(
|
|
1158
|
+
(a, b) => a.orderIndex - b.orderIndex || a.name.localeCompare(b.name)
|
|
1159
|
+
);
|
|
1160
|
+
const completedSuiteCount = suites.filter(
|
|
1161
|
+
(suite) => suite.completedFileCount === suite.fileCount
|
|
1162
|
+
).length;
|
|
1163
|
+
const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
|
|
1164
|
+
const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
|
|
1165
|
+
const durationMs =
|
|
1166
|
+
tracker.firstTaskAt && tracker.lastTaskAt
|
|
1167
|
+
? Math.max(tracker.lastTaskAt - tracker.firstTaskAt, accumulatedDurationMs)
|
|
1168
|
+
: Math.max(finishedAt - startedAt, accumulatedDurationMs);
|
|
1169
|
+
|
|
794
1170
|
return {
|
|
795
|
-
name:
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1171
|
+
name: tracker.name,
|
|
1172
|
+
failed:
|
|
1173
|
+
failedSuiteCount > 0 ||
|
|
1174
|
+
tracker.errors.length > 0 ||
|
|
1175
|
+
completedSuiteCount < tracker.suiteCount,
|
|
1176
|
+
skipped: false,
|
|
1177
|
+
suiteCount: tracker.suiteCount,
|
|
1178
|
+
completedSuiteCount,
|
|
1179
|
+
failedSuiteCount,
|
|
1180
|
+
durationMs,
|
|
1181
|
+
suites: suites.map((suite) => ({
|
|
1182
|
+
name: suite.name,
|
|
1183
|
+
type: suite.type,
|
|
1184
|
+
framework: suite.framework,
|
|
1185
|
+
failed: suite.failedFiles.length > 0,
|
|
1186
|
+
fileCount: suite.fileCount,
|
|
1187
|
+
failedFiles: suite.failedFiles,
|
|
1188
|
+
durationMs: suite.durationMs,
|
|
1189
|
+
error: suite.error,
|
|
1190
|
+
})),
|
|
1191
|
+
errors: tracker.errors,
|
|
802
1192
|
};
|
|
803
1193
|
}
|
|
804
1194
|
|
|
@@ -836,15 +1226,16 @@ function printRunSummary(results, durationMs) {
|
|
|
836
1226
|
);
|
|
837
1227
|
|
|
838
1228
|
if (result.failed) {
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
);
|
|
842
|
-
for (const suite of failedSuiteResults) {
|
|
1229
|
+
const failedSuitesForService = result.suites.filter((suite) => suite.failed);
|
|
1230
|
+
for (const suite of failedSuitesForService) {
|
|
843
1231
|
const fileDetail =
|
|
844
1232
|
suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
|
|
845
1233
|
console.log(
|
|
846
1234
|
` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
|
|
847
1235
|
);
|
|
1236
|
+
if (suite.error) {
|
|
1237
|
+
console.log(` ${suite.error}`);
|
|
1238
|
+
}
|
|
848
1239
|
}
|
|
849
1240
|
for (const error of result.errors) {
|
|
850
1241
|
console.log(` - worker error: ${error}`);
|
|
@@ -872,11 +1263,6 @@ function formatDuration(durationMs) {
|
|
|
872
1263
|
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
873
1264
|
}
|
|
874
1265
|
|
|
875
|
-
function formatError(error) {
|
|
876
|
-
if (error instanceof Error) return error.message;
|
|
877
|
-
return String(error);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
1266
|
function formatServiceSummary(result) {
|
|
881
1267
|
const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
|
|
882
1268
|
const notRun = result.suiteCount - result.completedSuiteCount;
|
|
@@ -887,97 +1273,216 @@ function formatServiceSummary(result) {
|
|
|
887
1273
|
return detail;
|
|
888
1274
|
}
|
|
889
1275
|
|
|
890
|
-
function
|
|
891
|
-
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
|
+
}
|
|
892
1289
|
|
|
893
1290
|
try {
|
|
894
|
-
|
|
895
|
-
return
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1291
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
1292
|
+
return {
|
|
1293
|
+
version: 1,
|
|
1294
|
+
files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
|
|
1295
|
+
};
|
|
1296
|
+
} catch {
|
|
1297
|
+
return {
|
|
1298
|
+
version: 1,
|
|
1299
|
+
files: {},
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function saveTimings(productDir, timings, updates) {
|
|
1305
|
+
if (updates.length === 0) return;
|
|
1306
|
+
|
|
1307
|
+
const next = {
|
|
1308
|
+
version: 1,
|
|
1309
|
+
files: { ...timings.files },
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
for (const update of updates) {
|
|
1313
|
+
const existing = next.files[update.key];
|
|
1314
|
+
if (!existing) {
|
|
1315
|
+
next.files[update.key] = {
|
|
1316
|
+
durationMs: Math.max(1, Math.round(update.durationMs)),
|
|
1317
|
+
runs: 1,
|
|
1318
|
+
updatedAt: new Date().toISOString(),
|
|
1319
|
+
};
|
|
1320
|
+
continue;
|
|
901
1321
|
}
|
|
1322
|
+
|
|
1323
|
+
const runs = Number(existing.runs || 0) + 1;
|
|
1324
|
+
const durationMs = Math.max(
|
|
1325
|
+
1,
|
|
1326
|
+
Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
|
|
1327
|
+
);
|
|
1328
|
+
next.files[update.key] = {
|
|
1329
|
+
durationMs,
|
|
1330
|
+
runs,
|
|
1331
|
+
updatedAt: new Date().toISOString(),
|
|
1332
|
+
};
|
|
902
1333
|
}
|
|
903
1334
|
|
|
1335
|
+
const rootDir = path.join(productDir, ".testkit");
|
|
1336
|
+
fs.mkdirSync(rootDir, { recursive: true });
|
|
1337
|
+
fs.writeFileSync(
|
|
1338
|
+
path.join(rootDir, TIMINGS_FILENAME),
|
|
1339
|
+
JSON.stringify(next, null, 2)
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function estimateTaskDuration(timings, timingKey, suite) {
|
|
1344
|
+
const cached = timings.files[timingKey];
|
|
1345
|
+
if (cached?.durationMs) return cached.durationMs;
|
|
1346
|
+
|
|
1347
|
+
const base =
|
|
1348
|
+
suite.framework === "playwright"
|
|
1349
|
+
? 20_000
|
|
1350
|
+
: suite.type === "dal"
|
|
1351
|
+
? 4_000
|
|
1352
|
+
: 8_000;
|
|
1353
|
+
return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function buildTimingKey(serviceName, suite, file) {
|
|
1357
|
+
return [
|
|
1358
|
+
serviceName,
|
|
1359
|
+
suite.framework,
|
|
1360
|
+
suite.type,
|
|
1361
|
+
normalizePathSeparators(file),
|
|
1362
|
+
].join("|");
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function parsePlaywrightJsonResults(stdout, cwd) {
|
|
1366
|
+
if (!stdout.trim()) {
|
|
1367
|
+
return { fileResults: new Map(), errors: [] };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
let parsed;
|
|
904
1371
|
try {
|
|
905
|
-
|
|
1372
|
+
parsed = JSON.parse(stdout);
|
|
906
1373
|
} catch (error) {
|
|
907
|
-
|
|
1374
|
+
return {
|
|
1375
|
+
fileResults: new Map(),
|
|
1376
|
+
errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
|
|
1377
|
+
};
|
|
908
1378
|
}
|
|
909
|
-
}
|
|
910
1379
|
|
|
911
|
-
|
|
912
|
-
|
|
1380
|
+
const fileResults = new Map();
|
|
1381
|
+
visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
|
|
1382
|
+
return {
|
|
1383
|
+
fileResults,
|
|
1384
|
+
errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
|
|
1385
|
+
};
|
|
913
1386
|
}
|
|
914
1387
|
|
|
915
|
-
function
|
|
916
|
-
|
|
917
|
-
|
|
1388
|
+
function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
|
|
1389
|
+
for (const suite of suites || []) {
|
|
1390
|
+
const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
|
|
1391
|
+
for (const child of suite.suites || []) {
|
|
1392
|
+
visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
|
|
1393
|
+
}
|
|
1394
|
+
for (const spec of suite.specs || []) {
|
|
1395
|
+
collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
918
1398
|
}
|
|
919
1399
|
|
|
920
|
-
function
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1400
|
+
function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
1401
|
+
const file = normalizeReportedFile(extractReporterFile(spec) || inheritedFile, cwd);
|
|
1402
|
+
if (!file) return;
|
|
1403
|
+
|
|
1404
|
+
const current = fileResults.get(file) || {
|
|
1405
|
+
failed: false,
|
|
1406
|
+
error: null,
|
|
1407
|
+
durationMs: 0,
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
for (const test of spec.tests || []) {
|
|
1411
|
+
const results = Array.isArray(test.results) ? test.results : [];
|
|
1412
|
+
current.durationMs += results.reduce(
|
|
1413
|
+
(sum, result) => sum + Number(result?.duration || 0),
|
|
1414
|
+
0
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
const final = choosePlaywrightFinalResult(results);
|
|
1418
|
+
const failed =
|
|
1419
|
+
test.outcome === "unexpected" ||
|
|
1420
|
+
!isPlaywrightPassingStatus(final?.status);
|
|
1421
|
+
|
|
1422
|
+
if (failed) {
|
|
1423
|
+
current.failed = true;
|
|
1424
|
+
current.error ||= extractPlaywrightFailure(final, spec, test);
|
|
927
1425
|
}
|
|
928
|
-
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
929
|
-
console.log(`${indent}${file}: ${value}`);
|
|
930
1426
|
}
|
|
1427
|
+
|
|
1428
|
+
fileResults.set(file, current);
|
|
931
1429
|
}
|
|
932
1430
|
|
|
933
|
-
function
|
|
934
|
-
if (!
|
|
1431
|
+
function choosePlaywrightFinalResult(results) {
|
|
1432
|
+
if (!results || results.length === 0) return null;
|
|
1433
|
+
return results[results.length - 1];
|
|
1434
|
+
}
|
|
935
1435
|
|
|
936
|
-
|
|
937
|
-
return
|
|
938
|
-
|
|
939
|
-
const settle = () => {
|
|
940
|
-
if (settled) return;
|
|
941
|
-
settled = true;
|
|
942
|
-
resolve();
|
|
943
|
-
};
|
|
1436
|
+
function isPlaywrightPassingStatus(status) {
|
|
1437
|
+
return !status || ["passed", "skipped", "expected"].includes(status);
|
|
1438
|
+
}
|
|
944
1439
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}
|
|
957
|
-
settle();
|
|
958
|
-
});
|
|
959
|
-
stream.on("close", settle);
|
|
960
|
-
stream.on("error", settle);
|
|
961
|
-
});
|
|
1440
|
+
function extractPlaywrightFailure(finalResult, spec, test) {
|
|
1441
|
+
const fromResult =
|
|
1442
|
+
finalResult?.error?.message ||
|
|
1443
|
+
finalResult?.error?.value ||
|
|
1444
|
+
finalResult?.error?.stack;
|
|
1445
|
+
if (fromResult) return firstLine(fromResult);
|
|
1446
|
+
|
|
1447
|
+
const fromTest = test?.errors?.[0]?.message;
|
|
1448
|
+
if (fromTest) return firstLine(fromTest);
|
|
1449
|
+
|
|
1450
|
+
return firstLine(spec?.title || "Playwright test failed");
|
|
962
1451
|
}
|
|
963
1452
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1453
|
+
function formatPlaywrightReporterError(error) {
|
|
1454
|
+
if (!error) return null;
|
|
1455
|
+
if (typeof error === "string") return firstLine(error);
|
|
1456
|
+
if (typeof error.message === "string") return firstLine(error.message);
|
|
1457
|
+
if (typeof error.value === "string") return firstLine(error.value);
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
967
1460
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1461
|
+
function extractReporterFile(node) {
|
|
1462
|
+
if (!node || typeof node !== "object") return null;
|
|
1463
|
+
if (typeof node.file === "string" && node.file.length > 0) return node.file;
|
|
1464
|
+
if (node.location && typeof node.location.file === "string" && node.location.file.length > 0) {
|
|
1465
|
+
return node.location.file;
|
|
1466
|
+
}
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
975
1469
|
|
|
976
|
-
|
|
1470
|
+
function normalizeReportedFile(filePath, cwd) {
|
|
1471
|
+
if (!filePath) return null;
|
|
1472
|
+
const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
1473
|
+
return normalizePathSeparators(path.relative(cwd, absolute));
|
|
977
1474
|
}
|
|
978
1475
|
|
|
979
|
-
function
|
|
980
|
-
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
|
+
}
|
|
981
1486
|
}
|
|
982
1487
|
|
|
983
1488
|
function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
@@ -1143,6 +1648,85 @@ async function isPortInUse({ host, port }) {
|
|
|
1143
1648
|
});
|
|
1144
1649
|
}
|
|
1145
1650
|
|
|
1651
|
+
function killChildProcess(child, signal) {
|
|
1652
|
+
if (!child?.pid) return;
|
|
1653
|
+
|
|
1654
|
+
try {
|
|
1655
|
+
process.kill(-child.pid, signal);
|
|
1656
|
+
return;
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
if (error?.code !== "ESRCH") {
|
|
1659
|
+
// Fall back to the direct child if process-group signalling is unavailable.
|
|
1660
|
+
} else {
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
try {
|
|
1666
|
+
child.kill(signal);
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
if (error?.code !== "ESRCH") throw error;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function readDatabaseUrl(stateDir) {
|
|
1673
|
+
return readStateValue(path.join(stateDir, "database_url"));
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function readStateValue(filePath) {
|
|
1677
|
+
if (!fs.existsSync(filePath)) return null;
|
|
1678
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function printStateDir(dir, indent) {
|
|
1682
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1683
|
+
const filePath = path.join(dir, entry.name);
|
|
1684
|
+
if (entry.isDirectory()) {
|
|
1685
|
+
console.log(`${indent}${entry.name}/`);
|
|
1686
|
+
printStateDir(filePath, `${indent} `);
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
const value =
|
|
1690
|
+
entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
|
|
1691
|
+
console.log(`${indent}${entry.name}: ${value}`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function pipeOutput(stream, prefix) {
|
|
1696
|
+
if (!stream) return Promise.resolve();
|
|
1697
|
+
|
|
1698
|
+
let pending = "";
|
|
1699
|
+
return new Promise((resolve) => {
|
|
1700
|
+
let settled = false;
|
|
1701
|
+
const settle = () => {
|
|
1702
|
+
if (settled) return;
|
|
1703
|
+
settled = true;
|
|
1704
|
+
resolve();
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
stream.on("data", (chunk) => {
|
|
1708
|
+
pending += chunk.toString();
|
|
1709
|
+
const lines = pending.split(/\r?\n/);
|
|
1710
|
+
pending = lines.pop() || "";
|
|
1711
|
+
for (const line of lines) {
|
|
1712
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
stream.on("end", () => {
|
|
1716
|
+
if (pending.length > 0) {
|
|
1717
|
+
console.log(`${prefix} ${pending}`);
|
|
1718
|
+
}
|
|
1719
|
+
settle();
|
|
1720
|
+
});
|
|
1721
|
+
stream.on("close", settle);
|
|
1722
|
+
stream.on("error", settle);
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function sleep(ms) {
|
|
1727
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1146
1730
|
function findRuntimeStateDirs(rootDir) {
|
|
1147
1731
|
const found = [];
|
|
1148
1732
|
|
|
@@ -1163,3 +1747,69 @@ function findRuntimeStateDirs(rootDir) {
|
|
|
1163
1747
|
visit(rootDir);
|
|
1164
1748
|
return found.sort((a, b) => b.length - a.length);
|
|
1165
1749
|
}
|
|
1750
|
+
|
|
1751
|
+
function findGraphDirsForService(productDir, serviceName) {
|
|
1752
|
+
const graphsRoot = path.join(productDir, ".testkit", "_graphs");
|
|
1753
|
+
if (!fs.existsSync(graphsRoot)) return [];
|
|
1754
|
+
|
|
1755
|
+
const matches = [];
|
|
1756
|
+
for (const entry of fs.readdirSync(graphsRoot, { withFileTypes: true })) {
|
|
1757
|
+
if (!entry.isDirectory()) continue;
|
|
1758
|
+
const graphDir = path.join(graphsRoot, entry.name);
|
|
1759
|
+
const metadata = readGraphMetadata(graphDir);
|
|
1760
|
+
if (!metadata) continue;
|
|
1761
|
+
if ((metadata.runtimeServices || []).includes(serviceName)) {
|
|
1762
|
+
matches.push(graphDir);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
return matches.sort();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function writeGraphMetadata(graphDir, graph) {
|
|
1770
|
+
fs.mkdirSync(graphDir, { recursive: true });
|
|
1771
|
+
const metadata = {
|
|
1772
|
+
runtimeServices: graph.runtimeNames,
|
|
1773
|
+
assignedTargets: [...graph.assignedTargets].sort(),
|
|
1774
|
+
rootService: graph.rootConfig.name,
|
|
1775
|
+
};
|
|
1776
|
+
fs.writeFileSync(
|
|
1777
|
+
path.join(graphDir, GRAPH_METADATA),
|
|
1778
|
+
JSON.stringify(metadata, null, 2)
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function readGraphMetadata(graphDir) {
|
|
1783
|
+
const metadataPath = path.join(graphDir, GRAPH_METADATA);
|
|
1784
|
+
if (!fs.existsSync(metadataPath)) return null;
|
|
1785
|
+
|
|
1786
|
+
try {
|
|
1787
|
+
return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
1788
|
+
} catch {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function isRuntimeSuperset(candidate, target) {
|
|
1794
|
+
return target.every((name) => candidate.includes(name));
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function compareGraphsForAssignment(left, right) {
|
|
1798
|
+
if (left.runtimeNames.length !== right.runtimeNames.length) {
|
|
1799
|
+
return left.runtimeNames.length - right.runtimeNames.length;
|
|
1800
|
+
}
|
|
1801
|
+
return left.key.localeCompare(right.key);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function buildGraphDirName(runtimeNames) {
|
|
1805
|
+
const slug = runtimeNames.map(slugSegment).join("__");
|
|
1806
|
+
return slug.length > 0 ? slug : "graph";
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function slugSegment(value) {
|
|
1810
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function normalizePathSeparators(filePath) {
|
|
1814
|
+
return filePath.split(path.sep).join("/");
|
|
1815
|
+
}
|