@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,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 printable summary lines", () => {
65
- const lines = buildRunSummaryLines(
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
- 1_000,
87
+ 20_000,
151
88
  {
152
89
  summary: {
153
- byCode: {
154
- closed_but_failing: 2,
155
- title_mismatch: 1,
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(lines.join("\n")).toContain("Known-failure issues:");
162
- expect(lines.join("\n")).toContain("2 closed issues still failing");
163
- expect(lines.join("\n")).toContain("1 title mismatch");
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("prefers structured HTTP assertion details over threshold wrapper text", () => {
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("GET /health expected 200, got 404");
238
- expect(lines.join("\n")).toContain('response: {"error":"nope"}');
239
- expect(lines.join("\n")).toContain("triage: known issue #42 open");
240
- expect(lines.join("\n")).not.toContain("Default runtime thresholds failed: checks(rate==1.0)\n response:");
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
  });
@@ -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 "../known-failures/github.mjs";
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 { loadKnownFailuresConfig } from "./triage.mjs";
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 knownFailures = loadKnownFailuresConfig(
59
+ const regressionCatalog = loadRegressionCatalogConfig(
60
60
  productDir,
61
- configs[0]?.testkit?.reporting || null
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
- knownFailures,
212
- issueValidationConfig: configs[0]?.testkit?.reporting?.issueValidation || null,
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.shouldFailIssueValidation) {
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
+ }