@elench/testkit 0.1.79 → 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 (47) 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 +31 -0
  8. package/lib/cli/presentation/run-reporter.mjs +63 -93
  9. package/lib/cli/presentation/run-reporter.test.mjs +137 -26
  10. package/lib/cli/presentation/summary-box.mjs +45 -0
  11. package/lib/cli/presentation/summary-box.test.mjs +43 -0
  12. package/lib/cli/presentation/terminal-layout.mjs +43 -0
  13. package/lib/cli/presentation/terminal-layout.test.mjs +23 -0
  14. package/lib/cli/viewer.mjs +18 -19
  15. package/lib/config/index.mjs +6 -6
  16. package/lib/config/runtime.mjs +8 -8
  17. package/lib/config-api/index.d.ts +4 -4
  18. package/lib/package.test.mjs +4 -4
  19. package/lib/{known-failures → regressions}/github.mjs +39 -77
  20. package/lib/regressions/github.test.mjs +324 -0
  21. package/lib/regressions/index.d.ts +189 -0
  22. package/lib/{known-failures → regressions}/index.mjs +90 -93
  23. package/lib/{known-failures → regressions}/index.test.mjs +37 -48
  24. package/lib/runner/formatting.mjs +105 -103
  25. package/lib/runner/formatting.test.mjs +94 -131
  26. package/lib/runner/metadata.mjs +1 -1
  27. package/lib/runner/orchestrator.mjs +7 -8
  28. package/lib/runner/regressions.mjs +304 -0
  29. package/lib/runner/{triage.test.mjs → regressions.test.mjs} +50 -36
  30. package/lib/runner/reporting.mjs +2 -2
  31. package/lib/runner/reporting.test.mjs +2 -2
  32. package/lib/runner/run-finalization.mjs +18 -30
  33. package/lib/runner/template-steps.mjs +2 -2
  34. package/lib/runner/worker-loop.mjs +1 -0
  35. package/node_modules/@elench/next-analysis/package.json +1 -1
  36. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  37. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  38. package/node_modules/@elench/ts-analysis/package.json +1 -1
  39. package/package.json +12 -9
  40. package/lib/cli/commands/known-failures/render.mjs +0 -19
  41. package/lib/cli/commands/known-failures/validate.mjs +0 -20
  42. package/lib/cli/known-failures.mjs +0 -164
  43. package/lib/known-failures/github.test.mjs +0 -512
  44. package/lib/known-failures/index.d.ts +0 -192
  45. package/lib/runner/triage.mjs +0 -221
  46. /package/lib/{known-failures → regressions}/github-cache.mjs +0 -0
  47. /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,14 +143,13 @@ 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 buildKnownFailureIssueValidationSummaryLines(result) {
151
+ export function buildRegressionSyncSummaryParts(result) {
161
152
  if (!result) return [];
162
-
163
153
  const parts = [];
164
154
  const byCode = result.summary?.byCode || {};
165
155
  if (byCode.closed_but_failing) {
@@ -168,44 +158,41 @@ export function buildKnownFailureIssueValidationSummaryLines(result) {
168
158
  if (byCode.issue_missing) {
169
159
  parts.push(`${byCode.issue_missing} missing issue reference${pluralSuffix(byCode.issue_missing)}`);
170
160
  }
171
- if (byCode.open_not_reproduced) {
172
- parts.push(`${byCode.open_not_reproduced} open issue${pluralSuffix(byCode.open_not_reproduced)} not reproduced`);
173
- }
174
161
  if (byCode.closed_not_reproduced) {
175
- 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)}`);
176
163
  }
177
- if (byCode.title_mismatch) {
178
- parts.push(`${byCode.title_mismatch} title mismatch${pluralSuffix(byCode.title_mismatch)}`);
179
- }
180
- if (byCode.state_mismatch) {
181
- 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`);
182
166
  }
183
167
  if (byCode.detected_repo_mismatch) {
184
168
  parts.push(`${byCode.detected_repo_mismatch} repo mismatch${pluralSuffix(byCode.detected_repo_mismatch)}`);
185
169
  }
186
170
  if (byCode.validation_unavailable) {
187
- parts.push("GitHub validation unavailable");
171
+ parts.push("GitHub issue sync unavailable");
188
172
  }
189
173
  if (byCode.used_stale_cache) {
190
174
  parts.push("used stale GitHub cache");
191
175
  }
192
176
 
193
- if (parts.length === 0) return [];
177
+ return parts;
178
+ }
194
179
 
