@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,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
buildFailurePresentation,
|
|
4
|
+
buildRunSummaryData,
|
|
3
5
|
buildRunSummaryLines,
|
|
4
6
|
formatDuration,
|
|
5
7
|
formatError,
|
|
@@ -61,8 +63,8 @@ describe("runner formatting", () => {
|
|
|
61
63
|
expect(longestServiceName([{ name: "api" }, { name: "frontend" }])).toBe(8);
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
it("builds
|
|
65
|
-
const
|
|
66
|
+
it("builds aggregate summary data without failure dumps", () => {
|
|
67
|
+
const summary = buildRunSummaryData(
|
|
66
68
|
[
|
|
67
69
|
{
|
|
68
70
|
name: "frontend",
|
|
@@ -74,96 +76,45 @@ describe("runner formatting", () => {
|
|
|
74
76
|
failedSuiteCount: 1,
|
|
75
77
|
totalFileCount: 3,
|
|
76
78
|
passedFileCount: 2,
|
|
79
|
+
failedFileCount: 1,
|
|
77
80
|
skippedFileCount: 0,
|
|
78
81
|
notRunFileCount: 0,
|
|
79
82
|
durationMs: 20_000,
|
|
80
|
-
suites: [
|
|
81
|
-
{
|
|
82
|
-
failed: true,
|
|
83
|
-
type: "e2e",
|
|
84
|
-
name: "auth",
|
|
85
|
-
framework: "playwright",
|
|
86
|
-
failedFiles: ["a.pw.testkit.ts"],
|
|
87
|
-
durationMs: 12_000,
|
|
88
|
-
error: "boom",
|
|
89
|
-
},
|
|
90
|
-
],
|
|
91
|
-
errors: ["worker broke"],
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
20_000
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
expect(lines.join("\n")).toContain("Summary: 2 passed, 0 failed, 0 skipped, 0 not run across 3 files");
|
|
98
|
-
expect(lines.join("\n")).toContain("Runtime Errors:");
|
|
99
|
-
expect(lines.join("\n")).toContain("worker broke");
|
|
100
|
-
expect(lines.at(-1)).toBe("Result: FAILED (1/1 services failed)");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("marks services with only skipped suites as SKIP", () => {
|
|
104
|
-
const lines = buildRunSummaryLines(
|
|
105
|
-
[
|
|
106
|
-
{
|
|
107
|
-
name: "api",
|
|
108
|
-
skipped: false,
|
|
109
|
-
failed: false,
|
|
110
|
-
suiteCount: 1,
|
|
111
|
-
completedSuiteCount: 1,
|
|
112
|
-
skippedSuiteCount: 1,
|
|
113
|
-
failedSuiteCount: 0,
|
|
114
|
-
totalFileCount: 1,
|
|
115
|
-
passedFileCount: 0,
|
|
116
|
-
skippedFileCount: 1,
|
|
117
|
-
notRunFileCount: 0,
|
|
118
|
-
durationMs: 0,
|
|
119
83
|
suites: [],
|
|
120
|
-
errors: [],
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
0
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
expect(lines.join("\n")).toContain("Summary: 0 passed, 0 failed, 1 skipped, 0 not run across 1 file");
|
|
127
|
-
expect(lines.at(-1)).toBe("Result: PASSED");
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("appends known-failure issue validation summary lines", () => {
|
|
131
|
-
const lines = buildRunSummaryLines(
|
|
132
|
-
[
|
|
133
|
-
{
|
|
134
|
-
name: "api",
|
|
135
|
-
skipped: false,
|
|
136
|
-
failed: false,
|
|
137
|
-
suiteCount: 1,
|
|
138
|
-
completedSuiteCount: 1,
|
|
139
|
-
skippedSuiteCount: 0,
|
|
140
|
-
failedSuiteCount: 0,
|
|
141
|
-
totalFileCount: 1,
|
|
142
|
-
passedFileCount: 1,
|
|
143
|
-
skippedFileCount: 0,
|
|
144
|
-
notRunFileCount: 0,
|
|
145
|
-
durationMs: 1_000,
|
|
146
|
-
suites: [],
|
|
147
|
-
errors: [],
|
|
84
|
+
errors: ["worker broke"],
|
|
148
85
|
},
|
|
149
86
|
],
|
|
150
|
-
|
|
87
|
+
20_000,
|
|
151
88
|
{
|
|
152
89
|
summary: {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
90
|
+
newRegressions: 1,
|
|
91
|
+
knownRegressions: 2,
|
|
92
|
+
fixedKnownRegressions: 3,
|
|
93
|
+
catalogStale: 4,
|
|
94
|
+
catalogSyncUnavailable: true,
|
|
95
|
+
usedStaleCache: false,
|
|
157
96
|
},
|
|
158
97
|
}
|
|
159
98
|
);
|
|
160
99
|
|
|
161
|
-
expect(
|
|
162
|
-
|
|
163
|
-
|
|
100
|
+
expect(summary).toMatchObject({
|
|
101
|
+
result: "FAILED",
|
|
102
|
+
passed: 2,
|
|
103
|
+
failed: 1,
|
|
104
|
+
skipped: 0,
|
|
105
|
+
notRun: 0,
|
|
106
|
+
files: 3,
|
|
107
|
+
duration: "20s",
|
|
108
|
+
serviceErrors: 1,
|
|
109
|
+
});
|
|
110
|
+
expect(summary.newRegressions).toBe(1);
|
|
111
|
+
expect(summary.knownRegressions).toBe(2);
|
|
112
|
+
expect(summary.fixedKnownRegressions).toBe(3);
|
|
113
|
+
expect(summary.catalogStale).toBe(4);
|
|
114
|
+
expect(summary.catalogSyncUnavailable).toBe(true);
|
|
164
115
|
});
|
|
165
116
|
|
|
166
|
-
it("
|
|
117
|
+
it("builds compact summary lines without failure and runtime dump sections", () => {
|
|
167
118
|
const lines = buildRunSummaryLines(
|
|
168
119
|
[
|
|
169
120
|
{
|
|
@@ -180,63 +131,75 @@ describe("runner formatting", () => {
|
|
|
180
131
|
skippedFileCount: 0,
|
|
181
132
|
notRunFileCount: 0,
|
|
182
133
|
durationMs: 500,
|
|
183
|
-
suites: [
|
|
184
|
-
|
|
185
|
-
failed: true,
|
|
186
|
-
type: "int",
|
|
187
|
-
name: "default",
|
|
188
|
-
framework: "k6",
|
|
189
|
-
failedFiles: ["__testkit__/health/health.int.testkit.ts"],
|
|
190
|
-
durationMs: 500,
|
|
191
|
-
files: [
|
|
192
|
-
{
|
|
193
|
-
path: "__testkit__/health/health.int.testkit.ts",
|
|
194
|
-
status: "failed",
|
|
195
|
-
error: "Default runtime thresholds failed: checks(rate==1.0)",
|
|
196
|
-
failureDetails: [
|
|
197
|
-
{
|
|
198
|
-
kind: "http-assertion",
|
|
199
|
-
key: "GET /health > status is 200",
|
|
200
|
-
title: "status is 200",
|
|
201
|
-
message: "GET /health expected 200, got 404",
|
|
202
|
-
request: {
|
|
203
|
-
method: "GET",
|
|
204
|
-
path: "/health",
|
|
205
|
-
requestId: "req-1",
|
|
206
|
-
},
|
|
207
|
-
response: {
|
|
208
|
-
status: 404,
|
|
209
|
-
bodyPreview: '{"error":"nope"}',
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
],
|
|
213
|
-
triage: {
|
|
214
|
-
status: "known_failure",
|
|
215
|
-
entries: [
|
|
216
|
-
{
|
|
217
|
-
id: "health-is-bad",
|
|
218
|
-
state: "open",
|
|
219
|
-
issue: {
|
|
220
|
-
repo: "acme/example",
|
|
221
|
-
number: 42,
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
|
-
},
|
|
226
|
-
},
|
|
227
|
-
],
|
|
228
|
-
error: null,
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
errors: [],
|
|
134
|
+
suites: [],
|
|
135
|
+
errors: ["worker broke"],
|
|
232
136
|
},
|
|
233
137
|
],
|
|
234
138
|
500
|
|
235
139
|
);
|
|
236
140
|
|
|
237
|
-
expect(lines.join("\n")).toContain("
|
|
238
|
-
expect(lines.join("\n")).toContain(
|
|
239
|
-
expect(lines.join("\n")).toContain("
|
|
240
|
-
expect(lines.join("\n")).not.toContain("
|
|
141
|
+
expect(lines.join("\n")).toContain("Summary: 0 passed, 1 failed, 0 skipped, 0 not run across 1 file");
|
|
142
|
+
expect(lines.join("\n")).toContain("Runtime errors: 1");
|
|
143
|
+
expect(lines.join("\n")).not.toContain("Catalog issues:");
|
|
144
|
+
expect(lines.join("\n")).not.toContain("Failures:");
|
|
145
|
+
expect(lines.join("\n")).not.toContain("Runtime Errors:");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("builds inline failure presentations from structured HTTP assertion details", () => {
|
|
149
|
+
const presentation = buildFailurePresentation(
|
|
150
|
+
{
|
|
151
|
+
service: "api",
|
|
152
|
+
type: "int",
|
|
153
|
+
path: "__testkit__/health/health.int.testkit.ts",
|
|
154
|
+
error: "Default runtime thresholds failed: checks(rate==1.0)",
|
|
155
|
+
failureDetails: [
|
|
156
|
+
{
|
|
157
|
+
kind: "http-assertion",
|
|
158
|
+
key: "GET /health > status is 200",
|
|
159
|
+
title: "status is 200",
|
|
160
|
+
message: "GET /health expected 200, got 404",
|
|
161
|
+
request: {
|
|
162
|
+
method: "GET",
|
|
163
|
+
path: "/health",
|
|
164
|
+
requestId: "req-1",
|
|
165
|
+
},
|
|
166
|
+
response: {
|
|
167
|
+
status: 404,
|
|
168
|
+
bodyPreview: '{"error":"nope"}',
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
issueRepo: "acme/example",
|
|
176
|
+
entries: [
|
|
177
|
+
{
|
|
178
|
+
id: "health-is-bad",
|
|
179
|
+
classification: "product_bug",
|
|
180
|
+
issue: {
|
|
181
|
+
repo: "acme/example",
|
|
182
|
+
number: 42,
|
|
183
|
+
},
|
|
184
|
+
summary: "Health is bad",
|
|
185
|
+
cause: "because",
|
|
186
|
+
lastReviewedAt: "2026-01-01",
|
|
187
|
+
fingerprints: [
|
|
188
|
+
{
|
|
189
|
+
service: "api",
|
|
190
|
+
type: "int",
|
|
191
|
+
path: "__testkit__/health/health.int.testkit.ts",
|
|
192
|
+
failureKey: "GET /health > status is 200",
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(presentation.primary).toBe("GET /health expected 200, got 404");
|
|
201
|
+
expect(presentation.details).toContain('response: {"error":"nope"}');
|
|
202
|
+
expect(presentation.details).toContain("regression: known #42 product_bug");
|
|
203
|
+
expect(presentation.details).toContain("logs: requestId=req-1");
|
|
241
204
|
});
|
|
242
205
|
});
|
package/lib/runner/metadata.mjs
CHANGED
|
@@ -3,7 +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 "../
|
|
6
|
+
import { parseGitHubRepoSlug } from "../regressions/github.mjs";
|
|
7
7
|
|
|
8
8
|
export function collectGitMetadata(productDir) {
|
|
9
9
|
const read = (args) => {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
recordGraphError,
|
|
14
14
|
recordTaskOutcome,
|
|
15
15
|
} from "./results.mjs";
|
|
16
|
-
import {
|
|
16
|
+
import { loadRegressionCatalogConfig } from "./regressions.mjs";
|
|
17
17
|
import { formatError } from "./formatting.mjs";
|
|
18
18
|
import {
|
|
19
19
|
loadTimings,
|
|
@@ -56,11 +56,12 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
56
56
|
},
|
|
57
57
|
testkitVersion: readPackageMetadata().version,
|
|
58
58
|
};
|
|
59
|
-
const
|
|
59
|
+
const regressionCatalog = loadRegressionCatalogConfig(
|
|
60
60
|
productDir,
|
|
61
|
-
configs[0]?.testkit?.
|
|
61
|
+
configs[0]?.testkit?.regressions || null
|
|
62
62
|
);
|
|
63
63
|
const reporter = opts.reporter || null;
|
|
64
|
+
reporter?.setRegressionCatalog?.(regressionCatalog);
|
|
64
65
|
const logRegistry = createRunLogRegistry(productDir);
|
|
65
66
|
const workerState = {
|
|
66
67
|
workerCount: 0,
|
|
@@ -113,7 +114,6 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
113
114
|
lifecycle.installSignalHandlers();
|
|
114
115
|
let results = [];
|
|
115
116
|
let finishedAt = Date.now();
|
|
116
|
-
let knownFailureIssueValidation = null;
|
|
117
117
|
writeLiveSnapshot();
|
|
118
118
|
|
|
119
119
|
try {
|
|
@@ -208,15 +208,14 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
|
|
|
208
208
|
metadata,
|
|
209
209
|
logRegistry,
|
|
210
210
|
setupRegistry,
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
regressionCatalog,
|
|
212
|
+
regressionSyncConfig: configs[0]?.testkit?.regressions?.sync || null,
|
|
213
213
|
telemetry,
|
|
214
214
|
reporter,
|
|
215
215
|
writeStatus: opts.writeStatus,
|
|
216
216
|
});
|
|
217
|
-
knownFailureIssueValidation = finalized.knownFailureIssueValidation;
|
|
218
217
|
if (results.some((result) => result.failed)) exitCode = 1;
|
|
219
|
-
if (finalized.
|
|
218
|
+
if (finalized.shouldFailRegressionSync) {
|
|
220
219
|
exitCode = 1;
|
|
221
220
|
}
|
|
222
221
|
if (lifecycle.isStopRequested()) exitCode = Math.max(exitCode, 130);
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRegressionFileIdentity,
|
|
3
|
+
findMatchingRegressionEntries,
|
|
4
|
+
loadRegressionCatalogConfig,
|
|
5
|
+
} from "../regressions/index.mjs";
|
|
6
|
+
|
|
7
|
+
export { loadRegressionCatalogConfig };
|
|
8
|
+
|
|
9
|
+
export function applyRegressionAnalysisToArtifacts(
|
|
10
|
+
runArtifact,
|
|
11
|
+
statusArtifact,
|
|
12
|
+
regressionCatalog,
|
|
13
|
+
regressionSync
|
|
14
|
+
) {
|
|
15
|
+
const runEntries = extractRunFileEntries(runArtifact);
|
|
16
|
+
const statusEntries = extractStatusFileEntries(statusArtifact);
|
|
17
|
+
const fileSummaries = new Map();
|
|
18
|
+
const syncById = new Map((regressionSync?.entries || []).map((entry) => [entry.id, entry]));
|
|
19
|
+
const staleEntryIds = new Set();
|
|
20
|
+
const newRegressionDrafts = [];
|
|
21
|
+
const fixedRegressionDrafts = [];
|
|
22
|
+
|
|
23
|
+
for (const entry of [...runEntries, ...statusEntries]) {
|
|
24
|
+
const key = buildRegressionFileIdentity(entry.service, entry.type, entry.path);
|
|
25
|
+
if (!fileSummaries.has(key)) {
|
|
26
|
+
fileSummaries.set(key, {
|
|
27
|
+
service: entry.service,
|
|
28
|
+
type: entry.type,
|
|
29
|
+
path: entry.path,
|
|
30
|
+
status: entry.status,
|
|
31
|
+
error: entry.error || null,
|
|
32
|
+
failureDetails: Array.isArray(entry.failureDetails) ? entry.failureDetails : [],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const diagnosesByFileKey = new Map();
|
|
38
|
+
|
|
39
|
+
for (const fileSummary of fileSummaries.values()) {
|
|
40
|
+
const diagnosis = buildFileDiagnosis(fileSummary, regressionCatalog, syncById);
|
|
41
|
+
const fileKey = buildRegressionFileIdentity(
|
|
42
|
+
fileSummary.service,
|
|
43
|
+
fileSummary.type,
|
|
44
|
+
fileSummary.path
|
|
45
|
+
);
|
|
46
|
+
diagnosesByFileKey.set(fileKey, diagnosis);
|
|
47
|
+
for (const entry of diagnosis.entries) {
|
|
48
|
+
if (entry.catalogFindings.length > 0) {
|
|
49
|
+
staleEntryIds.add(entry.id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (diagnosis.status === "new_regression") {
|
|
53
|
+
newRegressionDrafts.push(buildNewRegressionDraft(fileSummary, regressionCatalog));
|
|
54
|
+
}
|
|
55
|
+
if (diagnosis.status === "fixed_known_regression") {
|
|
56
|
+
for (const entry of diagnosis.entries) {
|
|
57
|
+
fixedRegressionDrafts.push({
|
|
58
|
+
id: entry.id,
|
|
59
|
+
issue: entry.issue,
|
|
60
|
+
summary: entry.summary,
|
|
61
|
+
suggestedAction: "review-for-removal",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const entry of [...runEntries, ...statusEntries]) {
|
|
68
|
+
const fileKey = buildRegressionFileIdentity(entry.service, entry.type, entry.path);
|
|
69
|
+
const diagnosis = diagnosesByFileKey.get(fileKey);
|
|
70
|
+
if (!diagnosis) continue;
|
|
71
|
+
setEntryDiagnosis(entry, diagnosis);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const summaryTests = statusArtifact?.tests || runEntries;
|
|
75
|
+
const report = buildRegressionReport(
|
|
76
|
+
summaryTests,
|
|
77
|
+
regressionCatalog,
|
|
78
|
+
regressionSync,
|
|
79
|
+
diagnosesByFileKey,
|
|
80
|
+
staleEntryIds,
|
|
81
|
+
newRegressionDrafts,
|
|
82
|
+
fixedRegressionDrafts
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
runArtifact.regressions = report;
|
|
86
|
+
if (statusArtifact) {
|
|
87
|
+
statusArtifact.regressions = report;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { runArtifact, statusArtifact, regressionReport: report };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildFileDiagnosis(fileSummary, regressionCatalog, syncById) {
|
|
94
|
+
const matchedEntries = regressionCatalog
|
|
95
|
+
? findMatchingRegressionEntries(regressionCatalog, fileSummary)
|
|
96
|
+
: [];
|
|
97
|
+
const status = resolveDiagnosisStatus(fileSummary.status, matchedEntries.length);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
status,
|
|
101
|
+
classifications: [...new Set(matchedEntries.map((entry) => entry.classification))].sort(),
|
|
102
|
+
entries: matchedEntries.map((entry) => toDiagnosisEntry(entry, syncById.get(entry.id) || null)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveDiagnosisStatus(fileStatus, matchCount) {
|
|
107
|
+
if (fileStatus === "failed") {
|
|
108
|
+
return matchCount > 0 ? "known_regression" : "new_regression";
|
|
109
|
+
}
|
|
110
|
+
if (fileStatus === "passed" && matchCount > 0) {
|
|
111
|
+
return "fixed_known_regression";
|
|
112
|
+
}
|
|
113
|
+
if (matchCount > 0) {
|
|
114
|
+
return "tracked_regression_not_executed";
|
|
115
|
+
}
|
|
116
|
+
return "not_applicable";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toDiagnosisEntry(entry, syncEntry) {
|
|
120
|
+
return {
|
|
121
|
+
id: entry.id,
|
|
122
|
+
classification: entry.classification,
|
|
123
|
+
issue: entry.issue,
|
|
124
|
+
summary: entry.summary,
|
|
125
|
+
cause: entry.cause,
|
|
126
|
+
lastReviewedAt: entry.lastReviewedAt,
|
|
127
|
+
github: syncEntry?.github || null,
|
|
128
|
+
syncStatus: syncEntry?.status || null,
|
|
129
|
+
catalogFindings: Array.isArray(syncEntry?.findings) ? syncEntry.findings : [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildRegressionReport(
|
|
134
|
+
tests,
|
|
135
|
+
regressionCatalog,
|
|
136
|
+
regressionSync,
|
|
137
|
+
diagnosesByFileKey,
|
|
138
|
+
staleEntryIds,
|
|
139
|
+
newRegressionDrafts,
|
|
140
|
+
fixedRegressionDrafts
|
|
141
|
+
) {
|
|
142
|
+
const summary = {
|
|
143
|
+
newRegressions: 0,
|
|
144
|
+
knownRegressions: 0,
|
|
145
|
+
fixedKnownRegressions: 0,
|
|
146
|
+
catalogStale: staleEntryIds.size,
|
|
147
|
+
catalogSyncUnavailable: (regressionSync?.summary?.byCode?.validation_unavailable || 0) > 0,
|
|
148
|
+
usedStaleCache: (regressionSync?.summary?.byCode?.used_stale_cache || 0) > 0,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (const test of tests) {
|
|
152
|
+
const diagnosis = test.diagnosis || diagnosesByFileKey.get(
|
|
153
|
+
buildRegressionFileIdentity(test.service, test.type, test.path)
|
|
154
|
+
);
|
|
155
|
+
switch (diagnosis?.status) {
|
|
156
|
+
case "new_regression":
|
|
157
|
+
summary.newRegressions += 1;
|
|
158
|
+
break;
|
|
159
|
+
case "known_regression":
|
|
160
|
+
summary.knownRegressions += 1;
|
|
161
|
+
break;
|
|
162
|
+
case "fixed_known_regression":
|
|
163
|
+
summary.fixedKnownRegressions += 1;
|
|
164
|
+
break;
|
|
165
|
+
default:
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
summary,
|
|
172
|
+
catalog: {
|
|
173
|
+
configured: Boolean(regressionCatalog),
|
|
174
|
+
entryCount: regressionCatalog?.entries?.length || 0,
|
|
175
|
+
staleEntries: (regressionSync?.entries || []).filter((entry) =>
|
|
176
|
+
Array.isArray(entry.findings) && entry.findings.length > 0
|
|
177
|
+
),
|
|
178
|
+
findings: regressionSync?.findings || [],
|
|
179
|
+
sync: {
|
|
180
|
+
mode: regressionSync?.mode || null,
|
|
181
|
+
checkedAt: regressionSync?.checkedAt || null,
|
|
182
|
+
usedStaleCache: summary.usedStaleCache,
|
|
183
|
+
unavailable: summary.catalogSyncUnavailable,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
drafts: {
|
|
187
|
+
newRegressions: newRegressionDrafts,
|
|
188
|
+
fixedRegressions: dedupeDraftsById(fixedRegressionDrafts),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildNewRegressionDraft(fileSummary, regressionCatalog) {
|
|
194
|
+
return {
|
|
195
|
+
id: suggestRegressionId(fileSummary),
|
|
196
|
+
classification: suggestRegressionClassification(fileSummary),
|
|
197
|
+
issue: {
|
|
198
|
+
repo: regressionCatalog?.issueRepo || null,
|
|
199
|
+
number: null,
|
|
200
|
+
},
|
|
201
|
+
summary: suggestRegressionSummary(fileSummary),
|
|
202
|
+
cause: suggestRegressionCause(fileSummary),
|
|
203
|
+
fingerprints: [
|
|
204
|
+
{
|
|
205
|
+
service: fileSummary.service,
|
|
206
|
+
type: fileSummary.type,
|
|
207
|
+
path: fileSummary.path,
|
|
208
|
+
...(fileSummary.failureDetails?.[0]?.key ? { failureKey: fileSummary.failureDetails[0].key } : {}),
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function suggestRegressionId(fileSummary) {
|
|
215
|
+
const base = `${fileSummary.service}-${fileSummary.type}-${fileSummary.path}`
|
|
216
|
+
.toLowerCase()
|
|
217
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
218
|
+
.replace(/^-+|-+$/g, "");
|
|
219
|
+
const detail = fileSummary.failureDetails?.[0]?.key
|
|
220
|
+
? `-${String(fileSummary.failureDetails[0].key).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
|
|
221
|
+
: "";
|
|
222
|
+
return `${base}${detail}`.replace(/-+/g, "-").slice(0, 96);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function suggestRegressionClassification(fileSummary) {
|
|
226
|
+
const message = [
|
|
227
|
+
fileSummary.error,
|
|
228
|
+
...((fileSummary.failureDetails || []).map((detail) => detail?.message || detail?.title || "")),
|
|
229
|
+
]
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join(" ")
|
|
232
|
+
.toLowerCase();
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
message.includes("timed out") ||
|
|
236
|
+
message.includes("already in use") ||
|
|
237
|
+
message.includes("never becomes ready") ||
|
|
238
|
+
message.includes("runtime error")
|
|
239
|
+
) {
|
|
240
|
+
return "infra";
|
|
241
|
+
}
|
|
242
|
+
return "product_bug";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function suggestRegressionSummary(fileSummary) {
|
|
246
|
+
const primaryDetail = fileSummary.failureDetails?.[0];
|
|
247
|
+
if (primaryDetail?.message) return String(primaryDetail.message).trim().slice(0, 160);
|
|
248
|
+
if (primaryDetail?.title) return String(primaryDetail.title).trim().slice(0, 160);
|
|
249
|
+
if (fileSummary.error) return String(fileSummary.error).trim().slice(0, 160);
|
|
250
|
+
return `${fileSummary.type} regression in ${fileSummary.path}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function suggestRegressionCause(fileSummary) {
|
|
254
|
+
const primaryDetail = fileSummary.failureDetails?.[0];
|
|
255
|
+
if (primaryDetail?.response?.bodyPreview) {
|
|
256
|
+
return `Observed response preview: ${String(primaryDetail.response.bodyPreview).slice(0, 200)}`;
|
|
257
|
+
}
|
|
258
|
+
if (fileSummary.error) {
|
|
259
|
+
return `Observed failure: ${String(fileSummary.error).slice(0, 200)}`;
|
|
260
|
+
}
|
|
261
|
+
return "Investigate the observed failure and replace this draft cause with the underlying technical root cause.";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function dedupeDraftsById(entries) {
|
|
265
|
+
const map = new Map();
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
if (!map.has(entry.id)) map.set(entry.id, entry);
|
|
268
|
+
}
|
|
269
|
+
return [...map.values()];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extractRunFileEntries(runArtifact) {
|
|
273
|
+
const entries = [];
|
|
274
|
+
|
|
275
|
+
for (const service of runArtifact.services || []) {
|
|
276
|
+
for (const suite of service.suites || []) {
|
|
277
|
+
for (const file of suite.files || []) {
|
|
278
|
+
entries.push({
|
|
279
|
+
target: file,
|
|
280
|
+
service: service.name,
|
|
281
|
+
type: suite.type,
|
|
282
|
+
path: file.path,
|
|
283
|
+
status: file.status,
|
|
284
|
+
error: file.error || null,
|
|
285
|
+
failureDetails: Array.isArray(file.failureDetails) ? file.failureDetails : [],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return entries;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function extractStatusFileEntries(statusArtifact) {
|
|
295
|
+
return statusArtifact?.tests || [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setEntryDiagnosis(entry, diagnosis) {
|
|
299
|
+
if (entry?.target) {
|
|
300
|
+
entry.target.diagnosis = diagnosis;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
entry.diagnosis = diagnosis;
|
|
304
|
+
}
|