@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.
- package/README.md +50 -35
- package/lib/cli/args.mjs +2 -14
- package/lib/cli/args.test.mjs +1 -17
- package/lib/cli/command-helpers.mjs +1 -20
- package/lib/cli/entrypoint.mjs +0 -4
- package/lib/cli/presentation/colors.mjs +1 -1
- package/lib/cli/presentation/failure-presentation.mjs +31 -0
- package/lib/cli/presentation/run-reporter.mjs +63 -93
- package/lib/cli/presentation/run-reporter.test.mjs +137 -26
- package/lib/cli/presentation/summary-box.mjs +45 -0
- package/lib/cli/presentation/summary-box.test.mjs +43 -0
- package/lib/cli/presentation/terminal-layout.mjs +43 -0
- package/lib/cli/presentation/terminal-layout.test.mjs +23 -0
- package/lib/cli/viewer.mjs +18 -19
- package/lib/config/index.mjs +6 -6
- package/lib/config/runtime.mjs +8 -8
- package/lib/config-api/index.d.ts +4 -4
- package/lib/package.test.mjs +4 -4
- package/lib/{known-failures → regressions}/github.mjs +39 -77
- package/lib/regressions/github.test.mjs +324 -0
- package/lib/regressions/index.d.ts +189 -0
- package/lib/{known-failures → regressions}/index.mjs +90 -93
- package/lib/{known-failures → regressions}/index.test.mjs +37 -48
- package/lib/runner/formatting.mjs +105 -103
- package/lib/runner/formatting.test.mjs +94 -131
- package/lib/runner/metadata.mjs +1 -1
- package/lib/runner/orchestrator.mjs +7 -8
- package/lib/runner/regressions.mjs +304 -0
- package/lib/runner/{triage.test.mjs → regressions.test.mjs} +50 -36
- package/lib/runner/reporting.mjs +2 -2
- package/lib/runner/reporting.test.mjs +2 -2
- package/lib/runner/run-finalization.mjs +18 -30
- package/lib/runner/template-steps.mjs +2 -2
- package/lib/runner/worker-loop.mjs +1 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +12 -9
- package/lib/cli/commands/known-failures/render.mjs +0 -19
- package/lib/cli/commands/known-failures/validate.mjs +0 -20
- package/lib/cli/known-failures.mjs +0 -164
- package/lib/known-failures/github.test.mjs +0 -512
- package/lib/known-failures/index.d.ts +0 -192
- package/lib/runner/triage.mjs +0 -221
- /package/lib/{known-failures → regressions}/github-cache.mjs +0 -0
- /package/lib/{known-failures → regressions}/github-transport.mjs +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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", "
|
|
20
|
+
const CACHE_PATH = [".testkit", "regressions", "github-issues-cache.json"];
|
|
19
21
|
const MODES = new Set(["off", "warn", "error"]);
|
|
20
22
|
|
|
21
|
-
export function
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
61
|
+
const normalizedConfig = normalizeRegressionSyncConfig(config);
|
|
65
62
|
if (!document || !normalizedConfig || normalizedConfig.mode === "off") return null;
|
|
66
63
|
|
|
67
|
-
const checks =
|
|
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
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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}
|
|
162
|
+
parts.push(`${byCode.closed_not_reproduced} fixed known regression${pluralSuffix(byCode.closed_not_reproduced)}`);
|
|
176
163
|
}
|
|
177
|
-
if (byCode.
|
|
178
|
-
parts.push(`${byCode.
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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 =
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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
|
-
|
|
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
|
|
342
|
+
status: resolveIssueValidationStatus(issueData, observed),
|
|
372
343
|
};
|
|
373
344
|
}
|
|
374
345
|
|
|
375
|
-
function resolveIssueValidationStatus(issueData, observed
|
|
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
|
+
});
|