@elench/testkit 0.1.80 → 0.1.81

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.
Files changed (43) hide show
  1. package/README.md +50 -35
  2. package/lib/cli/args.mjs +2 -14
  3. package/lib/cli/args.test.mjs +1 -17
  4. package/lib/cli/command-helpers.mjs +1 -20
  5. package/lib/cli/entrypoint.mjs +0 -4
  6. package/lib/cli/presentation/colors.mjs +1 -1
  7. package/lib/cli/presentation/failure-presentation.mjs +4 -4
  8. package/lib/cli/presentation/run-reporter.mjs +23 -9
  9. package/lib/cli/presentation/run-reporter.test.mjs +12 -6
  10. package/lib/cli/presentation/summary-box.test.mjs +4 -4
  11. package/lib/cli/viewer.mjs +18 -19
  12. package/lib/config/index.mjs +6 -6
  13. package/lib/config/runtime.mjs +8 -8
  14. package/lib/config-api/index.d.ts +4 -4
  15. package/lib/package.test.mjs +4 -4
  16. package/lib/{known-failures → regressions}/github.mjs +36 -78
  17. package/lib/regressions/github.test.mjs +324 -0
  18. package/lib/regressions/index.d.ts +189 -0
  19. package/lib/{known-failures → regressions}/index.mjs +90 -93
  20. package/lib/{known-failures → regressions}/index.test.mjs +37 -48
  21. package/lib/runner/formatting.mjs +49 -34
  22. package/lib/runner/formatting.test.mjs +16 -15
  23. package/lib/runner/metadata.mjs +1 -1
  24. package/lib/runner/orchestrator.mjs +7 -9
  25. package/lib/runner/regressions.mjs +304 -0
  26. package/lib/runner/{triage.test.mjs → regressions.test.mjs} +50 -36
  27. package/lib/runner/reporting.mjs +2 -2
  28. package/lib/runner/reporting.test.mjs +2 -2
  29. package/lib/runner/run-finalization.mjs +18 -30
  30. package/lib/runner/template-steps.mjs +2 -2
  31. package/node_modules/@elench/next-analysis/package.json +1 -1
  32. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  33. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  34. package/node_modules/@elench/ts-analysis/package.json +1 -1
  35. package/package.json +8 -8
  36. package/lib/cli/commands/known-failures/render.mjs +0 -19
  37. package/lib/cli/commands/known-failures/validate.mjs +0 -20
  38. package/lib/cli/known-failures.mjs +0 -164
  39. package/lib/known-failures/github.test.mjs +0 -512
  40. package/lib/known-failures/index.d.ts +0 -192
  41. package/lib/runner/triage.mjs +0 -221
  42. /package/lib/{known-failures → regressions}/github-cache.mjs +0 -0
  43. /package/lib/{known-failures → regressions}/github-transport.mjs +0 -0
