@elench/testkit 0.1.32 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/runner/artifacts.mjs +43 -0
- package/lib/runner/default-runtime-errors.mjs +53 -0
- package/lib/runner/default-runtime-errors.test.mjs +49 -0
- package/lib/runner/default-runtime-runner.mjs +119 -0
- package/lib/runner/formatting.mjs +129 -0
- package/lib/runner/formatting.test.mjs +100 -0
- package/lib/runner/index.mjs +2 -1575
- package/lib/runner/maintenance.mjs +72 -0
- package/lib/runner/orchestrator.mjs +254 -0
- package/lib/runner/playwright-config.mjs +53 -0
- package/lib/runner/playwright-runner.mjs +85 -0
- package/lib/runner/processes.mjs +106 -0
- package/lib/runner/readiness.mjs +117 -0
- package/lib/runner/reporting.mjs +180 -0
- package/lib/runner/reporting.test.mjs +193 -0
- package/lib/runner/results.mjs +36 -266
- package/lib/runner/results.test.mjs +4 -204
- package/lib/runner/runtime-contexts.mjs +133 -0
- package/lib/runner/selection.mjs +33 -0
- package/lib/runner/selection.test.mjs +25 -0
- package/lib/runner/services.mjs +73 -0
- package/lib/runner/state-io.mjs +25 -0
- package/lib/runner/worker-loop.mjs +95 -0
- package/package.json +1 -1
package/lib/runner/index.mjs
CHANGED
|
@@ -1,1575 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { spawn } from "child_process";
|
|
4
|
-
import net from "net";
|
|
5
|
-
import { pathToFileURL } from "url";
|
|
6
|
-
import { execa, execaCommand } from "execa";
|
|
7
|
-
import { bundleK6File } from "../bundler/index.mjs";
|
|
8
|
-
import { resolveK6Binary, resolveServiceCwd } from "../config/index.mjs";
|
|
9
|
-
import {
|
|
10
|
-
cleanupOrphanedLocalInfrastructure,
|
|
11
|
-
destroyRuntimeDatabase,
|
|
12
|
-
destroyServiceDatabaseCache,
|
|
13
|
-
isDatabaseStateDir,
|
|
14
|
-
prepareDatabaseRuntime,
|
|
15
|
-
showServiceDatabaseStatus,
|
|
16
|
-
} from "../database/index.mjs";
|
|
17
|
-
import {
|
|
18
|
-
batchNeedsLocalRuntime as batchNeedsLocalRuntimeModel,
|
|
19
|
-
buildGraphDirName as buildGraphDirNameModel,
|
|
20
|
-
buildRuntimeGraphs as buildRuntimeGraphsModel,
|
|
21
|
-
buildTaskQueue as buildTaskQueueModel,
|
|
22
|
-
claimNextBatch as claimNextBatchModel,
|
|
23
|
-
collectSuites as collectSuitesModel,
|
|
24
|
-
compareGraphsForAssignment as compareGraphsForAssignmentModel,
|
|
25
|
-
isRuntimeSuperset as isRuntimeSupersetModel,
|
|
26
|
-
orderedTypes as orderedTypesModel,
|
|
27
|
-
resolveRuntimeConfigs as resolveRuntimeConfigsModel,
|
|
28
|
-
applyShard as applyShardModel,
|
|
29
|
-
} from "./planning.mjs";
|
|
30
|
-
import {
|
|
31
|
-
buildExecutionEnv as buildExecutionEnvModel,
|
|
32
|
-
buildPlaywrightEnv as buildPlaywrightEnvModel,
|
|
33
|
-
buildPortMap as buildPortMapModel,
|
|
34
|
-
finalizeString as finalizeStringModel,
|
|
35
|
-
getWorkerServiceStateDir as getWorkerServiceStateDirModel,
|
|
36
|
-
normalizeSocketHost as normalizeSocketHostModel,
|
|
37
|
-
numericPortFromUrl as numericPortFromUrlModel,
|
|
38
|
-
resolveRuntimeUrl as resolveRuntimeUrlModel,
|
|
39
|
-
resolveServiceStateDir as resolveServiceStateDirModel,
|
|
40
|
-
resolveTemplateString as resolveTemplateStringModel,
|
|
41
|
-
resolveWorkerConfig as resolveWorkerConfigModel,
|
|
42
|
-
resolveWorkerRuntimeConfigs as resolveWorkerRuntimeConfigsModel,
|
|
43
|
-
rewriteUrlPort as rewriteUrlPortModel,
|
|
44
|
-
socketFromUrl as socketFromUrlModel,
|
|
45
|
-
} from "./template.mjs";
|
|
46
|
-
import {
|
|
47
|
-
addTrackerError as addTrackerErrorModel,
|
|
48
|
-
buildRunArtifact as buildRunArtifactModel,
|
|
49
|
-
buildStatusArtifact as buildStatusArtifactModel,
|
|
50
|
-
buildServiceTrackers as buildServiceTrackersModel,
|
|
51
|
-
finalizeServiceResult as finalizeServiceResultModel,
|
|
52
|
-
formatDuration as formatDurationModel,
|
|
53
|
-
formatError as formatErrorModel,
|
|
54
|
-
formatServiceSummary as formatServiceSummaryModel,
|
|
55
|
-
longestServiceName as longestServiceNameModel,
|
|
56
|
-
recordGraphError as recordGraphErrorModel,
|
|
57
|
-
recordTaskOutcome as recordTaskOutcomeModel,
|
|
58
|
-
summarizeDbBackend as summarizeDbBackendModel,
|
|
59
|
-
} from "./results.mjs";
|
|
60
|
-
import {
|
|
61
|
-
applyTimingUpdates,
|
|
62
|
-
buildTimingKey as buildTimingKeyModel,
|
|
63
|
-
createEmptyTimings,
|
|
64
|
-
estimateTaskDuration as estimateTaskDurationModel,
|
|
65
|
-
normalizeTimings,
|
|
66
|
-
} from "../timing/index.mjs";
|
|
67
|
-
import {
|
|
68
|
-
choosePlaywrightFinalResult as choosePlaywrightFinalResultModel,
|
|
69
|
-
collectPlaywrightSpec as collectPlaywrightSpecModel,
|
|
70
|
-
extractPlaywrightFailure as extractPlaywrightFailureModel,
|
|
71
|
-
extractReporterFile as extractReporterFileModel,
|
|
72
|
-
firstLine as firstLineModel,
|
|
73
|
-
formatPlaywrightReporterError as formatPlaywrightReporterErrorModel,
|
|
74
|
-
isPlaywrightPassingStatus as isPlaywrightPassingStatusModel,
|
|
75
|
-
normalizeReportedFile as normalizeReportedFileModel,
|
|
76
|
-
parsePlaywrightJsonResults as parsePlaywrightJsonResultsModel,
|
|
77
|
-
visitPlaywrightSuites as visitPlaywrightSuitesModel,
|
|
78
|
-
} from "../reporters/playwright.mjs";
|
|
79
|
-
import {
|
|
80
|
-
collectGitMetadata as collectGitMetadataModel,
|
|
81
|
-
readPackageMetadata as readPackageMetadataModel,
|
|
82
|
-
safeHostname as safeHostnameModel,
|
|
83
|
-
safeUsername as safeUsernameModel,
|
|
84
|
-
} from "./metadata.mjs";
|
|
85
|
-
import {
|
|
86
|
-
findGraphDirsForService as findGraphDirsForServiceModel,
|
|
87
|
-
findRuntimeStateDirs as findRuntimeStateDirsModel,
|
|
88
|
-
normalizePathSeparators as normalizePathSeparatorsModel,
|
|
89
|
-
readGraphMetadata as readGraphMetadataModel,
|
|
90
|
-
writeGraphMetadata as writeGraphMetadataModel,
|
|
91
|
-
} from "./state.mjs";
|
|
92
|
-
import { uploadTelemetryArtifact } from "../telemetry/index.mjs";
|
|
93
|
-
import {
|
|
94
|
-
cleanupRunById,
|
|
95
|
-
cleanupRuns,
|
|
96
|
-
cleanupStaleRuns,
|
|
97
|
-
createRunLifecycle,
|
|
98
|
-
findPortOwner,
|
|
99
|
-
formatRunSummary,
|
|
100
|
-
isPidRunning,
|
|
101
|
-
listRunManifests,
|
|
102
|
-
} from "./lifecycle.mjs";
|
|
103
|
-
|
|
104
|
-
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
105
|
-
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
106
|
-
const TIMINGS_FILENAME = "timings.json";
|
|
107
|
-
|
|
108
|
-
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
109
|
-
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
110
|
-
const startedAt = Date.now();
|
|
111
|
-
const telemetry = configs[0]?.telemetry || null;
|
|
112
|
-
const productDir = configs[0]?.productDir || process.cwd();
|
|
113
|
-
await cleanupStaleRuns(productDir);
|
|
114
|
-
const metadata = {
|
|
115
|
-
git: collectGitMetadata(productDir),
|
|
116
|
-
host: {
|
|
117
|
-
hostname: safeHostname(),
|
|
118
|
-
username: safeUsername(),
|
|
119
|
-
},
|
|
120
|
-
testkitVersion: readPackageMetadata().version,
|
|
121
|
-
};
|
|
122
|
-
const requestedFiles = opts.fileNames || [];
|
|
123
|
-
if (requestedFiles.length > 0) {
|
|
124
|
-
const unmatchedFiles = findUnmatchedRequestedFiles(
|
|
125
|
-
configs,
|
|
126
|
-
suiteType,
|
|
127
|
-
suiteNames,
|
|
128
|
-
opts.framework || "all",
|
|
129
|
-
requestedFiles
|
|
130
|
-
);
|
|
131
|
-
if (unmatchedFiles.length > 0) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Requested file${unmatchedFiles.length === 1 ? "" : "s"} did not match any selected suites:\n` +
|
|
134
|
-
unmatchedFiles.map((file) => `- ${file}`).join("\n")
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (opts.writeStatus && !opts.allowPartialStatus && !isFullRunSelection(suiteType, suiteNames, requestedFiles, opts)) {
|
|
139
|
-
throw new Error(
|
|
140
|
-
"Refusing to overwrite testkit.status.json from a filtered run. " +
|
|
141
|
-
"Run the full suite with --write-status, or pass --allow-partial-status to opt in."
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
145
|
-
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
146
|
-
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
147
|
-
let workerCount = 0;
|
|
148
|
-
let exitCode = 0;
|
|
149
|
-
const lifecycle = createRunLifecycle(productDir);
|
|
150
|
-
lifecycle.markRunning();
|
|
151
|
-
lifecycle.installSignalHandlers();
|
|
152
|
-
let results = [];
|
|
153
|
-
let finishedAt = Date.now();
|
|
154
|
-
try {
|
|
155
|
-
if (executedPlans.length > 0) {
|
|
156
|
-
const timings = loadTimings(productDir);
|
|
157
|
-
const graphs = buildRuntimeGraphs(executedPlans);
|
|
158
|
-
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
159
|
-
workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
|
|
160
|
-
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
161
|
-
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
162
|
-
createWorker(index + 1, productDir)
|
|
163
|
-
);
|
|
164
|
-
const timingUpdates = [];
|
|
165
|
-
|
|
166
|
-
const workerResults = await Promise.allSettled(
|
|
167
|
-
workers.map((worker) =>
|
|
168
|
-
runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle)
|
|
169
|
-
)
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
for (const result of workerResults) {
|
|
173
|
-
if (result.status === "rejected") {
|
|
174
|
-
const message = formatError(result.reason);
|
|
175
|
-
for (const tracker of trackers.values()) {
|
|
176
|
-
if (!tracker.skipped) addTrackerError(tracker, message);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
saveTimings(productDir, timings, timingUpdates);
|
|
182
|
-
}
|
|
183
|
-
finishedAt = Date.now();
|
|
184
|
-
results = configs.map((config) =>
|
|
185
|
-
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
186
|
-
);
|
|
187
|
-
const artifact = buildRunArtifact({
|
|
188
|
-
productDir,
|
|
189
|
-
results,
|
|
190
|
-
startedAt,
|
|
191
|
-
finishedAt,
|
|
192
|
-
requestedJobs: opts.jobs || 1,
|
|
193
|
-
workerCount,
|
|
194
|
-
suiteType,
|
|
195
|
-
suiteNames,
|
|
196
|
-
fileNames: requestedFiles,
|
|
197
|
-
framework: opts.framework || "all",
|
|
198
|
-
shard: opts.shard || null,
|
|
199
|
-
serviceFilter: opts.serviceFilter || null,
|
|
200
|
-
metadata,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
writeRunArtifact(productDir, artifact);
|
|
204
|
-
if (opts.writeStatus) {
|
|
205
|
-
writeStatusArtifact(
|
|
206
|
-
productDir,
|
|
207
|
-
buildStatusArtifact({
|
|
208
|
-
productDir,
|
|
209
|
-
results,
|
|
210
|
-
suiteType,
|
|
211
|
-
suiteNames,
|
|
212
|
-
fileNames: requestedFiles,
|
|
213
|
-
framework: opts.framework || "all",
|
|
214
|
-
shard: opts.shard || null,
|
|
215
|
-
serviceFilter: opts.serviceFilter || null,
|
|
216
|
-
metadata,
|
|
217
|
-
})
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
printRunSummary(results, finishedAt - startedAt);
|
|
222
|
-
await reportTelemetry(telemetry, artifact);
|
|
223
|
-
if (results.some((result) => result.failed)) exitCode = 1;
|
|
224
|
-
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
225
|
-
} finally {
|
|
226
|
-
lifecycle.removeSignalHandlers();
|
|
227
|
-
lifecycle.markFinished(
|
|
228
|
-
exitCode === 0 ? "finished" : lifecycle.isStopRequested() ? "interrupted" : "failed"
|
|
229
|
-
);
|
|
230
|
-
await cleanupRunById(productDir, lifecycle.runId);
|
|
231
|
-
await cleanupRuns(productDir, { includeActive: false });
|
|
232
|
-
lifecycle.removeManifest();
|
|
233
|
-
process.exitCode = exitCode;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export async function destroy(config) {
|
|
238
|
-
await cleanupRuns(config.productDir, { includeActive: true });
|
|
239
|
-
const roots = new Set([config.stateDir, ...findGraphDirsForService(config.productDir, config.name)]);
|
|
240
|
-
|
|
241
|
-
for (const rootDir of roots) {
|
|
242
|
-
if (!fs.existsSync(rootDir)) continue;
|
|
243
|
-
const runtimeStateDirs = findRuntimeStateDirs(rootDir);
|
|
244
|
-
for (const stateDir of runtimeStateDirs) {
|
|
245
|
-
await destroyRuntimeDatabase({
|
|
246
|
-
productDir: config.productDir,
|
|
247
|
-
stateDir,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
await destroyServiceDatabaseCache(config.productDir, config.name);
|
|
254
|
-
await cleanupOrphanedLocalInfrastructure(config.productDir);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function showStatus(config) {
|
|
258
|
-
printRunStatus(config.productDir);
|
|
259
|
-
const graphDirs = findGraphDirsForService(config.productDir, config.name);
|
|
260
|
-
const hasDirectState = fs.existsSync(config.stateDir);
|
|
261
|
-
const hasGraphState = graphDirs.length > 0;
|
|
262
|
-
|
|
263
|
-
if (!hasDirectState && !hasGraphState) {
|
|
264
|
-
console.log("No state — run tests first.");
|
|
265
|
-
} else {
|
|
266
|
-
if (hasDirectState) {
|
|
267
|
-
console.log(" service-state/");
|
|
268
|
-
printStateDir(config.stateDir, " ");
|
|
269
|
-
}
|
|
270
|
-
for (const graphDir of graphDirs) {
|
|
271
|
-
console.log(` graph-state/${path.basename(graphDir)}/`);
|
|
272
|
-
printStateDir(graphDir, " ");
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
showServiceDatabaseStatus(config.productDir, config.name);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export async function cleanup(productDir) {
|
|
280
|
-
const summary = await cleanupRuns(productDir, { includeActive: false });
|
|
281
|
-
if (summary.cleaned.length === 0 && summary.skippedActive.length === 0) {
|
|
282
|
-
console.log("No stale runs to clean.");
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
for (const manifest of summary.cleaned) {
|
|
287
|
-
console.log(`Cleaned stale run ${formatRunSummary(manifest)}`);
|
|
288
|
-
}
|
|
289
|
-
for (const manifest of summary.skippedActive) {
|
|
290
|
-
console.log(`Active run still present: ${formatRunSummary(manifest)}`);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
295
|
-
return configs.map((config) => {
|
|
296
|
-
console.log(`\n══ ${config.name} ══`);
|
|
297
|
-
const suites = applyShard(
|
|
298
|
-
collectSuites(config, suiteType, suiteNames, opts.framework, opts.fileNames || []),
|
|
299
|
-
opts.shard
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
if (suites.length === 0) {
|
|
303
|
-
console.log(
|
|
304
|
-
`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"} — skipping`
|
|
305
|
-
);
|
|
306
|
-
return {
|
|
307
|
-
config,
|
|
308
|
-
skipped: true,
|
|
309
|
-
suites: [],
|
|
310
|
-
runtimeConfigs: [],
|
|
311
|
-
runtimeNames: [],
|
|
312
|
-
runtimeKey: null,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const runtimeConfigs = resolveRuntimeConfigs(config, configMap);
|
|
317
|
-
return {
|
|
318
|
-
config,
|
|
319
|
-
skipped: false,
|
|
320
|
-
suites,
|
|
321
|
-
runtimeConfigs,
|
|
322
|
-
runtimeNames: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort(),
|
|
323
|
-
runtimeKey: runtimeConfigs.map((runtimeConfig) => runtimeConfig.name).sort().join("|"),
|
|
324
|
-
};
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function buildServiceTrackers(servicePlans, startedAt) {
|
|
329
|
-
return buildServiceTrackersModel(servicePlans, startedAt);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function buildRuntimeGraphs(servicePlans) {
|
|
333
|
-
return buildRuntimeGraphsModel(servicePlans);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function buildTaskQueue(servicePlans, graphs, timings) {
|
|
337
|
-
return buildTaskQueueModel(servicePlans, graphs, timings);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function createWorker(workerId, productDir) {
|
|
341
|
-
return {
|
|
342
|
-
workerId,
|
|
343
|
-
productDir,
|
|
344
|
-
currentGraphKey: null,
|
|
345
|
-
graphContexts: new Map(),
|
|
346
|
-
graphSwitches: 0,
|
|
347
|
-
taskCount: 0,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async function runWorker(worker, queue, graphByKey, trackers, timingUpdates, lifecycle) {
|
|
352
|
-
const startedAt = Date.now();
|
|
353
|
-
console.log(`\n══ global worker ${worker.workerId} ══`);
|
|
354
|
-
const errors = [];
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
while (true) {
|
|
358
|
-
if (lifecycle.isStopRequested()) break;
|
|
359
|
-
const batch = claimNextBatch(queue, worker.currentGraphKey);
|
|
360
|
-
if (!batch) break;
|
|
361
|
-
|
|
362
|
-
try {
|
|
363
|
-
const context = await ensureWorkerGraph(worker, batch, graphByKey, lifecycle);
|
|
364
|
-
const outcomes = await runBatch(context, batch, lifecycle);
|
|
365
|
-
for (const outcome of outcomes) {
|
|
366
|
-
recordTaskOutcome(trackers, outcome.task, outcome);
|
|
367
|
-
timingUpdates.push({
|
|
368
|
-
key: outcome.task.timingKey,
|
|
369
|
-
durationMs: outcome.durationMs,
|
|
370
|
-
});
|
|
371
|
-
worker.taskCount += 1;
|
|
372
|
-
}
|
|
373
|
-
} catch (error) {
|
|
374
|
-
const message = formatError(error);
|
|
375
|
-
errors.push(message);
|
|
376
|
-
recordGraphError(trackers, graphByKey.get(batch.graphKey), message);
|
|
377
|
-
await resetCurrentGraph(worker, lifecycle);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
} finally {
|
|
381
|
-
await cleanupWorker(worker, lifecycle);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
workerId: worker.workerId,
|
|
386
|
-
failed: errors.length > 0,
|
|
387
|
-
durationMs: Date.now() - startedAt,
|
|
388
|
-
taskCount: worker.taskCount,
|
|
389
|
-
graphSwitches: worker.graphSwitches,
|
|
390
|
-
errors,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function claimNextBatch(queue, preferredGraphKey) {
|
|
395
|
-
return claimNextBatchModel(queue, preferredGraphKey);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function ensureWorkerGraph(worker, batch, graphByKey, lifecycle) {
|
|
399
|
-
const graph = graphByKey.get(batch.graphKey);
|
|
400
|
-
if (!graph) {
|
|
401
|
-
throw new Error(`Unknown graph "${batch.graphKey}"`);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
if (worker.currentGraphKey && worker.currentGraphKey !== batch.graphKey) {
|
|
405
|
-
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
|
|
406
|
-
worker.graphSwitches += 1;
|
|
407
|
-
worker.currentGraphKey = null;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
let context = worker.graphContexts.get(batch.graphKey);
|
|
411
|
-
if (!context) {
|
|
412
|
-
context = createGraphContext(worker, graph);
|
|
413
|
-
worker.graphContexts.set(batch.graphKey, context);
|
|
414
|
-
lifecycle.trackGraphContext(context);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (!context.prepared) {
|
|
418
|
-
await prepareDatabases(context.runtimeConfigs);
|
|
419
|
-
context.prepared = true;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (batchNeedsLocalRuntime(batch) && !context.started) {
|
|
423
|
-
context.startedServices = await startLocalServices(context.runtimeConfigs, lifecycle);
|
|
424
|
-
context.started = true;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
worker.currentGraphKey = batch.graphKey;
|
|
428
|
-
return context;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function createGraphContext(worker, graph) {
|
|
432
|
-
const graphDir = path.join(worker.productDir, ".testkit", "_graphs", graph.dirName);
|
|
433
|
-
const workerStateDir = path.join(graphDir, "workers", `worker-${worker.workerId}`);
|
|
434
|
-
fs.mkdirSync(workerStateDir, { recursive: true });
|
|
435
|
-
writeGraphMetadata(graphDir, graph);
|
|
436
|
-
|
|
437
|
-
const runtimeConfigs = resolveWorkerRuntimeConfigs(
|
|
438
|
-
graph.rootConfig,
|
|
439
|
-
graph.runtimeConfigs,
|
|
440
|
-
worker.workerId,
|
|
441
|
-
workerStateDir
|
|
442
|
-
);
|
|
443
|
-
|
|
444
|
-
return {
|
|
445
|
-
graphKey: graph.key,
|
|
446
|
-
graphDir,
|
|
447
|
-
workerStateDir,
|
|
448
|
-
runtimeConfigs,
|
|
449
|
-
configByName: new Map(runtimeConfigs.map((config) => [config.name, config])),
|
|
450
|
-
prepared: false,
|
|
451
|
-
started: false,
|
|
452
|
-
startedServices: [],
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
async function deactivateGraphContext(context, lifecycle) {
|
|
457
|
-
if (!context?.started) return;
|
|
458
|
-
await stopLocalServices(context.startedServices, lifecycle);
|
|
459
|
-
context.started = false;
|
|
460
|
-
context.startedServices = [];
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
async function resetCurrentGraph(worker, lifecycle) {
|
|
464
|
-
if (!worker.currentGraphKey) return;
|
|
465
|
-
await deactivateGraphContext(worker.graphContexts.get(worker.currentGraphKey), lifecycle);
|
|
466
|
-
worker.currentGraphKey = null;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
async function cleanupWorker(worker, lifecycle) {
|
|
470
|
-
for (const context of worker.graphContexts.values()) {
|
|
471
|
-
await deactivateGraphContext(context, lifecycle);
|
|
472
|
-
}
|
|
473
|
-
worker.currentGraphKey = null;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
async function runBatch(context, batch, lifecycle) {
|
|
477
|
-
const targetConfig = context.configByName.get(batch.targetName);
|
|
478
|
-
if (!targetConfig) {
|
|
479
|
-
throw new Error(`Worker graph missing target config "${batch.targetName}"`);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (batch.framework === "playwright") {
|
|
483
|
-
return runPlaywrightBatch(targetConfig, batch, lifecycle);
|
|
484
|
-
}
|
|
485
|
-
if (batch.type === "dal") {
|
|
486
|
-
return runDalBatch(targetConfig, batch, lifecycle);
|
|
487
|
-
}
|
|
488
|
-
if (batch.framework === "k6" && HTTP_K6_TYPES.has(batch.type)) {
|
|
489
|
-
return runHttpK6Batch(targetConfig, batch, lifecycle);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
throw new Error(
|
|
493
|
-
`Unsupported task combination for ${batch.targetName}: type=${batch.type} framework=${batch.framework}`
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
async function prepareDatabases(runtimeConfigs) {
|
|
498
|
-
for (const config of runtimeConfigs) {
|
|
499
|
-
await prepareDatabaseRuntime(config, {
|
|
500
|
-
runMigrate: config.testkit.migrate
|
|
501
|
-
? (databaseUrl) => runMigrate(config, databaseUrl)
|
|
502
|
-
: null,
|
|
503
|
-
runSeed: config.testkit.seed ? (databaseUrl) => runSeed(config, databaseUrl) : null,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
async function runMigrate(config, databaseUrl) {
|
|
509
|
-
const migrate = config.testkit.migrate;
|
|
510
|
-
if (!migrate) return;
|
|
511
|
-
|
|
512
|
-
const env = buildExecutionEnv(config);
|
|
513
|
-
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
514
|
-
|
|
515
|
-
console.log(`\n── migrate:${config.workerLabel}:${config.name} ──`);
|
|
516
|
-
await execaCommand(migrate.cmd, {
|
|
517
|
-
cwd: resolveServiceCwd(config.productDir, migrate.cwd),
|
|
518
|
-
env,
|
|
519
|
-
stdio: "inherit",
|
|
520
|
-
shell: true,
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
async function runSeed(config, databaseUrl) {
|
|
525
|
-
const seed = config.testkit.seed;
|
|
526
|
-
if (!seed) return;
|
|
527
|
-
|
|
528
|
-
const env = buildExecutionEnv(config);
|
|
529
|
-
if (databaseUrl) env.DATABASE_URL = databaseUrl;
|
|
530
|
-
|
|
531
|
-
console.log(`\n── seed:${config.workerLabel}:${config.name} ──`);
|
|
532
|
-
await execaCommand(seed.cmd, {
|
|
533
|
-
cwd: resolveServiceCwd(config.productDir, seed.cwd),
|
|
534
|
-
env,
|
|
535
|
-
stdio: "inherit",
|
|
536
|
-
shell: true,
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async function startLocalServices(runtimeConfigs, lifecycle) {
|
|
541
|
-
const started = [];
|
|
542
|
-
|
|
543
|
-
try {
|
|
544
|
-
for (const config of runtimeConfigs) {
|
|
545
|
-
if (!config.testkit.local) continue;
|
|
546
|
-
const proc = await startLocalService(config, lifecycle);
|
|
547
|
-
started.push(proc);
|
|
548
|
-
}
|
|
549
|
-
} catch (error) {
|
|
550
|
-
await stopLocalServices(started);
|
|
551
|
-
throw error;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
return started;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
async function startLocalService(config, lifecycle) {
|
|
558
|
-
const cwd = resolveServiceCwd(config.productDir, config.testkit.local.cwd);
|
|
559
|
-
const env = buildExecutionEnv(config, config.testkit.local.env);
|
|
560
|
-
const port = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
561
|
-
if (port) {
|
|
562
|
-
env.PORT = String(port);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const dbUrl = readDatabaseUrl(config.stateDir);
|
|
566
|
-
if (dbUrl) {
|
|
567
|
-
env.DATABASE_URL = dbUrl;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
await assertLocalServicePortsAvailable(config);
|
|
571
|
-
|
|
572
|
-
console.log(`Starting ${config.workerLabel}:${config.name}: ${config.testkit.local.start}`);
|
|
573
|
-
const child = startDetachedCommand(config.testkit.local.start, cwd, env);
|
|
574
|
-
|
|
575
|
-
const outputDrains = [
|
|
576
|
-
pipeOutput(child.stdout, `[${config.workerLabel}:${config.name}]`),
|
|
577
|
-
pipeOutput(child.stderr, `[${config.workerLabel}:${config.name}]`),
|
|
578
|
-
];
|
|
579
|
-
lifecycle.registerService(config, child, cwd);
|
|
580
|
-
|
|
581
|
-
const readyTimeoutMs = config.testkit.local.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS;
|
|
582
|
-
|
|
583
|
-
try {
|
|
584
|
-
await waitForReady({
|
|
585
|
-
name: `${config.workerLabel}:${config.name}`,
|
|
586
|
-
url: config.testkit.local.readyUrl,
|
|
587
|
-
timeoutMs: readyTimeoutMs,
|
|
588
|
-
process: child,
|
|
589
|
-
signal: lifecycle.signal,
|
|
590
|
-
});
|
|
591
|
-
} catch (error) {
|
|
592
|
-
await stopChildProcess(child, outputDrains);
|
|
593
|
-
lifecycle.unregisterService(child.pid);
|
|
594
|
-
throw error;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
return { name: config.name, child, outputDrains };
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
async function runHttpK6Batch(targetConfig, batch, lifecycle) {
|
|
601
|
-
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
602
|
-
if (!baseUrl) {
|
|
603
|
-
throw new Error(`Service "${targetConfig.name}" requires local.baseUrl for HTTP suites`);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
console.log(
|
|
607
|
-
`\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
608
|
-
);
|
|
609
|
-
|
|
610
|
-
return Promise.all(
|
|
611
|
-
batch.tasks.map((task) => runHttpK6Task(targetConfig, task, baseUrl, lifecycle))
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async function runHttpK6Task(targetConfig, task, baseUrl, lifecycle) {
|
|
616
|
-
const absFile = path.join(targetConfig.productDir, task.file);
|
|
617
|
-
const k6Binary = resolveK6Binary();
|
|
618
|
-
const bundledFile = await bundleK6File({
|
|
619
|
-
productDir: targetConfig.productDir,
|
|
620
|
-
serviceName: targetConfig.name,
|
|
621
|
-
sourceFile: absFile,
|
|
622
|
-
});
|
|
623
|
-
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
624
|
-
return runDefaultRuntimeTask(targetConfig, task, [
|
|
625
|
-
"run",
|
|
626
|
-
"--address",
|
|
627
|
-
"127.0.0.1:0",
|
|
628
|
-
"-e",
|
|
629
|
-
`BASE_URL=${baseUrl}`,
|
|
630
|
-
bundledFile,
|
|
631
|
-
], lifecycle);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
async function runDalBatch(targetConfig, batch, lifecycle) {
|
|
635
|
-
const databaseUrl = readDatabaseUrl(targetConfig.stateDir);
|
|
636
|
-
if (!databaseUrl) {
|
|
637
|
-
throw new Error(`Service "${targetConfig.name}" requires a database for DAL suites`);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
console.log(
|
|
641
|
-
`\n── ${targetConfig.workerLabel} ${batch.type}:${batch.tasks[0].suiteName}${formatBatchDescriptor(batch)} ──`
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
return Promise.all(
|
|
645
|
-
batch.tasks.map((task) => runDalTask(targetConfig, task, databaseUrl, lifecycle))
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
async function runDalTask(targetConfig, task, databaseUrl, lifecycle) {
|
|
650
|
-
const absFile = path.join(targetConfig.productDir, task.file);
|
|
651
|
-
const k6Binary = resolveK6Binary();
|
|
652
|
-
const bundledFile = await bundleK6File({
|
|
653
|
-
productDir: targetConfig.productDir,
|
|
654
|
-
serviceName: targetConfig.name,
|
|
655
|
-
sourceFile: absFile,
|
|
656
|
-
});
|
|
657
|
-
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
658
|
-
return runDefaultRuntimeTask(targetConfig, task, [
|
|
659
|
-
"run",
|
|
660
|
-
"--address",
|
|
661
|
-
"127.0.0.1:0",
|
|
662
|
-
"-e",
|
|
663
|
-
`DATABASE_URL=${databaseUrl}`,
|
|
664
|
-
bundledFile,
|
|
665
|
-
], lifecycle);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
|
|
669
|
-
const local = targetConfig.testkit.local;
|
|
670
|
-
if (!local?.baseUrl) {
|
|
671
|
-
throw new Error(
|
|
672
|
-
`Service "${targetConfig.name}" requires local.baseUrl for Playwright suites`
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
console.log(
|
|
677
|
-
`\n── ${targetConfig.workerLabel} playwright:${targetConfig.name} (${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"})${formatPlaywrightBatchFiles(batch)} ──`
|
|
678
|
-
);
|
|
679
|
-
|
|
680
|
-
const cwd = resolveServiceCwd(targetConfig.productDir, local.cwd);
|
|
681
|
-
const requestedFiles = batch.tasks.map((task) =>
|
|
682
|
-
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
683
|
-
);
|
|
684
|
-
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
|
|
685
|
-
const startedAt = Date.now();
|
|
686
|
-
const result = await execa(
|
|
687
|
-
"npx",
|
|
688
|
-
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json"],
|
|
689
|
-
{
|
|
690
|
-
cwd,
|
|
691
|
-
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
692
|
-
reject: false,
|
|
693
|
-
cancelSignal: lifecycle.signal,
|
|
694
|
-
forceKillAfterDelay: 5_000,
|
|
695
|
-
}
|
|
696
|
-
);
|
|
697
|
-
|
|
698
|
-
if (result.stderr) {
|
|
699
|
-
printBufferedOutput(result.stderr, `[${targetConfig.workerLabel}:${targetConfig.name}:playwright]`);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const parsed = parsePlaywrightJsonResults(result.stdout, cwd);
|
|
703
|
-
const finishedAt = Date.now();
|
|
704
|
-
const batchDurationMs = finishedAt - startedAt;
|
|
705
|
-
const genericError =
|
|
706
|
-
result.exitCode === 0
|
|
707
|
-
? parsed.errors[0] || null
|
|
708
|
-
: parsed.errors[0] ||
|
|
709
|
-
result.stderr.trim() ||
|
|
710
|
-
`Playwright exited with code ${result.exitCode}`;
|
|
711
|
-
|
|
712
|
-
return batch.tasks.map((task) => {
|
|
713
|
-
const relativeFile = normalizePathSeparators(
|
|
714
|
-
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
715
|
-
);
|
|
716
|
-
const fileResult = parsed.fileResults.get(relativeFile);
|
|
717
|
-
if (fileResult) {
|
|
718
|
-
return {
|
|
719
|
-
task,
|
|
720
|
-
failed: fileResult.failed,
|
|
721
|
-
error: fileResult.error,
|
|
722
|
-
durationMs:
|
|
723
|
-
fileResult.durationMs > 0
|
|
724
|
-
? fileResult.durationMs
|
|
725
|
-
: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
726
|
-
startedAt,
|
|
727
|
-
finishedAt,
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
return {
|
|
732
|
-
task,
|
|
733
|
-
failed: result.exitCode !== 0,
|
|
734
|
-
error: result.exitCode !== 0 ? genericError : null,
|
|
735
|
-
durationMs: Math.round(batchDurationMs / Math.max(1, batch.tasks.length)),
|
|
736
|
-
startedAt,
|
|
737
|
-
finishedAt,
|
|
738
|
-
};
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
async function stopLocalServices(started, lifecycle) {
|
|
743
|
-
for (const service of [...started].reverse()) {
|
|
744
|
-
await stopChildProcess(service.child, service.outputDrains);
|
|
745
|
-
lifecycle?.unregisterService(service.child.pid);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
async function stopChildProcess(child, outputDrains = []) {
|
|
750
|
-
if (!child) return;
|
|
751
|
-
if (child.exitCode !== null) {
|
|
752
|
-
await Promise.all(outputDrains);
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
killChildProcess(child, "SIGTERM");
|
|
757
|
-
const exited = await Promise.race([
|
|
758
|
-
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
|
759
|
-
sleep(5_000).then(() => false),
|
|
760
|
-
]);
|
|
761
|
-
|
|
762
|
-
if (!exited && child.exitCode === null) {
|
|
763
|
-
killChildProcess(child, "SIGKILL");
|
|
764
|
-
await new Promise((resolve) => child.once("exit", resolve));
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
await Promise.all(outputDrains);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
async function waitForReady({ name, url, timeoutMs, process, signal }) {
|
|
771
|
-
const start = Date.now();
|
|
772
|
-
|
|
773
|
-
while (Date.now() - start < timeoutMs) {
|
|
774
|
-
if (signal?.aborted) {
|
|
775
|
-
throw signal.reason || new Error(`Service "${name}" startup aborted`);
|
|
776
|
-
}
|
|
777
|
-
if (process.exitCode !== null) {
|
|
778
|
-
throw new Error(`Service "${name}" exited before becoming ready`);
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
try {
|
|
782
|
-
const response = await fetch(url);
|
|
783
|
-
if (response.ok) return;
|
|
784
|
-
} catch {
|
|
785
|
-
// Service still warming up.
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
await sleep(1_000);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
throw new Error(`Timed out waiting for "${name}" to become ready at ${url}`);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function batchNeedsLocalRuntime(batch) {
|
|
795
|
-
return batchNeedsLocalRuntimeModel(batch);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
799
|
-
return resolveRuntimeConfigsModel(targetConfig, configMap);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
|
|
803
|
-
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter, fileNames);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function applyShard(suites, shard) {
|
|
807
|
-
return applyShardModel(suites, shard);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function orderedTypes(types) {
|
|
811
|
-
return orderedTypesModel(types);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
|
|
815
|
-
return resolveWorkerRuntimeConfigsModel(targetConfig, runtimeConfigs, workerId, workerStateDir);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
function buildPortMap(runtimeConfigs, workerId) {
|
|
819
|
-
return buildPortMapModel(runtimeConfigs, workerId);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
function resolveWorkerConfig(
|
|
823
|
-
config,
|
|
824
|
-
targetConfig,
|
|
825
|
-
workerId,
|
|
826
|
-
workerStateDir,
|
|
827
|
-
portMap,
|
|
828
|
-
baseUrlByService,
|
|
829
|
-
readyUrlByService,
|
|
830
|
-
urlMappings
|
|
831
|
-
) {
|
|
832
|
-
return resolveWorkerConfigModel(
|
|
833
|
-
config,
|
|
834
|
-
targetConfig,
|
|
835
|
-
workerId,
|
|
836
|
-
workerStateDir,
|
|
837
|
-
portMap,
|
|
838
|
-
baseUrlByService,
|
|
839
|
-
readyUrlByService,
|
|
840
|
-
urlMappings
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function resolveServiceStateDir(workerStateDir, targetName, config) {
|
|
845
|
-
return resolveServiceStateDirModel(workerStateDir, targetName, config);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
849
|
-
return getWorkerServiceStateDirModel(workerStateDir, targetName, serviceName);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function buildExecutionEnv(config, extraEnv = {}) {
|
|
853
|
-
return buildExecutionEnvModel(config, extraEnv, process.env);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function buildPlaywrightEnv(config, baseUrl) {
|
|
857
|
-
return buildPlaywrightEnvModel(config, baseUrl, process.env);
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function recordTaskOutcome(trackers, task, outcome) {
|
|
861
|
-
return recordTaskOutcomeModel(trackers, task, outcome);
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function recordGraphError(trackers, graph, message) {
|
|
865
|
-
return recordGraphErrorModel(trackers, graph, message);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function addTrackerError(tracker, message) {
|
|
869
|
-
return addTrackerErrorModel(tracker, message);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
873
|
-
return finalizeServiceResultModel(tracker, startedAt, finishedAt);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function printRunSummary(results, durationMs) {
|
|
877
|
-
const totalServices = results.length;
|
|
878
|
-
const executedServices = results.filter((result) => !result.skipped);
|
|
879
|
-
const skippedServices = results.filter((result) => result.skipped);
|
|
880
|
-
const failedServices = executedServices.filter((result) => result.failed);
|
|
881
|
-
const passedServices = executedServices.filter((result) => !result.failed);
|
|
882
|
-
const totalSuites = executedServices.reduce((sum, result) => sum + result.suiteCount, 0);
|
|
883
|
-
const completedSuites = executedServices.reduce(
|
|
884
|
-
(sum, result) => sum + result.completedSuiteCount,
|
|
885
|
-
0
|
|
886
|
-
);
|
|
887
|
-
const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
|
|
888
|
-
const passedSuites = completedSuites - failedSuites;
|
|
889
|
-
const totalFiles = executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
|
|
890
|
-
const passedFiles = executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
|
|
891
|
-
|
|
892
|
-
console.log("\n══ Summary ══");
|
|
893
|
-
console.log(
|
|
894
|
-
[
|
|
895
|
-
`services ${passedServices.length}/${executedServices.length} passed`,
|
|
896
|
-
`suites ${passedSuites}/${totalSuites} passed`,
|
|
897
|
-
totalFiles > 0 ? `files ${passedFiles}/${totalFiles} passed` : null,
|
|
898
|
-
skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
|
|
899
|
-
`duration ${formatDuration(durationMs)}`,
|
|
900
|
-
]
|
|
901
|
-
.filter(Boolean)
|
|
902
|
-
.join(" · ")
|
|
903
|
-
);
|
|
904
|
-
|
|
905
|
-
for (const result of results) {
|
|
906
|
-
const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
|
|
907
|
-
const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
|
|
908
|
-
console.log(
|
|
909
|
-
`${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
|
|
910
|
-
);
|
|
911
|
-
|
|
912
|
-
if (result.failed) {
|
|
913
|
-
const failedSuitesForService = result.suites.filter((suite) => suite.failed);
|
|
914
|
-
for (const suite of failedSuitesForService) {
|
|
915
|
-
const fileDetail =
|
|
916
|
-
suite.failedFiles.length > 0 ? ` (${suite.failedFiles.join(", ")})` : "";
|
|
917
|
-
console.log(
|
|
918
|
-
` - ${suite.type}:${suite.name}${formatSuiteFramework(suite.framework)}${fileDetail} · ${formatDuration(suite.durationMs)}`
|
|
919
|
-
);
|
|
920
|
-
if (suite.error) {
|
|
921
|
-
console.log(` ${suite.error}`);
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
for (const error of result.errors) {
|
|
925
|
-
console.log(` - worker error: ${error}`);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (failedServices.length > 0) {
|
|
931
|
-
console.log(`\nResult: FAILED (${failedServices.length}/${totalServices} services failed)`);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
console.log("\nResult: PASSED");
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
async function reportTelemetry(telemetry, artifact) {
|
|
939
|
-
if (!telemetry?.enabled) return;
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
const outcome = await uploadTelemetryArtifact(telemetry, artifact);
|
|
943
|
-
if (outcome?.ok) {
|
|
944
|
-
console.log("Telemetry: uploaded run artifact");
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
if (outcome?.reason === "missing-token") {
|
|
948
|
-
console.log(
|
|
949
|
-
`Telemetry: skipped upload because ${telemetry.tokenEnv || "configured token env"} is not set`
|
|
950
|
-
);
|
|
951
|
-
return;
|
|
952
|
-
}
|
|
953
|
-
if (outcome?.reason && !outcome.skipped) return;
|
|
954
|
-
} catch (error) {
|
|
955
|
-
console.log(`Telemetry: upload failed (${formatError(error)})`);
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
function longestServiceName(results) {
|
|
960
|
-
return longestServiceNameModel(results);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
function formatBatchDescriptor(batch) {
|
|
964
|
-
const fileLabel = `${batch.tasks.length} file${batch.tasks.length === 1 ? "" : "s"}`;
|
|
965
|
-
const frameworkLabel = formatFrameworkLabel(batch.framework);
|
|
966
|
-
return frameworkLabel ? ` (${frameworkLabel}, ${fileLabel})` : ` (${fileLabel})`;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function formatPlaywrightBatchFiles(batch) {
|
|
970
|
-
if (!batch?.tasks?.length) return "";
|
|
971
|
-
const files = batch.tasks.map((task) => task.file);
|
|
972
|
-
if (files.length === 1) return ` · ${files[0]}`;
|
|
973
|
-
const preview = files.slice(0, 3).join(", ");
|
|
974
|
-
const suffix = files.length > 3 ? `, +${files.length - 3} more` : "";
|
|
975
|
-
return ` · ${preview}${suffix}`;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function formatFrameworkLabel(framework) {
|
|
979
|
-
if (!framework || framework === "k6") return "";
|
|
980
|
-
return framework;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function formatSuiteFramework(framework) {
|
|
984
|
-
const label = formatFrameworkLabel(framework);
|
|
985
|
-
return label ? ` [${label}]` : "";
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function buildRunArtifact({
|
|
989
|
-
productDir,
|
|
990
|
-
results,
|
|
991
|
-
startedAt,
|
|
992
|
-
finishedAt,
|
|
993
|
-
requestedJobs,
|
|
994
|
-
workerCount,
|
|
995
|
-
suiteType,
|
|
996
|
-
suiteNames,
|
|
997
|
-
fileNames,
|
|
998
|
-
framework,
|
|
999
|
-
shard,
|
|
1000
|
-
serviceFilter,
|
|
1001
|
-
metadata,
|
|
1002
|
-
}) {
|
|
1003
|
-
return buildRunArtifactModel({
|
|
1004
|
-
productDir,
|
|
1005
|
-
results,
|
|
1006
|
-
startedAt,
|
|
1007
|
-
finishedAt,
|
|
1008
|
-
requestedJobs,
|
|
1009
|
-
workerCount,
|
|
1010
|
-
suiteType,
|
|
1011
|
-
suiteNames,
|
|
1012
|
-
fileNames,
|
|
1013
|
-
framework,
|
|
1014
|
-
shard,
|
|
1015
|
-
serviceFilter,
|
|
1016
|
-
metadata,
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
function writeRunArtifact(productDir, artifact) {
|
|
1021
|
-
const resultsDir = path.join(productDir, ".testkit", "results");
|
|
1022
|
-
fs.mkdirSync(resultsDir, { recursive: true });
|
|
1023
|
-
fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
function buildStatusArtifact({
|
|
1027
|
-
productDir,
|
|
1028
|
-
results,
|
|
1029
|
-
suiteType,
|
|
1030
|
-
suiteNames,
|
|
1031
|
-
fileNames,
|
|
1032
|
-
framework,
|
|
1033
|
-
shard,
|
|
1034
|
-
serviceFilter,
|
|
1035
|
-
metadata,
|
|
1036
|
-
}) {
|
|
1037
|
-
return buildStatusArtifactModel({
|
|
1038
|
-
productDir,
|
|
1039
|
-
results,
|
|
1040
|
-
suiteType,
|
|
1041
|
-
suiteNames,
|
|
1042
|
-
fileNames,
|
|
1043
|
-
framework,
|
|
1044
|
-
shard,
|
|
1045
|
-
serviceFilter,
|
|
1046
|
-
metadata,
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
function writeStatusArtifact(productDir, artifact) {
|
|
1051
|
-
fs.writeFileSync(
|
|
1052
|
-
path.join(productDir, "testkit.status.json"),
|
|
1053
|
-
`${JSON.stringify(artifact, null, 2)}\n`
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
1058
|
-
const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
|
|
1059
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
1060
|
-
const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
|
|
1061
|
-
const baseConfigPath = findPlaywrightConfig(cwd);
|
|
1062
|
-
const normalizedFiles = requestedFiles.map(normalizePathSeparators);
|
|
1063
|
-
|
|
1064
|
-
let source = "";
|
|
1065
|
-
if (baseConfigPath) {
|
|
1066
|
-
source = `import baseConfig from ${JSON.stringify(pathToFileURL(baseConfigPath).href)};\n` +
|
|
1067
|
-
`const resolvedBase = typeof baseConfig === "function" ? await baseConfig() : baseConfig;\n` +
|
|
1068
|
-
`export default {\n` +
|
|
1069
|
-
` ...(resolvedBase || {}),\n` +
|
|
1070
|
-
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
1071
|
-
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
1072
|
-
`};\n`;
|
|
1073
|
-
} else {
|
|
1074
|
-
source =
|
|
1075
|
-
`export default {\n` +
|
|
1076
|
-
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
1077
|
-
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
1078
|
-
`};\n`;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
fs.writeFileSync(configPath, source);
|
|
1082
|
-
return configPath;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
function findPlaywrightConfig(cwd) {
|
|
1086
|
-
const candidates = [
|
|
1087
|
-
"playwright.config.ts",
|
|
1088
|
-
"playwright.config.mts",
|
|
1089
|
-
"playwright.config.js",
|
|
1090
|
-
"playwright.config.mjs",
|
|
1091
|
-
"playwright.config.cjs",
|
|
1092
|
-
"playwright.config.cts",
|
|
1093
|
-
];
|
|
1094
|
-
|
|
1095
|
-
for (const candidate of candidates) {
|
|
1096
|
-
const candidatePath = path.join(cwd, candidate);
|
|
1097
|
-
if (fs.existsSync(candidatePath)) {
|
|
1098
|
-
return candidatePath;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
return null;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function summarizeDbBackend(results) {
|
|
1106
|
-
return summarizeDbBackendModel(results);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function collectGitMetadata(productDir) {
|
|
1110
|
-
return collectGitMetadataModel(productDir);
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
function readPackageMetadata() {
|
|
1114
|
-
return readPackageMetadataModel();
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
function safeHostname() {
|
|
1118
|
-
return safeHostnameModel();
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function safeUsername() {
|
|
1122
|
-
return safeUsernameModel();
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function formatDuration(durationMs) {
|
|
1126
|
-
return formatDurationModel(durationMs);
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function formatServiceSummary(result) {
|
|
1130
|
-
return formatServiceSummaryModel(result);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
function formatError(error) {
|
|
1134
|
-
return formatErrorModel(error);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle) {
|
|
1138
|
-
const k6Binary = resolveK6Binary();
|
|
1139
|
-
const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
|
|
1140
|
-
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
1141
|
-
const startedAt = Date.now();
|
|
1142
|
-
const result = await execa(k6Binary, [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)], {
|
|
1143
|
-
cwd: targetConfig.productDir,
|
|
1144
|
-
env: buildExecutionEnv(targetConfig),
|
|
1145
|
-
reject: false,
|
|
1146
|
-
cancelSignal: lifecycle.signal,
|
|
1147
|
-
forceKillAfterDelay: 5_000,
|
|
1148
|
-
});
|
|
1149
|
-
|
|
1150
|
-
if (result.stdout) process.stdout.write(result.stdout);
|
|
1151
|
-
if (result.stderr) process.stderr.write(result.stderr);
|
|
1152
|
-
|
|
1153
|
-
const summary = readDefaultRuntimeSummary(summaryFile);
|
|
1154
|
-
const runtimeError = determineDefaultRuntimeFailure(result, summary);
|
|
1155
|
-
const finishedAt = Date.now();
|
|
1156
|
-
|
|
1157
|
-
return {
|
|
1158
|
-
task,
|
|
1159
|
-
failed: runtimeError !== null,
|
|
1160
|
-
error: runtimeError,
|
|
1161
|
-
durationMs: finishedAt - startedAt,
|
|
1162
|
-
startedAt,
|
|
1163
|
-
finishedAt,
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
function buildDefaultRuntimeSummaryPath(targetConfig, task) {
|
|
1168
|
-
return path.join(
|
|
1169
|
-
targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
|
|
1170
|
-
"_runtime",
|
|
1171
|
-
`task-${task.id}.summary.json`
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
function readDefaultRuntimeSummary(filePath) {
|
|
1176
|
-
try {
|
|
1177
|
-
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
1178
|
-
} catch {
|
|
1179
|
-
return null;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
function determineDefaultRuntimeFailure(result, summary) {
|
|
1184
|
-
const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "");
|
|
1185
|
-
if (fatalRuntimeError) {
|
|
1186
|
-
return `Default runtime uncaught error: ${fatalRuntimeError}`;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const failedThresholds = extractDefaultRuntimeThresholdFailures(summary);
|
|
1190
|
-
if (failedThresholds.length > 0) {
|
|
1191
|
-
return `Default runtime thresholds failed: ${failedThresholds.join(", ")}`;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
if (result.exitCode !== 0) {
|
|
1195
|
-
return sanitizeDefaultRuntimeExitError(result.exitCode, result.stderr || result.stdout || "");
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
return null;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function extractDefaultRuntimeFatalError(stderr) {
|
|
1202
|
-
if (!stderr || !/source=stacktrace/.test(stderr)) return null;
|
|
1203
|
-
const matched = stderr.match(/Error:\s([^\n]+)/);
|
|
1204
|
-
return matched?.[1]?.trim() || firstLine(stderr);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
function extractDefaultRuntimeThresholdFailures(summary) {
|
|
1208
|
-
const metrics = summary?.metrics;
|
|
1209
|
-
if (!metrics || typeof metrics !== "object") return [];
|
|
1210
|
-
|
|
1211
|
-
const failures = [];
|
|
1212
|
-
for (const [metricName, metricSummary] of Object.entries(metrics)) {
|
|
1213
|
-
const thresholds = metricSummary?.thresholds;
|
|
1214
|
-
if (!thresholds || typeof thresholds !== "object") continue;
|
|
1215
|
-
for (const [threshold, crossed] of Object.entries(thresholds)) {
|
|
1216
|
-
if (crossed === true) {
|
|
1217
|
-
failures.push(`${metricName}(${threshold})`);
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
return failures.sort();
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
function sanitizeDefaultRuntimeExitError(exitCode, output) {
|
|
1226
|
-
const message = firstLine(output);
|
|
1227
|
-
if (message) {
|
|
1228
|
-
return `Default runtime failed with exit code ${exitCode}: ${message}`;
|
|
1229
|
-
}
|
|
1230
|
-
return `Default runtime failed with exit code ${exitCode}`;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
function findUnmatchedRequestedFiles(configs, suiteType, suiteNames, framework, fileNames) {
|
|
1234
|
-
const matchedFiles = new Set();
|
|
1235
|
-
for (const config of configs) {
|
|
1236
|
-
const suites = collectSuites(config, suiteType, suiteNames, framework, []);
|
|
1237
|
-
for (const suite of suites) {
|
|
1238
|
-
for (const file of suite.files) {
|
|
1239
|
-
matchedFiles.add(normalizePathSeparators(file));
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
return [...new Set(fileNames.map(normalizePathSeparators))].filter((file) => !matchedFiles.has(file));
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
function isFullRunSelection(suiteType, suiteNames, fileNames, opts) {
|
|
1248
|
-
return (
|
|
1249
|
-
(suiteNames || []).length === 0 &&
|
|
1250
|
-
(fileNames || []).length === 0 &&
|
|
1251
|
-
(opts.framework || "all") === "all" &&
|
|
1252
|
-
(opts.shard || null) === null &&
|
|
1253
|
-
(opts.serviceFilter || null) === null
|
|
1254
|
-
);
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function loadTimings(productDir) {
|
|
1258
|
-
const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
|
|
1259
|
-
if (!fs.existsSync(filePath)) {
|
|
1260
|
-
return createEmptyTimings();
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
try {
|
|
1264
|
-
return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
1265
|
-
} catch {
|
|
1266
|
-
return createEmptyTimings();
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
function saveTimings(productDir, timings, updates) {
|
|
1271
|
-
if (updates.length === 0) return;
|
|
1272
|
-
const next = applyTimingUpdates(timings, updates);
|
|
1273
|
-
|
|
1274
|
-
const rootDir = path.join(productDir, ".testkit");
|
|
1275
|
-
fs.mkdirSync(rootDir, { recursive: true });
|
|
1276
|
-
fs.writeFileSync(
|
|
1277
|
-
path.join(rootDir, TIMINGS_FILENAME),
|
|
1278
|
-
JSON.stringify(next, null, 2)
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
function estimateTaskDuration(timings, timingKey, suite) {
|
|
1283
|
-
return estimateTaskDurationModel(timings, timingKey, suite);
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
function buildTimingKey(serviceName, suite, file) {
|
|
1287
|
-
return buildTimingKeyModel(serviceName, suite, file);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
function parsePlaywrightJsonResults(stdout, cwd) {
|
|
1291
|
-
return parsePlaywrightJsonResultsModel(stdout, cwd);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
|
|
1295
|
-
return visitPlaywrightSuitesModel(suites, inheritedFile, fileResults, cwd);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
1299
|
-
return collectPlaywrightSpecModel(spec, inheritedFile, fileResults, cwd);
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
function choosePlaywrightFinalResult(results) {
|
|
1303
|
-
return choosePlaywrightFinalResultModel(results);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
function isPlaywrightPassingStatus(status) {
|
|
1307
|
-
return isPlaywrightPassingStatusModel(status);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function extractPlaywrightFailure(finalResult, spec, test) {
|
|
1311
|
-
return extractPlaywrightFailureModel(finalResult, spec, test);
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function formatPlaywrightReporterError(error) {
|
|
1315
|
-
return formatPlaywrightReporterErrorModel(error);
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
function extractReporterFile(node) {
|
|
1319
|
-
return extractReporterFileModel(node);
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
function normalizeReportedFile(filePath, cwd) {
|
|
1323
|
-
return normalizeReportedFileModel(filePath, cwd);
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
function firstLine(value) {
|
|
1327
|
-
return firstLineModel(value);
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
function printBufferedOutput(output, prefix) {
|
|
1331
|
-
for (const line of output.split(/\r?\n/)) {
|
|
1332
|
-
if (line.trim().length > 0) {
|
|
1333
|
-
console.log(`${prefix} ${line}`);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
1339
|
-
return resolveRuntimeUrlModel(rawUrl, serviceName, targetConfig, workerId, context);
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
function finalizeString(value, context) {
|
|
1343
|
-
return finalizeStringModel(value, context);
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
function resolveTemplateString(value, context) {
|
|
1347
|
-
return resolveTemplateStringModel(value, context);
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
function rewriteUrlPort(rawUrl, port) {
|
|
1351
|
-
return rewriteUrlPortModel(rawUrl, port);
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
function numericPortFromUrl(rawUrl) {
|
|
1355
|
-
return numericPortFromUrlModel(rawUrl);
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
async function assertLocalServicePortsAvailable(config) {
|
|
1359
|
-
const endpoints = [config.testkit.local.baseUrl, config.testkit.local.readyUrl];
|
|
1360
|
-
const seen = new Set();
|
|
1361
|
-
|
|
1362
|
-
for (const endpoint of endpoints) {
|
|
1363
|
-
const socket = socketFromUrl(endpoint);
|
|
1364
|
-
if (!socket) continue;
|
|
1365
|
-
|
|
1366
|
-
const key = `${socket.host}:${socket.port}`;
|
|
1367
|
-
if (seen.has(key)) continue;
|
|
1368
|
-
seen.add(key);
|
|
1369
|
-
|
|
1370
|
-
if (await isPortInUse(socket)) {
|
|
1371
|
-
await cleanupStaleRuns(config.productDir);
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
if (await isPortInUse(socket)) {
|
|
1375
|
-
const owner = findPortOwner(config.productDir, socket);
|
|
1376
|
-
const ownerDetail = owner
|
|
1377
|
-
? owner.active
|
|
1378
|
-
? ` Active testkit run ${formatRunSummary(owner.manifest)} owns ${key} via ${owner.service.workerLabel}:${owner.service.serviceName}.`
|
|
1379
|
-
: ` Stale testkit run ${formatRunSummary(owner.manifest)} owns ${key}.`
|
|
1380
|
-
: "";
|
|
1381
|
-
throw new Error(
|
|
1382
|
-
`Cannot start "${config.workerLabel}:${config.name}" because ${key} is already in use. ` +
|
|
1383
|
-
`Stop the existing process and rerun testkit.${ownerDetail}`
|
|
1384
|
-
);
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
function printRunStatus(productDir) {
|
|
1390
|
-
const manifests = listRunManifests(productDir);
|
|
1391
|
-
if (manifests.length === 0) return;
|
|
1392
|
-
|
|
1393
|
-
console.log(" runs/");
|
|
1394
|
-
for (const manifest of manifests) {
|
|
1395
|
-
const state = isPidRunning(manifest.pid) ? "active" : "stale";
|
|
1396
|
-
const ports = [...new Set((manifest.services || []).flatMap((service) => (service.ports || []).map((socket) => `${socket.host}:${socket.port}`)))];
|
|
1397
|
-
console.log(
|
|
1398
|
-
` ${manifest.runId}: ${state} pid=${manifest.pid}${ports.length > 0 ? ` ports=${ports.join(",")}` : ""}`
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
function socketFromUrl(rawUrl) {
|
|
1404
|
-
return socketFromUrlModel(rawUrl);
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
function normalizeSocketHost(hostname) {
|
|
1408
|
-
return normalizeSocketHostModel(hostname);
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
async function isPortInUse({ host, port }) {
|
|
1412
|
-
return new Promise((resolve, reject) => {
|
|
1413
|
-
const socket = new net.Socket();
|
|
1414
|
-
let settled = false;
|
|
1415
|
-
|
|
1416
|
-
const finish = (value, error = null) => {
|
|
1417
|
-
if (settled) return;
|
|
1418
|
-
settled = true;
|
|
1419
|
-
socket.destroy();
|
|
1420
|
-
if (error) {
|
|
1421
|
-
reject(error);
|
|
1422
|
-
return;
|
|
1423
|
-
}
|
|
1424
|
-
resolve(value);
|
|
1425
|
-
};
|
|
1426
|
-
|
|
1427
|
-
socket.setTimeout(1_000);
|
|
1428
|
-
socket.once("connect", () => finish(true));
|
|
1429
|
-
socket.once("timeout", () => finish(false));
|
|
1430
|
-
socket.once("error", (error) => {
|
|
1431
|
-
if (["ECONNREFUSED", "EHOSTUNREACH", "ENOTFOUND"].includes(error.code)) {
|
|
1432
|
-
finish(false);
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
finish(false, error);
|
|
1436
|
-
});
|
|
1437
|
-
|
|
1438
|
-
socket.connect(port, host);
|
|
1439
|
-
});
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
function killChildProcess(child, signal) {
|
|
1443
|
-
if (!child?.pid) return;
|
|
1444
|
-
|
|
1445
|
-
try {
|
|
1446
|
-
process.kill(-child.pid, signal);
|
|
1447
|
-
return;
|
|
1448
|
-
} catch (error) {
|
|
1449
|
-
if (error?.code !== "ESRCH") {
|
|
1450
|
-
// Fall back to the direct child if process-group signalling is unavailable.
|
|
1451
|
-
} else {
|
|
1452
|
-
return;
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
try {
|
|
1457
|
-
child.kill(signal);
|
|
1458
|
-
} catch (error) {
|
|
1459
|
-
if (error?.code !== "ESRCH") throw error;
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
function startDetachedCommand(command, cwd, env) {
|
|
1464
|
-
if (process.platform === "win32") {
|
|
1465
|
-
return spawn(command, {
|
|
1466
|
-
cwd,
|
|
1467
|
-
env,
|
|
1468
|
-
detached: true,
|
|
1469
|
-
shell: true,
|
|
1470
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1471
|
-
});
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
const shell = process.env.SHELL || "/bin/sh";
|
|
1475
|
-
return spawn(shell, ["-lc", `exec ${command}`], {
|
|
1476
|
-
cwd,
|
|
1477
|
-
env,
|
|
1478
|
-
detached: true,
|
|
1479
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
function readDatabaseUrl(stateDir) {
|
|
1484
|
-
return readStateValue(path.join(stateDir, "database_url"));
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
function readStateValue(filePath) {
|
|
1488
|
-
if (!fs.existsSync(filePath)) return null;
|
|
1489
|
-
return fs.readFileSync(filePath, "utf8").trim();
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
function printStateDir(dir, indent) {
|
|
1493
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1494
|
-
const filePath = path.join(dir, entry.name);
|
|
1495
|
-
if (entry.isDirectory()) {
|
|
1496
|
-
console.log(`${indent}${entry.name}/`);
|
|
1497
|
-
printStateDir(filePath, `${indent} `);
|
|
1498
|
-
continue;
|
|
1499
|
-
}
|
|
1500
|
-
const value =
|
|
1501
|
-
entry.name === "password" ? "********" : fs.readFileSync(filePath, "utf8").trim();
|
|
1502
|
-
console.log(`${indent}${entry.name}: ${value}`);
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
function pipeOutput(stream, prefix) {
|
|
1507
|
-
if (!stream) return Promise.resolve();
|
|
1508
|
-
|
|
1509
|
-
let pending = "";
|
|
1510
|
-
return new Promise((resolve) => {
|
|
1511
|
-
let settled = false;
|
|
1512
|
-
const settle = () => {
|
|
1513
|
-
if (settled) return;
|
|
1514
|
-
settled = true;
|
|
1515
|
-
resolve();
|
|
1516
|
-
};
|
|
1517
|
-
|
|
1518
|
-
stream.on("data", (chunk) => {
|
|
1519
|
-
pending += chunk.toString();
|
|
1520
|
-
const lines = pending.split(/\r?\n/);
|
|
1521
|
-
pending = lines.pop() || "";
|
|
1522
|
-
for (const line of lines) {
|
|
1523
|
-
if (line.length > 0) console.log(`${prefix} ${line}`);
|
|
1524
|
-
}
|
|
1525
|
-
});
|
|
1526
|
-
stream.on("end", () => {
|
|
1527
|
-
if (pending.length > 0) {
|
|
1528
|
-
console.log(`${prefix} ${pending}`);
|
|
1529
|
-
}
|
|
1530
|
-
settle();
|
|
1531
|
-
});
|
|
1532
|
-
stream.on("close", settle);
|
|
1533
|
-
stream.on("error", settle);
|
|
1534
|
-
});
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
function sleep(ms) {
|
|
1538
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
function findRuntimeStateDirs(rootDir) {
|
|
1542
|
-
return findRuntimeStateDirsModel(rootDir, isDatabaseStateDir);
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function findGraphDirsForService(productDir, serviceName) {
|
|
1546
|
-
return findGraphDirsForServiceModel(productDir, serviceName);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
function writeGraphMetadata(graphDir, graph) {
|
|
1550
|
-
return writeGraphMetadataModel(graphDir, graph);
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
function readGraphMetadata(graphDir) {
|
|
1554
|
-
return readGraphMetadataModel(graphDir);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
function isRuntimeSuperset(candidate, target) {
|
|
1558
|
-
return isRuntimeSupersetModel(candidate, target);
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
function compareGraphsForAssignment(left, right) {
|
|
1562
|
-
return compareGraphsForAssignmentModel(left, right);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
function buildGraphDirName(runtimeNames) {
|
|
1566
|
-
return buildGraphDirNameModel(runtimeNames);
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
function slugSegment(value) {
|
|
1570
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "svc";
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
function normalizePathSeparators(filePath) {
|
|
1574
|
-
return normalizePathSeparatorsModel(filePath);
|
|
1575
|
-
}
|
|
1
|
+
export { runAll } from "./orchestrator.mjs";
|
|
2
|
+
export { destroy, showStatus, cleanup } from "./maintenance.mjs";
|