@elench/testkit 0.1.43 → 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.
@@ -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
+ });
@@ -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
  });
@@ -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
+ }
@@ -78,7 +78,7 @@ export function buildStatusArtifact({
78
78
  scope.serviceFilter === null;
79
79
 
80
80
  return {
81
- schemaVersion: 4,
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: 4,
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(4);
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: 4,
152
+ schemaVersion: 5,
153
153
  source: "testkit",
154
154
  notice: "Generated file. Do not edit manually.",
155
155
  product: {