180
+ export function buildRegressionSyncSummaryLines(result) {
181
+ const parts = buildRegressionSyncSummaryParts(result);
182
+ if (parts.length === 0) return [];
195
183
  return [
196
184
  "",
197
- "Known-failure issues:",
185
+ "Catalog issues:",
198
186
  ...parts.map((part) => ` - ${part}`),
199
187
  ];
200
188
  }
201
189
 
202
-
203
- function collectObservedKnownFailureEntries(document, runArtifact, statusArtifact) {
190
+ function collectObservedRegressionEntries(document, runArtifact, statusArtifact) {
204
191
  const observedTests = collectObservedTests(runArtifact, statusArtifact);
205
192
  const checks = new Map(document.entries.map((entry) => [entry.id, createObservedCheck(entry)]));
206
193
 
207
194
  for (const test of observedTests) {
208
- const matchedEntries = findMatchingKnownFailureEntries(document, test);
195
+ const matchedEntries = findMatchingRegressionEntries(document, test);
209
196
  for (const entry of matchedEntries) {
210
197
  const observed = checks.get(entry.id) || createObservedCheck(entry);
211
198
  observed.matchedTests += 1;
@@ -288,23 +275,7 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
288
275
  findings.push({
289
276
  code: "issue_missing",
290
277
  severity: "error",
291
- message: `Known failure ${entry.id} references missing issue #${entry.issue.number} in ${entry.issue.repo}`,
292
- });
293
- }
294
-
295
- if (issueData?.exists && issueData.title && issueData.title !== entry.title) {
296
- findings.push({
297
- code: "title_mismatch",
298
- severity: "error",
299
- message: `Known failure ${entry.id} title does not match issue #${entry.issue.number}`,
300
- });
301
- }
302
-
303
- if (issueData?.exists && normalizedIssueState && normalizedIssueState !== entry.state) {
304
- findings.push({
305
- code: "state_mismatch",
306
- severity: "error",
307
- 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}`,
308
279
  });
309
280
  }
310
281
 
@@ -312,7 +283,7 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
312
283
  findings.push({
313
284
  code: "closed_but_failing",
314
285
  severity: "error",
315
- 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`,
316
287
  });
317
288
  }
318
289
 
@@ -326,20 +297,20 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
326
297
  findings.push({
327
298
  code: "open_not_reproduced",
328
299
  severity: "warning",
329
- 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`,
330
301
  });
331
302
  } else if (normalizedIssueState === "closed") {
332
303
  findings.push({
333
304
  code: "closed_not_reproduced",
334
305
  severity: "warning",
335
- 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`,
336
307
  });
337
308
  }
338
309
  }
339
310
 
340
311
  return {
341
312
  id: entry.id,
342
- title: entry.title,
313
+ summary: entry.summary,
343
314
  issue: entry.issue,
344
315
  observed: {
345
316
  matchedTests: observed.matchedTests,
@@ -368,11 +339,11 @@ function buildIssueValidationEntry({ entry, observed, issueData }) {
368
339
  cached: false,
369
340
  },
370
341
  findings,
371
- status: resolveIssueValidationStatus(issueData, observed, findings),
342
+ status: resolveIssueValidationStatus(issueData, observed),
372
343
  };
373
344
  }
374
345
 
375
- function resolveIssueValidationStatus(issueData, observed, findings) {
346
+ function resolveIssueValidationStatus(issueData, observed) {
376
347
  if (!issueData) return observed.matchedTests > 0 ? "validation_unavailable" : "not_observed";
377
348
  if (issueData.exists === false) return "issue_missing";
378
349
  const normalizedState = normalizeIssueState(issueData.state);
@@ -395,7 +366,6 @@ function resolveIssueValidationStatus(issueData, observed, findings) {
395
366
  ) {
396
367
  return "closed_not_reproduced";
397
368
  }
398
- if (findings.length > 0) return "metadata_mismatch";
399
369
  return "not_observed";
400
370
  }
401
371
 
@@ -449,14 +419,6 @@ function normalizePositiveInteger(value, label) {
449
419
  return value;
450
420
  }
451
421
 
452
- function chunkValues(values, size) {
453
- const chunks = [];
454
- for (let index = 0; index < values.length; index += size) {
455
- chunks.push(values.slice(index, index + size));
456
- }
457
- return chunks;
458
- }
459
-
460
422
  function pluralSuffix(value) {
461
423
  return value === 1 ? "" : "s";
462
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
+ });