@elench/testkit 0.1.16 → 0.1.18
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 +44 -19
- package/bin/testkit.mjs +1 -1
- package/lib/cli/args.mjs +57 -0
- package/lib/cli/args.test.mjs +62 -0
- package/lib/cli/index.mjs +88 -0
- package/lib/config/index.mjs +294 -0
- package/lib/config/index.test.mjs +12 -0
- package/lib/config/model.mjs +422 -0
- package/lib/config/model.test.mjs +193 -0
- package/lib/database/fingerprint.mjs +61 -0
- package/lib/database/fingerprint.test.mjs +93 -0
- package/lib/{database.mjs → database/index.mjs} +45 -160
- package/lib/database/naming.mjs +47 -0
- package/lib/database/naming.test.mjs +39 -0
- package/lib/database/state.mjs +52 -0
- package/lib/database/state.test.mjs +66 -0
- package/lib/reporters/playwright.mjs +125 -0
- package/lib/reporters/playwright.test.mjs +73 -0
- package/lib/runner/index.mjs +1221 -0
- package/lib/runner/metadata.mjs +55 -0
- package/lib/runner/metadata.test.mjs +52 -0
- package/lib/runner/planning.mjs +270 -0
- package/lib/runner/planning.test.mjs +127 -0
- package/lib/runner/results.mjs +285 -0
- package/lib/runner/results.test.mjs +144 -0
- package/lib/runner/state.mjs +71 -0
- package/lib/runner/state.test.mjs +64 -0
- package/lib/runner/template.mjs +320 -0
- package/lib/runner/template.test.mjs +150 -0
- package/lib/telemetry/index.mjs +43 -0
- package/lib/timing/index.mjs +73 -0
- package/lib/timing/index.test.mjs +64 -0
- package/package.json +11 -3
- package/infra/neon-down.sh +0 -18
- package/infra/neon-up.sh +0 -124
- package/lib/cli.mjs +0 -132
- package/lib/config.mjs +0 -666
- package/lib/exec.mjs +0 -20
- package/lib/runner.mjs +0 -1165
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import net from "net";
|
|
5
|
+
import { execa, execaCommand } from "execa";
|
|
6
|
+
import { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
|
|
7
|
+
import {
|
|
8
|
+
cleanupOrphanedLocalInfrastructure,
|
|
9
|
+
destroyRuntimeDatabase,
|
|
10
|
+
destroyServiceDatabaseCache,
|
|
11
|
+
isDatabaseStateDir,
|
|
12
|
+
prepareDatabaseRuntime,
|
|
13
|
+
showServiceDatabaseStatus,
|
|
14
|
+
} from "../database/index.mjs";
|
|
15
|
+
import {
|
|
16
|
+
batchNeedsLocalRuntime as batchNeedsLocalRuntimeModel,
|
|
17
|
+
buildGraphDirName as buildGraphDirNameModel,
|
|
18
|
+
buildRuntimeGraphs as buildRuntimeGraphsModel,
|
|
19
|
+
buildTaskQueue as buildTaskQueueModel,
|
|
20
|
+
claimNextBatch as claimNextBatchModel,
|
|
21
|
+
collectSuites as collectSuitesModel,
|
|
22
|
+
compareGraphsForAssignment as compareGraphsForAssignmentModel,
|
|
23
|
+
isRuntimeSuperset as isRuntimeSupersetModel,
|
|
24
|
+
orderedTypes as orderedTypesModel,
|
|
25
|
+
resolveRuntimeConfigs as resolveRuntimeConfigsModel,
|
|
26
|
+
applyShard as applyShardModel,
|
|
27
|
+
} from "./planning.mjs";
|
|
28
|
+
import {
|
|
29
|
+
buildExecutionEnv as buildExecutionEnvModel,
|
|
30
|
+
buildPlaywrightEnv as buildPlaywrightEnvModel,
|
|
31
|
+
buildPortMap as buildPortMapModel,
|
|
32
|
+
finalizeString as finalizeStringModel,
|
|
33
|
+
getWorkerServiceStateDir as getWorkerServiceStateDirModel,
|
|
34
|
+
normalizeSocketHost as normalizeSocketHostModel,
|
|
35
|
+
numericPortFromUrl as numericPortFromUrlModel,
|
|
36
|
+
resolveRuntimeUrl as resolveRuntimeUrlModel,
|
|
37
|
+
resolveServiceStateDir as resolveServiceStateDirModel,
|
|
38
|
+
resolveTemplateString as resolveTemplateStringModel,
|
|
39
|
+
resolveWorkerConfig as resolveWorkerConfigModel,
|
|
40
|
+
resolveWorkerRuntimeConfigs as resolveWorkerRuntimeConfigsModel,
|
|
41
|
+
rewriteUrlPort as rewriteUrlPortModel,
|
|
42
|
+
socketFromUrl as socketFromUrlModel,
|
|
43
|
+
} from "./template.mjs";
|
|
44
|
+
import {
|
|
45
|
+
addTrackerError as addTrackerErrorModel,
|
|
46
|
+
buildRunArtifact as buildRunArtifactModel,
|
|
47
|
+
buildServiceTrackers as buildServiceTrackersModel,
|
|
48
|
+
finalizeServiceResult as finalizeServiceResultModel,
|
|
49
|
+
formatDuration as formatDurationModel,
|
|
50
|
+
formatError as formatErrorModel,
|
|
51
|
+
formatServiceSummary as formatServiceSummaryModel,
|
|
52
|
+
longestServiceName as longestServiceNameModel,
|
|
53
|
+
recordGraphError as recordGraphErrorModel,
|
|
54
|
+
recordTaskOutcome as recordTaskOutcomeModel,
|
|
55
|
+
summarizeDbBackend as summarizeDbBackendModel,
|
|
56
|
+
} from "./results.mjs";
|
|
57
|
+
import {
|
|
58
|
+
applyTimingUpdates,
|
|
59
|
+
buildTimingKey as buildTimingKeyModel,
|
|
60
|
+
createEmptyTimings,
|
|
61
|
+
estimateTaskDuration as estimateTaskDurationModel,
|
|
62
|
+
normalizeTimings,
|
|
63
|
+
} from "../timing/index.mjs";
|
|
64
|
+
import {
|
|
65
|
+
choosePlaywrightFinalResult as choosePlaywrightFinalResultModel,
|
|
66
|
+
collectPlaywrightSpec as collectPlaywrightSpecModel,
|
|
67
|
+
extractPlaywrightFailure as extractPlaywrightFailureModel,
|
|
68
|
+
extractReporterFile as extractReporterFileModel,
|
|
69
|
+
firstLine as firstLineModel,
|
|
70
|
+
formatPlaywrightReporterError as formatPlaywrightReporterErrorModel,
|
|
71
|
+
isPlaywrightPassingStatus as isPlaywrightPassingStatusModel,
|
|
72
|
+
normalizeReportedFile as normalizeReportedFileModel,
|
|
73
|
+
parsePlaywrightJsonResults as parsePlaywrightJsonResultsModel,
|
|
74
|
+
visitPlaywrightSuites as visitPlaywrightSuitesModel,
|
|
75
|
+
} from "../reporters/playwright.mjs";
|
|
76
|
+
import {
|
|
77
|
+
collectGitMetadata as collectGitMetadataModel,
|
|
78
|
+
readPackageMetadata as readPackageMetadataModel,
|
|
79
|
+
safeHostname as safeHostnameModel,
|
|
80
|
+
safeUsername as safeUsernameModel,
|
|
81
|
+
} from "./metadata.mjs";
|
|
82
|
+
import {
|
|
83
|
+
findGraphDirsForService as findGraphDirsForServiceModel,
|
|
84
|
+
findRuntimeStateDirs as findRuntimeStateDirsModel,
|
|
85
|
+
normalizePathSeparators as normalizePathSeparatorsModel,
|
|
86
|
+
readGraphMetadata as readGraphMetadataModel,
|
|
87
|
+
writeGraphMetadata as writeGraphMetadataModel,
|
|
88
|
+
} from "./state.mjs";
|
|
89
|
+
import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
|
|
90
|
+
|
|
91
|
+
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
92
|
+
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
93
|
+
const TIMINGS_FILENAME = "timings.json";
|
|
94
|
+
|
|
95
|
+
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
96
|
+
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
const telemetry = configs[0]?.telemetry || null;
|
|
99
|
+
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
100
|
+
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
101
|
+
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
102
|
+
let workerCount = 0;
|
|
103
|
+
|
|
104
|
+
if (executedPlans.length > 0) {
|
|
105
|
+
const productDir = executedPlans[0].config.productDir;
|
|
106
|
+
const timings = loadTimings(productDir);
|
|
107
|
+
const graphs = buildRuntimeGraphs(executedPlans);
|
|
108
|
+
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
109
|
+
workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
|
|
110
|
+
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
111
|
+
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
112
|
+
createWorker(index + 1, productDir)
|
|
113
|
+
);
|
|
114
|
+
const timingUpdates = [];
|
|
115
|
+
|
|
116
|
+
const workerResults = await Promise.allSettled(
|
|
117
|
+
workers.map((worker) =>
|
|
118
|
+
runWorker(worker, queue, graphByKey, trackers, timingUpdates)
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
for (const result of workerResults) {
|
|
123
|
+
if (result.status === "rejected") {
|
|
124
|
+
const message = formatError(result.reason);
|
|
125
|
+
for (const tracker of trackers.values()) {
|
|
126
|
+
if (!tracker.skipped) addTrackerError(tracker, message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
saveTimings(productDir, timings, timingUpdates);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const finishedAt = Date.now();
|
|
135
|
+
const results = configs.map((config) =>
|
|
136
|
+
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
137
|
+
);
|
|
138
|
+
const artifact = buildRunArtifact({
|
|
139
|
+
productDir: configs[0]?.productDir || process.cwd(),
|
|
140
|
+
results,
|
|
141
|
+
startedAt,
|
|
142
|
+
finishedAt,
|
|
143
|
+
requestedJobs: opts.jobs || 1,
|
|
144
|
+
workerCount,
|
|
145
|
+
suiteType,
|
|
146
|
+
suiteNames,
|
|
147
|
+
framework: opts.framework || "all",
|
|
148
|
+
shard: opts.shard || null,
|
|
149
|
+
serviceFilter: configs.length === 1 ? configs[0].name : null,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
writeRunArtifact(configs[0]?.productDir || process.cwd(), artifact);
|
|
153
|
+
|
|
154
|
+
printRunSummary(results, finishedAt - startedAt);
|
|
155
|
+
await reportTelemetry(telemetry, artifact);
|
|
156
|
+
if (results.some((result) => result.failed)) process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function destroy(config) {
|
|
160
|
+
const roots = new Set([config.stateDir, ...findGraphDirsForService(config.productDir, config.name)]);
|
|
161
|
+
|
|
162
|
+
for (const rootDir of roots) {
|
|
163
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
164
|
+
const runtimeStateDirs = findRuntimeStateDirs(rootDir);
|
|
165
|
+
for (const stateDir of runtimeStateDirs) {
|
|
166
|
+
await destroyRuntimeDatabase({
|
|
167
|
+
productDir: config.productDir,
|
|
168
|
+
stateDir,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await destroyServiceDatabaseCache(config.productDir, config.name);
|
|
175
|
+
await cleanupOrphanedLocalInfrastructure(config.productDir);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function showStatus(config) {
|
|
179
|
+
const graphDirs = findGraphDirsForService(config.productDir, config.name);
|
|
180
|
+
const hasDirectState = fs.existsSync(config.stateDir);
|
|
181
|
+
const hasGraphState = graphDirs.length > 0;
|
|
182
|
+
|
|
183
|
+
if (!hasDirectState && !hasGraphState) {
|
|
184
|
+
console.log("No state — run tests first.");
|
|
185
|
+
} else {
|
|
186
|
+
if (hasDirectState) {
|
|
187
|
+
console.log(" service-state/");
|
|
188
|
+
printStateDir(config.stateDir, " ");
|
|
189
|
+
}
|
|
190
|
+
for (const graphDir of graphDirs) {
|
|
191
|
+
console.log(` graph-state/${path.basename(graphDir)}/`);
|
|
192
|
+
printStateDir(graphDir, " ");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
showServiceDatabaseStatus(config.productDir, config.name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
200
|
+
return configs.map((config) => {
|
|
201
|
+
console.log(`\n══ ${config.name} ══`);
|
|
202
|
+
const suites = applyShard(
|
|
203
|
+
collectSuites(config, suiteType, suiteNames, opts.framework),
|
|
204
|
+
opts.shard
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (suites.length === 0) {
|
|
208
|
+
console.log(
|
|
209
|
+
`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
210
|
+
);
|
|
211
|
+
return {
|
|
212
|
+
config,
|
|
213
|
+
skipped: true,
|
|
214
|
+
suites: [],
|
|
215
|
+
runtimeConfigs: [],
|
|
216
|
+
runtimeNames: [],
|
|
217
|
+
runtimeKey: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
|
|
222
|
+
return {
|
|
223
|
+
config,
|
|
224
|
+
skipped: false,
|
|
225
|
+
suites,
|
|
226
|
+
runtimeConfigs,
|
|
227
|
+
runtimeNames: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort(),
|
|
228
|
+
runtimeKey: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort().join("|"),
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildServiceTrackers(servicePlans, startedAt) {
|
|
234
|
+
return buildServiceTrackersModel(servicePlans, startedAt);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildRuntimeGraphs(servicePlans) {
|
|
238
|
+
return buildRuntimeGraphsModel(servicePlans);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildTaskQueue(servicePlans, graphs, timings) {
|
|
242
|
+
return buildTaskQueueModel(servicePlans, graphs, timings);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createWorker(workerId, productDir) {
|
|
246
|
+
return {
|
|
247
|
+
workerId,
|
|
248
|
+
productDir,
|
|
249
|
+
currentGraphKey: null,
|
|
250
|
+
graphContexts: new Map(),
|
|
251
|
+
graphSwitches: 0,
|
|
252
|
+
taskCount: 0,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
|
|
257
|
+
const startedAt = Date.now();
|
|
258
|
+
console.log(`\n══ global worker ${worker.workerId} ══`);
|
|
259
|
+
const errors = [];
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
while (true) {
|
|
263
|
+
const batch = claimNextBatch(queue, worker.currentGraphKey);
|
|
264
|
+
if (!batch) break;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const context = await ensureWorkerGraph(worker, batch, graphByKey);
|
|
268
|
+
const outcomes = await runBatch(context, batch);
|
|
269
|
+
for (const outcome of outcomes) {
|
|
270
|
+
recordTaskOutcome(trackers, outcome.task, outcome);
|
|
271
|
+
timingUpdates.push({
|
|
272
|
+
key: outcome.task.timingKey,
|
|
273
|
+
durationMs: outcome.durationMs,
|
|
274
|
+
});
|
|
275
|
+
worker.taskCount += 1;
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const message = formatError(error);
|
|
279
|
+
errors.push(message);
|
|
280
|
+
recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
|
|
281
|
+
await resetCurrentGraph(worker);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
await cleanupWorker(worker);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
workerId: worker.workerId,
|
|
290
|
+
failed: errors.length > 0,
|
|
291
|
+
durationMs: Date.now() - startedAt,
|
|
292
|
+
taskCount: worker.taskCount,
|
|
293
|
+
graphSwitches: worker.graphSwitches,
|
|
294
|
+
errors,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function claimNextBatch(queue, preferredGraphKey) {
|
|
299
|
+
return claimNextBatchModel(queue, preferredGraphKey);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function ensureWorkerGraph(worker, batch, graphByKey) {
|
|
303
|
+
const graph = graphByKey.get(batch.graphKey);
|
|
304
|
+
if (!graph) {
|
|
305
|
+
throw new Error(`Unknown graph "${batch.graphKey}"`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
|
|
309
|
+
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
|
|
310
|
+
worker.graphSwitches += 1;
|
|
311
|
+
worker.currentGraphKey = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let context = worker.graphContexts.get(batch.graphKey);
|
|
315
|
+
if (!context) {
|
|
316
|
+
context = createGraphContext(worker, graph);
|
|
317
|
+
worker.graphContexts.set(batch.graphKey, context);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!context.prepared) {
|
|
321
|
+
await prepareDatabases(context.runtimeConfigs);
|
|
322
|
+
context.prepared = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (batchNeedsLocalRuntime(batch) && !context.started) {
|
|
326
|
+
context.startedServices = await startLocalServices(context.runtimeConfigs);
|
|
327
|
+
context.started = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
worker.currentGraphKey = batch.graphKey;
|
|
331
|
+
return context;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function createGraphContext(worker, graph) {
|
|
335
|
+
const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
|
|
336
|
+
const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
|
|
337
|
+
fs.mkdirSync(workerStateDir, { recursive: true });
|
|
338
|
+
writeGraphMetadata(graphDir, graph);
|
|
339
|
+
|
|
340
|
+
const runtimeConfigs = resolveWorkerRuntimeConfigs(
|
|
341
|
+
graph.rootConfig,
|
|
342
|
+
graph.runtimeConfigs,
|
|
343
|
+
worker.workerId,
|
|
344
|
+
workerStateDir
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
graphKey: graph.key,
|
|
349
|
+
graphDir,
|
|
350
|
+
workerStateDir,
|
|
351
|
+
runtimeConfigs,
|
|
352
|
+
configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
|
|
353
|
+
prepared: false,
|
|
354
|
+
started: false,
|
|
355
|
+
startedServices: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function deactivateGraphContext(context) {
|
|
360
|
+
if (!context?.started) return;
|
|
361
|
+
await stopLocalServices(context.startedServices);
|
|
362
|
+
context.started = false;
|
|
363
|
+
context.startedServices = [];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function resetCurrentGraph(worker) {
|
|
367
|
+
if (!worker.currentGraphKey) return;
|
|
368
|
+
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey));
|
|
369
|
+
worker.currentGraphKey = null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function cleanupWorker(worker) {
|
|
373
|
+
for (const context of worker.graphContexts.values()) {
|
|
374
|
+
await deactivateGraphContext(context);
|
|
375
|
+
}
|
|
376
|
+
worker.currentGraphKey = null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function runBatch(context, batch) {
|
|
380
|
+
const targetConfig = context.configByName.get(batch.targetName);
|
|
381
|
+
if (!targetConfig) {
|
|
382
|
+
throw new Error(`Worker graph missing target config "${batch.targetName}"`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (batch.framework === "playwright") {
|
|
386
|
+
return runPlaywrightBatch(targetConfig, batch);
|
|
387
|
+
}
|
|
388
|
+
if (batch.type === "dal") {
|
|
389
|
+
return runDalBatch(targetConfig, batch);
|
|
390
|
+
}
|
|
391
|
+
if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
|
|
392
|
+
return runHttpK6Batch(targetConfig, batch);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Unsupported task combination for ${batch.targetName}: type=${batch.type} framework=${batch.framework}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function prepareDatabases(runtimeConfigs) {
|
|
401
|
+
for (const config of runtimeConfigs) {
|
|
402
|
+
await prepareDatabaseRuntime(config, {
|
|
403
|
+
runMigrate: config.testkit.migrate
|
|
404
|
+
? (databaseUrl) => runMigrate(config, databaseUrl)
|
|
405
|
+
: null,
|
|
406
|
+
runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function runMigrate(config, databaseUrl) {
|
|
412
|
+
const migrate = config.testkit.migrate;
|
|
413
|
+
if (!migrate) return;
|
|
414
|
+
|
|
415
|
+
const env = buildExecutionEnv(config);
|
|
416
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
417
|
+
|
|
418
|
+
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
419
|
+
await execaCommand(migrate.cmd, {
|
|
420
|
+
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
421
|
+
env,
|
|
422
|
+
stdio: "inherit",
|
|
423
|
+
shell: true,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function runSeed(config, databaseUrl) {
|
|
428
|
+
const seed = config.testkit.seed;
|
|
429
|
+
if (!seed) return;
|
|
430
|
+
|
|
431
|
+
const env = buildExecutionEnv(config);
|
|
432
|
+
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
433
|
+
|
|
434
|
+
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
435
|
+
await execaCommand(seed.cmd, {
|
|
436
|
+
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
437
|
+
env,
|
|
438
|
+
stdio: "inherit",
|
|
439
|
+
shell: true,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function startLocalServices(runtimeConfigs) {
|
|
444
|
+
const started = [];
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
for (const config of runtimeConfigs) {
|
|
448
|
+
if (!config.testkit.local) continue;
|
|
449
|
+
const proc = await startLocalService(config);
|
|
450
|
+
started.push(proc);
|
|
451
|
+
}
|
|
452
|
+
} catch (error) {
|
|
453
|
+
await stopLocalServices(started);
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return started;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function startLocalService(config) {
|
|
461
|
+
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
462
|
+
const env = buildExecutionEnv(config, config.testkit.local.env);
|
|
463
|
+
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
464
|
+
if (port) {
|
|
465
|
+
env.PORT = String(port);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
469
|
+
if (dbUrl) {
|
|
470
|
+
env.DATABASE_URL = dbUrl;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await assertLocalServicePortsAvailable(config);
|
|
474
|
+
|
|
475
|
+
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
476
|
+
const child = spawn(config.testkit.local.start, {
|
|
477
|
+
cwd,
|
|
478
|
+
env,
|
|
479
|
+
detached: true,
|
|
480
|
+
shell: true,
|
|
481
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const outputDrains = [
|
|
485
|
+
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
|
|
486
|
+
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
await waitForReady({
|
|
493
|
+
name: `${config.workerLabel}:${config.name}`,
|
|
494
|
+
url: config.testkit.local.readyUrl,
|
|
495
|
+
timeoutMs: readyTimeoutMs,
|
|
496
|
+
process: child,
|
|
497
|
+
});
|
|
498
|
+
} catch (error) {
|
|
499
|
+
await stopChildProcess(child, outputDrains);
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { name: config.name, child, outputDrains };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function runHttpK6Batch(targetConfig, batch) {
|
|
507
|
+
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
508
|
+
if (!baseUrl) {
|
|
509
|
+
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP k6 suites`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log(
|
|
513
|
+
`\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName} (${batch.framework}, ${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
return Promise.all(
|
|
517
|
+
batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl))
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
522
|
+
const absFile = path.join(targetConfig.productDir, task.file);
|
|
523
|
+
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
524
|
+
const startedAt = Date.now();
|
|
525
|
+
try {
|
|
526
|
+
await execa("k6", ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, absFile], {
|
|
527
|
+
cwd: targetConfig.productDir,
|
|
528
|
+
env: buildExecutionEnv(targetConfig),
|
|
529
|
+
stdio: "inherit",
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
task,
|
|
533
|
+
failed: false,
|
|
534
|
+
error: null,
|
|
535
|
+
durationMs: Date.now() - startedAt,
|
|
536
|
+
};
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return {
|
|
539
|
+
task,
|
|
540
|
+
failed: true,
|
|
541
|
+
error: formatError(error),
|
|
542
|
+
durationMs: Date.now() - startedAt,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function runDalBatch(targetConfig, batch) {
|
|
548
|
+
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
549
|
+
if (!databaseUrl) {
|
|
550
|
+
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
console.log(
|
|
554
|
+
`\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName} (${batch.framework}, ${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
return Promise.all(
|
|
558
|
+
batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl))
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function runDalTask(targetConfig, task, databaseUrl) {
|
|
563
|
+
const absFile = path.join(targetConfig.productDir, task.file);
|
|
564
|
+
const k6Binary = resolveDalBinary();
|
|
565
|
+
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
566
|
+
const startedAt = Date.now();
|
|
567
|
+
try {
|
|
568
|
+
await execa(
|
|
569
|
+
k6Binary,
|
|
570
|
+
["run", "--address", "127.0.0.1:0", "-e", `DATABASE_URL=${databaseUrl}`, absFile],
|
|
571
|
+
{
|
|
572
|
+
cwd: targetConfig.productDir,
|
|
573
|
+
env: buildExecutionEnv(targetConfig),
|
|
574
|
+
stdio: "inherit",
|
|
575
|
+
}
|
|
576
|
+
);
|
|
577
|
+
return {
|
|
578
|
+
task,
|
|
579
|
+
failed: false,
|
|
580
|
+
error: null,
|
|
581
|
+
durationMs: Date.now() - startedAt,
|
|
582
|
+
};
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return {
|
|
585
|
+
task,
|
|
586
|
+
failed: true,
|
|
587
|
+
error: formatError(error),
|
|
588
|
+
durationMs: Date.now() - startedAt,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function runPlaywrightBatch(targetConfig, batch) {
|
|
594
|
+
const local = targetConfig.testkit.local;
|
|
595
|
+
if (!local?.baseUrl) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
console.log(
|
|
602
|
+
`\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}) ──`
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
606
|
+
const requestedFiles = batch.tasks.map((task) =>
|
|
607
|
+
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
608
|
+
);
|
|
609
|
+
const startedAt = Date.now();
|
|
610
|
+
const result = await execa(
|
|
611
|
+
"npx",
|
|
612
|
+
["playwright", "test", "--reporter=json", ...requestedFiles],
|
|
613
|
+
{
|
|
614
|
+
cwd,
|
|
615
|
+
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
616
|
+
reject: false,
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (result.stderr) {
|
|
621
|
+
printBufferedOutput(result.stderr, `[${targetConfig.workerLabel}:${targetConfig.name}:playwright]`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
|
|
625
|
+
const batchDurationMs = Date.now() - startedAt;
|
|
626
|
+
const genericError =
|
|
627
|
+
result.exitCode === 0
|
|
628
|
+
? parsed.errors[0] || null
|
|
629
|
+
: parsed.errors[0] ||
|
|
630
|
+
result.stderr.trim() ||
|
|
631
|
+
`Playwright exited with code ${result.exitCode}`;
|
|
632
|
+
|
|
633
|
+
return batch.tasks.map((task) => {
|
|
634
|
+
const relativeFile = normalizePathSeparators(
|
|
635
|
+
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
636
|
+
);
|
|
637
|
+
const fileResult = parsed.fileResults.get(relativeFile);
|
|
638
|
+
if (fileResult) {
|
|
639
|
+
return {
|
|
640
|
+
task,
|
|
641
|
+
failed: fileResult.failed,
|
|
642
|
+
error: fileResult.error,
|
|
643
|
+
durationMs:
|
|
644
|
+
fileResult.durationMs > 0
|
|
645
|
+
? fileResult.durationMs
|
|
646
|
+
: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
task,
|
|
652
|
+
failed: result.exitCode !== 0,
|
|
653
|
+
error: result.exitCode !== 0 ? genericError : null,
|
|
654
|
+
durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
655
|
+
};
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function stopLocalServices(started) {
|
|
660
|
+
for (const service of [...started].reverse()) {
|
|
661
|
+
await stopChildProcess(service.child, service.outputDrains);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function stopChildProcess(child, outputDrains = []) {
|
|
666
|
+
if (!child) return;
|
|
667
|
+
if (child.exitCode !== null) {
|
|
668
|
+
await Promise.all(outputDrains);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
killChildProcess(child, "SIGTERM");
|
|
673
|
+
const exited = await Promise.race([
|
|
674
|
+
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
675
|
+
sleep(5_000).then(() => false),
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
if (!exited && child.exitCode === null) {
|
|
679
|
+
killChildProcess(child, "SIGKILL");
|
|
680
|
+
await new Promise((resolve) => child.once("exit", resolve));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await Promise.all(outputDrains);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function waitForReady({ name, url, timeoutMs, process }) {
|
|
687
|
+
const start = Date.now();
|
|
688
|
+
|
|
689
|
+
while (Date.now() - start < timeoutMs) {
|
|
690
|
+
if (process.exitCode !== null) {
|
|
691
|
+
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
try {
|
|
695
|
+
const response = await fetch(url);
|
|
696
|
+
if (response.ok) return;
|
|
697
|
+
} catch {
|
|
698
|
+
// Service still warming up.
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
await sleep(1_000);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function batchNeedsLocalRuntime(batch) {
|
|
708
|
+
return batchNeedsLocalRuntimeModel(batch);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
712
|
+
return resolveRuntimeConfigsModel(targetConfig, configMap);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
716
|
+
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function applyShard(suites, shard) {
|
|
720
|
+
return applyShardModel(suites, shard);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function orderedTypes(types) {
|
|
724
|
+
return orderedTypesModel(types);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
|
|
728
|
+
return resolveWorkerRuntimeConfigsModel(targetConfig, runtimeConfigs, workerId, workerStateDir);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function buildPortMap(runtimeConfigs, workerId) {
|
|
732
|
+
return buildPortMapModel(runtimeConfigs, workerId);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function resolveWorkerConfig(
|
|
736
|
+
config,
|
|
737
|
+
targetConfig,
|
|
738
|
+
workerId,
|
|
739
|
+
workerStateDir,
|
|
740
|
+
portMap,
|
|
741
|
+
baseUrlByService,
|
|
742
|
+
readyUrlByService,
|
|
743
|
+
urlMappings
|
|
744
|
+
) {
|
|
745
|
+
return resolveWorkerConfigModel(
|
|
746
|
+
config,
|
|
747
|
+
targetConfig,
|
|
748
|
+
workerId,
|
|
749
|
+
workerStateDir,
|
|
750
|
+
portMap,
|
|
751
|
+
baseUrlByService,
|
|
752
|
+
readyUrlByService,
|
|
753
|
+
urlMappings
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function resolveServiceStateDir(workerStateDir, targetName, config) {
|
|
758
|
+
return resolveServiceStateDirModel(workerStateDir, targetName, config);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
762
|
+
return getWorkerServiceStateDirModel(workerStateDir, targetName, serviceName);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function buildExecutionEnv(config, extraEnv = {}) {
|
|
766
|
+
return buildExecutionEnvModel(config, extraEnv, process.env);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function buildPlaywrightEnv(config, baseUrl) {
|
|
770
|
+
return buildPlaywrightEnvModel(config, baseUrl, process.env);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function recordTaskOutcome(trackers, task, outcome) {
|
|
774
|
+
return recordTaskOutcomeModel(trackers, task, outcome);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function recordGraphError(trackers, graph, message) {
|
|
778
|
+
return recordGraphErrorModel(trackers, graph, message);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function addTrackerError(tracker, message) {
|
|
782
|
+
return addTrackerErrorModel(tracker, message);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
786
|
+
return finalizeServiceResultModel(tracker, startedAt, finishedAt);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function printRunSummary(results, durationMs) {
|
|
790
|
+
const totalServices = results.length;
|
|
791
|
+
const executedServices = results.filter((result) => !result.skipped);
|
|
792
|
+
const skippedServices = results.filter((result) => result.skipped);
|
|
793
|
+
const failedServices = executedServices.filter((result) => result.failed);
|
|
794
|
+
const passedServices = executedServices.filter((result) => !result.failed);
|
|
795
|
+
const totalSuites = executedServices.reduce((sum, result) => sum + result.suiteCount, 0);
|
|
796
|
+
const completedSuites = executedServices.reduce(
|
|
797
|
+
(sum, result) => sum + result.completedSuiteCount,
|
|
798
|
+
0
|
|
799
|
+
);
|
|
800
|
+
const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
|
|
801
|
+
const passedSuites = completedSuites - failedSuites;
|
|
802
|
+
|
|
803
|
+
console.log("\n══ Summary ══");
|
|
804
|
+
console.log(
|
|
805
|
+
[
|
|
806
|
+
`services ${passedServices.length}/${executedServices.length} passed`,
|
|
807
|
+
`suites ${passedSuites}/${totalSuites} passed`,
|
|
808
|
+
skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
|
|
809
|
+
`duration ${formatDuration(durationMs)}`,
|
|
810
|
+
]
|
|
811
|
+
.filter(Boolean)
|
|
812
|
+
.join(" · ")
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
for (const result of results) {
|
|
816
|
+
const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
|
|
817
|
+
const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
|
|
818
|
+
console.log(
|
|
819
|
+
`${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
if (result.failed) {
|
|
823
|
+
const failedSuitesForService = result.suites.filter((suite) => suite.failed);
|
|
824
|
+
for (const suite of failedSuitesForService) {
|
|
825
|
+
const fileDetail =
|
|
826
|
+
suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
|
|
827
|
+
console.log(
|
|
828
|
+
` - ${suite.type}:${suite.name} [${suite.framework}]${fileDetail} · ${formatDuration(suite.durationMs)}`
|
|
829
|
+
);
|
|
830
|
+
if (suite.error) {
|
|
831
|
+
console.log(` ${suite.error}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const error of result.errors) {
|
|
835
|
+
console.log(` - worker error: ${error}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (failedServices.length > 0) {
|
|
841
|
+
console.log(`\nResult: FAILED (${failedServices.length}/${totalServices} services failed)`);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
console.log("\nResult: PASSED");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function reportTelemetry(telemetry, artifact) {
|
|
849
|
+
if (!telemetry?.enabled) return;
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const outcome = await uploadTelemetryArtifact(telemetry, artifact);
|
|
853
|
+
if (outcome?.ok) {
|
|
854
|
+
console.log("Telemetry: uploaded run artifact");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (outcome?.reason === "missing-token") {
|
|
858
|
+
console.log(
|
|
859
|
+
`Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (outcome?.reason && !outcome.skipped) return;
|
|
864
|
+
} catch (error) {
|
|
865
|
+
console.log(`Telemetry: upload failed (${formatError(error)})`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function longestServiceName(results) {
|
|
870
|
+
return longestServiceNameModel(results);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function buildRunArtifact({
|
|
874
|
+
productDir,
|
|
875
|
+
results,
|
|
876
|
+
startedAt,
|
|
877
|
+
finishedAt,
|
|
878
|
+
requestedJobs,
|
|
879
|
+
workerCount,
|
|
880
|
+
suiteType,
|
|
881
|
+
suiteNames,
|
|
882
|
+
framework,
|
|
883
|
+
shard,
|
|
884
|
+
serviceFilter,
|
|
885
|
+
}) {
|
|
886
|
+
return buildRunArtifactModel({
|
|
887
|
+
productDir,
|
|
888
|
+
results,
|
|
889
|
+
startedAt,
|
|
890
|
+
finishedAt,
|
|
891
|
+
requestedJobs,
|
|
892
|
+
workerCount,
|
|
893
|
+
suiteType,
|
|
894
|
+
suiteNames,
|
|
895
|
+
framework,
|
|
896
|
+
shard,
|
|
897
|
+
serviceFilter,
|
|
898
|
+
metadata: {
|
|
899
|
+
git: collectGitMetadata(productDir),
|
|
900
|
+
host: {
|
|
901
|
+
hostname: safeHostname(),
|
|
902
|
+
username: safeUsername(),
|
|
903
|
+
},
|
|
904
|
+
testkitVersion: readPackageMetadata().version,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function writeRunArtifact(productDir, artifact) {
|
|
910
|
+
const resultsDir = path.join(productDir, ".testkit", "results");
|
|
911
|
+
fs.mkdirSync(resultsDir, { recursive: true });
|
|
912
|
+
fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function summarizeDbBackend(results) {
|
|
916
|
+
return summarizeDbBackendModel(results);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function collectGitMetadata(productDir) {
|
|
920
|
+
return collectGitMetadataModel(productDir);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function readPackageMetadata() {
|
|
924
|
+
return readPackageMetadataModel();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function safeHostname() {
|
|
928
|
+
return safeHostnameModel();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function safeUsername() {
|
|
932
|
+
return safeUsernameModel();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function formatDuration(durationMs) {
|
|
936
|
+
return formatDurationModel(durationMs);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function formatServiceSummary(result) {
|
|
940
|
+
return formatServiceSummaryModel(result);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function formatError(error) {
|
|
944
|
+
return formatErrorModel(error);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function loadTimings(productDir) {
|
|
948
|
+
const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
|
|
949
|
+
if (!fs.existsSync(filePath)) {
|
|
950
|
+
return createEmptyTimings();
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
955
|
+
} catch {
|
|
956
|
+
return createEmptyTimings();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function saveTimings(productDir, timings, updates) {
|
|
961
|
+
if (updates.length === 0) return;
|
|
962
|
+
const next = applyTimingUpdates(timings, updates);
|
|
963
|
+
|
|
964
|
+
const rootDir = path.join(productDir, ".testkit");
|
|
965
|
+
fs.mkdirSync(rootDir, { recursive: true });
|
|
966
|
+
fs.writeFileSync(
|
|
967
|
+
path.join(rootDir, TIMINGS_FILENAME),
|
|
968
|
+
JSON.stringify(next, null, 2)
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function estimateTaskDuration(timings, timingKey, suite) {
|
|
973
|
+
return estimateTaskDurationModel(timings, timingKey, suite);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function buildTimingKey(serviceName, suite, file) {
|
|
977
|
+
return buildTimingKeyModel(serviceName, suite, file);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function parsePlaywrightJsonResults(stdout, cwd) {
|
|
981
|
+
return parsePlaywrightJsonResultsModel(stdout, cwd);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
|
|
985
|
+
return visitPlaywrightSuitesModel(suites, inheritedFile, fileResults, cwd);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
989
|
+
return collectPlaywrightSpecModel(spec, inheritedFile, fileResults, cwd);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function choosePlaywrightFinalResult(results) {
|
|
993
|
+
return choosePlaywrightFinalResultModel(results);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function isPlaywrightPassingStatus(status) {
|
|
997
|
+
return isPlaywrightPassingStatusModel(status);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function extractPlaywrightFailure(finalResult, spec, test) {
|
|
1001
|
+
return extractPlaywrightFailureModel(finalResult, spec, test);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function formatPlaywrightReporterError(error) {
|
|
1005
|
+
return formatPlaywrightReporterErrorModel(error);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function extractReporterFile(node) {
|
|
1009
|
+
return extractReporterFileModel(node);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function normalizeReportedFile(filePath, cwd) {
|
|
1013
|
+
return normalizeReportedFileModel(filePath, cwd);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function firstLine(value) {
|
|
1017
|
+
return firstLineModel(value);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function printBufferedOutput(output, prefix) {
|
|
1021
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1022
|
+
if (line.trim().length > 0) {
|
|
1023
|
+
console.log(`${prefix} ${line}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
1029
|
+
return resolveRuntimeUrlModel(rawUrl, serviceName, targetConfig, workerId, context);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function finalizeString(value, context) {
|
|
1033
|
+
return finalizeStringModel(value, context);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function resolveTemplateString(value, context) {
|
|
1037
|
+
return resolveTemplateStringModel(value, context);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function rewriteUrlPort(rawUrl, port) {
|
|
1041
|
+
return rewriteUrlPortModel(rawUrl, port);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function numericPortFromUrl(rawUrl) {
|
|
1045
|
+
return numericPortFromUrlModel(rawUrl);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function assertLocalServicePortsAvailable(config) {
|
|
1049
|
+
const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
|
|
1050
|
+
const seen = new Set();
|
|
1051
|
+
|
|
1052
|
+
for (const endpoint of endpoints) {
|
|
1053
|
+
const socket = socketFromUrl(endpoint);
|
|
1054
|
+
if (!socket) continue;
|
|
1055
|
+
|
|
1056
|
+
const key = `${socket.host}:${socket.port}`;
|
|
1057
|
+
if (seen.has(key)) continue;
|
|
1058
|
+
seen.add(key);
|
|
1059
|
+
|
|
1060
|
+
if (await isPortInUse(socket)) {
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
`Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
|
|
1063
|
+
`Stop the existing process and rerun testkit.`
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function socketFromUrl(rawUrl) {
|
|
1070
|
+
return socketFromUrlModel(rawUrl);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function normalizeSocketHost(hostname) {
|
|
1074
|
+
return normalizeSocketHostModel(hostname);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function isPortInUse({ host, port }) {
|
|
1078
|
+
return new Promise((resolve, reject) => {
|
|
1079
|
+
const socket = new net.Socket();
|
|
1080
|
+
let settled = false;
|
|
1081
|
+
|
|
1082
|
+
const finish = (value, error = null) => {
|
|
1083
|
+
if (settled) return;
|
|
1084
|
+
settled = true;
|
|
1085
|
+
socket.destroy();
|
|
1086
|
+
if (error) {
|
|
1087
|
+
reject(error);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
resolve(value);
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
socket.setTimeout(1_000);
|
|
1094
|
+
socket.once("connect", () => finish(true));
|
|
1095
|
+
socket.once("timeout", () => finish(false));
|
|
1096
|
+
socket.once("error", (error) => {
|
|
1097
|
+
if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
|
|
1098
|
+
finish(false);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
finish(false, error);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
socket.connect(port, host);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function killChildProcess(child, signal) {
|
|
1109
|
+
if (!child?.pid) return;
|
|
1110
|
+
|
|
1111
|
+
try {
|
|
1112
|
+
process.kill(-child.pid, signal);
|
|
1113
|
+
return;
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (error?.code !== "ESRCH") {
|
|
1116
|
+
// Fall back to the direct child if process-group signalling is unavailable.
|
|
1117
|
+
} else {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
child.kill(signal);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
if (error?.code !== "ESRCH") throw error;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function readDatabaseUrl(stateDir) {
|
|
1130
|
+
return readStateValue(path.join(stateDir, "database_url"));
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function readStateValue(filePath) {
|
|
1134
|
+
if (!fs.existsSync(filePath)) return null;
|
|
1135
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function printStateDir(dir, indent) {
|
|
1139
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1140
|
+
const filePath = path.join(dir, entry.name);
|
|
1141
|
+
if (entry.isDirectory()) {
|
|
1142
|
+
console.log(`${indent}${entry.name}/`);
|
|
1143
|
+
printStateDir(filePath, `${indent} `);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
const value =
|
|
1147
|
+
entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
|
|
1148
|
+
console.log(`${indent}${entry.name}: ${value}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function pipeOutput(stream, prefix) {
|
|
1153
|
+
if (!stream) return Promise.resolve();
|
|
1154
|
+
|
|
1155
|
+
let pending = "";
|
|
1156
|
+
return new Promise((resolve) => {
|
|
1157
|
+
let settled = false;
|
|
1158
|
+
const settle = () => {
|
|
1159
|
+
if (settled) return;
|
|
1160
|
+
settled = true;
|
|
1161
|
+
resolve();
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
stream.on("data", (chunk) => {
|
|
1165
|
+
pending += chunk.toString();
|
|
1166
|
+
const lines = pending.split(/\r?\n/);
|
|
1167
|
+
pending = lines.pop() || "";
|
|
1168
|
+
for (const line of lines) {
|
|
1169
|
+
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
stream.on("end", () => {
|
|
1173
|
+
if (pending.length > 0) {
|
|
1174
|
+
console.log(`${prefix} ${pending}`);
|
|
1175
|
+
}
|
|
1176
|
+
settle();
|
|
1177
|
+
});
|
|
1178
|
+
stream.on("close", settle);
|
|
1179
|
+
stream.on("error", settle);
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function sleep(ms) {
|
|
1184
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function findRuntimeStateDirs(rootDir) {
|
|
1188
|
+
return findRuntimeStateDirsModel(rootDir, isDatabaseStateDir);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function findGraphDirsForService(productDir, serviceName) {
|
|
1192
|
+
return findGraphDirsForServiceModel(productDir, serviceName);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function writeGraphMetadata(graphDir, graph) {
|
|
1196
|
+
return writeGraphMetadataModel(graphDir, graph);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function readGraphMetadata(graphDir) {
|
|
1200
|
+
return readGraphMetadataModel(graphDir);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function isRuntimeSuperset(candidate, target) {
|
|
1204
|
+
return isRuntimeSupersetModel(candidate, target);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function compareGraphsForAssignment(left, right) {
|
|
1208
|
+
return compareGraphsForAssignmentModel(left, right);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function buildGraphDirName(runtimeNames) {
|
|
1212
|
+
return buildGraphDirNameModel(runtimeNames);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function slugSegment(value) {
|
|
1216
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function normalizePathSeparators(filePath) {
|
|
1220
|
+
return normalizePathSeparatorsModel(filePath);
|
|
1221
|
+
}
|