@elench/testkit 0.1.41 → 0.1.43
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 +13 -0
- package/bin/testkit.mjs +6 -1
- package/lib/cli/index.mjs +4 -2
- package/lib/config/index.mjs +34 -0
- package/lib/reporters/playwright.mjs +34 -5
- package/lib/reporters/playwright.test.mjs +11 -0
- package/lib/runner/default-runtime-runner.mjs +29 -6
- package/lib/runner/execution-config.mjs +17 -0
- package/lib/runner/execution-config.test.mjs +8 -0
- package/lib/runner/failure-details.mjs +91 -0
- package/lib/runner/failure-details.test.mjs +63 -0
- package/lib/runner/lifecycle.mjs +99 -1
- package/lib/runner/orchestrator.mjs +26 -9
- package/lib/runner/planning.mjs +28 -6
- package/lib/runner/planning.test.mjs +38 -0
- package/lib/runner/playwright-config.mjs +5 -0
- package/lib/runner/playwright-config.test.mjs +6 -1
- package/lib/runner/playwright-runner.mjs +21 -4
- package/lib/runner/reporting.mjs +10 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/results.mjs +8 -0
- package/lib/runner/runtime-manager.mjs +90 -43
- package/lib/runner/runtime-manager.test.mjs +36 -11
- package/lib/runner/services.mjs +4 -2
- package/lib/runner/triage.mjs +330 -0
- package/lib/runner/triage.test.mjs +156 -0
- package/lib/runner/worker-loop.mjs +8 -2
- package/lib/runtime/index.mjs +2 -1
- package/lib/runtime-src/k6/checks.js +130 -0
- package/lib/runtime-src/k6/dal-suite.js +12 -1
- package/lib/runtime-src/k6/suite.js +10 -1
- package/lib/setup/index.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,9 @@ export default defineTestkitSetup({
|
|
|
68
68
|
workers: 8,
|
|
69
69
|
fileTimeoutSeconds: 60,
|
|
70
70
|
},
|
|
71
|
+
reporting: {
|
|
72
|
+
knownFailuresFile: "testkit.known-failures.json",
|
|
73
|
+
},
|
|
71
74
|
services: {
|
|
72
75
|
api: service({
|
|
73
76
|
...tsxService({
|
|
@@ -83,6 +86,7 @@ export default defineTestkitSetup({
|
|
|
83
86
|
}),
|
|
84
87
|
runtime: {
|
|
85
88
|
instances: 1,
|
|
89
|
+
maxConcurrentTasks: 4,
|
|
86
90
|
},
|
|
87
91
|
requirements: {
|
|
88
92
|
files: [
|
|
@@ -134,14 +138,23 @@ for:
|
|
|
134
138
|
- per-file wall clock timeout budget
|
|
135
139
|
- multi-service graphs
|
|
136
140
|
- local runtime instance counts
|
|
141
|
+
- per-runtime concurrent task caps
|
|
137
142
|
- local DB binding configuration
|
|
138
143
|
- explicit per-file or per-suite locks
|
|
139
144
|
- migrate / seed commands
|
|
140
145
|
- test-local migrate / seed overrides
|
|
141
146
|
- named HTTP suite profiles
|
|
147
|
+
- known-failure annotation merge for enriched status/run artifacts
|
|
142
148
|
- repo-declared suite/file skip policies with explicit reasons
|
|
143
149
|
- telemetry upload configuration
|
|
144
150
|
|
|
151
|
+
If `reporting.knownFailuresFile` is configured, `testkit` enriches
|
|
152
|
+
`.testkit/results/latest.json` and `testkit.status.json` with:
|
|
153
|
+
|
|
154
|
+
- per-file `failureDetails`
|
|
155
|
+
- per-file `triage` metadata (issue, classification, description)
|
|
156
|
+
- top-level `triageSummary` counts for known vs untriaged failures
|
|
157
|
+
|
|
145
158
|
## Authoring
|
|
146
159
|
|
|
147
160
|
HTTP suites:
|
package/bin/testkit.mjs
CHANGED
package/lib/cli/index.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./args.mjs";
|
|
12
12
|
import * as runner from "../runner/index.mjs";
|
|
13
13
|
|
|
14
|
-
export function run() {
|
|
14
|
+
export async function run(argv = process.argv) {
|
|
15
15
|
const cli = cac("testkit");
|
|
16
16
|
|
|
17
17
|
cli
|
|
@@ -91,5 +91,7 @@ export function run() {
|
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
cli.help();
|
|
94
|
-
cli.parse();
|
|
94
|
+
const parsed = cli.parse(argv, { run: false });
|
|
95
|
+
await cli.runMatchedCommand();
|
|
96
|
+
return parsed;
|
|
95
97
|
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
13
13
|
normalizeDatabaseBinding,
|
|
14
14
|
normalizeExecutionConfig,
|
|
15
|
+
normalizeRuntimeMaxConcurrentTasks,
|
|
15
16
|
normalizeRuntimeInstances,
|
|
16
17
|
} from "../runner/execution-config.mjs";
|
|
17
18
|
|
|
@@ -29,6 +30,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
29
30
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
30
31
|
const { setup, setupFile } = await loadTestkitSetup(productDir);
|
|
31
32
|
const execution = normalizeRepoExecution(setup.execution);
|
|
33
|
+
const reporting = normalizeReportingConfig(setup.reporting);
|
|
32
34
|
const explicitServices = setup.services || {};
|
|
33
35
|
const discovery = discoverProject(productDir, explicitServices);
|
|
34
36
|
const serviceNames = new Set([
|
|
@@ -45,6 +47,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
45
47
|
setup,
|
|
46
48
|
setupFile,
|
|
47
49
|
execution,
|
|
50
|
+
reporting,
|
|
48
51
|
explicitService: explicitServices[name] || {},
|
|
49
52
|
discoveredService: discovery.services[name] || null,
|
|
50
53
|
suites: discovery.suitesByService[name] || {},
|
|
@@ -104,6 +107,7 @@ function normalizeServiceConfig({
|
|
|
104
107
|
setup,
|
|
105
108
|
setupFile,
|
|
106
109
|
execution,
|
|
110
|
+
reporting,
|
|
107
111
|
explicitService,
|
|
108
112
|
discoveredService,
|
|
109
113
|
suites,
|
|
@@ -156,6 +160,7 @@ function normalizeServiceConfig({
|
|
|
156
160
|
suites,
|
|
157
161
|
testkit: {
|
|
158
162
|
execution,
|
|
163
|
+
reporting,
|
|
159
164
|
dependsOn: explicitService.dependsOn || [],
|
|
160
165
|
database,
|
|
161
166
|
databaseFrom: explicitService.databaseFrom,
|
|
@@ -171,6 +176,19 @@ function normalizeServiceConfig({
|
|
|
171
176
|
};
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
function normalizeReportingConfig(value) {
|
|
180
|
+
if (!value) return null;
|
|
181
|
+
|
|
182
|
+
const knownFailuresFile = normalizeOptionalString(value.knownFailuresFile);
|
|
183
|
+
if (!knownFailuresFile) {
|
|
184
|
+
throw new Error('testkit.setup.ts reporting.knownFailuresFile must be a non-empty string');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
knownFailuresFile,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
174
192
|
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
175
193
|
if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
|
|
176
194
|
if (explicitService.local === false) {
|
|
@@ -265,6 +283,7 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
265
283
|
if (!value) {
|
|
266
284
|
return {
|
|
267
285
|
instances: 1,
|
|
286
|
+
maxConcurrentTasks: Number.POSITIVE_INFINITY,
|
|
268
287
|
};
|
|
269
288
|
}
|
|
270
289
|
|
|
@@ -273,9 +292,19 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
273
292
|
value.instances ?? 1,
|
|
274
293
|
`Service "${serviceName}" runtime.instances`
|
|
275
294
|
),
|
|
295
|
+
maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
|
|
296
|
+
value.maxConcurrentTasks,
|
|
297
|
+
`Service "${serviceName}" runtime.maxConcurrentTasks`
|
|
298
|
+
),
|
|
276
299
|
};
|
|
277
300
|
}
|
|
278
301
|
|
|
302
|
+
function normalizeOptionalString(value) {
|
|
303
|
+
if (typeof value !== "string") return null;
|
|
304
|
+
const normalized = value.trim();
|
|
305
|
+
return normalized.length > 0 ? normalized : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
279
308
|
function normalizeLifecycle(value) {
|
|
280
309
|
if (!value) return undefined;
|
|
281
310
|
if (!value.cmd && !value.testkitCmd) {
|
|
@@ -578,6 +607,11 @@ function validateServiceConfig({
|
|
|
578
607
|
if (runtime.instances < 1) {
|
|
579
608
|
throw new Error(`Service "${name}" runtime.instances must be a positive integer`);
|
|
580
609
|
}
|
|
610
|
+
if (runtime.maxConcurrentTasks <= 0) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`Service "${name}" runtime.maxConcurrentTasks must be a positive integer when provided`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
581
615
|
|
|
582
616
|
for (const depName of dependsOn || []) {
|
|
583
617
|
if (depName === name) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import { mergeFailureDetails } from "../runner/failure-details.mjs";
|
|
2
3
|
|
|
3
4
|
export function parsePlaywrightJsonResults(stdout, cwd) {
|
|
4
5
|
if (!stdout.trim()) {
|
|
@@ -16,34 +17,42 @@ export function parsePlaywrightJsonResults(stdout, cwd) {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const fileResults = new Map();
|
|
19
|
-
visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
|
|
20
|
+
visitPlaywrightSuites(parsed.suites || [], null, [], fileResults, cwd);
|
|
20
21
|
return {
|
|
21
22
|
fileResults: sanitizePlaywrightFileResults(fileResults),
|
|
22
23
|
errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function visitPlaywrightSuites(suites, inheritedFile, fileResults, cwd) {
|
|
27
|
+
export function visitPlaywrightSuites(suites, inheritedFile, inheritedTitlePath, fileResults, cwd) {
|
|
27
28
|
for (const suite of suites || []) {
|
|
28
29
|
const suiteFile = normalizeReportedFile(extractReporterFile(suite) || inheritedFile, cwd);
|
|
30
|
+
const suiteTitle = normalizeSuiteTitle(suite?.title, suiteFile);
|
|
31
|
+
const suiteTitlePath = suiteTitle
|
|
32
|
+
? [...inheritedTitlePath, suiteTitle].filter(Boolean)
|
|
33
|
+
: inheritedTitlePath;
|
|
29
34
|
for (const child of suite.suites || []) {
|
|
30
|
-
visitPlaywrightSuites([child], suiteFile, fileResults, cwd);
|
|
35
|
+
visitPlaywrightSuites([child], suiteFile, suiteTitlePath, fileResults, cwd);
|
|
31
36
|
}
|
|
32
37
|
for (const spec of suite.specs || []) {
|
|
33
|
-
collectPlaywrightSpec(spec, suiteFile, fileResults, cwd);
|
|
38
|
+
collectPlaywrightSpec(spec, suiteFile, suiteTitlePath, fileResults, cwd);
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
43
|
+
export function collectPlaywrightSpec(spec, inheritedFile, suiteTitlePath, fileResults, cwd) {
|
|
39
44
|
const file = normalizeReportedFile(extractReporterFile(spec) || inheritedFile, cwd);
|
|
40
45
|
if (!file) return;
|
|
46
|
+
const specTitle = firstLine(spec?.title || "Playwright spec failed") || "Playwright spec failed";
|
|
47
|
+
const specPath = [...suiteTitlePath, specTitle].filter(Boolean);
|
|
48
|
+
const failureKey = specPath.join(" > ");
|
|
41
49
|
|
|
42
50
|
const current = fileResults.get(file) || {
|
|
43
51
|
failed: false,
|
|
44
52
|
status: "passed",
|
|
45
53
|
error: null,
|
|
46
54
|
durationMs: 0,
|
|
55
|
+
failureDetails: [],
|
|
47
56
|
passedCount: 0,
|
|
48
57
|
skippedCount: 0,
|
|
49
58
|
};
|
|
@@ -62,6 +71,13 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
|
62
71
|
current.failed = true;
|
|
63
72
|
current.status = "failed";
|
|
64
73
|
current.error ||= extractPlaywrightFailure(final, spec, test);
|
|
74
|
+
current.failureDetails.push({
|
|
75
|
+
kind: "playwright-spec",
|
|
76
|
+
key: failureKey,
|
|
77
|
+
title: specTitle,
|
|
78
|
+
suitePath: suiteTitlePath,
|
|
79
|
+
message: extractPlaywrightFailure(final, spec, test),
|
|
80
|
+
});
|
|
65
81
|
continue;
|
|
66
82
|
}
|
|
67
83
|
|
|
@@ -76,6 +92,7 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
|
|
|
76
92
|
if (!current.failed) {
|
|
77
93
|
current.status = current.passedCount === 0 && current.skippedCount > 0 ? "skipped" : "passed";
|
|
78
94
|
}
|
|
95
|
+
current.failureDetails = mergeFailureDetails(current.failureDetails);
|
|
79
96
|
fileResults.set(file, current);
|
|
80
97
|
}
|
|
81
98
|
|
|
@@ -155,7 +172,19 @@ function sanitizePlaywrightFileResults(fileResults) {
|
|
|
155
172
|
status: result.status,
|
|
156
173
|
error: result.error,
|
|
157
174
|
durationMs: result.durationMs,
|
|
175
|
+
failureDetails: mergeFailureDetails(result.failureDetails),
|
|
158
176
|
});
|
|
159
177
|
}
|
|
160
178
|
return sanitized;
|
|
161
179
|
}
|
|
180
|
+
|
|
181
|
+
function normalizeSuiteTitle(title, reportedFile) {
|
|
182
|
+
const normalizedTitle = firstLine(title || "");
|
|
183
|
+
if (!normalizedTitle) return null;
|
|
184
|
+
if (!reportedFile) return normalizedTitle;
|
|
185
|
+
const fileName = reportedFile.split("/").pop();
|
|
186
|
+
if (normalizedTitle === reportedFile || normalizedTitle === fileName) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return normalizedTitle;
|
|
190
|
+
}
|
|
@@ -52,6 +52,15 @@ describe("playwright-report", () => {
|
|
|
52
52
|
status: "failed",
|
|
53
53
|
error: "boom",
|
|
54
54
|
durationMs: 15,
|
|
55
|
+
failureDetails: [
|
|
56
|
+
{
|
|
57
|
+
kind: "playwright-spec",
|
|
58
|
+
key: "auth works",
|
|
59
|
+
title: "auth works",
|
|
60
|
+
count: 1,
|
|
61
|
+
message: "boom",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
55
64
|
});
|
|
56
65
|
});
|
|
57
66
|
|
|
@@ -86,6 +95,7 @@ describe("playwright-report", () => {
|
|
|
86
95
|
status: "skipped",
|
|
87
96
|
error: null,
|
|
88
97
|
durationMs: 7,
|
|
98
|
+
failureDetails: [],
|
|
89
99
|
});
|
|
90
100
|
});
|
|
91
101
|
|
|
@@ -134,6 +144,7 @@ describe("playwright-report", () => {
|
|
|
134
144
|
status: "passed",
|
|
135
145
|
error: null,
|
|
136
146
|
durationMs: 8,
|
|
147
|
+
failureDetails: [],
|
|
137
148
|
});
|
|
138
149
|
});
|
|
139
150
|
|
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
} from "../shared/file-timeout.mjs";
|
|
10
10
|
import { persistTaskArtifacts } from "./artifacts.mjs";
|
|
11
11
|
import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
|
|
12
|
+
import { collectFailureDetailsFromRuntimeArtifacts } from "./failure-details.mjs";
|
|
12
13
|
import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
|
|
13
14
|
import { readDatabaseUrl } from "./state-io.mjs";
|
|
14
15
|
import { buildTaskExecutionEnv } from "./template.mjs";
|
|
16
|
+
import { killChildProcess } from "./processes.mjs";
|
|
15
17
|
|
|
16
18
|
export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
|
|
17
19
|
const baseUrl = targetConfig.testkit.local?.baseUrl;
|
|
@@ -24,7 +26,9 @@ export async function runHttpK6Task(targetConfig, task, lifecycle, lease) {
|
|
|
24
26
|
serviceName: targetConfig.name,
|
|
25
27
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
26
28
|
});
|
|
27
|
-
|
|
29
|
+
if (lifecycle.isStopRequested()) {
|
|
30
|
+
throw new Error(`testkit run interrupted before starting ${task.file}`);
|
|
31
|
+
}
|
|
28
32
|
return runDefaultRuntimeTask(
|
|
29
33
|
targetConfig,
|
|
30
34
|
task,
|
|
@@ -45,7 +49,9 @@ export async function runDalTask(targetConfig, task, lifecycle, lease) {
|
|
|
45
49
|
serviceName: targetConfig.name,
|
|
46
50
|
sourceFile: path.join(targetConfig.productDir, task.file),
|
|
47
51
|
});
|
|
48
|
-
|
|
52
|
+
if (lifecycle.isStopRequested()) {
|
|
53
|
+
throw new Error(`testkit run interrupted before starting ${task.file}`);
|
|
54
|
+
}
|
|
49
55
|
return runDefaultRuntimeTask(
|
|
50
56
|
targetConfig,
|
|
51
57
|
task,
|
|
@@ -74,11 +80,25 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
|
|
|
74
80
|
process.env
|
|
75
81
|
),
|
|
76
82
|
reject: false,
|
|
77
|
-
cancelSignal: lifecycle.signal,
|
|
78
83
|
forceKillAfterDelay: 5_000,
|
|
79
84
|
}
|
|
80
85
|
);
|
|
81
|
-
|
|
86
|
+
lifecycle.registerProcess(subprocess, () => {
|
|
87
|
+
killChildProcess(subprocess, "SIGINT");
|
|
88
|
+
});
|
|
89
|
+
if (lifecycle.isStopRequested()) {
|
|
90
|
+
const interruptSubprocess = () => killChildProcess(subprocess, "SIGINT");
|
|
91
|
+
if (subprocess.pid) interruptSubprocess();
|
|
92
|
+
else subprocess.once?.("spawn", interruptSubprocess);
|
|
93
|
+
}
|
|
94
|
+
console.log(`·· ${targetConfig.runtimeLabel}:${task.suiteName} → ${task.file}`);
|
|
95
|
+
let result;
|
|
96
|
+
let timedOut;
|
|
97
|
+
try {
|
|
98
|
+
({ result, timedOut } = await settleSubprocess(subprocess, fileTimeoutSeconds));
|
|
99
|
+
} finally {
|
|
100
|
+
lifecycle.unregisterProcess(subprocess.pid);
|
|
101
|
+
}
|
|
82
102
|
|
|
83
103
|
const stdout = parseDefaultRuntimeOutput(result.stdout || "");
|
|
84
104
|
const stderr = parseDefaultRuntimeOutput(result.stderr || "");
|
|
@@ -86,11 +106,13 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
|
|
|
86
106
|
if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
|
|
87
107
|
|
|
88
108
|
const summary = readDefaultRuntimeSummary(summaryFile);
|
|
109
|
+
const rawRuntimeArtifacts = [...stdout.artifacts, ...stderr.artifacts];
|
|
89
110
|
const runtimeArtifacts = persistTaskArtifacts(
|
|
90
111
|
targetConfig.productDir,
|
|
91
112
|
task,
|
|
92
|
-
|
|
113
|
+
rawRuntimeArtifacts
|
|
93
114
|
);
|
|
115
|
+
const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
|
|
94
116
|
const runtimeError = timedOut
|
|
95
117
|
? formatFileTimeoutBudgetError(fileTimeoutSeconds)
|
|
96
118
|
: determineDefaultRuntimeFailure(result, summary, getFirstLine);
|
|
@@ -104,6 +126,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
|
|
|
104
126
|
startedAt,
|
|
105
127
|
finishedAt,
|
|
106
128
|
artifacts: runtimeArtifacts,
|
|
129
|
+
failureDetails,
|
|
107
130
|
};
|
|
108
131
|
}
|
|
109
132
|
|
|
@@ -118,7 +141,7 @@ export async function settleSubprocess(subprocess, fileTimeoutSeconds) {
|
|
|
118
141
|
new Promise((resolve) => {
|
|
119
142
|
timeoutHandle = setTimeout(async () => {
|
|
120
143
|
timedOut = true;
|
|
121
|
-
subprocess
|
|
144
|
+
killChildProcess(subprocess, "SIGTERM");
|
|
122
145
|
const result = await subprocess.catch((error) => error);
|
|
123
146
|
resolve({ result, timedOut: true });
|
|
124
147
|
}, timeoutMs);
|
|
@@ -20,6 +20,23 @@ export function normalizeRuntimeInstances(value, label = "runtime.instances") {
|
|
|
20
20
|
return normalizePositiveInteger(value, label);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function parseRuntimeMaxConcurrentTasksOption(
|
|
24
|
+
value,
|
|
25
|
+
label = "runtime.maxConcurrentTasks"
|
|
26
|
+
) {
|
|
27
|
+
return parsePositiveInteger(value, label);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeRuntimeMaxConcurrentTasks(
|
|
31
|
+
value,
|
|
32
|
+
label = "runtime.maxConcurrentTasks"
|
|
33
|
+
) {
|
|
34
|
+
if (value === undefined || value === null) {
|
|
35
|
+
return Number.POSITIVE_INFINITY;
|
|
36
|
+
}
|
|
37
|
+
return normalizePositiveInteger(value, label);
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
export function normalizeDatabaseBinding(value, label = "database.binding") {
|
|
24
41
|
const normalized = String(value || "").trim();
|
|
25
42
|
if (!DATABASE_BINDINGS.has(normalized)) {
|
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
DEFAULT_FILE_TIMEOUT_SECONDS,
|
|
6
6
|
normalizeDatabaseBinding,
|
|
7
7
|
normalizeExecutionConfig,
|
|
8
|
+
normalizeRuntimeMaxConcurrentTasks,
|
|
8
9
|
normalizeRuntimeInstances,
|
|
9
10
|
parseFileTimeoutOption,
|
|
11
|
+
parseRuntimeMaxConcurrentTasksOption,
|
|
10
12
|
parseRuntimeInstancesOption,
|
|
11
13
|
parseWorkersOption,
|
|
12
14
|
resolveExecutionConfig,
|
|
@@ -16,9 +18,13 @@ describe("execution-config", () => {
|
|
|
16
18
|
it("parses worker and runtime-instance options", () => {
|
|
17
19
|
expect(parseWorkersOption("8")).toBe(8);
|
|
18
20
|
expect(parseRuntimeInstancesOption("2")).toBe(2);
|
|
21
|
+
expect(parseRuntimeMaxConcurrentTasksOption("4")).toBe(4);
|
|
19
22
|
expect(parseFileTimeoutOption("45")).toBe(45);
|
|
20
23
|
expect(() => parseWorkersOption("0")).toThrow('Invalid --workers value "0"');
|
|
21
24
|
expect(() => parseRuntimeInstancesOption("0")).toThrow('Invalid runtime.instances value "0"');
|
|
25
|
+
expect(() => parseRuntimeMaxConcurrentTasksOption("0")).toThrow(
|
|
26
|
+
'Invalid runtime.maxConcurrentTasks value "0"'
|
|
27
|
+
);
|
|
22
28
|
expect(() => parseFileTimeoutOption("0")).toThrow(
|
|
23
29
|
'Invalid --file-timeout-seconds value "0"'
|
|
24
30
|
);
|
|
@@ -49,6 +55,8 @@ describe("execution-config", () => {
|
|
|
49
55
|
|
|
50
56
|
it("normalizes runtime instances and database bindings", () => {
|
|
51
57
|
expect(normalizeRuntimeInstances(2)).toBe(2);
|
|
58
|
+
expect(normalizeRuntimeMaxConcurrentTasks(undefined)).toBe(Number.POSITIVE_INFINITY);
|
|
59
|
+
expect(normalizeRuntimeMaxConcurrentTasks(3)).toBe(3);
|
|
52
60
|
for (const binding of DATABASE_BINDINGS) {
|
|
53
61
|
expect(normalizeDatabaseBinding(binding)).toBe(binding);
|
|
54
62
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export function normalizeFailureDetail(detail) {
|
|
2
|
+
if (!detail || typeof detail !== "object") return null;
|
|
3
|
+
|
|
4
|
+
const kind = normalizeNonEmptyString(detail.kind);
|
|
5
|
+
const key = normalizeNonEmptyString(detail.key);
|
|
6
|
+
const title = normalizeNonEmptyString(detail.title);
|
|
7
|
+
if (!kind || !key || !title) return null;
|
|
8
|
+
|
|
9
|
+
const normalized = {
|
|
10
|
+
kind,
|
|
11
|
+
key,
|
|
12
|
+
title,
|
|
13
|
+
count: normalizePositiveInteger(detail.count) || 1,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const groupPath = normalizeStringArray(detail.groupPath);
|
|
17
|
+
if (groupPath.length > 0) normalized.groupPath = groupPath;
|
|
18
|
+
|
|
19
|
+
const checkName = normalizeNonEmptyString(detail.checkName);
|
|
20
|
+
if (checkName) normalized.checkName = checkName;
|
|
21
|
+
|
|
22
|
+
const suitePath = normalizeStringArray(detail.suitePath);
|
|
23
|
+
if (suitePath.length > 0) normalized.suitePath = suitePath;
|
|
24
|
+
|
|
25
|
+
const phase = normalizeNonEmptyString(detail.phase);
|
|
26
|
+
if (phase) normalized.phase = phase;
|
|
27
|
+
|
|
28
|
+
const message = normalizeNonEmptyString(detail.message);
|
|
29
|
+
if (message) normalized.message = message;
|
|
30
|
+
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function mergeFailureDetails(details) {
|
|
35
|
+
const mergedByKey = new Map();
|
|
36
|
+
|
|
37
|
+
for (const detail of Array.isArray(details) ? details : []) {
|
|
38
|
+
const normalized = normalizeFailureDetail(detail);
|
|
39
|
+
if (!normalized) continue;
|
|
40
|
+
const dedupeKey = `${normalized.kind}::${normalized.key}`;
|
|
41
|
+
const existing = mergedByKey.get(dedupeKey);
|
|
42
|
+
if (!existing) {
|
|
43
|
+
mergedByKey.set(dedupeKey, { ...normalized });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
existing.count += normalized.count;
|
|
47
|
+
if (!existing.message && normalized.message) {
|
|
48
|
+
existing.message = normalized.message;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [...mergedByKey.values()].sort(
|
|
53
|
+
(left, right) =>
|
|
54
|
+
left.kind.localeCompare(right.kind) || left.key.localeCompare(right.key)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function collectFailureDetailsFromRuntimeArtifacts(artifacts) {
|
|
59
|
+
const details = [];
|
|
60
|
+
|
|
61
|
+
for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
|
|
62
|
+
if (artifact?.kind !== "testkit.failure-details") continue;
|
|
63
|
+
const phase = normalizeNonEmptyString(artifact?.data?.phase);
|
|
64
|
+
for (const detail of Array.isArray(artifact?.data?.failures) ? artifact.data.failures : []) {
|
|
65
|
+
details.push({
|
|
66
|
+
...detail,
|
|
67
|
+
phase: detail?.phase || phase || null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return mergeFailureDetails(details);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeStringArray(value) {
|
|
76
|
+
if (!Array.isArray(value)) return [];
|
|
77
|
+
return value
|
|
78
|
+
.map((entry) => normalizeNonEmptyString(entry))
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeNonEmptyString(value) {
|
|
83
|
+
if (typeof value !== "string") return null;
|
|
84
|
+
const normalized = value.trim();
|
|
85
|
+
return normalized.length > 0 ? normalized : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizePositiveInteger(value) {
|
|
89
|
+
if (!Number.isInteger(value) || value <= 0) return null;
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
collectFailureDetailsFromRuntimeArtifacts,
|
|
4
|
+
mergeFailureDetails,
|
|
5
|
+
} from "./failure-details.mjs";
|
|
6
|
+
|
|
7
|
+
describe("runner failure details", () => {
|
|
8
|
+
it("merges duplicate failure details by kind and key", () => {
|
|
9
|
+
expect(
|
|
10
|
+
mergeFailureDetails([
|
|
11
|
+
{ kind: "k6-check", key: "group > check", title: "check" },
|
|
12
|
+
{ kind: "k6-check", key: "group > check", title: "check" },
|
|
13
|
+
{ kind: "playwright-spec", key: "spec title", title: "spec title" },
|
|
14
|
+
])
|
|
15
|
+
).toEqual([
|
|
16
|
+
{
|
|
17
|
+
kind: "k6-check",
|
|
18
|
+
key: "group > check",
|
|
19
|
+
title: "check",
|
|
20
|
+
count: 2,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
kind: "playwright-spec",
|
|
24
|
+
key: "spec title",
|
|
25
|
+
title: "spec title",
|
|
26
|
+
count: 1,
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts and normalizes runtime failure-detail artifacts", () => {
|
|
32
|
+
expect(
|
|
33
|
+
collectFailureDetailsFromRuntimeArtifacts([
|
|
34
|
+
{
|
|
35
|
+
kind: "testkit.failure-details",
|
|
36
|
+
data: {
|
|
37
|
+
phase: "exec",
|
|
38
|
+
failures: [
|
|
39
|
+
{
|
|
40
|
+
kind: "k6-check",
|
|
41
|
+
key: "status is 200",
|
|
42
|
+
title: "status is 200",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: "k6-check",
|
|
46
|
+
key: "status is 200",
|
|
47
|
+
title: "status is 200",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
])
|
|
53
|
+
).toEqual([
|
|
54
|
+
{
|
|
55
|
+
kind: "k6-check",
|
|
56
|
+
key: "status is 200",
|
|
57
|
+
title: "status is 200",
|
|
58
|
+
count: 2,
|
|
59
|
+
phase: "exec",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|