@elench/testkit 0.1.43 → 0.1.45
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 +29 -0
- package/lib/config/index.mjs +4 -0
- package/lib/known-failures/github.mjs +739 -0
- package/lib/known-failures/github.test.mjs +322 -0
- package/lib/known-failures/index.d.ts +188 -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/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 +32 -3
- package/lib/runner/reporting.mjs +2 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/triage.mjs +20 -196
- package/lib/runner/triage.test.mjs +2 -4
- package/lib/setup/index.d.ts +7 -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,3 +1,5 @@
|
|
|
1
|
+
import { buildKnownFailureIssueValidationSummaryLines } from "../known-failures/github.mjs";
|
|
2
|
+
|
|
1
3
|
export function formatDuration(durationMs) {
|
|
2
4
|
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
3
5
|
const minutes = Math.floor(totalSeconds / 60);
|
|
@@ -46,7 +48,7 @@ export function formatSuiteFramework(framework) {
|
|
|
46
48
|
return label ? ` [${label}]` : "";
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
export function buildRunSummaryLines(results, durationMs) {
|
|
51
|
+
export function buildRunSummaryLines(results, durationMs, knownFailureIssueValidation = null) {
|
|
50
52
|
const totalServices = results.length;
|
|
51
53
|
const executedServices = results.filter((result) => !result.skipped);
|
|
52
54
|
const skippedServices = results.filter((result) => result.skipped);
|
|
@@ -115,6 +117,13 @@ export function buildRunSummaryLines(results, durationMs) {
|
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
const knownFailureIssueLines = buildKnownFailureIssueValidationSummaryLines(
|
|
121
|
+
knownFailureIssueValidation
|
|
122
|
+
);
|
|
123
|
+
if (knownFailureIssueLines.length > 0) {
|
|
124
|
+
lines.push(...knownFailureIssueLines);
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
if (failedServices.length > 0) {
|
|
119
128
|
lines.push("", `Result: FAILED (${failedServices.length}/${totalServices} services failed)`);
|
|
120
129
|
return lines;
|
|
@@ -128,4 +128,40 @@ describe("runner formatting", () => {
|
|
|
128
128
|
expect(lines.join("\n")).toContain("SKIP api");
|
|
129
129
|
expect(lines.at(-1)).toBe("Result: PASSED");
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
it("appends known-failure issue validation summary lines", () => {
|
|
133
|
+
const lines = buildRunSummaryLines(
|
|
134
|
+
[
|
|
135
|
+
{
|
|
136
|
+
name: "api",
|
|
137
|
+
skipped: false,
|
|
138
|
+
failed: false,
|
|
139
|
+
suiteCount: 1,
|
|
140
|
+
completedSuiteCount: 1,
|
|
141
|
+
skippedSuiteCount: 0,
|
|
142
|
+
failedSuiteCount: 0,
|
|
143
|
+
totalFileCount: 1,
|
|
144
|
+
passedFileCount: 1,
|
|
145
|
+
skippedFileCount: 0,
|
|
146
|
+
notRunFileCount: 0,
|
|
147
|
+
durationMs: 1_000,
|
|
148
|
+
suites: [],
|
|
149
|
+
errors: [],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
1_000,
|
|
153
|
+
{
|
|
154
|
+
summary: {
|
|
155
|
+
byCode: {
|
|
156
|
+
closed_but_failing: 2,
|
|
157
|
+
title_mismatch: 1,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(lines.join("\n")).toContain("Known-failure issues:");
|
|
164
|
+
expect(lines.join("\n")).toContain("2 closed issues still failing");
|
|
165
|
+
expect(lines.join("\n")).toContain("1 title mismatch");
|
|
166
|
+
});
|
|
131
167
|
});
|
package/lib/runner/metadata.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from "os";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { execFileSync } from "child_process";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { parseGitHubRepoSlug } from "../known-failures/github.mjs";
|
|
6
7
|
|
|
7
8
|
export function collectGitMetadata(productDir) {
|
|
8
9
|
const read = (args) => {
|
|
@@ -13,10 +14,14 @@ export function collectGitMetadata(productDir) {
|
|
|
13
14
|
}
|
|
14
15
|
};
|
|
15
16
|
|
|
17
|
+
const remoteUrl = read(["remote", "get-url", "origin"]);
|
|
18
|
+
|
|
16
19
|
return {
|
|
17
20
|
branch: read(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
18
21
|
commitSha: read(["rev-parse", "HEAD"]),
|
|
19
22
|
repoRoot: read(["rev-parse", "--show-toplevel"]),
|
|
23
|
+
remoteUrl,
|
|
24
|
+
repoSlug: parseGitHubRepoSlug(remoteUrl),
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
|
|
18
18
|
import { applyKnownFailuresToArtifacts, loadKnownFailuresConfig } from "./triage.mjs";
|
|
19
19
|
import { buildRunSummaryLines, formatError } from "./formatting.mjs";
|
|
20
|
+
import {
|
|
21
|
+
shouldFailKnownFailureIssueValidation,
|
|
22
|
+
validateKnownFailureIssues,
|
|
23
|
+
} from "../known-failures/github.mjs";
|
|
20
24
|
import {
|
|
21
25
|
loadTimings,
|
|
22
26
|
resetResultArtifacts,
|
|
@@ -118,6 +122,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
118
122
|
lifecycle.installSignalHandlers();
|
|
119
123
|
let results = [];
|
|
120
124
|
let finishedAt = Date.now();
|
|
125
|
+
let knownFailureIssueValidation = null;
|
|
121
126
|
|
|
122
127
|
try {
|
|
123
128
|
if (executedPlans.length > 0) {
|
|
@@ -207,15 +212,31 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
207
212
|
statusArtifact,
|
|
208
213
|
knownFailures
|
|
209
214
|
);
|
|
215
|
+
knownFailureIssueValidation = await validateKnownFailureIssues({
|
|
216
|
+
productDir,
|
|
217
|
+
document: knownFailures,
|
|
218
|
+
runArtifact: enrichedArtifacts.runArtifact,
|
|
219
|
+
statusArtifact: enrichedArtifacts.statusArtifact,
|
|
220
|
+
config: configs[0]?.testkit?.reporting?.issueValidation || null,
|
|
221
|
+
gitMetadata: metadata.git,
|
|
222
|
+
});
|
|
223
|
+
attachKnownFailureIssueValidation(
|
|
224
|
+
enrichedArtifacts.runArtifact,
|
|
225
|
+
enrichedArtifacts.statusArtifact,
|
|
226
|
+
knownFailureIssueValidation
|
|
227
|
+
);
|
|
210
228
|
|
|
211
229
|
writeRunArtifact(productDir, enrichedArtifacts.runArtifact);
|
|
212
230
|
if (opts.writeStatus) {
|
|
213
231
|
writeStatusArtifact(productDir, enrichedArtifacts.statusArtifact);
|
|
214
232
|
}
|
|
215
233
|
|
|
216
|
-
printRunSummary(results, finishedAt - startedAt);
|
|
234
|
+
printRunSummary(results, finishedAt - startedAt, knownFailureIssueValidation);
|
|
217
235
|
await reportTelemetry(telemetry, enrichedArtifacts.runArtifact);
|
|
218
236
|
if (results.some((result) => result.failed)) exitCode = 1;
|
|
237
|
+
if (shouldFailKnownFailureIssueValidation(knownFailureIssueValidation)) {
|
|
238
|
+
exitCode = 1;
|
|
239
|
+
}
|
|
219
240
|
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
220
241
|
} finally {
|
|
221
242
|
if (lifecycle.isStopRequested()) {
|
|
@@ -269,8 +290,8 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
|
|
|
269
290
|
});
|
|
270
291
|
}
|
|
271
292
|
|
|
272
|
-
function printRunSummary(results, durationMs) {
|
|
273
|
-
for (const line of buildRunSummaryLines(results, durationMs)) {
|
|
293
|
+
function printRunSummary(results, durationMs, knownFailureIssueValidation = null) {
|
|
294
|
+
for (const line of buildRunSummaryLines(results, durationMs, knownFailureIssueValidation)) {
|
|
274
295
|
console.log(line);
|
|
275
296
|
}
|
|
276
297
|
}
|
|
@@ -299,3 +320,11 @@ async function reportTelemetry(telemetry, artifact) {
|
|
|
299
320
|
function normalizePathSeparators(filePath) {
|
|
300
321
|
return filePath.split("\\").join("/");
|
|
301
322
|
}
|
|
323
|
+
|
|
324
|
+
function attachKnownFailureIssueValidation(runArtifact, statusArtifact, validation) {
|
|
325
|
+
if (!validation) return;
|
|
326
|
+
runArtifact.knownFailuresIssueValidation = validation;
|
|
327
|
+
if (statusArtifact) {
|
|
328
|
+
statusArtifact.knownFailuresIssueValidation = validation;
|
|
329
|
+
}
|
|
330
|
+
}
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export function buildStatusArtifact({
|
|
|
78
78
|
scope.serviceFilter === null;
|
|
79
79
|
|
|
80
80
|
return {
|
|
81
|
-
schemaVersion:
|
|
81
|
+
schemaVersion: 5,
|
|
82
82
|
source: "testkit",
|
|
83
83
|
notice: "Generated file. Do not edit manually.",
|
|
84
84
|
product: {
|
|
@@ -127,7 +127,7 @@ export function buildRunArtifact({
|
|
|
127
127
|
const dbBackend = summarizeDbBackend(results);
|
|
128
128
|
|
|
129
129
|
return {
|
|
130
|
-
schemaVersion:
|
|
130
|
+
schemaVersion: 5,
|
|
131
131
|
source: "testkit",
|
|
132
132
|
generatedAt: new Date(finishedAt).toISOString(),
|
|
133
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(5);
|
|
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: 5,
|
|
153
153
|
source: "testkit",
|
|
154
154
|
notice: "Generated file. Do not edit manually.",
|
|
155
155
|
product: {
|