@@ -1,4 +1,4 @@
1
- import { findMatchingKnownFailureEntries } from "./index.mjs";
1
+ import { findMatchingRegressionEntries } from "./index.mjs";
2
2
  import {
3
3
  applyStaleCacheFallback,
4
4
  loadIssueCache,
@@ -12,37 +12,34 @@ import {
12
12
  fetchIssuesByRepo,
13
13
  parseGitHubRepoSlug,
14
14
  } from "./github-transport.mjs";
15
+
15
16
  export { parseGitHubRepoSlug } from "./github-transport.mjs";
17
+
16
18
  const DEFAULT_CACHE_TTL_SECONDS = 15 * 60;
17
19
  const CACHE_SCHEMA_VERSION = 1;
18
- const CACHE_PATH = [".testkit", "known-failures", "github-issues-cache.json"];
20
+ const CACHE_PATH = [".testkit", "regressions", "github-issues-cache.json"];
19
21
  const MODES = new Set(["off", "warn", "error"]);
20
22
 
21
- export function normalizeKnownFailureIssueValidationConfig(value) {
23
+ export function normalizeRegressionSyncConfig(value) {
22
24
  if (value == null) return null;
23
25
  if (!value || typeof value !== "object") {
24
- throw new Error("testkit.config.ts reporting.issueValidation must be an object");
26
+ throw new Error("testkit.config.ts regressions.sync must be an object");
25
27
  }
26
28
 
27
29
  const provider = normalizeOptionalString(value.provider) || "github";
28
30
  if (provider !== "github") {
29
- throw new Error('testkit.config.ts reporting.issueValidation.provider must be "github"');
31
+ throw new Error('testkit.config.ts regressions.sync.provider must be "github"');
30
32
  }
31
33
 
32
34
  const mode = normalizeOptionalString(value.mode) || "warn";
33
35
  if (!MODES.has(mode)) {
34
- throw new Error(
35
- 'testkit.config.ts reporting.issueValidation.mode must be one of: off, warn, error'
36
- );
36
+ throw new Error('testkit.config.ts regressions.sync.mode must be one of: off, warn, error');
37
37
  }
38
38
 
39
39
  const cacheTtlSeconds =
40
40
  value.cacheTtlSeconds == null
41
41
  ? DEFAULT_CACHE_TTL_SECONDS
42
- : normalizePositiveInteger(
43
- value.cacheTtlSeconds,
44
- "testkit.config.ts reporting.issueValidation.cacheTtlSeconds"
45
- );
42
+ : normalizePositiveInteger(value.cacheTtlSeconds, "testkit.config.ts regressions.sync.cacheTtlSeconds");
46
43
 
47
44
  return {
48
45
  provider,
@@ -51,7 +48,7 @@ export function normalizeKnownFailureIssueValidationConfig(value) {
51
48
  };
52
49
  }
53
50
 
54
- export async function validateKnownFailureIssues({
51
+ export async function validateRegressionIssues({
55
52
  productDir,
56
53
  document,
57
54
  runArtifact,
@@ -61,10 +58,10 @@ export async function validateKnownFailureIssues({
61
58
  transport = null,
62
59
  now = Date.now(),
63
60
  }) {
64
- const normalizedConfig = normalizeKnownFailureIssueValidationConfig(config);
61
+ const normalizedConfig = normalizeRegressionSyncConfig(config);
65
62
  if (!document || !normalizedConfig || normalizedConfig.mode === "off") return null;
66
63
 
67
- const checks = collectObservedKnownFailureEntries(document, runArtifact, statusArtifact);
64
+ const checks = collectObservedRegressionEntries(document, runArtifact, statusArtifact);
68
65
  const issueNumbersByRepo = groupIssueNumbersByRepo(document.entries);
69
66
  const repoSlug = gitMetadata?.repoSlug || null;
70
67
  const remoteUrl = gitMetadata?.remoteUrl || null;
@@ -87,11 +84,10 @@ export async function validateKnownFailureIssues({
87
84
  cacheResolution.staleByRepo,
88
85
  availabilityFindings
89
86
  );
90
- const severity = normalizedConfig.mode === "error" ? "error" : "warning";
91
87
  availabilityFindings.push({
92
88
  code: "validation_unavailable",
93
- severity,
94
- message: `GitHub issue validation unavailable: ${formatErrorMessage(error)}`,
89
+ severity: normalizedConfig.mode === "error" ? "error" : "warning",
90
+ message: `GitHub issue sync unavailable: ${formatErrorMessage(error)}`,
95
91
  });
96
92
  }
97
93
  } else {
@@ -100,12 +96,11 @@ export async function validateKnownFailureIssues({
100
96
  cacheResolution.staleByRepo,
101
97
  availabilityFindings
102
98
  );
103
- const severity = normalizedConfig.mode === "error" ? "error" : "warning";
104
99
  availabilityFindings.push({
105
100
  code: "validation_unavailable",
106
- severity,
101
+ severity: normalizedConfig.mode === "error" ? "error" : "warning",
107
102
  message:
108
- "GitHub issue validation unavailable: set GH_TOKEN/GITHUB_TOKEN or install/authenticate gh",
103
+ "GitHub issue sync unavailable: set GH_TOKEN/GITHUB_TOKEN or install/authenticate gh",
109
104
  });
110
105
  }
111
106
  }
@@ -113,11 +108,7 @@ export async function validateKnownFailureIssues({
113
108
  const entries = document.entries.map((entry) => {
114
109
  const observed = checks.get(entry.id) || createObservedCheck(entry);
115
110
  const issueData = issuesByRepo.get(entry.issue.repo)?.get(entry.issue.number) || null;
116
- return buildIssueValidationEntry({
117
- entry,
118
- observed,
119
- issueData,
120
- });
111
+ return buildIssueValidationEntry({ entry, observed, issueData });
121
112
  });
122
113
 
123
114
  const globalFindings = [...availabilityFindings];
@@ -126,7 +117,7 @@ export async function validateKnownFailureIssues({
126
117
  code: "detected_repo_mismatch",
127
118
  severity: normalizedConfig.mode === "error" ? "error" : "warning",
128
119
  message:
129
- `Known failures issueRepo ${document.issueRepo} does not match detected repo ${repoSlug}` +
120
+ `Regression catalog issueRepo ${document.issueRepo} does not match detected repo ${repoSlug}` +
130
121
  (remoteUrl ? ` (${remoteUrl})` : ""),
131
122
  });
132
123
  }
@@ -152,12 +143,12 @@ export async function validateKnownFailureIssues({
152
143
  };
153
144
  }
154
145
 
155
- export function shouldFailKnownFailureIssueValidation(result) {
146
+ export function shouldFailRegressionSync(result) {
156
147
  if (!result || result.mode !== "error") return false;
157
148
  return (result.summary?.errors || 0) > 0;
158
149
  }
159
150
 
160
- export function buildKnownFailureIssueValidationSummaryParts(result) {
151
+ export function buildRegressionSyncSummaryParts(result) {
161
152
  if (!result) return [];
162
153
  const parts = [];
163
154
  const byCode = result.summary?.byCode || {};
@@ -167,23 +158,17 @@ export function buildKnownFailureIssueValidationSummaryParts(result) {
167
158
  if (byCode.issue_missing) {
168
159
  parts.push(`${byCode.issue_missing} missing issue reference${pluralSuffix(byCode.issue_missing)}`);
169
160
  }
170
- if (byCode.open_not_reproduced) {
171
- parts.push(`${byCode.open_not_reproduced} open issue${pluralSuffix(byCode.open_not_reproduced)} not reproduced`);
172
- }
173
161
  if (byCode.closed_not_reproduced) {
174
- parts.push(`${byCode.closed_not_reproduced} closed issue${pluralSuffix(byCode.closed_not_reproduced)} not reproduced`);
162
+ parts.push(`${byCode.closed_not_reproduced} fixed known regression${pluralSuffix(byCode.closed_not_reproduced)}`);
175
163
  }
176
- if (byCode.title_mismatch) {
177
- parts.push(`${byCode.title_mismatch} title mismatch${pluralSuffix(byCode.title_mismatch)}`);
178
- }
179
- if (byCode.state_mismatch) {
180
- parts.push(`${byCode.state_mismatch} state mismatch${pluralSuffix(byCode.state_mismatch)}`);
164
+ if (byCode.open_not_reproduced) {
165
+ parts.push(`${byCode.open_not_reproduced} known regression${pluralSuffix(byCode.open_not_reproduced)} no longer reproducing`);
181
166
  }
182
167
  if (byCode.detected_repo_mismatch) {
183
168
  parts.push(`${byCode.detected_repo_mismatch} repo mismatch${pluralSuffix(byCode.detected_repo_mismatch)}`);
184
169
  }
185
170
  if (byCode.validation_unavailable) {
186
- parts.push("GitHub validation unavailable");
171
+ parts.push("GitHub issue sync unavailable");
187
172
  }
188
173
  if (byCode.used_stale_cache) {
189
174
  parts.push("used stale GitHub cache");
@@ -192,24 +177,22 @@ export function buildKnownFailureIssueValidationSummaryParts(result) {
192
177
  return parts;
193
178
  }
194
179
 
195
- export function buildKnownFailureIssueValidationSummaryLines(result) {
196
- const parts = buildKnownFailureIssueValidationSummaryParts(result);
180
+ export function buildRegressionSyncSummaryLines(result) {
181
+ const parts = buildRegressionSyncSummaryParts(result);
197
182
  if (parts.length === 0) return [];
198
-
199
183
  return [
200
184
  "",
201
- "Known-failure issues:",
185
+ "Catalog issues:",
202
186
  ...parts.map((part) => ` - ${part}`),
203
187
  ];
204
188
  }
205
189
 
206
-
207
- function collectObservedKnownFailureEntries(document, runArtifact, statusArtifact) {
190
+ function collectObservedRegressionEntries(document, runArtifact, statusArtifact) {
208
191
  const observedTests = collectObservedTests(runArtifact, statusArtifact);
209
192
  const checks = new Map(document.entries.map((entry) => [entry.id, createObservedCheck(entry)]));
210
193
 
211
194
  for (const test of observedTests) {
212
- const matchedEntries = findMatchingKnownFailureEntries(document, test);
195
+ const matchedEntries = findMatchingRegressionEntries(document, test);
213
196
  for (const entry of matchedEntries) {
214
197
  const observed = checks.get(entry.id) || createObservedCheck(entry);
215
198
  observed.matchedTests += 1;
@@ -292,23 +275,7 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
292
275
  findings.push({
293
276
  code: "issue_missing",
294
277
  severity: "error",
295
- message: `Known failure ${entry.id} references missing issue #${entry.issue.number} in ${entry.issue.repo}`,
296
- });
297
- }
298
-
299
- if (issueData?.exists && issueData.title && issueData.title !== entry.title) {
300
- findings.push({
301
- code: "title_mismatch",
302
- severity: "error",
303
- message: `Known failure ${entry.id} title does not match issue #${entry.issue.number}`,
304
- });
305
- }
306
-
307
- if (issueData?.exists && normalizedIssueState && normalizedIssueState !== entry.state) {
308
- findings.push({
309
- code: "state_mismatch",
310
- severity: "error",
311
- message: `Known failure ${entry.id} state ${entry.state} does not match issue #${entry.issue.number} state ${normalizedIssueState}`,
278
+ message: `Regression catalog entry ${entry.id} references missing issue #${entry.issue.number} in ${entry.issue.repo}`,
312
279
  });
313
280
  }
314
281
 
@@ -316,7 +283,7 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
316
283
  findings.push({
317
284
  code: "closed_but_failing",
318
285
  severity: "error",
319
- message: `Known failure ${entry.id} still fails but issue #${entry.issue.number} is closed`,
286
+ message: `Regression ${entry.id} still fails but issue #${entry.issue.number} is closed`,
320
287
  });
321
288
  }
322
289
 
@@ -330,20 +297,20 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
330
297
  findings.push({
331
298
  code: "open_not_reproduced",
332
299
  severity: "warning",
333
- message: `Known failure ${entry.id} did not reproduce but issue #${entry.issue.number} is open`,
300
+ message: `Regression ${entry.id} did not reproduce but issue #${entry.issue.number} is open`,
334
301
  });
335
302
  } else if (normalizedIssueState === "closed") {
336
303
  findings.push({
337
304
  code: "closed_not_reproduced",
338
305
  severity: "warning",
339
- message: `Known failure ${entry.id} did not reproduce and issue #${entry.issue.number} is closed`,
306
+ message: `Regression ${entry.id} did not reproduce and issue #${entry.issue.number} is closed`,
340
307
  });
341
308
  }
342
309
  }
343
310
 
344
311
  return {
345
312
  id: entry.id,
346
- title: entry.title,
313
+ summary: entry.summary,
347
314
  issue: entry.issue,
348
315
  observed: {
349
316
  matchedTests: observed.matchedTests,
@@ -372,11 +339,11 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
372
339
  cached: false,
373
340
  },
374
341
  findings,
375
- status: resolveIssueValidationStatus(issueData, observed, findings),
342
+ status: resolveIssueValidationStatus(issueData, observed),
376
343
  };
377
344
  }
378
345
 
379
- function resolveIssueValidationStatus(issueData, observed, findings) {
346
+ function resolveIssueValidationStatus(issueData, observed) {
380
347
  if (!issueData) return observed.matchedTests > 0 ? "validation_unavailable" : "not_observed";
381
348
  if (issueData.exists === false) return "issue_missing";
382
349
  const normalizedState = normalizeIssueState(issueData.state);
@@ -399,7 +366,6 @@ function resolveIssueValidationStatus(issueData, observed, findings) {
399
366
  ) {
400
367
  return "closed_not_reproduced";
401
368
  }
402
- if (findings.length > 0) return "metadata_mismatch";
403
369
  return "not_observed";
404
370
  }
405
371
 
@@ -453,14 +419,6 @@ function normalizePositiveInteger(value, label) {
453
419
  return value;
454
420
  }
455
421
 
456
- function chunkValues(values, size) {
457
- const chunks = [];
458
- for (let index = 0; index < values.length; index += size) {
459
- chunks.push(values.slice(index, index + size));
460
- }
461
- return chunks;
462
- }
463
-
464
422
  function pluralSuffix(value) {
465
423
  return value === 1 ? "" : "s";
466
424
  }
@@ -0,0 +1,324 @@
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 { normalizeRegressionCatalogDocument } from "./index.mjs";
6
+ import {
7
+ parseGitHubRepoSlug,
8
+ shouldFailRegressionSync,
9
+ validateRegressionIssues,
10
+ } from "./github.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("regression GitHub sync", () => {
21
+ it("parses GitHub repo slugs from common origin URLs", () => {
22
+ expect(parseGitHubRepoSlug("https://github.com/acme/repo.git")).toBe("acme/repo");
23
+ expect(parseGitHubRepoSlug("git@github.com:acme/repo.git")).toBe("acme/repo");
24
+ expect(parseGitHubRepoSlug("ssh://git@github.com/acme/repo.git")).toBe("acme/repo");
25
+ expect(parseGitHubRepoSlug("https://gitlab.com/acme/repo.git")).toBe(null);
26
+ });
27
+
28
+ it("fails sync when a linked issue is closed but the regression still reproduces", async () => {
29
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-regressions-gh-"));
30
+ tempDirs.push(tempDir);
31
+ const document = normalizeRegressionCatalogDocument({
32
+ schemaVersion: 1,
33
+ issueRepo: "acme/repo",
34
+ entries: [
35
+ {
36
+ id: "bad-message",
37
+ classification: "product_bug",
38
+ issue: {
39
+ repo: "acme/repo",
40
+ number: 12,
41
+ },
42
+ summary: "API returns the wrong message",
43
+ cause: "The payload is wrong.",
44
+ lastReviewedAt: "2026-05-04",
45
+ fingerprints: [
46
+ {
47
+ service: "api",
48
+ type: "int",
49
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ });
55
+
56
+ const result = await validateRegressionIssues({
57
+ productDir: tempDir,
58
+ document,
59
+ statusArtifact: {
60
+ tests: [
61
+ {
62
+ service: "api",
63
+ type: "int",
64
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
65
+ status: "failed",
66
+ },
67
+ ],
68
+ },
69
+ config: {
70
+ provider: "github",
71
+ mode: "error",
72
+ cacheTtlSeconds: 60,
73
+ },
74
+ gitMetadata: {
75
+ repoSlug: "acme/repo",
76
+ remoteUrl: "https://github.com/acme/repo.git",
77
+ },
78
+ transport: {
79
+ async fetchRepoIssues(repo, numbers) {
80
+ const map = new Map();
81
+ map.set(numbers[0], {
82
+ repo,
83
+ number: numbers[0],
84
+ exists: true,
85
+ title: "API returns the wrong message",
86
+ state: "CLOSED",
87
+ url: `https://github.com/${repo}/issues/${numbers[0]}`,
88
+ checkedAt: "2026-05-04T00:00:00.000Z",
89
+ source: "github",
90
+ });
91
+ return map;
92
+ },
93
+ },
94
+ });
95
+
96
+ expect(result.summary.byCode.closed_but_failing).toBe(1);
97
+ expect(result.summary.errors).toBeGreaterThan(0);
98
+ expect(result.entries[0].status).toBe("closed_but_failing");
99
+ expect(shouldFailRegressionSync(result)).toBe(true);
100
+ });
101
+
102
+ it("allows multiple local entries to share one GitHub issue", async () => {
103
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-regressions-gh-shared-"));
104
+ tempDirs.push(tempDir);
105
+ const document = normalizeRegressionCatalogDocument({
106
+ schemaVersion: 1,
107
+ issueRepo: "acme/repo",
108
+ entries: [
109
+ {
110
+ id: "route-a-invalid-uuid",
111
+ classification: "product_bug",
112
+ issue: {
113
+ repo: "acme/repo",
114
+ number: 77,
115
+ },
116
+ summary: "Route A leaks UUID parse errors",
117
+ cause: "Route A is missing UUID validation.",
118
+ lastReviewedAt: "2026-05-04",
119
+ fingerprints: [
120
+ {
121
+ service: "api",
122
+ type: "int",
123
+ path: "src/api/routes/__testkit__/route-a.int.testkit.ts",
124
+ },
125
+ ],
126
+ },
127
+ {
128
+ id: "route-b-invalid-uuid",
129
+ classification: "product_bug",
130
+ issue: {
131
+ repo: "acme/repo",
132
+ number: 77,
133
+ },
134
+ summary: "Route B leaks UUID parse errors",
135
+ cause: "Route B is missing UUID validation.",
136
+ lastReviewedAt: "2026-05-04",
137
+ fingerprints: [
138
+ {
139
+ service: "api",
140
+ type: "int",
141
+ path: "src/api/routes/__testkit__/route-b.int.testkit.ts",
142
+ },
143
+ ],
144
+ },
145
+ ],
146
+ });
147
+
148
+ const result = await validateRegressionIssues({
149
+ productDir: tempDir,
150
+ document,
151
+ statusArtifact: {
152
+ tests: [
153
+ {
154
+ service: "api",
155
+ type: "int",
156
+ path: "src/api/routes/__testkit__/route-a.int.testkit.ts",
157
+ status: "failed",
158
+ },
159
+ {
160
+ service: "api",
161
+ type: "int",
162
+ path: "src/api/routes/__testkit__/route-b.int.testkit.ts",
163
+ status: "failed",
164
+ },
165
+ ],
166
+ },
167
+ config: {
168
+ provider: "github",
169
+ mode: "error",
170
+ },
171
+ transport: {
172
+ async fetchRepoIssues(repo, numbers) {
173
+ const map = new Map();
174
+ map.set(numbers[0], {
175
+ repo,
176
+ number: numbers[0],
177
+ exists: true,
178
+ title: "UUID path params reach Postgres and return 500 instead of 400",
179
+ state: "OPEN",
180
+ url: `https://github.com/${repo}/issues/${numbers[0]}`,
181
+ checkedAt: "2026-05-04T00:00:00.000Z",
182
+ source: "github",
183
+ });
184
+ return map;
185
+ },
186
+ },
187
+ });
188
+
189
+ expect(result.summary.errors).toBe(0);
190
+ expect(result.entries).toHaveLength(2);
191
+ expect(result.entries[0].findings).toEqual([]);
192
+ expect(result.entries[1].findings).toEqual([]);
193
+ expect(result.entries[0].status).toBe("open_and_failing");
194
+ expect(result.entries[1].status).toBe("open_and_failing");
195
+ });
196
+
197
+ it("warns when an open issue is not reproduced", async () => {
198
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-regressions-gh-open-"));
199
+ tempDirs.push(tempDir);
200
+ const document = normalizeRegressionCatalogDocument({
201
+ schemaVersion: 1,
202
+ issueRepo: "acme/repo",
203
+ entries: [
204
+ {
205
+ id: "bad-message",
206
+ classification: "product_bug",
207
+ issue: {
208
+ repo: "acme/repo",
209
+ number: 12,
210
+ },
211
+ summary: "API returns the wrong message",
212
+ cause: "The payload is wrong.",
213
+ lastReviewedAt: "2026-05-04",
214
+ fingerprints: [
215
+ {
216
+ service: "api",
217
+ type: "int",
218
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ });
224
+
225
+ const result = await validateRegressionIssues({
226
+ productDir: tempDir,
227
+ document,
228
+ statusArtifact: {
229
+ tests: [
230
+ {
231
+ service: "api",
232
+ type: "int",
233
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
234
+ status: "passed",
235
+ },
236
+ ],
237
+ },
238
+ config: {
239
+ provider: "github",
240
+ mode: "warn",
241
+ },
242
+ gitMetadata: {
243
+ repoSlug: "acme/repo",
244
+ },
245
+ transport: {
246
+ async fetchRepoIssues(repo, numbers) {
247
+ const map = new Map();
248
+ map.set(numbers[0], {
249
+ repo,
250
+ number: numbers[0],
251
+ exists: true,
252
+ title: "API returns the wrong message",
253
+ state: "OPEN",
254
+ url: `https://github.com/${repo}/issues/${numbers[0]}`,
255
+ checkedAt: "2026-05-04T00:00:00.000Z",
256
+ source: "github",
257
+ });
258
+ return map;
259
+ },
260
+ },
261
+ });
262
+
263
+ expect(result.summary.byCode.open_not_reproduced).toBe(1);
264
+ expect(result.summary.errors).toBe(0);
265
+ expect(result.summary.warnings).toBe(1);
266
+ expect(result.entries[0].status).toBe("open_not_reproduced");
267
+ });
268
+
269
+ it("falls back to warning when sync is unavailable", async () => {
270
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-regressions-gh-unavailable-"));
271
+ tempDirs.push(tempDir);
272
+ const document = normalizeRegressionCatalogDocument({
273
+ schemaVersion: 1,
274
+ entries: [
275
+ {
276
+ id: "bad-message",
277
+ classification: "product_bug",
278
+ issue: {
279
+ repo: "acme/repo",
280
+ number: 12,
281
+ },
282
+ summary: "API returns the wrong message",
283
+ cause: "The payload is wrong.",
284
+ lastReviewedAt: "2026-05-04",
285
+ fingerprints: [
286
+ {
287
+ service: "api",
288
+ type: "int",
289
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
290
+ },
291
+ ],
292
+ },
293
+ ],
294
+ });
295
+
296
+ const result = await validateRegressionIssues({
297
+ productDir: tempDir,
298
+ document,
299
+ statusArtifact: {
300
+ tests: [
301
+ {
302
+ service: "api",
303
+ type: "int",
304
+ path: "src/api/routes/__testkit__/failing.int.testkit.ts",
305
+ status: "failed",
306
+ },
307
+ ],
308
+ },
309
+ config: {
310
+ provider: "github",
311
+ mode: "warn",
312
+ },
313
+ transport: {
314
+ async fetchRepoIssues() {
315
+ throw new Error("boom");
316
+ },
317
+ },
318
+ });
319
+
320
+ expect(result.summary.byCode.validation_unavailable).toBe(1);
321
+ expect(result.summary.errors).toBe(0);
322
+ expect(result.summary.warnings).toBe(1);
323
+ });
324
+ });