@elench/testkit 0.1.17 → 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 +36 -15
- 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.mjs → runner/index.mjs} +239 -833
- 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
|
@@ -3,7 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import net from "net";
|
|
5
5
|
import { execa, execaCommand } from "execa";
|
|
6
|
-
import { resolveDalBinary, resolveServiceCwd } from "
|
|
6
|
+
import { resolveDalBinary, resolveServiceCwd } from "../config/index.mjs";
|
|
7
7
|
import {
|
|
8
8
|
cleanupOrphanedLocalInfrastructure,
|
|
9
9
|
destroyRuntimeDatabase,
|
|
@@ -11,28 +11,102 @@ import {
|
|
|
11
11
|
isDatabaseStateDir,
|
|
12
12
|
prepareDatabaseRuntime,
|
|
13
13
|
showServiceDatabaseStatus,
|
|
14
|
-
} from "
|
|
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";
|
|
15
90
|
|
|
16
|
-
const TYPE_ORDER = ["dal", "integration", "e2e", "load"];
|
|
17
91
|
const HTTP_K6_TYPES = new Set(["integration", "e2e", "load"]);
|
|
18
92
|
const DEFAULT_READY_TIMEOUT_MS = 120_000;
|
|
19
|
-
const PORT_STRIDE = 100;
|
|
20
93
|
const TIMINGS_FILENAME = "timings.json";
|
|
21
|
-
const GRAPH_METADATA = "graph.json";
|
|
22
94
|
|
|
23
95
|
export async function runAll(configs, suiteType, suiteNames, opts, allConfigs = configs) {
|
|
24
96
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
25
97
|
const startedAt = Date.now();
|
|
98
|
+
const telemetry = configs[0]?.telemetry || null;
|
|
26
99
|
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
27
100
|
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
28
101
|
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
102
|
+
let workerCount = 0;
|
|
29
103
|
|
|
30
104
|
if (executedPlans.length > 0) {
|
|
31
105
|
const productDir = executedPlans[0].config.productDir;
|
|
32
106
|
const timings = loadTimings(productDir);
|
|
33
107
|
const graphs = buildRuntimeGraphs(executedPlans);
|
|
34
108
|
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
35
|
-
|
|
109
|
+
workerCount = Math.max(1, Math.min(opts.jobs || 1, queue.length));
|
|
36
110
|
const graphByKey = new Map(graphs.map((graph) => [graph.key, graph]));
|
|
37
111
|
const workers = Array.from({ length: workerCount }, (_unused, index) =>
|
|
38
112
|
createWorker(index + 1, productDir)
|
|
@@ -61,8 +135,24 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
61
135
|
const results = configs.map((config) =>
|
|
62
136
|
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
63
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);
|
|
64
153
|
|
|
65
154
|
printRunSummary(results, finishedAt - startedAt);
|
|
155
|
+
await reportTelemetry(telemetry, artifact);
|
|
66
156
|
if (results.some((result) => result.failed)) process.exit(1);
|
|
67
157
|
}
|
|
68
158
|
|
|
@@ -141,161 +231,15 @@ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
|
141
231
|
}
|
|
142
232
|
|
|
143
233
|
function buildServiceTrackers(servicePlans, startedAt) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
for (const plan of servicePlans) {
|
|
147
|
-
if (plan.skipped) {
|
|
148
|
-
trackers.set(plan.config.name, {
|
|
149
|
-
name: plan.config.name,
|
|
150
|
-
skipped: true,
|
|
151
|
-
suiteCount: 0,
|
|
152
|
-
suites: [],
|
|
153
|
-
suitesByKey: new Map(),
|
|
154
|
-
errors: [],
|
|
155
|
-
errorSet: new Set(),
|
|
156
|
-
startedAt,
|
|
157
|
-
firstTaskAt: null,
|
|
158
|
-
lastTaskAt: null,
|
|
159
|
-
});
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const suites = plan.suites.map((suite) => ({
|
|
164
|
-
key: `${suite.type}:${suite.name}`,
|
|
165
|
-
name: suite.name,
|
|
166
|
-
type: suite.type,
|
|
167
|
-
framework: suite.framework,
|
|
168
|
-
orderIndex: suite.orderIndex,
|
|
169
|
-
fileCount: suite.files.length,
|
|
170
|
-
completedFileCount: 0,
|
|
171
|
-
failedFiles: [],
|
|
172
|
-
failedFileSet: new Set(),
|
|
173
|
-
durationMs: 0,
|
|
174
|
-
error: null,
|
|
175
|
-
}));
|
|
176
|
-
|
|
177
|
-
trackers.set(plan.config.name, {
|
|
178
|
-
name: plan.config.name,
|
|
179
|
-
skipped: false,
|
|
180
|
-
suiteCount: suites.length,
|
|
181
|
-
suites,
|
|
182
|
-
suitesByKey: new Map(suites.map((suite) => [suite.key, suite])),
|
|
183
|
-
errors: [],
|
|
184
|
-
errorSet: new Set(),
|
|
185
|
-
startedAt,
|
|
186
|
-
firstTaskAt: null,
|
|
187
|
-
lastTaskAt: null,
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return trackers;
|
|
234
|
+
return buildServiceTrackersModel(servicePlans, startedAt);
|
|
192
235
|
}
|
|
193
236
|
|
|
194
237
|
function buildRuntimeGraphs(servicePlans) {
|
|
195
|
-
|
|
196
|
-
const uniqueGraphs = [];
|
|
197
|
-
const graphByRuntimeKey = new Map();
|
|
198
|
-
|
|
199
|
-
for (const plan of executed) {
|
|
200
|
-
if (graphByRuntimeKey.has(plan.runtimeKey)) {
|
|
201
|
-
graphByRuntimeKey.get(plan.runtimeKey).exactTargets.push(plan.config.name);
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const graph = {
|
|
206
|
-
key: plan.runtimeKey,
|
|
207
|
-
runtimeNames: plan.runtimeNames,
|
|
208
|
-
runtimeConfigs: plan.runtimeConfigs,
|
|
209
|
-
exactTargets: [plan.config.name],
|
|
210
|
-
assignedTargets: [],
|
|
211
|
-
dirName: null,
|
|
212
|
-
rootConfig: null,
|
|
213
|
-
};
|
|
214
|
-
uniqueGraphs.push(graph);
|
|
215
|
-
graphByRuntimeKey.set(plan.runtimeKey, graph);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const maximalGraphs = uniqueGraphs.filter(
|
|
219
|
-
(graph) =>
|
|
220
|
-
!uniqueGraphs.some(
|
|
221
|
-
(other) =>
|
|
222
|
-
other.key !== graph.key &&
|
|
223
|
-
isRuntimeSuperset(other.runtimeNames, graph.runtimeNames)
|
|
224
|
-
)
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
for (const plan of executed) {
|
|
228
|
-
const compatible = maximalGraphs.filter((graph) =>
|
|
229
|
-
isRuntimeSuperset(graph.runtimeNames, plan.runtimeNames)
|
|
230
|
-
);
|
|
231
|
-
if (compatible.length === 0) {
|
|
232
|
-
throw new Error(`No runtime graph found for service "${plan.config.name}"`);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const assigned = compatible.sort(compareGraphsForAssignment)[0];
|
|
236
|
-
plan.assignedGraphKey = assigned.key;
|
|
237
|
-
assigned.assignedTargets.push(plan.config.name);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
for (const graph of maximalGraphs) {
|
|
241
|
-
const rootName = [...graph.exactTargets].sort()[0];
|
|
242
|
-
const rootPlan = executed.find((plan) => plan.config.name === rootName);
|
|
243
|
-
if (!rootPlan) {
|
|
244
|
-
throw new Error(`Missing root plan for graph "${graph.key}"`);
|
|
245
|
-
}
|
|
246
|
-
graph.rootConfig = rootPlan.config;
|
|
247
|
-
graph.dirName = buildGraphDirName(graph.runtimeNames);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return maximalGraphs.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
238
|
+
return buildRuntimeGraphsModel(servicePlans);
|
|
251
239
|
}
|
|
252
240
|
|
|
253
241
|
function buildTaskQueue(servicePlans, graphs, timings) {
|
|
254
|
-
|
|
255
|
-
const tasks = [];
|
|
256
|
-
let nextId = 1;
|
|
257
|
-
|
|
258
|
-
for (const plan of servicePlans) {
|
|
259
|
-
if (plan.skipped) continue;
|
|
260
|
-
|
|
261
|
-
const graph = graphByKey.get(plan.assignedGraphKey);
|
|
262
|
-
if (!graph) {
|
|
263
|
-
throw new Error(`Assigned graph "${plan.assignedGraphKey}" not found for ${plan.config.name}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
for (const suite of plan.suites) {
|
|
267
|
-
for (const file of suite.files) {
|
|
268
|
-
const timingKey = buildTimingKey(plan.config.name, suite, file);
|
|
269
|
-
tasks.push({
|
|
270
|
-
id: nextId,
|
|
271
|
-
graphKey: graph.key,
|
|
272
|
-
targetName: plan.config.name,
|
|
273
|
-
serviceName: plan.config.name,
|
|
274
|
-
suiteKey: `${suite.type}:${suite.name}`,
|
|
275
|
-
suiteName: suite.name,
|
|
276
|
-
type: suite.type,
|
|
277
|
-
framework: suite.framework,
|
|
278
|
-
orderIndex: suite.orderIndex,
|
|
279
|
-
file,
|
|
280
|
-
timingKey,
|
|
281
|
-
estimatedDurationMs: estimateTaskDuration(timings, timingKey, suite),
|
|
282
|
-
maxBatchSize:
|
|
283
|
-
suite.framework === "playwright"
|
|
284
|
-
? Number.POSITIVE_INFINITY
|
|
285
|
-
: suite.maxFileConcurrency || 1,
|
|
286
|
-
});
|
|
287
|
-
nextId += 1;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return tasks.sort(
|
|
293
|
-
(a, b) =>
|
|
294
|
-
b.estimatedDurationMs - a.estimatedDurationMs ||
|
|
295
|
-
a.serviceName.localeCompare(b.serviceName) ||
|
|
296
|
-
a.suiteKey.localeCompare(b.suiteKey) ||
|
|
297
|
-
a.file.localeCompare(b.file)
|
|
298
|
-
);
|
|
242
|
+
return buildTaskQueueModel(servicePlans, graphs, timings);
|
|
299
243
|
}
|
|
300
244
|
|
|
301
245
|
function createWorker(workerId, productDir) {
|
|
@@ -352,58 +296,7 @@ async function runWorker(worker, queue, graphByKey, trackers, timingUpdates) {
|
|
|
352
296
|
}
|
|
353
297
|
|
|
354
298
|
function claimNextBatch(queue, preferredGraphKey) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
let index = -1;
|
|
358
|
-
if (preferredGraphKey) {
|
|
359
|
-
index = queue.findIndex((task) => task.graphKey === preferredGraphKey);
|
|
360
|
-
}
|
|
361
|
-
if (index === -1) index = 0;
|
|
362
|
-
|
|
363
|
-
const seed = queue.splice(index, 1)[0];
|
|
364
|
-
const tasks = [seed];
|
|
365
|
-
|
|
366
|
-
if (seed.framework === "playwright") {
|
|
367
|
-
for (let cursor = queue.length - 1; cursor >= 0; cursor -= 1) {
|
|
368
|
-
const candidate = queue[cursor];
|
|
369
|
-
if (
|
|
370
|
-
candidate.framework === "playwright" &&
|
|
371
|
-
candidate.graphKey === seed.graphKey &&
|
|
372
|
-
candidate.targetName === seed.targetName
|
|
373
|
-
) {
|
|
374
|
-
tasks.push(candidate);
|
|
375
|
-
queue.splice(cursor, 1);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
} else if (seed.maxBatchSize > 1) {
|
|
379
|
-
for (let cursor = queue.length - 1; cursor >= 0 && tasks.length < seed.maxBatchSize; cursor -= 1) {
|
|
380
|
-
const candidate = queue[cursor];
|
|
381
|
-
if (
|
|
382
|
-
candidate.framework === seed.framework &&
|
|
383
|
-
candidate.type === seed.type &&
|
|
384
|
-
candidate.graphKey === seed.graphKey &&
|
|
385
|
-
candidate.targetName === seed.targetName &&
|
|
386
|
-
candidate.suiteKey === seed.suiteKey
|
|
387
|
-
) {
|
|
388
|
-
tasks.push(candidate);
|
|
389
|
-
queue.splice(cursor, 1);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
tasks.sort(
|
|
395
|
-
(a, b) =>
|
|
396
|
-
a.orderIndex - b.orderIndex ||
|
|
397
|
-
a.file.localeCompare(b.file)
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
return {
|
|
401
|
-
graphKey: seed.graphKey,
|
|
402
|
-
targetName: seed.targetName,
|
|
403
|
-
framework: seed.framework,
|
|
404
|
-
type: seed.type,
|
|
405
|
-
tasks,
|
|
406
|
-
};
|
|
299
|
+
return claimNextBatchModel(queue, preferredGraphKey);
|
|
407
300
|
}
|
|
408
301
|
|
|
409
302
|
async function ensureWorkerGraph(worker, batch, graphByKey) {
|
|
@@ -812,168 +705,31 @@ async function waitForReady({ name, url, timeoutMs, process }) {
|
|
|
812
705
|
}
|
|
813
706
|
|
|
814
707
|
function batchNeedsLocalRuntime(batch) {
|
|
815
|
-
return batch
|
|
708
|
+
return batchNeedsLocalRuntimeModel(batch);
|
|
816
709
|
}
|
|
817
710
|
|
|
818
711
|
function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
819
|
-
|
|
820
|
-
const visiting = new Set();
|
|
821
|
-
const seen = new Set();
|
|
822
|
-
|
|
823
|
-
const visit = (config) => {
|
|
824
|
-
if (seen.has(config.name)) return;
|
|
825
|
-
if (visiting.has(config.name)) {
|
|
826
|
-
throw new Error(`Dependency cycle detected involving "${config.name}"`);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
visiting.add(config.name);
|
|
830
|
-
for (const depName of config.testkit.dependsOn || []) {
|
|
831
|
-
const dep = configMap.get(depName);
|
|
832
|
-
if (!dep) {
|
|
833
|
-
throw new Error(`Service "${config.name}" depends on unknown service "${depName}"`);
|
|
834
|
-
}
|
|
835
|
-
visit(dep);
|
|
836
|
-
}
|
|
837
|
-
visiting.delete(config.name);
|
|
838
|
-
seen.add(config.name);
|
|
839
|
-
ordered.push(config);
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
visit(targetConfig);
|
|
843
|
-
return ordered;
|
|
712
|
+
return resolveRuntimeConfigsModel(targetConfig, configMap);
|
|
844
713
|
}
|
|
845
714
|
|
|
846
715
|
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
847
|
-
|
|
848
|
-
suiteType === "all"
|
|
849
|
-
? orderedTypes(Object.keys(config.suites))
|
|
850
|
-
: [suiteType === "int" ? "integration" : suiteType];
|
|
851
|
-
|
|
852
|
-
const selectedNames = new Set(suiteNames);
|
|
853
|
-
const suites = [];
|
|
854
|
-
let orderIndex = 0;
|
|
855
|
-
|
|
856
|
-
for (const type of types) {
|
|
857
|
-
for (const suite of config.suites[type] || []) {
|
|
858
|
-
const framework = suite.framework || "k6";
|
|
859
|
-
if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
|
|
860
|
-
if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
|
|
861
|
-
|
|
862
|
-
suites.push({
|
|
863
|
-
...suite,
|
|
864
|
-
framework,
|
|
865
|
-
type,
|
|
866
|
-
orderIndex,
|
|
867
|
-
sortKey: `${type}:${suite.name}`,
|
|
868
|
-
weight:
|
|
869
|
-
suite.testkit?.weight ||
|
|
870
|
-
(framework === "playwright"
|
|
871
|
-
? Math.max(2, suite.files.length)
|
|
872
|
-
: Math.max(1, suite.files.length)),
|
|
873
|
-
maxFileConcurrency:
|
|
874
|
-
framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
|
|
875
|
-
});
|
|
876
|
-
orderIndex += 1;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
return suites;
|
|
716
|
+
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
|
|
881
717
|
}
|
|
882
718
|
|
|
883
719
|
function applyShard(suites, shard) {
|
|
884
|
-
|
|
885
|
-
return suites.filter((unused, index) => index % shard.total === shard.index - 1);
|
|
720
|
+
return applyShardModel(suites, shard);
|
|
886
721
|
}
|
|
887
722
|
|
|
888
723
|
function orderedTypes(types) {
|
|
889
|
-
|
|
890
|
-
for (const known of TYPE_ORDER) {
|
|
891
|
-
if (types.includes(known)) ordered.push(known);
|
|
892
|
-
}
|
|
893
|
-
for (const type of types) {
|
|
894
|
-
if (!ordered.includes(type)) ordered.push(type);
|
|
895
|
-
}
|
|
896
|
-
return ordered;
|
|
724
|
+
return orderedTypesModel(types);
|
|
897
725
|
}
|
|
898
726
|
|
|
899
727
|
function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, workerId, workerStateDir) {
|
|
900
|
-
|
|
901
|
-
const baseUrlByService = new Map();
|
|
902
|
-
const readyUrlByService = new Map();
|
|
903
|
-
|
|
904
|
-
for (const config of runtimeConfigs) {
|
|
905
|
-
if (!config.testkit.local) continue;
|
|
906
|
-
baseUrlByService.set(
|
|
907
|
-
config.name,
|
|
908
|
-
resolveRuntimeUrl(config.testkit.local.baseUrl, config.name, targetConfig, workerId, {
|
|
909
|
-
workerStateDir,
|
|
910
|
-
portMap,
|
|
911
|
-
baseUrlByService,
|
|
912
|
-
readyUrlByService,
|
|
913
|
-
})
|
|
914
|
-
);
|
|
915
|
-
readyUrlByService.set(
|
|
916
|
-
config.name,
|
|
917
|
-
resolveRuntimeUrl(config.testkit.local.readyUrl, config.name, targetConfig, workerId, {
|
|
918
|
-
workerStateDir,
|
|
919
|
-
portMap,
|
|
920
|
-
baseUrlByService,
|
|
921
|
-
readyUrlByService,
|
|
922
|
-
})
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
const urlMappings = [];
|
|
927
|
-
for (const config of runtimeConfigs) {
|
|
928
|
-
if (!config.testkit.local) continue;
|
|
929
|
-
const resolvedBaseUrl = baseUrlByService.get(config.name);
|
|
930
|
-
const resolvedReadyUrl = readyUrlByService.get(config.name);
|
|
931
|
-
if (resolvedBaseUrl) {
|
|
932
|
-
urlMappings.push([config.testkit.local.baseUrl, resolvedBaseUrl]);
|
|
933
|
-
}
|
|
934
|
-
if (resolvedReadyUrl) {
|
|
935
|
-
urlMappings.push([config.testkit.local.readyUrl, resolvedReadyUrl]);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
return runtimeConfigs.map((config) =>
|
|
940
|
-
resolveWorkerConfig(
|
|
941
|
-
config,
|
|
942
|
-
targetConfig,
|
|
943
|
-
workerId,
|
|
944
|
-
workerStateDir,
|
|
945
|
-
portMap,
|
|
946
|
-
baseUrlByService,
|
|
947
|
-
readyUrlByService,
|
|
948
|
-
urlMappings
|
|
949
|
-
)
|
|
950
|
-
);
|
|
728
|
+
return resolveWorkerRuntimeConfigsModel(targetConfig, runtimeConfigs, workerId, workerStateDir);
|
|
951
729
|
}
|
|
952
730
|
|
|
953
731
|
function buildPortMap(runtimeConfigs, workerId) {
|
|
954
|
-
|
|
955
|
-
const seen = new Map();
|
|
956
|
-
const offset = PORT_STRIDE * (workerId - 1);
|
|
957
|
-
|
|
958
|
-
for (const config of runtimeConfigs) {
|
|
959
|
-
if (!config.testkit.local) continue;
|
|
960
|
-
|
|
961
|
-
const basePort = config.testkit.local.port || numericPortFromUrl(config.testkit.local.baseUrl);
|
|
962
|
-
if (!basePort) continue;
|
|
963
|
-
|
|
964
|
-
const actualPort = basePort + offset;
|
|
965
|
-
const existing = seen.get(actualPort);
|
|
966
|
-
if (existing) {
|
|
967
|
-
throw new Error(
|
|
968
|
-
`Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
|
|
969
|
-
`Assign distinct local.port/baseUrl ports in testkit.config.json.`
|
|
970
|
-
);
|
|
971
|
-
}
|
|
972
|
-
seen.set(actualPort, config.name);
|
|
973
|
-
portMap.set(config.name, actualPort);
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
return portMap;
|
|
732
|
+
return buildPortMapModel(runtimeConfigs, workerId);
|
|
977
733
|
}
|
|
978
734
|
|
|
979
735
|
function resolveWorkerConfig(
|
|
@@ -986,210 +742,48 @@ function resolveWorkerConfig(
|
|
|
986
742
|
readyUrlByService,
|
|
987
743
|
urlMappings
|
|
988
744
|
) {
|
|
989
|
-
|
|
990
|
-
|
|
745
|
+
return resolveWorkerConfigModel(
|
|
746
|
+
config,
|
|
747
|
+
targetConfig,
|
|
991
748
|
workerId,
|
|
992
|
-
|
|
993
|
-
targetName: targetConfig.name,
|
|
994
|
-
serviceStateDir: stateDir,
|
|
749
|
+
workerStateDir,
|
|
995
750
|
portMap,
|
|
996
751
|
baseUrlByService,
|
|
997
752
|
readyUrlByService,
|
|
998
|
-
urlMappings
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const database = config.testkit.database
|
|
1002
|
-
? {
|
|
1003
|
-
...config.testkit.database,
|
|
1004
|
-
branchName:
|
|
1005
|
-
config.testkit.database.provider === "neon" &&
|
|
1006
|
-
config.testkit.database.branchName !== undefined
|
|
1007
|
-
? finalizeString(config.testkit.database.branchName, context)
|
|
1008
|
-
: config.testkit.database.provider === "neon"
|
|
1009
|
-
? `${targetConfig.name}-${config.name}-w${workerId}-testkit`
|
|
1010
|
-
: undefined,
|
|
1011
|
-
}
|
|
1012
|
-
: undefined;
|
|
1013
|
-
|
|
1014
|
-
const migrate = config.testkit.migrate
|
|
1015
|
-
? {
|
|
1016
|
-
...config.testkit.migrate,
|
|
1017
|
-
cmd: finalizeString(config.testkit.migrate.cmd, context),
|
|
1018
|
-
cwd:
|
|
1019
|
-
config.testkit.migrate.cwd !== undefined
|
|
1020
|
-
? finalizeString(config.testkit.migrate.cwd, context)
|
|
1021
|
-
: config.testkit.migrate.cwd,
|
|
1022
|
-
}
|
|
1023
|
-
: undefined;
|
|
1024
|
-
|
|
1025
|
-
const seed = config.testkit.seed
|
|
1026
|
-
? {
|
|
1027
|
-
...config.testkit.seed,
|
|
1028
|
-
cmd: finalizeString(config.testkit.seed.cmd, context),
|
|
1029
|
-
cwd:
|
|
1030
|
-
config.testkit.seed.cwd !== undefined
|
|
1031
|
-
? finalizeString(config.testkit.seed.cwd, context)
|
|
1032
|
-
: config.testkit.seed.cwd,
|
|
1033
|
-
}
|
|
1034
|
-
: undefined;
|
|
1035
|
-
|
|
1036
|
-
const local = config.testkit.local
|
|
1037
|
-
? {
|
|
1038
|
-
...config.testkit.local,
|
|
1039
|
-
start: finalizeString(config.testkit.local.start, context),
|
|
1040
|
-
cwd:
|
|
1041
|
-
config.testkit.local.cwd !== undefined
|
|
1042
|
-
? finalizeString(config.testkit.local.cwd, context)
|
|
1043
|
-
: config.testkit.local.cwd,
|
|
1044
|
-
port: portMap.get(config.name) || config.testkit.local.port,
|
|
1045
|
-
baseUrl: baseUrlByService.get(config.name),
|
|
1046
|
-
readyUrl: readyUrlByService.get(config.name),
|
|
1047
|
-
env: Object.fromEntries(
|
|
1048
|
-
Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
|
|
1049
|
-
key,
|
|
1050
|
-
finalizeString(String(value), context),
|
|
1051
|
-
])
|
|
1052
|
-
),
|
|
1053
|
-
}
|
|
1054
|
-
: undefined;
|
|
1055
|
-
|
|
1056
|
-
return {
|
|
1057
|
-
...config,
|
|
1058
|
-
stateDir,
|
|
1059
|
-
workerId,
|
|
1060
|
-
workerLabel: `w${workerId}`,
|
|
1061
|
-
targetName: targetConfig.name,
|
|
1062
|
-
testkit: {
|
|
1063
|
-
...config.testkit,
|
|
1064
|
-
database,
|
|
1065
|
-
migrate,
|
|
1066
|
-
seed,
|
|
1067
|
-
local,
|
|
1068
|
-
},
|
|
1069
|
-
};
|
|
753
|
+
urlMappings
|
|
754
|
+
);
|
|
1070
755
|
}
|
|
1071
756
|
|
|
1072
757
|
function resolveServiceStateDir(workerStateDir, targetName, config) {
|
|
1073
|
-
|
|
1074
|
-
return getWorkerServiceStateDir(workerStateDir, targetName, dbSource);
|
|
758
|
+
return resolveServiceStateDirModel(workerStateDir, targetName, config);
|
|
1075
759
|
}
|
|
1076
760
|
|
|
1077
761
|
function getWorkerServiceStateDir(workerStateDir, targetName, serviceName) {
|
|
1078
|
-
|
|
1079
|
-
return workerStateDir;
|
|
1080
|
-
}
|
|
1081
|
-
return path.join(workerStateDir, "deps", serviceName);
|
|
762
|
+
return getWorkerServiceStateDirModel(workerStateDir, targetName, serviceName);
|
|
1082
763
|
}
|
|
1083
764
|
|
|
1084
765
|
function buildExecutionEnv(config, extraEnv = {}) {
|
|
1085
|
-
return
|
|
1086
|
-
...process.env,
|
|
1087
|
-
...(config.testkit.serviceEnv || {}),
|
|
1088
|
-
...extraEnv,
|
|
1089
|
-
};
|
|
766
|
+
return buildExecutionEnvModel(config, extraEnv, process.env);
|
|
1090
767
|
}
|
|
1091
768
|
|
|
1092
769
|
function buildPlaywrightEnv(config, baseUrl) {
|
|
1093
|
-
return
|
|
1094
|
-
BASE_URL: baseUrl,
|
|
1095
|
-
PLAYWRIGHT_HTML_OPEN: "never",
|
|
1096
|
-
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
|
|
1097
|
-
process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
|
|
1098
|
-
TESTKIT_MANAGED_SERVERS: "1",
|
|
1099
|
-
TESTKIT_WORKER_ID: String(config.workerId),
|
|
1100
|
-
});
|
|
770
|
+
return buildPlaywrightEnvModel(config, baseUrl, process.env);
|
|
1101
771
|
}
|
|
1102
772
|
|
|
1103
773
|
function recordTaskOutcome(trackers, task, outcome) {
|
|
1104
|
-
|
|
1105
|
-
if (!tracker || tracker.skipped) return;
|
|
1106
|
-
|
|
1107
|
-
const finishedAt = Date.now();
|
|
1108
|
-
if (!tracker.firstTaskAt) tracker.firstTaskAt = finishedAt;
|
|
1109
|
-
tracker.lastTaskAt = finishedAt;
|
|
1110
|
-
|
|
1111
|
-
const suite = tracker.suitesByKey.get(task.suiteKey);
|
|
1112
|
-
if (!suite) return;
|
|
1113
|
-
|
|
1114
|
-
suite.completedFileCount += 1;
|
|
1115
|
-
suite.durationMs += outcome.durationMs;
|
|
1116
|
-
if (outcome.failed && !suite.failedFileSet.has(task.file)) {
|
|
1117
|
-
suite.failedFileSet.add(task.file);
|
|
1118
|
-
suite.failedFiles.push(task.file);
|
|
1119
|
-
}
|
|
1120
|
-
if (outcome.error && !suite.error) {
|
|
1121
|
-
suite.error = outcome.error;
|
|
1122
|
-
}
|
|
774
|
+
return recordTaskOutcomeModel(trackers, task, outcome);
|
|
1123
775
|
}
|
|
1124
776
|
|
|
1125
777
|
function recordGraphError(trackers, graph, message) {
|
|
1126
|
-
|
|
1127
|
-
for (const targetName of targetNames) {
|
|
1128
|
-
const tracker = trackers.get(targetName);
|
|
1129
|
-
if (tracker && !tracker.skipped) {
|
|
1130
|
-
addTrackerError(tracker, message);
|
|
1131
|
-
tracker.lastTaskAt = Date.now();
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
778
|
+
return recordGraphErrorModel(trackers, graph, message);
|
|
1134
779
|
}
|
|
1135
780
|
|
|
1136
781
|
function addTrackerError(tracker, message) {
|
|
1137
|
-
|
|
1138
|
-
tracker.errorSet.add(message);
|
|
1139
|
-
tracker.errors.push(message);
|
|
782
|
+
return addTrackerErrorModel(tracker, message);
|
|
1140
783
|
}
|
|
1141
784
|
|
|
1142
785
|
function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
1143
|
-
|
|
1144
|
-
return {
|
|
1145
|
-
name: tracker?.name || "unknown",
|
|
1146
|
-
failed: false,
|
|
1147
|
-
skipped: true,
|
|
1148
|
-
suiteCount: 0,
|
|
1149
|
-
completedSuiteCount: 0,
|
|
1150
|
-
failedSuiteCount: 0,
|
|
1151
|
-
durationMs: 0,
|
|
1152
|
-
suites: [],
|
|
1153
|
-
errors: [],
|
|
1154
|
-
};
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const suites = [...tracker.suites].sort(
|
|
1158
|
-
(a, b) => a.orderIndex - b.orderIndex || a.name.localeCompare(b.name)
|
|
1159
|
-
);
|
|
1160
|
-
const completedSuiteCount = suites.filter(
|
|
1161
|
-
(suite) => suite.completedFileCount === suite.fileCount
|
|
1162
|
-
).length;
|
|
1163
|
-
const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
|
|
1164
|
-
const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
|
|
1165
|
-
const durationMs =
|
|
1166
|
-
tracker.firstTaskAt && tracker.lastTaskAt
|
|
1167
|
-
? Math.max(tracker.lastTaskAt - tracker.firstTaskAt, accumulatedDurationMs)
|
|
1168
|
-
: Math.max(finishedAt - startedAt, accumulatedDurationMs);
|
|
1169
|
-
|
|
1170
|
-
return {
|
|
1171
|
-
name: tracker.name,
|
|
1172
|
-
failed:
|
|
1173
|
-
failedSuiteCount > 0 ||
|
|
1174
|
-
tracker.errors.length > 0 ||
|
|
1175
|
-
completedSuiteCount < tracker.suiteCount,
|
|
1176
|
-
skipped: false,
|
|
1177
|
-
suiteCount: tracker.suiteCount,
|
|
1178
|
-
completedSuiteCount,
|
|
1179
|
-
failedSuiteCount,
|
|
1180
|
-
durationMs,
|
|
1181
|
-
suites: suites.map((suite) => ({
|
|
1182
|
-
name: suite.name,
|
|
1183
|
-
type: suite.type,
|
|
1184
|
-
framework: suite.framework,
|
|
1185
|
-
failed: suite.failedFiles.length > 0,
|
|
1186
|
-
fileCount: suite.fileCount,
|
|
1187
|
-
failedFiles: suite.failedFiles,
|
|
1188
|
-
durationMs: suite.durationMs,
|
|
1189
|
-
error: suite.error,
|
|
1190
|
-
})),
|
|
1191
|
-
errors: tracker.errors,
|
|
1192
|
-
};
|
|
786
|
+
return finalizeServiceResultModel(tracker, startedAt, finishedAt);
|
|
1193
787
|
}
|
|
1194
788
|
|
|
1195
789
|
function printRunSummary(results, durationMs) {
|
|
@@ -1251,86 +845,121 @@ function printRunSummary(results, durationMs) {
|
|
|
1251
845
|
console.log("\nResult: PASSED");
|
|
1252
846
|
}
|
|
1253
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
|
+
|
|
1254
869
|
function longestServiceName(results) {
|
|
1255
|
-
return 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();
|
|
1256
933
|
}
|
|
1257
934
|
|
|
1258
935
|
function formatDuration(durationMs) {
|
|
1259
|
-
|
|
1260
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
1261
|
-
const seconds = totalSeconds % 60;
|
|
1262
|
-
if (minutes === 0) return `${seconds}s`;
|
|
1263
|
-
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
936
|
+
return formatDurationModel(durationMs);
|
|
1264
937
|
}
|
|
1265
938
|
|
|
1266
939
|
function formatServiceSummary(result) {
|
|
1267
|
-
|
|
1268
|
-
const notRun = result.suiteCount - result.completedSuiteCount;
|
|
1269
|
-
let detail = `${passedSuites}/${result.suiteCount} suites passed`;
|
|
1270
|
-
if (notRun > 0) {
|
|
1271
|
-
detail += `, ${notRun} not run`;
|
|
1272
|
-
}
|
|
1273
|
-
return detail;
|
|
940
|
+
return formatServiceSummaryModel(result);
|
|
1274
941
|
}
|
|
1275
942
|
|
|
1276
943
|
function formatError(error) {
|
|
1277
|
-
|
|
1278
|
-
return String(error);
|
|
944
|
+
return formatErrorModel(error);
|
|
1279
945
|
}
|
|
1280
946
|
|
|
1281
947
|
function loadTimings(productDir) {
|
|
1282
948
|
const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
|
|
1283
949
|
if (!fs.existsSync(filePath)) {
|
|
1284
|
-
return
|
|
1285
|
-
version: 1,
|
|
1286
|
-
files: {},
|
|
1287
|
-
};
|
|
950
|
+
return createEmptyTimings();
|
|
1288
951
|
}
|
|
1289
952
|
|
|
1290
953
|
try {
|
|
1291
|
-
|
|
1292
|
-
return {
|
|
1293
|
-
version: 1,
|
|
1294
|
-
files: parsed.files && typeof parsed.files === "object" ? parsed.files : {},
|
|
1295
|
-
};
|
|
954
|
+
return normalizeTimings(JSON.parse(fs.readFileSync(filePath, "utf8")));
|
|
1296
955
|
} catch {
|
|
1297
|
-
return
|
|
1298
|
-
version: 1,
|
|
1299
|
-
files: {},
|
|
1300
|
-
};
|
|
956
|
+
return createEmptyTimings();
|
|
1301
957
|
}
|
|
1302
958
|
}
|
|
1303
959
|
|
|
1304
960
|
function saveTimings(productDir, timings, updates) {
|
|
1305
961
|
if (updates.length === 0) return;
|
|
1306
|
-
|
|
1307
|
-
const next = {
|
|
1308
|
-
version: 1,
|
|
1309
|
-
files: { ...timings.files },
|
|
1310
|
-
};
|
|
1311
|
-
|
|
1312
|
-
for (const update of updates) {
|
|
1313
|
-
const existing = next.files[update.key];
|
|
1314
|
-
if (!existing) {
|
|
1315
|
-
next.files[update.key] = {
|
|
1316
|
-
durationMs: Math.max(1, Math.round(update.durationMs)),
|
|
1317
|
-
runs: 1,
|
|
1318
|
-
updatedAt: new Date().toISOString(),
|
|
1319
|
-
};
|
|
1320
|
-
continue;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
const runs = Number(existing.runs || 0) + 1;
|
|
1324
|
-
const durationMs = Math.max(
|
|
1325
|
-
1,
|
|
1326
|
-
Math.round(((existing.durationMs || update.durationMs) * (runs - 1) + update.durationMs) / runs)
|
|
1327
|
-
);
|
|
1328
|
-
next.files[update.key] = {
|
|
1329
|
-
durationMs,
|
|
1330
|
-
runs,
|
|
1331
|
-
updatedAt: new Date().toISOString(),
|
|
1332
|
-
};
|
|
1333
|
-
}
|
|
962
|
+
const next = applyTimingUpdates(timings, updates);
|
|
1334
963
|
|
|
1335
964
|
const rootDir = path.join(productDir, ".testkit");
|
|
1336
965
|
fs.mkdirSync(rootDir, { recursive: true });
|
|
@@ -1341,140 +970,51 @@ function saveTimings(productDir, timings, updates) {
|
|
|
1341
970
|
}
|
|
1342
971
|
|
|
1343
972
|
function estimateTaskDuration(timings, timingKey, suite) {
|
|
1344
|
-
|
|
1345
|
-
if (cached?.durationMs) return cached.durationMs;
|
|
1346
|
-
|
|
1347
|
-
const base =
|
|
1348
|
-
suite.framework === "playwright"
|
|
1349
|
-
? 20_000
|
|
1350
|
-
: suite.type === "dal"
|
|
1351
|
-
? 4_000
|
|
1352
|
-
: 8_000;
|
|
1353
|
-
return Math.max(1_000, Math.round((base * suite.weight) / Math.max(1, suite.files.length)));
|
|
973
|
+
return estimateTaskDurationModel(timings, timingKey, suite);
|
|
1354
974
|
}
|
|
1355
975
|
|
|
1356
976
|
function buildTimingKey(serviceName, suite, file) {
|
|
1357
|
-
return
|
|
1358
|
-
serviceName,
|
|
1359
|
-
suite.framework,
|
|
1360
|
-
suite.type,
|
|
1361
|
-
normalizePathSeparators(file),
|
|
1362
|
-
].join("|");
|
|
977
|
+
return buildTimingKeyModel(serviceName, suite, file);
|
|
1363
978
|
}
|
|
1364
979
|
|
|
1365
980
|
function parsePlaywrightJsonResults(stdout, cwd) {
|
|
1366
|
-
|
|
1367
|
-
return { fileResults: new Map(), errors: [] };
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
let parsed;
|
|
1371
|
-
try {
|
|
1372
|
-
parsed = JSON.parse(stdout);
|
|
1373
|
-
} catch (error) {
|
|
1374
|
-
return {
|
|
1375
|
-
fileResults: new Map(),
|
|
1376
|
-
errors: [`Could not parse Playwright JSON output: ${formatError(error)}`],
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
const fileResults = new Map();
|
|
1381
|
-
visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
|
|
1382
|
-
return {
|
|
1383
|
-
fileResults,
|
|
1384
|
-
errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
|
|
1385
|
-
};
|
|
981
|
+
return parsePlaywrightJsonResultsModel(stdout, cwd);
|
|
1386
982
|
}
|
|
1387
983
|
|
|
1388
984
|
function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
|
|
1389
|
-
|
|
1390
|
-
const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
|
|
1391
|
-
for (const child of suite.suites || []) {
|
|
1392
|
-
visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
|
|
1393
|
-
}
|
|
1394
|
-
for (const spec of suite.specs || []) {
|
|
1395
|
-
collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
985
|
+
return visitPlaywrightSuitesModel(suites, inheritedFile, fileResults, cwd);
|
|
1398
986
|
}
|
|
1399
987
|
|
|
1400
988
|
function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
1401
|
-
|
|
1402
|
-
if (!file) return;
|
|
1403
|
-
|
|
1404
|
-
const current = fileResults.get(file) || {
|
|
1405
|
-
failed: false,
|
|
1406
|
-
error: null,
|
|
1407
|
-
durationMs: 0,
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
|
-
for (const test of spec.tests || []) {
|
|
1411
|
-
const results = Array.isArray(test.results) ? test.results : [];
|
|
1412
|
-
current.durationMs += results.reduce(
|
|
1413
|
-
(sum, result) => sum + Number(result?.duration || 0),
|
|
1414
|
-
0
|
|
1415
|
-
);
|
|
1416
|
-
|
|
1417
|
-
const final = choosePlaywrightFinalResult(results);
|
|
1418
|
-
const failed =
|
|
1419
|
-
test.outcome === "unexpected" ||
|
|
1420
|
-
!isPlaywrightPassingStatus(final?.status);
|
|
1421
|
-
|
|
1422
|
-
if (failed) {
|
|
1423
|
-
current.failed = true;
|
|
1424
|
-
current.error ||= extractPlaywrightFailure(final, spec, test);
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
fileResults.set(file, current);
|
|
989
|
+
return collectPlaywrightSpecModel(spec, inheritedFile, fileResults, cwd);
|
|
1429
990
|
}
|
|
1430
991
|
|
|
1431
992
|
function choosePlaywrightFinalResult(results) {
|
|
1432
|
-
|
|
1433
|
-
return results[results.length - 1];
|
|
993
|
+
return choosePlaywrightFinalResultModel(results);
|
|
1434
994
|
}
|
|
1435
995
|
|
|
1436
996
|
function isPlaywrightPassingStatus(status) {
|
|
1437
|
-
return
|
|
997
|
+
return isPlaywrightPassingStatusModel(status);
|
|
1438
998
|
}
|
|
1439
999
|
|
|
1440
1000
|
function extractPlaywrightFailure(finalResult, spec, test) {
|
|
1441
|
-
|
|
1442
|
-
finalResult?.error?.message ||
|
|
1443
|
-
finalResult?.error?.value ||
|
|
1444
|
-
finalResult?.error?.stack;
|
|
1445
|
-
if (fromResult) return firstLine(fromResult);
|
|
1446
|
-
|
|
1447
|
-
const fromTest = test?.errors?.[0]?.message;
|
|
1448
|
-
if (fromTest) return firstLine(fromTest);
|
|
1449
|
-
|
|
1450
|
-
return firstLine(spec?.title || "Playwright test failed");
|
|
1001
|
+
return extractPlaywrightFailureModel(finalResult, spec, test);
|
|
1451
1002
|
}
|
|
1452
1003
|
|
|
1453
1004
|
function formatPlaywrightReporterError(error) {
|
|
1454
|
-
|
|
1455
|
-
if (typeof error === "string") return firstLine(error);
|
|
1456
|
-
if (typeof error.message === "string") return firstLine(error.message);
|
|
1457
|
-
if (typeof error.value === "string") return firstLine(error.value);
|
|
1458
|
-
return null;
|
|
1005
|
+
return formatPlaywrightReporterErrorModel(error);
|
|
1459
1006
|
}
|
|
1460
1007
|
|
|
1461
1008
|
function extractReporterFile(node) {
|
|
1462
|
-
|
|
1463
|
-
if (typeof node.file === "string" && node.file.length > 0) return node.file;
|
|
1464
|
-
if (node.location && typeof node.location.file === "string" && node.location.file.length > 0) {
|
|
1465
|
-
return node.location.file;
|
|
1466
|
-
}
|
|
1467
|
-
return null;
|
|
1009
|
+
return extractReporterFileModel(node);
|
|
1468
1010
|
}
|
|
1469
1011
|
|
|
1470
1012
|
function normalizeReportedFile(filePath, cwd) {
|
|
1471
|
-
|
|
1472
|
-
const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
1473
|
-
return normalizePathSeparators(path.relative(cwd, absolute));
|
|
1013
|
+
return normalizeReportedFileModel(filePath, cwd);
|
|
1474
1014
|
}
|
|
1475
1015
|
|
|
1476
1016
|
function firstLine(value) {
|
|
1477
|
-
return
|
|
1017
|
+
return firstLineModel(value);
|
|
1478
1018
|
}
|
|
1479
1019
|
|
|
1480
1020
|
function printBufferedOutput(output, prefix) {
|
|
@@ -1486,95 +1026,23 @@ function printBufferedOutput(output, prefix) {
|
|
|
1486
1026
|
}
|
|
1487
1027
|
|
|
1488
1028
|
function resolveRuntimeUrl(rawUrl, serviceName, targetConfig, workerId, context) {
|
|
1489
|
-
|
|
1490
|
-
...context,
|
|
1491
|
-
targetName: targetConfig.name,
|
|
1492
|
-
workerId,
|
|
1493
|
-
serviceName,
|
|
1494
|
-
});
|
|
1495
|
-
const actualPort = context.portMap.get(serviceName);
|
|
1496
|
-
return actualPort ? rewriteUrlPort(resolved, actualPort) : resolved;
|
|
1029
|
+
return resolveRuntimeUrlModel(rawUrl, serviceName, targetConfig, workerId, context);
|
|
1497
1030
|
}
|
|
1498
1031
|
|
|
1499
1032
|
function finalizeString(value, context) {
|
|
1500
|
-
|
|
1501
|
-
for (const [source, destination] of context.urlMappings || []) {
|
|
1502
|
-
if (source && destination && source !== destination) {
|
|
1503
|
-
resolved = resolved.split(source).join(destination);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
return resolved;
|
|
1033
|
+
return finalizeStringModel(value, context);
|
|
1507
1034
|
}
|
|
1508
1035
|
|
|
1509
1036
|
function resolveTemplateString(value, context) {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
return value.replace(/\{([a-zA-Z]+)(?::([a-zA-Z0-9_-]+))?\}/g, (_match, token, arg) => {
|
|
1513
|
-
switch (token) {
|
|
1514
|
-
case "worker":
|
|
1515
|
-
return String(context.workerId);
|
|
1516
|
-
case "target":
|
|
1517
|
-
return context.targetName;
|
|
1518
|
-
case "service":
|
|
1519
|
-
return context.serviceName;
|
|
1520
|
-
case "stateDir":
|
|
1521
|
-
return context.serviceStateDir;
|
|
1522
|
-
case "port": {
|
|
1523
|
-
const serviceName = arg || context.serviceName;
|
|
1524
|
-
const port = context.portMap.get(serviceName);
|
|
1525
|
-
if (!port) {
|
|
1526
|
-
throw new Error(`Unknown port placeholder for service "${serviceName}"`);
|
|
1527
|
-
}
|
|
1528
|
-
return String(port);
|
|
1529
|
-
}
|
|
1530
|
-
case "baseUrl": {
|
|
1531
|
-
const serviceName = arg || context.serviceName;
|
|
1532
|
-
const baseUrl = context.baseUrlByService.get(serviceName);
|
|
1533
|
-
if (!baseUrl) {
|
|
1534
|
-
throw new Error(`Unknown baseUrl placeholder for service "${serviceName}"`);
|
|
1535
|
-
}
|
|
1536
|
-
return baseUrl;
|
|
1537
|
-
}
|
|
1538
|
-
case "readyUrl": {
|
|
1539
|
-
const serviceName = arg || context.serviceName;
|
|
1540
|
-
const readyUrl = context.readyUrlByService.get(serviceName);
|
|
1541
|
-
if (!readyUrl) {
|
|
1542
|
-
throw new Error(`Unknown readyUrl placeholder for service "${serviceName}"`);
|
|
1543
|
-
}
|
|
1544
|
-
return readyUrl;
|
|
1545
|
-
}
|
|
1546
|
-
default:
|
|
1547
|
-
throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
|
|
1548
|
-
}
|
|
1549
|
-
});
|
|
1037
|
+
return resolveTemplateStringModel(value, context);
|
|
1550
1038
|
}
|
|
1551
1039
|
|
|
1552
1040
|
function rewriteUrlPort(rawUrl, port) {
|
|
1553
|
-
|
|
1554
|
-
const original = new URL(rawUrl);
|
|
1555
|
-
if (!original.port) return rawUrl;
|
|
1556
|
-
|
|
1557
|
-
const rewritten = new URL(rawUrl);
|
|
1558
|
-
rewritten.port = String(port);
|
|
1559
|
-
|
|
1560
|
-
let next = rewritten.toString();
|
|
1561
|
-
if (!rawUrl.endsWith("/") && original.pathname === "/" && next.endsWith("/")) {
|
|
1562
|
-
next = next.slice(0, -1);
|
|
1563
|
-
}
|
|
1564
|
-
return next;
|
|
1565
|
-
} catch {
|
|
1566
|
-
return rawUrl;
|
|
1567
|
-
}
|
|
1041
|
+
return rewriteUrlPortModel(rawUrl, port);
|
|
1568
1042
|
}
|
|
1569
1043
|
|
|
1570
1044
|
function numericPortFromUrl(rawUrl) {
|
|
1571
|
-
|
|
1572
|
-
const url = new URL(rawUrl);
|
|
1573
|
-
const port = Number(url.port);
|
|
1574
|
-
return Number.isInteger(port) && port > 0 ? port : null;
|
|
1575
|
-
} catch {
|
|
1576
|
-
return null;
|
|
1577
|
-
}
|
|
1045
|
+
return numericPortFromUrlModel(rawUrl);
|
|
1578
1046
|
}
|
|
1579
1047
|
|
|
1580
1048
|
async function assertLocalServicePortsAvailable(config) {
|
|
@@ -1599,22 +1067,11 @@ async function assertLocalServicePortsAvailable(config) {
|
|
|
1599
1067
|
}
|
|
1600
1068
|
|
|
1601
1069
|
function socketFromUrl(rawUrl) {
|
|
1602
|
-
|
|
1603
|
-
const url = new URL(rawUrl);
|
|
1604
|
-
const port = Number(url.port);
|
|
1605
|
-
if (!Number.isInteger(port) || port <= 0) return null;
|
|
1606
|
-
|
|
1607
|
-
const host = normalizeSocketHost(url.hostname);
|
|
1608
|
-
return host ? { host, port } : null;
|
|
1609
|
-
} catch {
|
|
1610
|
-
return null;
|
|
1611
|
-
}
|
|
1070
|
+
return socketFromUrlModel(rawUrl);
|
|
1612
1071
|
}
|
|
1613
1072
|
|
|
1614
1073
|
function normalizeSocketHost(hostname) {
|
|
1615
|
-
|
|
1616
|
-
if (hostname === "[::1]") return "::1";
|
|
1617
|
-
return hostname;
|
|
1074
|
+
return normalizeSocketHostModel(hostname);
|
|
1618
1075
|
}
|
|
1619
1076
|
|
|
1620
1077
|
async function isPortInUse({ host, port }) {
|
|
@@ -1728,82 +1185,31 @@ function sleep(ms) {
|
|
|
1728
1185
|
}
|
|
1729
1186
|
|
|
1730
1187
|
function findRuntimeStateDirs(rootDir) {
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
const visit = (dir) => {
|
|
1734
|
-
if (!fs.existsSync(dir)) return;
|
|
1735
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1736
|
-
if (isDatabaseStateDir(dir)) {
|
|
1737
|
-
found.push(dir);
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
for (const entry of entries) {
|
|
1741
|
-
if (entry.isDirectory()) {
|
|
1742
|
-
visit(path.join(dir, entry.name));
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
};
|
|
1746
|
-
|
|
1747
|
-
visit(rootDir);
|
|
1748
|
-
return found.sort((a, b) => b.length - a.length);
|
|
1188
|
+
return findRuntimeStateDirsModel(rootDir, isDatabaseStateDir);
|
|
1749
1189
|
}
|
|
1750
1190
|
|
|
1751
1191
|
function findGraphDirsForService(productDir, serviceName) {
|
|
1752
|
-
|
|
1753
|
-
if (!fs.existsSync(graphsRoot)) return [];
|
|
1754
|
-
|
|
1755
|
-
const matches = [];
|
|
1756
|
-
for (const entry of fs.readdirSync(graphsRoot, { withFileTypes: true })) {
|
|
1757
|
-
if (!entry.isDirectory()) continue;
|
|
1758
|
-
const graphDir = path.join(graphsRoot, entry.name);
|
|
1759
|
-
const metadata = readGraphMetadata(graphDir);
|
|
1760
|
-
if (!metadata) continue;
|
|
1761
|
-
if ((metadata.runtimeServices || []).includes(serviceName)) {
|
|
1762
|
-
matches.push(graphDir);
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
return matches.sort();
|
|
1192
|
+
return findGraphDirsForServiceModel(productDir, serviceName);
|
|
1767
1193
|
}
|
|
1768
1194
|
|
|
1769
1195
|
function writeGraphMetadata(graphDir, graph) {
|
|
1770
|
-
|
|
1771
|
-
const metadata = {
|
|
1772
|
-
runtimeServices: graph.runtimeNames,
|
|
1773
|
-
assignedTargets: [...graph.assignedTargets].sort(),
|
|
1774
|
-
rootService: graph.rootConfig.name,
|
|
1775
|
-
};
|
|
1776
|
-
fs.writeFileSync(
|
|
1777
|
-
path.join(graphDir, GRAPH_METADATA),
|
|
1778
|
-
JSON.stringify(metadata, null, 2)
|
|
1779
|
-
);
|
|
1196
|
+
return writeGraphMetadataModel(graphDir, graph);
|
|
1780
1197
|
}
|
|
1781
1198
|
|
|
1782
1199
|
function readGraphMetadata(graphDir) {
|
|
1783
|
-
|
|
1784
|
-
if (!fs.existsSync(metadataPath)) return null;
|
|
1785
|
-
|
|
1786
|
-
try {
|
|
1787
|
-
return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
1788
|
-
} catch {
|
|
1789
|
-
return null;
|
|
1790
|
-
}
|
|
1200
|
+
return readGraphMetadataModel(graphDir);
|
|
1791
1201
|
}
|
|
1792
1202
|
|
|
1793
1203
|
function isRuntimeSuperset(candidate, target) {
|
|
1794
|
-
return
|
|
1204
|
+
return isRuntimeSupersetModel(candidate, target);
|
|
1795
1205
|
}
|
|
1796
1206
|
|
|
1797
1207
|
function compareGraphsForAssignment(left, right) {
|
|
1798
|
-
|
|
1799
|
-
return left.runtimeNames.length - right.runtimeNames.length;
|
|
1800
|
-
}
|
|
1801
|
-
return left.key.localeCompare(right.key);
|
|
1208
|
+
return compareGraphsForAssignmentModel(left, right);
|
|
1802
1209
|
}
|
|
1803
1210
|
|
|
1804
1211
|
function buildGraphDirName(runtimeNames) {
|
|
1805
|
-
|
|
1806
|
-
return slug.length > 0 ? slug : "graph";
|
|
1212
|
+
return buildGraphDirNameModel(runtimeNames);
|
|
1807
1213
|
}
|
|
1808
1214
|
|
|
1809
1215
|
function slugSegment(value) {
|
|
@@ -1811,5 +1217,5 @@ function slugSegment(value) {
|
|
|
1811
1217
|
}
|
|
1812
1218
|
|
|
1813
1219
|
function normalizePathSeparators(filePath) {
|
|
1814
|
-
return filePath
|
|
1220
|
+
return normalizePathSeparatorsModel(filePath);
|
|
1815
1221
|
}
|