@elench/testkit 0.1.42 → 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 +11 -0
- package/lib/config/index.mjs +23 -0
- package/lib/reporters/playwright.mjs +34 -5
- package/lib/reporters/playwright.test.mjs +11 -0
- package/lib/runner/default-runtime-runner.mjs +5 -1
- package/lib/runner/failure-details.mjs +91 -0
- package/lib/runner/failure-details.test.mjs +63 -0
- package/lib/runner/orchestrator.mjs +19 -9
- package/lib/runner/playwright-runner.mjs +1 -0
- package/lib/runner/reporting.mjs +8 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/results.mjs +8 -0
- package/lib/runner/triage.mjs +330 -0
- package/lib/runner/triage.test.mjs +156 -0
- 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 +3 -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({
|
|
@@ -141,9 +144,17 @@ for:
|
|
|
141
144
|
- migrate / seed commands
|
|
142
145
|
- test-local migrate / seed overrides
|
|
143
146
|
- named HTTP suite profiles
|
|
147
|
+
- known-failure annotation merge for enriched status/run artifacts
|
|
144
148
|
- repo-declared suite/file skip policies with explicit reasons
|
|
145
149
|
- telemetry upload configuration
|
|
146
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
|
+
|
|
147
158
|
## Authoring
|
|
148
159
|
|
|
149
160
|
HTTP suites:
|
package/lib/config/index.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
30
30
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
31
31
|
const { setup, setupFile } = await loadTestkitSetup(productDir);
|
|
32
32
|
const execution = normalizeRepoExecution(setup.execution);
|
|
33
|
+
const reporting = normalizeReportingConfig(setup.reporting);
|
|
33
34
|
const explicitServices = setup.services || {};
|
|
34
35
|
const discovery = discoverProject(productDir, explicitServices);
|
|
35
36
|
const serviceNames = new Set([
|
|
@@ -46,6 +47,7 @@ export async function loadConfigs(opts = {}) {
|
|
|
46
47
|
setup,
|
|
47
48
|
setupFile,
|
|
48
49
|
execution,
|
|
50
|
+
reporting,
|
|
49
51
|
explicitService: explicitServices[name] || {},
|
|
50
52
|
discoveredService: discovery.services[name] || null,
|
|
51
53
|
suites: discovery.suitesByService[name] || {},
|
|
@@ -105,6 +107,7 @@ function normalizeServiceConfig({
|
|
|
105
107
|
setup,
|
|
106
108
|
setupFile,
|
|
107
109
|
execution,
|
|
110
|
+
reporting,
|
|
108
111
|
explicitService,
|
|
109
112
|
discoveredService,
|
|
110
113
|
suites,
|
|
@@ -157,6 +160,7 @@ function normalizeServiceConfig({
|
|
|
157
160
|
suites,
|
|
158
161
|
testkit: {
|
|
159
162
|
execution,
|
|
163
|
+
reporting,
|
|
160
164
|
dependsOn: explicitService.dependsOn || [],
|
|
161
165
|
database,
|
|
162
166
|
databaseFrom: explicitService.databaseFrom,
|
|
@@ -172,6 +176,19 @@ function normalizeServiceConfig({
|
|
|
172
176
|
};
|
|
173
177
|
}
|
|
174
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
|
+
|
|
175
192
|
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
176
193
|
if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
|
|
177
194
|
if (explicitService.local === false) {
|
|
@@ -282,6 +299,12 @@ function normalizeRuntimeConfig(value, serviceName) {
|
|
|
282
299
|
};
|
|
283
300
|
}
|
|
284
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
|
+
|
|
285
308
|
function normalizeLifecycle(value) {
|
|
286
309
|
if (!value) return undefined;
|
|
287
310
|
if (!value.cmd && !value.testkitCmd) {
|
|
@@ -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,6 +9,7 @@ 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";
|
|
@@ -105,11 +106,13 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
|
|
|
105
106
|
if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
|
|
106
107
|
|
|
107
108
|
const summary = readDefaultRuntimeSummary(summaryFile);
|
|
109
|
+
const rawRuntimeArtifacts = [...stdout.artifacts, ...stderr.artifacts];
|
|
108
110
|
const runtimeArtifacts = persistTaskArtifacts(
|
|
109
111
|
targetConfig.productDir,
|
|
110
112
|
task,
|
|
111
|
-
|
|
113
|
+
rawRuntimeArtifacts
|
|
112
114
|
);
|
|
115
|
+
const failureDetails = collectFailureDetailsFromRuntimeArtifacts(rawRuntimeArtifacts);
|
|
113
116
|
const runtimeError = timedOut
|
|
114
117
|
? formatFileTimeoutBudgetError(fileTimeoutSeconds)
|
|
115
118
|
: determineDefaultRuntimeFailure(result, summary, getFirstLine);
|
|
@@ -123,6 +126,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, lease, args, lif
|
|
|
123
126
|
startedAt,
|
|
124
127
|
finishedAt,
|
|
125
128
|
artifacts: runtimeArtifacts,
|
|
129
|
+
failureDetails,
|
|
126
130
|
};
|
|
127
131
|
}
|
|
128
132
|
|
|
@@ -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
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
summarizeDbBackend,
|
|
16
16
|
} from "./results.mjs";
|
|
17
17
|
import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
18
|
+
import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
|
|
18
19
|
import { buildRunSummaryLines, formatError } from "./formatting.mjs";
|
|
19
20
|
import {
|
|
20
21
|
loadTimings,
|
|
@@ -56,6 +57,10 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
56
57
|
},
|
|
57
58
|
testkitVersion: readPackageMetadata().version,
|
|
58
59
|
};
|
|
60
|
+
const knownFailures = loadKnownFailuresConfig(
|
|
61
|
+
productDir,
|
|
62
|
+
configs[0]?.testkit?.reporting || null
|
|
63
|
+
);
|
|
59
64
|
const requestedFiles = opts.fileNames || [];
|
|
60
65
|
if (requestedFiles.length > 0) {
|
|
61
66
|
const unmatchedFiles = findUnmatchedRequestedFiles(
|
|
@@ -168,7 +173,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
168
173
|
results = configs.map((config) =>
|
|
169
174
|
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
170
175
|
);
|
|
171
|
-
const
|
|
176
|
+
const runArtifact = buildRunArtifact({
|
|
172
177
|
productDir,
|
|
173
178
|
results,
|
|
174
179
|
startedAt,
|
|
@@ -185,12 +190,8 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
185
190
|
metadata,
|
|
186
191
|
summarizeDbBackend,
|
|
187
192
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (opts.writeStatus) {
|
|
191
|
-
writeStatusArtifact(
|
|
192
|
-
productDir,
|
|
193
|
-
buildStatusArtifact({
|
|
193
|
+
const statusArtifact = opts.writeStatus
|
|
194
|
+
? buildStatusArtifact({
|
|
194
195
|
productDir,
|
|
195
196
|
results,
|
|
196
197
|
typeValues,
|
|
@@ -200,11 +201,20 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
200
201
|
serviceFilter: opts.serviceFilter || null,
|
|
201
202
|
metadata,
|
|
202
203
|
})
|
|
203
|
-
|
|
204
|
+
: null;
|
|
205
|
+
const enrichedArtifacts = applyKnownFailuresToArtifacts(
|
|
206
|
+
runArtifact,
|
|
207
|
+
statusArtifact,
|
|
208
|
+
knownFailures
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
writeRunArtifact(productDir, enrichedArtifacts.runArtifact);
|
|
212
|
+
if (opts.writeStatus) {
|
|
213
|
+
writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
printRunSummary(results, finishedAt - startedAt);
|
|
207
|
-
await reportTelemetry(telemetry,
|
|
217
|
+
await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
|
|
208
218
|
if (results.some((result) => result.failed)) exitCode = 1;
|
|
209
219
|
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
210
220
|
} finally {
|
|
@@ -76,5 +76,6 @@ export async function runPlaywrightTask(targetConfig, task, lifecycle, lease) {
|
|
|
76
76
|
durationMs: fileResult?.durationMs > 0 ? fileResult.durationMs : durationMs,
|
|
77
77
|
startedAt,
|
|
78
78
|
finishedAt,
|
|
79
|
+
failureDetails: timedOut ? [] : fileResult?.failureDetails || [],
|
|
79
80
|
};
|
|
80
81
|
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -23,9 +23,15 @@ export function buildStatusArtifact({
|
|
|
23
23
|
path: file.path,
|
|
24
24
|
status: file.status,
|
|
25
25
|
};
|
|
26
|
+
if (file.error) {
|
|
27
|
+
test.error = file.error;
|
|
28
|
+
}
|
|
26
29
|
if (file.reason) {
|
|
27
30
|
test.reason = file.reason;
|
|
28
31
|
}
|
|
32
|
+
if (Array.isArray(file.failureDetails) && file.failureDetails.length > 0) {
|
|
33
|
+
test.failureDetails = file.failureDetails;
|
|
34
|
+
}
|
|
29
35
|
tests.push(test);
|
|
30
36
|
}
|
|
31
37
|
}
|
|
@@ -72,7 +78,7 @@ export function buildStatusArtifact({
|
|
|
72
78
|
scope.serviceFilter === null;
|
|
73
79
|
|
|
74
80
|
return {
|
|
75
|
-
schemaVersion:
|
|
81
|
+
schemaVersion: 4,
|
|
76
82
|
source: "testkit",
|
|
77
83
|
notice: "Generated file. Do not edit manually.",
|
|
78
84
|
product: {
|
|
@@ -121,7 +127,7 @@ export function buildRunArtifact({
|
|
|
121
127
|
const dbBackend = summarizeDbBackend(results);
|
|
122
128
|
|
|
123
129
|
return {
|
|
124
|
-
schemaVersion:
|
|
130
|
+
schemaVersion: 4,
|
|
125
131
|
source: "testkit",
|
|
126
132
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
127
133
|
product: {
|
|
@@ -78,7 +78,7 @@ describe("runner reporting", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
expect(artifact.product.name).toBe("my-product");
|
|
81
|
-
expect(artifact.schemaVersion).toBe(
|
|
81
|
+
expect(artifact.schemaVersion).toBe(4);
|
|
82
82
|
expect(artifact.run).toMatchObject({
|
|
83
83
|
workers: 2,
|
|
84
84
|
fileTimeoutSeconds: 60,
|
|
@@ -149,7 +149,7 @@ describe("runner reporting", () => {
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
expect(status).toEqual({
|
|
152
|
-
schemaVersion:
|
|
152
|
+
schemaVersion: 4,
|
|
153
153
|
source: "testkit",
|
|
154
154
|
notice: "Generated file. Do not edit manually.",
|
|
155
155
|
product: {
|
package/lib/runner/results.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
+
import { mergeFailureDetails } from "./failure-details.mjs";
|
|
2
3
|
|
|
3
4
|
export function buildServiceTrackers(servicePlans, startedAt) {
|
|
4
5
|
const trackers = new Map();
|
|
@@ -46,6 +47,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
46
47
|
reason: null,
|
|
47
48
|
status: "not_run",
|
|
48
49
|
artifacts: [],
|
|
50
|
+
failureDetails: [],
|
|
49
51
|
},
|
|
50
52
|
];
|
|
51
53
|
}),
|
|
@@ -59,6 +61,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
59
61
|
reason: file.reason,
|
|
60
62
|
status: "skipped",
|
|
61
63
|
artifacts: [],
|
|
64
|
+
failureDetails: [],
|
|
62
65
|
},
|
|
63
66
|
]),
|
|
64
67
|
]),
|
|
@@ -121,6 +124,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
121
124
|
existingFileResult.reason = outcome.reason || null;
|
|
122
125
|
existingFileResult.status = status;
|
|
123
126
|
existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
|
|
127
|
+
existingFileResult.failureDetails = mergeFailureDetails(outcome.failureDetails);
|
|
124
128
|
} else {
|
|
125
129
|
suite.fileResultsByPath.set(normalizedPath, {
|
|
126
130
|
path: normalizedPath,
|
|
@@ -130,6 +134,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
130
134
|
reason: outcome.reason || null,
|
|
131
135
|
status,
|
|
132
136
|
artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
|
|
137
|
+
failureDetails: mergeFailureDetails(outcome.failureDetails),
|
|
133
138
|
});
|
|
134
139
|
}
|
|
135
140
|
if (status === "failed" && !suite.failedFileSet.has(task.file)) {
|
|
@@ -247,6 +252,9 @@ function finalizeSuite(suite) {
|
|
|
247
252
|
durationMs: file.durationMs,
|
|
248
253
|
error: file.error,
|
|
249
254
|
reason: file.reason,
|
|
255
|
+
...(Array.isArray(file.failureDetails) && file.failureDetails.length > 0
|
|
256
|
+
? { failureDetails: file.failureDetails }
|
|
257
|
+
: {}),
|
|
250
258
|
...(Array.isArray(file.artifacts) && file.artifacts.length > 0
|
|
251
259
|
? { artifacts: file.artifacts }
|
|
252
260
|
: {}),
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const CLASSIFICATIONS = new Set([
|
|
5
|
+
"expected_failure",
|
|
6
|
+
"infra",
|
|
7
|
+
"product_bug",
|
|
8
|
+
"stale_test",
|
|
9
|
+
"test_bug",
|
|
10
|
+
]);
|
|
11
|
+
const STATES = new Set(["closed", "open"]);
|
|
12
|
+
|
|
13
|
+
export function loadKnownFailuresConfig(productDir, config) {
|
|
14
|
+
const relativePath = config?.knownFailuresFile;
|
|
15
|
+
if (!relativePath) return null;
|
|
16
|
+
|
|
17
|
+
const absolutePath = path.resolve(productDir, relativePath);
|
|
18
|
+
if (!fs.existsSync(absolutePath)) {
|
|
19
|
+
throw new Error(`Known failures file not found: ${relativePath}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Could not parse known failures file ${relativePath}: ${formatErrorMessage(error)}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return normalizeKnownFailuresDocument(parsed, relativePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyKnownFailuresToArtifacts(runArtifact, statusArtifact, knownFailures) {
|
|
35
|
+
if (!knownFailures) return { runArtifact, statusArtifact };
|
|
36
|
+
|
|
37
|
+
const runEntries = extractRunFileEntries(runArtifact);
|
|
38
|
+
const statusEntries = extractStatusFileEntries(statusArtifact);
|
|
39
|
+
const fileSummaries = new Map();
|
|
40
|
+
|
|
41
|
+
for (const entry of [...runEntries, ...statusEntries]) {
|
|
42
|
+
const key = buildFileIdentity(entry.service, entry.type, entry.path);
|
|
43
|
+
if (!fileSummaries.has(key)) {
|
|
44
|
+
fileSummaries.set(key, {
|
|
45
|
+
service: entry.service,
|
|
46
|
+
type: entry.type,
|
|
47
|
+
path: entry.path,
|
|
48
|
+
status: entry.status,
|
|
49
|
+
error: entry.error || null,
|
|
50
|
+
failureDetails: Array.isArray(entry.failureDetails) ? entry.failureDetails : [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const matchesByFileKey = new Map();
|
|
56
|
+
const matchedByFailedEntryIds = new Set();
|
|
57
|
+
|
|
58
|
+
for (const entry of knownFailures.entries) {
|
|
59
|
+
for (const fileSummary of fileSummaries.values()) {
|
|
60
|
+
if (!matchesKnownFailureEntry(entry, fileSummary)) continue;
|
|
61
|
+
const fileKey = buildFileIdentity(fileSummary.service, fileSummary.type, fileSummary.path);
|
|
62
|
+
if (!matchesByFileKey.has(fileKey)) {
|
|
63
|
+
matchesByFileKey.set(fileKey, []);
|
|
64
|
+
}
|
|
65
|
+
matchesByFileKey.get(fileKey).push(toArtifactTriageEntry(entry));
|
|
66
|
+
if (fileSummary.status === "failed") {
|
|
67
|
+
matchedByFailedEntryIds.add(entry.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const entry of [...runEntries, ...statusEntries]) {
|
|
73
|
+
const fileKey = buildFileIdentity(entry.service, entry.type, entry.path);
|
|
74
|
+
const matches = matchesByFileKey.get(fileKey) || [];
|
|
75
|
+
if (matches.length === 0) {
|
|
76
|
+
if (entry.status === "failed") {
|
|
77
|
+
setEntryTriage(entry, {
|
|
78
|
+
status: "untriaged",
|
|
79
|
+
entries: [],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setEntryTriage(entry, {
|
|
86
|
+
status: entry.status === "failed" ? "known_failure" : "known_issue_not_reproduced",
|
|
87
|
+
classifications: [...new Set(matches.map((match) => match.classification))].sort(),
|
|
88
|
+
entries: matches,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const summaryTests = statusArtifact?.tests || runEntries;
|
|
93
|
+
const triageSummary = buildTriageSummary(
|
|
94
|
+
summaryTests,
|
|
95
|
+
knownFailures.entries,
|
|
96
|
+
matchedByFailedEntryIds
|
|
97
|
+
);
|
|
98
|
+
runArtifact.triageSummary = triageSummary;
|
|
99
|
+
if (statusArtifact) {
|
|
100
|
+
statusArtifact.triageSummary = triageSummary;
|
|
101
|
+
}
|
|
102
|
+
return { runArtifact, statusArtifact };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function normalizeKnownFailuresDocument(document, relativePath = "known failures file") {
|
|
106
|
+
if (!document || typeof document !== "object") {
|
|
107
|
+
throw new Error(`${relativePath} must contain a JSON object`);
|
|
108
|
+
}
|
|
109
|
+
if (document.schemaVersion !== 1) {
|
|
110
|
+
throw new Error(`${relativePath} schemaVersion must be 1`);
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(document.entries)) {
|
|
113
|
+
throw new Error(`${relativePath} entries must be an array`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ids = new Set();
|
|
117
|
+
const entries = document.entries.map((entry, index) => {
|
|
118
|
+
const normalized = normalizeKnownFailureEntry(entry, `${relativePath} entries[${index}]`);
|
|
119
|
+
if (ids.has(normalized.id)) {
|
|
120
|
+
throw new Error(`${relativePath} has duplicate entry id "${normalized.id}"`);
|
|
121
|
+
}
|
|
122
|
+
ids.add(normalized.id);
|
|
123
|
+
return normalized;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
issueRepo: normalizeOptionalString(document.issueRepo),
|
|
129
|
+
entries,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeKnownFailureEntry(entry, label) {
|
|
134
|
+
if (!entry || typeof entry !== "object") {
|
|
135
|
+
throw new Error(`${label} must be an object`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const id = requireNonEmptyString(entry.id, `${label}.id`);
|
|
139
|
+
const title = requireNonEmptyString(entry.title, `${label}.title`);
|
|
140
|
+
const classification = requireEnumValue(
|
|
141
|
+
entry.classification,
|
|
142
|
+
CLASSIFICATIONS,
|
|
143
|
+
`${label}.classification`
|
|
144
|
+
);
|
|
145
|
+
const state = requireEnumValue(entry.state, STATES, `${label}.state`);
|
|
146
|
+
const description = requireNonEmptyString(entry.description, `${label}.description`);
|
|
147
|
+
const whyFailing = requireNonEmptyString(entry.whyFailing, `${label}.whyFailing`);
|
|
148
|
+
const lastReviewedAt = requireNonEmptyString(entry.lastReviewedAt, `${label}.lastReviewedAt`);
|
|
149
|
+
if (!Array.isArray(entry.matches) || entry.matches.length === 0) {
|
|
150
|
+
throw new Error(`${label}.matches must be a non-empty array`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
id,
|
|
155
|
+
title,
|
|
156
|
+
classification,
|
|
157
|
+
state,
|
|
158
|
+
issue: normalizeKnownFailureIssue(entry.issue, `${label}.issue`),
|
|
159
|
+
description,
|
|
160
|
+
whyFailing,
|
|
161
|
+
lastReviewedAt,
|
|
162
|
+
matches: entry.matches.map((match, index) =>
|
|
163
|
+
normalizeKnownFailureMatch(match, `${label}.matches[${index}]`)
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeKnownFailureIssue(issue, label) {
|
|
169
|
+
if (!issue || typeof issue !== "object") {
|
|
170
|
+
throw new Error(`${label} must be an object`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const repo = requireNonEmptyString(issue.repo, `${label}.repo`);
|
|
174
|
+
const number = issue.number;
|
|
175
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
176
|
+
throw new Error(`${label}.number must be a positive integer`);
|
|
177
|
+
}
|
|
178
|
+
const url = requireNonEmptyString(issue.url, `${label}.url`);
|
|
179
|
+
|
|
180
|
+
return { repo, number, url };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeKnownFailureMatch(match, label) {
|
|
184
|
+
if (!match || typeof match !== "object") {
|
|
185
|
+
throw new Error(`${label} must be an object`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const pathValue = requireNonEmptyString(match.path, `${label}.path`);
|
|
189
|
+
const normalized = {
|
|
190
|
+
path: pathValue,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const service = normalizeOptionalString(match.service);
|
|
194
|
+
if (service) normalized.service = service;
|
|
195
|
+
|
|
196
|
+
const type = normalizeOptionalString(match.type);
|
|
197
|
+
if (type) normalized.type = type;
|
|
198
|
+
|
|
199
|
+
const failureKey = normalizeOptionalString(match.failureKey);
|
|
200
|
+
if (failureKey) normalized.failureKey = failureKey;
|
|
201
|
+
|
|
202
|
+
const errorIncludes = normalizeOptionalString(match.errorIncludes);
|
|
203
|
+
if (errorIncludes) normalized.errorIncludes = errorIncludes;
|
|
204
|
+
|
|
205
|
+
return normalized;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function matchesKnownFailureEntry(entry, fileSummary) {
|
|
209
|
+
return entry.matches.some((match) => matchesKnownFailureMatch(match, fileSummary));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function matchesKnownFailureMatch(match, fileSummary) {
|
|
213
|
+
if (match.service && match.service !== fileSummary.service) return false;
|
|
214
|
+
if (match.type && match.type !== fileSummary.type) return false;
|
|
215
|
+
if (match.path !== fileSummary.path) return false;
|
|
216
|
+
if (match.failureKey) {
|
|
217
|
+
const failureKeys = Array.isArray(fileSummary.failureDetails)
|
|
218
|
+
? fileSummary.failureDetails.map((detail) => detail.key)
|
|
219
|
+
: [];
|
|
220
|
+
if (!failureKeys.includes(match.failureKey)) return false;
|
|
221
|
+
}
|
|
222
|
+
if (match.errorIncludes && !String(fileSummary.error || "").includes(match.errorIncludes)) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function toArtifactTriageEntry(entry) {
|
|
229
|
+
return {
|
|
230
|
+
id: entry.id,
|
|
231
|
+
title: entry.title,
|
|
232
|
+
classification: entry.classification,
|
|
233
|
+
state: entry.state,
|
|
234
|
+
issue: entry.issue,
|
|
235
|
+
description: entry.description,
|
|
236
|
+
whyFailing: entry.whyFailing,
|
|
237
|
+
lastReviewedAt: entry.lastReviewedAt,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildTriageSummary(tests, entries, matchedEntryIds) {
|
|
242
|
+
const failedTests = tests.filter((test) => test.status === "failed");
|
|
243
|
+
const knownFailedTests = failedTests.filter((test) => test.triage?.status === "known_failure");
|
|
244
|
+
const byClassification = {};
|
|
245
|
+
|
|
246
|
+
for (const test of knownFailedTests) {
|
|
247
|
+
for (const classification of test.triage?.classifications || []) {
|
|
248
|
+
byClassification[classification] = (byClassification[classification] || 0) + 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
failed: {
|
|
254
|
+
total: failedTests.length,
|
|
255
|
+
known: knownFailedTests.length,
|
|
256
|
+
untriaged: failedTests.length - knownFailedTests.length,
|
|
257
|
+
byClassification,
|
|
258
|
+
},
|
|
259
|
+
entries: {
|
|
260
|
+
total: entries.length,
|
|
261
|
+
matchedByFailedTests: matchedEntryIds.size,
|
|
262
|
+
unmatched: entries.length - matchedEntryIds.size,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function extractRunFileEntries(runArtifact) {
|
|
268
|
+
const entries = [];
|
|
269
|
+
|
|
270
|
+
for (const service of runArtifact.services || []) {
|
|
271
|
+
for (const suite of service.suites || []) {
|
|
272
|
+
for (const file of suite.files || []) {
|
|
273
|
+
entries.push({
|
|
274
|
+
target: file,
|
|
275
|
+
service: service.name,
|
|
276
|
+
type: suite.type,
|
|
277
|
+
path: file.path,
|
|
278
|
+
status: file.status,
|
|
279
|
+
error: file.error || null,
|
|
280
|
+
failureDetails: Array.isArray(file.failureDetails) ? file.failureDetails : [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return entries;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function extractStatusFileEntries(statusArtifact) {
|
|
290
|
+
return statusArtifact?.tests || [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function setEntryTriage(entry, triage) {
|
|
294
|
+
if (entry?.target) {
|
|
295
|
+
entry.target.triage = triage;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
entry.triage = triage;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildFileIdentity(service, type, filePath) {
|
|
302
|
+
return `${service}::${type}::${filePath}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function requireNonEmptyString(value, label) {
|
|
306
|
+
const normalized = normalizeOptionalString(value);
|
|
307
|
+
if (!normalized) {
|
|
308
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
309
|
+
}
|
|
310
|
+
return normalized;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function requireEnumValue(value, allowed, label) {
|
|
314
|
+
const normalized = requireNonEmptyString(value, label);
|
|
315
|
+
if (!allowed.has(normalized)) {
|
|
316
|
+
throw new Error(`${label} must be one of: ${[...allowed].sort().join(", ")}`);
|
|
317
|
+
}
|
|
318
|
+
return normalized;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeOptionalString(value) {
|
|
322
|
+
if (typeof value !== "string") return null;
|
|
323
|
+
const normalized = value.trim();
|
|
324
|
+
return normalized.length > 0 ? normalized : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function formatErrorMessage(error) {
|
|
328
|
+
if (error instanceof Error) return error.message;
|
|
329
|
+
return String(error);
|
|
330
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyKnownFailuresToArtifacts,
|
|
4
|
+
normalizeKnownFailuresDocument,
|
|
5
|
+
} from "./triage.mjs";
|
|
6
|
+
|
|
7
|
+
describe("runner triage", () => {
|
|
8
|
+
it("matches exact failure keys and enriches both artifacts", () => {
|
|
9
|
+
const knownFailures = normalizeKnownFailuresDocument({
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
issueRepo: "acme/repo",
|
|
12
|
+
entries: [
|
|
13
|
+
{
|
|
14
|
+
id: "bad-message",
|
|
15
|
+
title: "Bad message bug",
|
|
16
|
+
classification: "product_bug",
|
|
17
|
+
state: "open",
|
|
18
|
+
issue: {
|
|
19
|
+
repo: "acme/repo",
|
|
20
|
+
number: 12,
|
|
21
|
+
url: "https://github.com/acme/repo/issues/12",
|
|
22
|
+
},
|
|
23
|
+
description: "The API returns the wrong message.",
|
|
24
|
+
whyFailing: "The endpoint payload is wrong.",
|
|
25
|
+
lastReviewedAt: "2026-04-27",
|
|
26
|
+
matches: [
|
|
27
|
+
{
|
|
28
|
+
service: "api",
|
|
29
|
+
type: "int",
|
|
30
|
+
path: "__testkit__/http/failing.int.testkit.ts",
|
|
31
|
+
failureKey: "returns the wrong message",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const runArtifact = {
|
|
39
|
+
services: [
|
|
40
|
+
{
|
|
41
|
+
name: "api",
|
|
42
|
+
suites: [
|
|
43
|
+
{
|
|
44
|
+
type: "int",
|
|
45
|
+
files: [
|
|
46
|
+
{
|
|
47
|
+
path: "__testkit__/http/failing.int.testkit.ts",
|
|
48
|
+
status: "failed",
|
|
49
|
+
error: "Default runtime thresholds failed: checks(rate==1.0)",
|
|
50
|
+
failureDetails: [
|
|
51
|
+
{
|
|
52
|
+
kind: "k6-check",
|
|
53
|
+
key: "returns the wrong message",
|
|
54
|
+
title: "returns the wrong message",
|
|
55
|
+
count: 1,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const statusArtifact = {
|
|
66
|
+
tests: [
|
|
67
|
+
{
|
|
68
|
+
service: "api",
|
|
69
|
+
type: "int",
|
|
70
|
+
path: "__testkit__/http/failing.int.testkit.ts",
|
|
71
|
+
status: "failed",
|
|
72
|
+
error: "Default runtime thresholds failed: checks(rate==1.0)",
|
|
73
|
+
failureDetails: [
|
|
74
|
+
{
|
|
75
|
+
kind: "k6-check",
|
|
76
|
+
key: "returns the wrong message",
|
|
77
|
+
title: "returns the wrong message",
|
|
78
|
+
count: 1,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const enriched = applyKnownFailuresToArtifacts(runArtifact, statusArtifact, knownFailures);
|
|
86
|
+
expect(enriched.statusArtifact.tests[0].triage).toMatchObject({
|
|
87
|
+
status: "known_failure",
|
|
88
|
+
classifications: ["product_bug"],
|
|
89
|
+
});
|
|
90
|
+
expect(enriched.runArtifact.services[0].suites[0].files[0].triage.entries[0]).toMatchObject({
|
|
91
|
+
id: "bad-message",
|
|
92
|
+
issue: {
|
|
93
|
+
number: 12,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(enriched.statusArtifact.triageSummary).toEqual({
|
|
97
|
+
failed: {
|
|
98
|
+
total: 1,
|
|
99
|
+
known: 1,
|
|
100
|
+
untriaged: 0,
|
|
101
|
+
byClassification: {
|
|
102
|
+
product_bug: 1,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
entries: {
|
|
106
|
+
total: 1,
|
|
107
|
+
matchedByFailedTests: 1,
|
|
108
|
+
unmatched: 0,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("marks unmatched failed tests as untriaged", () => {
|
|
114
|
+
const enriched = applyKnownFailuresToArtifacts(
|
|
115
|
+
{
|
|
116
|
+
services: [
|
|
117
|
+
{
|
|
118
|
+
name: "api",
|
|
119
|
+
suites: [
|
|
120
|
+
{
|
|
121
|
+
type: "int",
|
|
122
|
+
files: [
|
|
123
|
+
{
|
|
124
|
+
path: "__testkit__/http/failing.int.testkit.ts",
|
|
125
|
+
status: "failed",
|
|
126
|
+
error: "boom",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
tests: [
|
|
136
|
+
{
|
|
137
|
+
service: "api",
|
|
138
|
+
type: "int",
|
|
139
|
+
path: "__testkit__/http/failing.int.testkit.ts",
|
|
140
|
+
status: "failed",
|
|
141
|
+
error: "boom",
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
normalizeKnownFailuresDocument({
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
entries: [],
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(enriched.statusArtifact.tests[0].triage).toEqual({
|
|
152
|
+
status: "untriaged",
|
|
153
|
+
entries: [],
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
package/lib/runtime/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import rawHttp from "k6/http";
|
|
2
2
|
import { Rate, Trend } from "k6/metrics";
|
|
3
|
-
import {
|
|
3
|
+
import { fail, sleep } from "k6";
|
|
4
4
|
import {
|
|
5
5
|
formatWaitForTimeoutError,
|
|
6
6
|
normalizeWaitIntervalSeconds,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
remainingFileTimeoutMs,
|
|
9
9
|
remainingFileTimeoutSeconds,
|
|
10
10
|
} from "../shared/file-timeout.mjs";
|
|
11
|
+
import { check, group } from "../runtime-src/k6/checks.js";
|
|
11
12
|
|
|
12
13
|
export { check, fail, group, sleep };
|
|
13
14
|
export { Rate, Trend };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { check as k6Check, group as k6Group } from "k6";
|
|
1
2
|
import { Rate } from "k6/metrics";
|
|
3
|
+
import { emitArtifact } from "./artifacts.js";
|
|
2
4
|
|
|
3
5
|
export const runtimeFailures = new Rate("testkit_runtime_failures");
|
|
6
|
+
const failureState = createFailureState();
|
|
4
7
|
|
|
5
8
|
export function singleIterationOptions(overrides = {}) {
|
|
6
9
|
return {
|
|
@@ -16,6 +19,41 @@ export function singleIterationOptions(overrides = {}) {
|
|
|
16
19
|
|
|
17
20
|
export const defaultOptions = singleIterationOptions();
|
|
18
21
|
|
|
22
|
+
export function check(value, checks) {
|
|
23
|
+
let allPassed = true;
|
|
24
|
+
|
|
25
|
+
for (const [name, predicate] of Object.entries(checks || {})) {
|
|
26
|
+
const checkName = normalizeLabel(name, "unnamed check");
|
|
27
|
+
const passed = k6Check(value, { [checkName]: predicate });
|
|
28
|
+
if (!passed) {
|
|
29
|
+
recordFailureDetail({
|
|
30
|
+
kind: "k6-check",
|
|
31
|
+
key: buildFailureKey(failureState.groupStack, checkName),
|
|
32
|
+
title: checkName,
|
|
33
|
+
checkName,
|
|
34
|
+
groupPath: [...failureState.groupStack],
|
|
35
|
+
phase: failureState.phase,
|
|
36
|
+
});
|
|
37
|
+
allPassed = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return allPassed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function group(name, fn) {
|
|
45
|
+
const groupName = normalizeLabel(name, "unnamed group");
|
|
46
|
+
|
|
47
|
+
return k6Group(groupName, () => {
|
|
48
|
+
failureState.groupStack.push(groupName);
|
|
49
|
+
try {
|
|
50
|
+
return fn();
|
|
51
|
+
} finally {
|
|
52
|
+
failureState.groupStack.pop();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
export function json(res) {
|
|
20
58
|
return JSON.parse(res.body);
|
|
21
59
|
}
|
|
@@ -46,3 +84,95 @@ export function isSorted(rows, field, direction = "asc") {
|
|
|
46
84
|
export function recordRuntimeFailure() {
|
|
47
85
|
runtimeFailures.add(1);
|
|
48
86
|
}
|
|
87
|
+
|
|
88
|
+
export function startFailureCollection(phase) {
|
|
89
|
+
failureState.phase = normalizeLabel(phase, "exec");
|
|
90
|
+
failureState.groupStack = [];
|
|
91
|
+
failureState.detailsByKey = new Map();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function emitFailureCollectionArtifact() {
|
|
95
|
+
const failures = [...failureState.detailsByKey.values()]
|
|
96
|
+
.sort((left, right) => left.key.localeCompare(right.key))
|
|
97
|
+
.map((detail) => ({ ...detail }));
|
|
98
|
+
|
|
99
|
+
if (failures.length > 0) {
|
|
100
|
+
emitArtifact(
|
|
101
|
+
"failure-details",
|
|
102
|
+
{
|
|
103
|
+
phase: failureState.phase,
|
|
104
|
+
failures,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
kind: "testkit.failure-details",
|
|
108
|
+
summary: `${failures.length} failure detail(s)`,
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
startFailureCollection(failureState.phase);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function recordFailureDetail(detail) {
|
|
117
|
+
const normalized = normalizeFailureDetail(detail);
|
|
118
|
+
if (!normalized) return;
|
|
119
|
+
|
|
120
|
+
const existing = failureState.detailsByKey.get(normalized.key);
|
|
121
|
+
if (!existing) {
|
|
122
|
+
failureState.detailsByKey.set(normalized.key, normalized);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
existing.count += normalized.count;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createFailureState() {
|
|
130
|
+
return {
|
|
131
|
+
phase: "exec",
|
|
132
|
+
groupStack: [],
|
|
133
|
+
detailsByKey: new Map(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeFailureDetail(detail) {
|
|
138
|
+
if (!detail || typeof detail !== "object") return null;
|
|
139
|
+
|
|
140
|
+
const kind = normalizeLabel(detail.kind, null);
|
|
141
|
+
const key = normalizeLabel(detail.key, null);
|
|
142
|
+
const title = normalizeLabel(detail.title, null);
|
|
143
|
+
if (!kind || !key || !title) return null;
|
|
144
|
+
|
|
145
|
+
const normalized = {
|
|
146
|
+
kind,
|
|
147
|
+
key,
|
|
148
|
+
title,
|
|
149
|
+
count: 1,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const checkName = normalizeLabel(detail.checkName, null);
|
|
153
|
+
if (checkName) normalized.checkName = checkName;
|
|
154
|
+
|
|
155
|
+
const phase = normalizeLabel(detail.phase, null);
|
|
156
|
+
if (phase) normalized.phase = phase;
|
|
157
|
+
|
|
158
|
+
const message = normalizeLabel(detail.message, null);
|
|
159
|
+
if (message) normalized.message = message;
|
|
160
|
+
|
|
161
|
+
const groupPath = Array.isArray(detail.groupPath)
|
|
162
|
+
? detail.groupPath.map((entry) => normalizeLabel(entry, null)).filter(Boolean)
|
|
163
|
+
: [];
|
|
164
|
+
if (groupPath.length > 0) normalized.groupPath = groupPath;
|
|
165
|
+
|
|
166
|
+
return normalized;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildFailureKey(groupPath, title) {
|
|
170
|
+
if (!Array.isArray(groupPath) || groupPath.length === 0) return title;
|
|
171
|
+
return [...groupPath, title].join(" > ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeLabel(value, fallback) {
|
|
175
|
+
if (typeof value !== "string") return fallback;
|
|
176
|
+
const normalized = value.trim();
|
|
177
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
178
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { fail } from "k6";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defaultOptions,
|
|
4
|
+
emitFailureCollectionArtifact,
|
|
5
|
+
recordRuntimeFailure,
|
|
6
|
+
startFailureCollection,
|
|
7
|
+
} from "./checks.js";
|
|
3
8
|
import { createDalContext, openDb } from "./dal.js";
|
|
4
9
|
|
|
5
10
|
export function defineDalSuite(configOrRun, maybeRun) {
|
|
@@ -11,14 +16,18 @@ export function defineDalSuite(configOrRun, maybeRun) {
|
|
|
11
16
|
options: config.options || defaultOptions,
|
|
12
17
|
setup() {
|
|
13
18
|
if (typeof config.setup !== "function") return null;
|
|
19
|
+
startFailureCollection("setup");
|
|
14
20
|
try {
|
|
15
21
|
return config.setup({ db, dal });
|
|
16
22
|
} catch (error) {
|
|
17
23
|
recordRuntimeFailure();
|
|
18
24
|
fail(formatFatalSuiteError("setup", error));
|
|
25
|
+
} finally {
|
|
26
|
+
emitFailureCollectionArtifact();
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
exec(setupData) {
|
|
30
|
+
startFailureCollection("exec");
|
|
22
31
|
try {
|
|
23
32
|
return run({
|
|
24
33
|
db,
|
|
@@ -28,6 +37,8 @@ export function defineDalSuite(configOrRun, maybeRun) {
|
|
|
28
37
|
} catch (error) {
|
|
29
38
|
recordRuntimeFailure();
|
|
30
39
|
fail(formatFatalSuiteError("exec", error));
|
|
40
|
+
} finally {
|
|
41
|
+
emitFailureCollectionArtifact();
|
|
31
42
|
}
|
|
32
43
|
},
|
|
33
44
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { fail } from "k6";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defaultOptions,
|
|
4
|
+
emitFailureCollectionArtifact,
|
|
5
|
+
recordRuntimeFailure,
|
|
6
|
+
startFailureCollection,
|
|
7
|
+
} from "./checks.js";
|
|
3
8
|
import { createHttpClient, getEnv } from "./http.js";
|
|
4
9
|
import {
|
|
5
10
|
clearRuntimeContext,
|
|
@@ -16,6 +21,7 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
16
21
|
},
|
|
17
22
|
setup() {
|
|
18
23
|
const resolved = resolveRuntimeConfig(config);
|
|
24
|
+
startFailureCollection("setup");
|
|
19
25
|
try {
|
|
20
26
|
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
21
27
|
if (typeof resolved.auth?.setup !== "function") return null;
|
|
@@ -24,11 +30,13 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
24
30
|
recordRuntimeFailure();
|
|
25
31
|
fail(formatFatalSuiteError("setup", error));
|
|
26
32
|
} finally {
|
|
33
|
+
emitFailureCollectionArtifact();
|
|
27
34
|
clearRuntimeContext();
|
|
28
35
|
}
|
|
29
36
|
},
|
|
30
37
|
exec(setupData) {
|
|
31
38
|
const resolved = resolveRuntimeConfig(config);
|
|
39
|
+
startFailureCollection("exec");
|
|
32
40
|
try {
|
|
33
41
|
registerRuntimeContext({ env: resolved.env, http: resolved.client.rawHttp || null });
|
|
34
42
|
return run({
|
|
@@ -43,6 +51,7 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
43
51
|
recordRuntimeFailure();
|
|
44
52
|
fail(formatFatalSuiteError("exec", error));
|
|
45
53
|
} finally {
|
|
54
|
+
emitFailureCollectionArtifact();
|
|
46
55
|
clearRuntimeContext();
|
|
47
56
|
}
|
|
48
57
|
},
|
package/lib/setup/index.d.ts
CHANGED