@elench/testkit 0.1.42 → 0.1.44
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 +23 -0
- package/lib/config/index.mjs +27 -0
- package/lib/known-failures/github.mjs +739 -0
- package/lib/known-failures/github.test.mjs +219 -0
- package/lib/known-failures/index.d.ts +185 -0
- package/lib/known-failures/index.mjs +329 -0
- package/lib/known-failures/index.test.mjs +152 -0
- package/lib/package.test.mjs +5 -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/formatting.mjs +10 -1
- package/lib/runner/formatting.test.mjs +36 -0
- package/lib/runner/metadata.mjs +5 -0
- package/lib/runner/orchestrator.mjs +51 -12
- 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 +154 -0
- package/lib/runner/triage.test.mjs +154 -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 +10 -0
- package/package.json +5 -1
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
return loadKnownFailuresDocument(absolutePath, relativePath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadKnownFailuresDocument(filePath, relativePath = filePath) {
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Could not parse known failures file ${relativePath}: ${formatErrorMessage(error)}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return normalizeKnownFailuresDocument(parsed, relativePath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeKnownFailuresDocument(document, relativePath = "known failures file") {
|
|
39
|
+
if (!document || typeof document !== "object") {
|
|
40
|
+
throw new Error(`${relativePath} must contain a JSON object`);
|
|
41
|
+
}
|
|
42
|
+
if (document.schemaVersion !== 1) {
|
|
43
|
+
throw new Error(`${relativePath} schemaVersion must be 1`);
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(document.entries)) {
|
|
46
|
+
throw new Error(`${relativePath} entries must be an array`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ids = new Set();
|
|
50
|
+
const entries = document.entries.map((entry, index) => {
|
|
51
|
+
const normalized = normalizeKnownFailureEntry(entry, `${relativePath} entries[${index}]`);
|
|
52
|
+
if (ids.has(normalized.id)) {
|
|
53
|
+
throw new Error(`${relativePath} has duplicate entry id "${normalized.id}"`);
|
|
54
|
+
}
|
|
55
|
+
ids.add(normalized.id);
|
|
56
|
+
return normalized;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
issueRepo: normalizeOptionalString(document.issueRepo),
|
|
62
|
+
entries,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function validateKnownFailuresDocument(
|
|
67
|
+
document,
|
|
68
|
+
{ productDir, statusArtifactPath, statusArtifact } = {}
|
|
69
|
+
) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
const warnings = [];
|
|
72
|
+
const matchKeys = new Set();
|
|
73
|
+
let matchCount = 0;
|
|
74
|
+
|
|
75
|
+
for (const entry of document.entries) {
|
|
76
|
+
for (const match of entry.matches) {
|
|
77
|
+
matchCount += 1;
|
|
78
|
+
if (productDir) {
|
|
79
|
+
const absolutePath = path.join(productDir, match.path);
|
|
80
|
+
if (!fs.existsSync(absolutePath)) {
|
|
81
|
+
errors.push(`Missing matched file: ${match.path} (${entry.id})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const matchKey = [
|
|
86
|
+
entry.id,
|
|
87
|
+
match.service || "",
|
|
88
|
+
match.type || "",
|
|
89
|
+
match.path,
|
|
90
|
+
match.failureKey || "",
|
|
91
|
+
match.errorIncludes || "",
|
|
92
|
+
].join("::");
|
|
93
|
+
if (matchKeys.has(matchKey)) {
|
|
94
|
+
errors.push(`Duplicate match selector in ${entry.id}: ${match.path}`);
|
|
95
|
+
}
|
|
96
|
+
matchKeys.add(matchKey);
|
|
97
|
+
|
|
98
|
+
if (!match.path.includes("__testkit__") || !match.path.endsWith(".testkit.ts")) {
|
|
99
|
+
warnings.push(`Match path does not look like a testkit file: ${match.path} (${entry.id})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (document.issueRepo && entry.issue.repo !== document.issueRepo) {
|
|
104
|
+
warnings.push(
|
|
105
|
+
`Entry ${entry.id} uses issue repo ${entry.issue.repo} instead of document issueRepo ${document.issueRepo}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!entry.issue.url.endsWith(`/issues/${entry.issue.number}`)) {
|
|
109
|
+
errors.push(`Issue URL does not match issue number for ${entry.id}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let failedTests = 0;
|
|
114
|
+
let triagedFailedTests = 0;
|
|
115
|
+
let untriagedFailedTests = 0;
|
|
116
|
+
const parsedStatusArtifact = statusArtifact
|
|
117
|
+
? parseStatusArtifact(statusArtifact)
|
|
118
|
+
: statusArtifactPath && fs.existsSync(statusArtifactPath)
|
|
119
|
+
? parseStatusArtifact(JSON.parse(fs.readFileSync(statusArtifactPath, "utf8")))
|
|
120
|
+
: { tests: [] };
|
|
121
|
+
|
|
122
|
+
const failed = parsedStatusArtifact.tests.filter((test) => test.status === "failed");
|
|
123
|
+
failedTests = failed.length;
|
|
124
|
+
for (const test of failed) {
|
|
125
|
+
if (findMatchingKnownFailureEntries(document, test).length > 0) {
|
|
126
|
+
triagedFailedTests += 1;
|
|
127
|
+
} else {
|
|
128
|
+
untriagedFailedTests += 1;
|
|
129
|
+
warnings.push(`Untriaged failed test: ${test.path}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
errors,
|
|
135
|
+
warnings,
|
|
136
|
+
stats: {
|
|
137
|
+
entries: document.entries.length,
|
|
138
|
+
matches: matchCount,
|
|
139
|
+
failedTests,
|
|
140
|
+
triagedFailedTests,
|
|
141
|
+
untriagedFailedTests,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderKnownFailuresMarkdown(document) {
|
|
147
|
+
const lines = [
|
|
148
|
+
"# Bourne Bugs",
|
|
149
|
+
"",
|
|
150
|
+
"Canonical source: `testkit.known-failures.json`",
|
|
151
|
+
"",
|
|
152
|
+
`Tracked bug classes: ${document.entries.length}`,
|
|
153
|
+
`Tracked issue references: ${new Set(document.entries.map((entry) => entry.issue.number)).size}`,
|
|
154
|
+
"",
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
document.entries.forEach((entry, index) => {
|
|
158
|
+
lines.push(
|
|
159
|
+
`## ${index + 1}. ${entry.title} — [#${entry.issue.number}](${entry.issue.url})`,
|
|
160
|
+
"",
|
|
161
|
+
`- Classification: \`${entry.classification}\``,
|
|
162
|
+
`- State: \`${entry.state}\``,
|
|
163
|
+
`- Description: ${entry.description}`,
|
|
164
|
+
`- Why failing: ${entry.whyFailing}`,
|
|
165
|
+
`- Last reviewed: ${entry.lastReviewedAt}`,
|
|
166
|
+
"- Matches:"
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
for (const match of entry.matches) {
|
|
170
|
+
lines.push(
|
|
171
|
+
` - \`${match.path}\`${match.failureKey ? ` — \`${match.failureKey}\`` : ""}${
|
|
172
|
+
match.errorIncludes ? ` — error contains \`${match.errorIncludes}\`` : ""
|
|
173
|
+
}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
lines.push("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function findMatchingKnownFailureEntries(document, fileSummary) {
|
|
184
|
+
return document.entries.filter((entry) => matchesKnownFailureEntry(entry, fileSummary));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function matchesKnownFailureEntry(entry, fileSummary) {
|
|
188
|
+
return entry.matches.some((match) => matchesKnownFailureMatch(match, fileSummary));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function matchesKnownFailureMatch(match, fileSummary) {
|
|
192
|
+
if (match.service && match.service !== fileSummary.service) return false;
|
|
193
|
+
if (match.type && match.type !== fileSummary.type) return false;
|
|
194
|
+
if (match.path !== fileSummary.path) return false;
|
|
195
|
+
if (match.failureKey) {
|
|
196
|
+
const failureKeys = Array.isArray(fileSummary.failureDetails)
|
|
197
|
+
? fileSummary.failureDetails.map((detail) => detail?.key)
|
|
198
|
+
: [];
|
|
199
|
+
if (!failureKeys.includes(match.failureKey)) return false;
|
|
200
|
+
}
|
|
201
|
+
if (match.errorIncludes && !String(fileSummary.error || "").includes(match.errorIncludes)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function buildKnownFailureFileIdentity(service, type, filePath) {
|
|
208
|
+
return `${service}::${type}::${filePath}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeKnownFailureEntry(entry, label) {
|
|
212
|
+
if (!entry || typeof entry !== "object") {
|
|
213
|
+
throw new Error(`${label} must be an object`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const id = requireNonEmptyString(entry.id, `${label}.id`);
|
|
217
|
+
const title = requireNonEmptyString(entry.title, `${label}.title`);
|
|
218
|
+
const classification = requireEnumValue(
|
|
219
|
+
entry.classification,
|
|
220
|
+
CLASSIFICATIONS,
|
|
221
|
+
`${label}.classification`
|
|
222
|
+
);
|
|
223
|
+
const state = requireEnumValue(entry.state, STATES, `${label}.state`);
|
|
224
|
+
const description = requireNonEmptyString(entry.description, `${label}.description`);
|
|
225
|
+
const whyFailing = requireNonEmptyString(entry.whyFailing, `${label}.whyFailing`);
|
|
226
|
+
const lastReviewedAt = requireNonEmptyString(entry.lastReviewedAt, `${label}.lastReviewedAt`);
|
|
227
|
+
if (!Array.isArray(entry.matches) || entry.matches.length === 0) {
|
|
228
|
+
throw new Error(`${label}.matches must be a non-empty array`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
id,
|
|
233
|
+
title,
|
|
234
|
+
classification,
|
|
235
|
+
state,
|
|
236
|
+
issue: normalizeKnownFailureIssue(entry.issue, `${label}.issue`),
|
|
237
|
+
description,
|
|
238
|
+
whyFailing,
|
|
239
|
+
lastReviewedAt,
|
|
240
|
+
matches: entry.matches.map((match, index) =>
|
|
241
|
+
normalizeKnownFailureMatch(match, `${label}.matches[${index}]`)
|
|
242
|
+
),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeKnownFailureIssue(issue, label) {
|
|
247
|
+
if (!issue || typeof issue !== "object") {
|
|
248
|
+
throw new Error(`${label} must be an object`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const repo = requireNonEmptyString(issue.repo, `${label}.repo`);
|
|
252
|
+
const number = issue.number;
|
|
253
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
254
|
+
throw new Error(`${label}.number must be a positive integer`);
|
|
255
|
+
}
|
|
256
|
+
const url = requireNonEmptyString(issue.url, `${label}.url`);
|
|
257
|
+
|
|
258
|
+
return { repo, number, url };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeKnownFailureMatch(match, label) {
|
|
262
|
+
if (!match || typeof match !== "object") {
|
|
263
|
+
throw new Error(`${label} must be an object`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const pathValue = requireNonEmptyString(match.path, `${label}.path`);
|
|
267
|
+
const normalized = {
|
|
268
|
+
path: pathValue,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const service = normalizeOptionalString(match.service);
|
|
272
|
+
if (service) normalized.service = service;
|
|
273
|
+
|
|
274
|
+
const type = normalizeOptionalString(match.type);
|
|
275
|
+
if (type) normalized.type = type;
|
|
276
|
+
|
|
277
|
+
const failureKey = normalizeOptionalString(match.failureKey);
|
|
278
|
+
if (failureKey) normalized.failureKey = failureKey;
|
|
279
|
+
|
|
280
|
+
const errorIncludes = normalizeOptionalString(match.errorIncludes);
|
|
281
|
+
if (errorIncludes) normalized.errorIncludes = errorIncludes;
|
|
282
|
+
|
|
283
|
+
return normalized;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseStatusArtifact(value) {
|
|
287
|
+
if (!value || typeof value !== "object") {
|
|
288
|
+
return { tests: [] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
tests: Array.isArray(value.tests) ? value.tests : [],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function requireNonEmptyString(value, label) {
|
|
297
|
+
const normalized = normalizeOptionalString(value);
|
|
298
|
+
if (!normalized) {
|
|
299
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
300
|
+
}
|
|
301
|
+
return normalized;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function requireEnumValue(value, allowed, label) {
|
|
305
|
+
const normalized = requireNonEmptyString(value, label);
|
|
306
|
+
if (!allowed.has(normalized)) {
|
|
307
|
+
throw new Error(`${label} must be one of: ${[...allowed].sort().join(", ")}`);
|
|
308
|
+
}
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizeOptionalString(value) {
|
|
313
|
+
if (typeof value !== "string") return null;
|
|
314
|
+
const normalized = value.trim();
|
|
315
|
+
return normalized.length > 0 ? normalized : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function formatErrorMessage(error) {
|
|
319
|
+
if (error instanceof Error) return error.message;
|
|
320
|
+
return String(error);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export {
|
|
324
|
+
buildKnownFailureIssueValidationSummaryLines,
|
|
325
|
+
normalizeKnownFailureIssueValidationConfig,
|
|
326
|
+
parseGitHubRepoSlug,
|
|
327
|
+
shouldFailKnownFailureIssueValidation,
|
|
328
|
+
validateKnownFailureIssues,
|
|
329
|
+
} from "./github.mjs";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
findMatchingKnownFailureEntries,
|
|
7
|
+
normalizeKnownFailuresDocument,
|
|
8
|
+
renderKnownFailuresMarkdown,
|
|
9
|
+
validateKnownFailuresDocument,
|
|
10
|
+
} from "./index.mjs";
|
|
11
|
+
|
|
12
|
+
const tempDirs = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const tempDir of tempDirs.splice(0)) {
|
|
16
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("known failures core", () => {
|
|
21
|
+
it("matches entries by service, type, path, and failure key", () => {
|
|
22
|
+
const document = normalizeKnownFailuresDocument({
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
entries: [
|
|
25
|
+
{
|
|
26
|
+
id: "bad-message",
|
|
27
|
+
title: "Bad message bug",
|
|
28
|
+
classification: "product_bug",
|
|
29
|
+
state: "open",
|
|
30
|
+
issue: {
|
|
31
|
+
repo: "acme/repo",
|
|
32
|
+
number: 12,
|
|
33
|
+
url: "https://github.com/acme/repo/issues/12",
|
|
34
|
+
},
|
|
35
|
+
description: "Wrong message",
|
|
36
|
+
whyFailing: "Payload is wrong",
|
|
37
|
+
lastReviewedAt: "2026-04-27",
|
|
38
|
+
matches: [
|
|
39
|
+
{
|
|
40
|
+
service: "api",
|
|
41
|
+
type: "int",
|
|
42
|
+
path: "src/api/routes/__testkit__/failing.int.testkit.ts",
|
|
43
|
+
failureKey: "returns the wrong message",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const matches = findMatchingKnownFailureEntries(document, {
|
|
51
|
+
service: "api",
|
|
52
|
+
type: "int",
|
|
53
|
+
path: "src/api/routes/__testkit__/failing.int.testkit.ts",
|
|
54
|
+
error: "boom",
|
|
55
|
+
failureDetails: [{ key: "returns the wrong message" }],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(matches).toHaveLength(1);
|
|
59
|
+
expect(matches[0].id).toBe("bad-message");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("validates status coverage and filesystem matches", () => {
|
|
63
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-known-failures-"));
|
|
64
|
+
tempDirs.push(tempDir);
|
|
65
|
+
const testPath = path.join(tempDir, "src/api/routes/__testkit__/failing.int.testkit.ts");
|
|
66
|
+
fs.mkdirSync(path.dirname(testPath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(testPath, "export default {};\n");
|
|
68
|
+
|
|
69
|
+
const document = normalizeKnownFailuresDocument({
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
issueRepo: "acme/repo",
|
|
72
|
+
entries: [
|
|
73
|
+
{
|
|
74
|
+
id: "bad-message",
|
|
75
|
+
title: "Bad message bug",
|
|
76
|
+
classification: "product_bug",
|
|
77
|
+
state: "open",
|
|
78
|
+
issue: {
|
|
79
|
+
repo: "acme/repo",
|
|
80
|
+
number: 12,
|
|
81
|
+
url: "https://github.com/acme/repo/issues/12",
|
|
82
|
+
},
|
|
83
|
+
description: "Wrong message",
|
|
84
|
+
whyFailing: "Payload is wrong",
|
|
85
|
+
lastReviewedAt: "2026-04-27",
|
|
86
|
+
matches: [
|
|
87
|
+
{
|
|
88
|
+
service: "api",
|
|
89
|
+
type: "int",
|
|
90
|
+
path: "src/api/routes/__testkit__/failing.int.testkit.ts",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = validateKnownFailuresDocument(document, {
|
|
98
|
+
productDir: tempDir,
|
|
99
|
+
statusArtifact: {
|
|
100
|
+
tests: [
|
|
101
|
+
{
|
|
102
|
+
service: "api",
|
|
103
|
+
type: "int",
|
|
104
|
+
path: "src/api/routes/__testkit__/failing.int.testkit.ts",
|
|
105
|
+
status: "failed",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
service: "api",
|
|
109
|
+
type: "int",
|
|
110
|
+
path: "src/api/routes/__testkit__/other.int.testkit.ts",
|
|
111
|
+
status: "failed",
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.errors).toEqual([]);
|
|
118
|
+
expect(result.stats.failedTests).toBe(2);
|
|
119
|
+
expect(result.stats.triagedFailedTests).toBe(1);
|
|
120
|
+
expect(result.warnings).toContain(
|
|
121
|
+
"Untriaged failed test: src/api/routes/__testkit__/other.int.testkit.ts"
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders markdown from the canonical document", () => {
|
|
126
|
+
const markdown = renderKnownFailuresMarkdown(
|
|
127
|
+
normalizeKnownFailuresDocument({
|
|
128
|
+
schemaVersion: 1,
|
|
129
|
+
entries: [
|
|
130
|
+
{
|
|
131
|
+
id: "bad-message",
|
|
132
|
+
title: "Bad message bug",
|
|
133
|
+
classification: "product_bug",
|
|
134
|
+
state: "open",
|
|
135
|
+
issue: {
|
|
136
|
+
repo: "acme/repo",
|
|
137
|
+
number: 12,
|
|
138
|
+
url: "https://github.com/acme/repo/issues/12",
|
|
139
|
+
},
|
|
140
|
+
description: "Wrong message",
|
|
141
|
+
whyFailing: "Payload is wrong",
|
|
142
|
+
lastReviewedAt: "2026-04-27",
|
|
143
|
+
matches: [{ path: "src/api/routes/__testkit__/failing.int.testkit.ts" }],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(markdown).toContain("# Bourne Bugs");
|
|
150
|
+
expect(markdown).toContain("[#12](https://github.com/acme/repo/issues/12)");
|
|
151
|
+
});
|
|
152
|
+
});
|
package/lib/package.test.mjs
CHANGED
|
@@ -22,8 +22,13 @@ describe("package metadata", () => {
|
|
|
22
22
|
types: "./lib/runtime/index.d.ts",
|
|
23
23
|
default: "./lib/runtime/index.mjs",
|
|
24
24
|
});
|
|
25
|
+
expect(packageJson.exports["./known-failures"]).toEqual({
|
|
26
|
+
types: "./lib/known-failures/index.d.ts",
|
|
27
|
+
default: "./lib/known-failures/index.mjs",
|
|
28
|
+
});
|
|
25
29
|
expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
|
|
26
30
|
expect(fs.existsSync(path.join(rootDir, "lib", "setup", "index.d.ts"))).toBe(true);
|
|
27
31
|
expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
|
|
32
|
+
expect(fs.existsSync(path.join(rootDir, "lib", "known-failures", "index.d.ts"))).toBe(true);
|
|
28
33
|
});
|
|
29
34
|
});
|
|
@@ -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
|
|