@sentinelqa/playwright-reporter 0.1.47 → 0.1.50
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/dist/localReport.js +34 -11
- package/dist/quickDiagnosis.d.ts +68 -4
- package/dist/quickDiagnosis.js +813 -97
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +81 -20
- package/dist/runHistory.d.ts +4 -0
- package/dist/runHistory.js +113 -3
- package/dist/terminalSummary.d.ts +27 -1
- package/dist/terminalSummary.js +229 -40
- package/package.json +2 -2
package/dist/quickDiagnosis.js
CHANGED
|
@@ -3,24 +3,213 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.
|
|
6
|
+
exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.collectFailureFacts = exports.parseFailureFacts = exports.describeFailure = void 0;
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const REPORTER_HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
|
|
11
|
+
const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
|
|
12
|
+
const normalizeTitleParts = (value) => value
|
|
13
|
+
.split(" > ")
|
|
14
|
+
.map((part) => part.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
const cleanTitleParts = (parts) => {
|
|
17
|
+
const withoutUnnamed = parts.filter((part) => part && part !== "Unnamed test");
|
|
18
|
+
return withoutUnnamed.length ? withoutUnnamed : parts.filter(Boolean);
|
|
19
|
+
};
|
|
20
|
+
const pickHumanTitleParts = (value) => cleanTitleParts(normalizeTitleParts(value)).slice(-2);
|
|
21
|
+
const formatTitle = (title) => pickHumanTitleParts(title).join(" > ") || title;
|
|
22
|
+
const shortenTitle = (value) => {
|
|
23
|
+
const parts = pickHumanTitleParts(value);
|
|
24
|
+
return parts[parts.length - 1] || value;
|
|
25
|
+
};
|
|
26
|
+
const normalizePath = (value) => (value || "").replace(/\\/g, "/").toLowerCase().trim();
|
|
27
|
+
const basename = (value) => {
|
|
28
|
+
const normalized = normalizePath(value);
|
|
29
|
+
if (!normalized)
|
|
30
|
+
return "";
|
|
31
|
+
return normalized.split("/").pop() || normalized;
|
|
32
|
+
};
|
|
33
|
+
const dirnameToken = (value) => {
|
|
34
|
+
const normalized = normalizePath(value);
|
|
35
|
+
if (!normalized || !normalized.includes("/"))
|
|
36
|
+
return "";
|
|
37
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
38
|
+
return parts.length > 1 ? parts[parts.length - 2] : "";
|
|
39
|
+
};
|
|
40
|
+
const gitOutput = (args) => {
|
|
41
|
+
try {
|
|
42
|
+
return (0, node_child_process_1.execFileSync)("git", args, {
|
|
43
|
+
cwd: process.cwd(),
|
|
44
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
45
|
+
encoding: "utf8"
|
|
46
|
+
}).trim();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const currentSha = () => (process.env.GITHUB_SHA ||
|
|
53
|
+
process.env.CI_COMMIT_SHA ||
|
|
54
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
55
|
+
gitOutput(["rev-parse", "HEAD"]) ||
|
|
56
|
+
"").trim();
|
|
57
|
+
const currentBranch = () => (process.env.GITHUB_REF_NAME ||
|
|
58
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
59
|
+
process.env.CI_COMMIT_BRANCH ||
|
|
60
|
+
process.env.BRANCH_NAME ||
|
|
61
|
+
gitOutput(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
|
62
|
+
"main").trim();
|
|
8
63
|
const normalizeMessageFingerprint = (message) => stripAnsi(message)
|
|
9
64
|
.split(/\r?\n/)
|
|
10
65
|
.map((line) => line.trim())
|
|
11
66
|
.filter(Boolean)
|
|
12
|
-
.slice(0,
|
|
67
|
+
.slice(0, 4)
|
|
13
68
|
.join(" | ")
|
|
14
69
|
.replace(/\b\d+ms\b/gi, "<ms>")
|
|
15
70
|
.replace(/:\d+:\d+/g, ":<line>:<col>")
|
|
16
71
|
.replace(/\s+/g, " ")
|
|
17
|
-
.slice(0,
|
|
18
|
-
const
|
|
72
|
+
.slice(0, 240);
|
|
73
|
+
const readJson = (filePath) => {
|
|
74
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
75
|
+
return null;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const isMeaningfulHistorySnapshot = (snapshot) => {
|
|
84
|
+
if (!snapshot)
|
|
85
|
+
return false;
|
|
86
|
+
if (typeof snapshot.totalTests === 'number' && snapshot.totalTests <= 0)
|
|
87
|
+
return false;
|
|
88
|
+
if (typeof snapshot.failedCount === 'number' && typeof snapshot.passedCount === 'number') {
|
|
89
|
+
if (snapshot.failedCount === 0 && snapshot.passedCount === 0)
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
const listHistorySnapshots = (branch) => {
|
|
95
|
+
const dir = node_path_1.default.resolve(process.cwd(), REPORTER_HISTORY_DIR);
|
|
96
|
+
if (!node_fs_1.default.existsSync(dir))
|
|
97
|
+
return [];
|
|
98
|
+
return node_fs_1.default
|
|
99
|
+
.readdirSync(dir)
|
|
100
|
+
.filter((file) => file.endsWith(".json"))
|
|
101
|
+
.map((file) => readJson(node_path_1.default.join(dir, file)))
|
|
102
|
+
.filter((value) => Boolean(value))
|
|
103
|
+
.filter((snapshot) => (snapshot.branch || branch) === branch)
|
|
104
|
+
.filter((snapshot) => isMeaningfulHistorySnapshot(snapshot))
|
|
105
|
+
.sort((a, b) => String(b.generatedAt || "").localeCompare(String(a.generatedAt || "")));
|
|
106
|
+
};
|
|
107
|
+
const getLastPassingSnapshot = () => {
|
|
108
|
+
const branch = currentBranch();
|
|
109
|
+
const sha = currentSha();
|
|
110
|
+
const snapshots = listHistorySnapshots(branch);
|
|
111
|
+
const sameCommitFailure = sha
|
|
112
|
+
? snapshots.find((snapshot) => snapshot.gitSha === sha &&
|
|
113
|
+
Number(snapshot.failedCount || 0) > 0)
|
|
114
|
+
: null;
|
|
115
|
+
if (sameCommitFailure) {
|
|
116
|
+
return {
|
|
117
|
+
sha: sameCommitFailure.gitSha || "unknown",
|
|
118
|
+
reason: "current commit has already failed in local history"
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const sameCommitPass = sha
|
|
122
|
+
? snapshots.find((snapshot) => snapshot.gitSha === sha &&
|
|
123
|
+
Number(snapshot.failedCount || 0) === 0 &&
|
|
124
|
+
Number(snapshot.passedCount || 0) > 0)
|
|
125
|
+
: null;
|
|
126
|
+
if (sameCommitPass) {
|
|
127
|
+
return {
|
|
128
|
+
sha: sameCommitPass.gitSha || "unknown",
|
|
129
|
+
reason: "current commit already passed locally; no new code change separates that pass from this failure"
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const match = snapshots.find((snapshot) => Number(snapshot.failedCount || 0) === 0 &&
|
|
133
|
+
Number(snapshot.passedCount || 0) > 0 &&
|
|
134
|
+
Number(snapshot.totalTests || 0) > 0 &&
|
|
135
|
+
snapshot.gitSha !== sha &&
|
|
136
|
+
snapshot.gitSha &&
|
|
137
|
+
snapshot.gitSha !== "unknown");
|
|
138
|
+
if (!match?.gitSha)
|
|
139
|
+
return null;
|
|
140
|
+
return { sha: match.gitSha, reason: null };
|
|
141
|
+
};
|
|
142
|
+
const resolveAttachmentPath = (filePath) => {
|
|
143
|
+
if (!filePath)
|
|
144
|
+
return null;
|
|
145
|
+
return node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.resolve(process.cwd(), filePath);
|
|
146
|
+
};
|
|
147
|
+
const readAttachmentJson = (result, name) => {
|
|
148
|
+
const attachment = (result?.attachments || []).find((item) => item?.name === name && item.path);
|
|
149
|
+
const resolved = resolveAttachmentPath(attachment?.path);
|
|
150
|
+
return resolved ? readJson(resolved) : null;
|
|
151
|
+
};
|
|
152
|
+
const extractFocusLineFromSnippet = (snippet) => {
|
|
153
|
+
if (!snippet)
|
|
154
|
+
return null;
|
|
155
|
+
const lines = snippet.split(/\r?\n/);
|
|
156
|
+
const marked = lines.find((line) => /^\s*>\s*\d+\s*\|/.test(line));
|
|
157
|
+
const preferred = marked || lines.find((line) => /\S/.test(line));
|
|
158
|
+
if (!preferred)
|
|
159
|
+
return null;
|
|
160
|
+
return preferred
|
|
161
|
+
.replace(/^\s*>\s*/, "")
|
|
162
|
+
.replace(/^\s*\d+\s*\|\s*/, "")
|
|
163
|
+
.trim() || null;
|
|
164
|
+
};
|
|
165
|
+
const parseCommitLine = (line) => {
|
|
166
|
+
const [sha, author, message] = line.split("\u001f");
|
|
167
|
+
if (!sha)
|
|
168
|
+
return null;
|
|
169
|
+
const changedFilesRaw = gitOutput(["show", "--pretty=", "--name-only", sha]) || "";
|
|
170
|
+
return {
|
|
171
|
+
sha,
|
|
172
|
+
author: author || "Unknown",
|
|
173
|
+
message: message || "Recent commit",
|
|
174
|
+
changedFiles: changedFilesRaw
|
|
175
|
+
.split(/\r?\n/)
|
|
176
|
+
.map((item) => item.trim())
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.slice(0, 80)
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const getCommitWindow = (limit = 24) => {
|
|
182
|
+
const sha = currentSha();
|
|
183
|
+
const lastPassing = getLastPassingSnapshot();
|
|
184
|
+
const lastPassingSha = lastPassing?.sha || null;
|
|
185
|
+
const pretty = `--pretty=format:%H%x1f%an%x1f%s`;
|
|
186
|
+
if (!sha || !lastPassingSha || lastPassingSha === sha) {
|
|
187
|
+
return { commits: [], trusted: false, reason: lastPassing?.reason || "no trusted last passing commit available yet" };
|
|
188
|
+
}
|
|
189
|
+
const mergeBase = gitOutput(["merge-base", lastPassingSha, sha]);
|
|
190
|
+
if (!mergeBase || mergeBase !== lastPassingSha) {
|
|
191
|
+
return { commits: [], trusted: false, reason: "local git history does not contain a safe pass-to-fail commit range" };
|
|
192
|
+
}
|
|
193
|
+
const log = gitOutput(["log", "--first-parent", "--ancestry-path", `${lastPassingSha}..${sha}`, pretty]) || "";
|
|
194
|
+
const commits = log
|
|
195
|
+
.split(/\r?\n/)
|
|
196
|
+
.map((line) => parseCommitLine(line))
|
|
197
|
+
.filter((value) => Boolean(value))
|
|
198
|
+
.slice(0, limit);
|
|
199
|
+
if (!commits.length) {
|
|
200
|
+
return { commits: [], trusted: false, reason: "no commits found between the last pass and current failure" };
|
|
201
|
+
}
|
|
202
|
+
return { commits, trusted: true, reason: null };
|
|
203
|
+
};
|
|
204
|
+
const normalizeStatus = (status) => {
|
|
205
|
+
if (status === "failed" || status === "timedOut" || status === "interrupted")
|
|
206
|
+
return "failed";
|
|
207
|
+
if (status === "passed" || status === "flaky")
|
|
208
|
+
return "passed";
|
|
209
|
+
return "skipped";
|
|
210
|
+
};
|
|
19
211
|
const toMessage = (result) => {
|
|
20
|
-
const direct = result.error?.message ||
|
|
21
|
-
result.error?.stack ||
|
|
22
|
-
result.error?.value ||
|
|
23
|
-
null;
|
|
212
|
+
const direct = result.error?.message || result.error?.stack || result.error?.value || null;
|
|
24
213
|
if (direct)
|
|
25
214
|
return stripAnsi(String(direct));
|
|
26
215
|
const first = result.errors?.find(Boolean);
|
|
@@ -28,7 +217,10 @@ const toMessage = (result) => {
|
|
|
28
217
|
};
|
|
29
218
|
const classifySignal = (message) => {
|
|
30
219
|
const lower = message.toLowerCase();
|
|
31
|
-
if (/
|
|
220
|
+
if (/browser has been closed|target page, context or browser has been closed|crash|page crashed|browser disconnected/.test(lower)) {
|
|
221
|
+
return "infra";
|
|
222
|
+
}
|
|
223
|
+
if (/expected substring|expected string|received string|tohavetext|tocontaintext|tohavevalue|tobechecked/.test(lower)) {
|
|
32
224
|
return "assertion_mismatch";
|
|
33
225
|
}
|
|
34
226
|
if (/timeout|timed out|waiting for/.test(lower))
|
|
@@ -39,10 +231,12 @@ const classifySignal = (message) => {
|
|
|
39
231
|
if (/not visible|not enabled|not stable|intercepts pointer events|not actionable/.test(lower)) {
|
|
40
232
|
return "actionability";
|
|
41
233
|
}
|
|
42
|
-
if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed/.test(lower)) {
|
|
234
|
+
if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed|socket hang up|econnreset|503|502|500/.test(lower)) {
|
|
43
235
|
return "network";
|
|
44
236
|
}
|
|
45
|
-
if (/
|
|
237
|
+
if (/simulated flaky retry|retry guard|flaky retry/.test(lower))
|
|
238
|
+
return "runtime";
|
|
239
|
+
if (/^error:|\nerror:|typeerror|referenceerror|syntaxerror|unhandled/.test(lower))
|
|
46
240
|
return "runtime";
|
|
47
241
|
return "unknown";
|
|
48
242
|
};
|
|
@@ -77,82 +271,282 @@ const extractLastUrl = (message) => {
|
|
|
77
271
|
const match = message.match(/https?:\/\/[^\s)"']+/i);
|
|
78
272
|
return match?.[0] || null;
|
|
79
273
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
274
|
+
const extractApiHint = (message, codeContext) => {
|
|
275
|
+
if (codeContext?.apiCall)
|
|
276
|
+
return codeContext.apiCall;
|
|
277
|
+
const url = extractLastUrl(message);
|
|
278
|
+
if (url)
|
|
279
|
+
return url;
|
|
280
|
+
const apiMatch = message.match(/\/(api|graphql|rest)\/[^\s)"']+/i);
|
|
281
|
+
return apiMatch?.[0] || null;
|
|
282
|
+
};
|
|
283
|
+
const extractStackLocation = (message) => {
|
|
284
|
+
const lines = stripAnsi(message).split(/\r?\n/);
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
const match = line.match(/((?:[A-Za-z]:)?[^:\n]+\.[cm]?[jt]sx?):(\d+):(\d+)/);
|
|
287
|
+
if (!match)
|
|
288
|
+
continue;
|
|
289
|
+
const file = match[1]?.trim();
|
|
290
|
+
if (!file || file.includes("node_modules"))
|
|
291
|
+
continue;
|
|
292
|
+
const lineNumber = Number(match[2]);
|
|
293
|
+
const columnNumber = Number(match[3]);
|
|
294
|
+
return {
|
|
295
|
+
file,
|
|
296
|
+
line: Number.isFinite(lineNumber) ? lineNumber : null,
|
|
297
|
+
column: Number.isFinite(columnNumber) ? columnNumber : null
|
|
298
|
+
};
|
|
96
299
|
}
|
|
300
|
+
return null;
|
|
301
|
+
};
|
|
302
|
+
const loadCodeContext = (result) => readAttachmentJson(result, "sentinel-code-context");
|
|
303
|
+
const loadDomCapture = (result) => readAttachmentJson(result, "sentinel-dom-capture");
|
|
304
|
+
const inferLikelyFile = (file, codeContext) => codeContext?.file || file || null;
|
|
305
|
+
const inferLikelyModule = (file, locator, codeContext) => {
|
|
306
|
+
const fileBase = basename(inferLikelyFile(file, codeContext));
|
|
307
|
+
if (fileBase)
|
|
308
|
+
return fileBase.replace(/\.(spec|test|e2e)?\.[cm]?[jt]sx?$/i, "");
|
|
309
|
+
const locatorToken = (locator || "")
|
|
310
|
+
.toLowerCase()
|
|
311
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
312
|
+
.split(" ")
|
|
313
|
+
.find((token) => token.length >= 4);
|
|
314
|
+
return locatorToken || null;
|
|
97
315
|
};
|
|
316
|
+
const describeDomState = (failure) => {
|
|
317
|
+
const dom = failure.domCapture;
|
|
318
|
+
if (!dom)
|
|
319
|
+
return null;
|
|
320
|
+
if (dom.targetFound === false || dom.matchedCount === 0) {
|
|
321
|
+
return failure.locator
|
|
322
|
+
? `${failure.locator} never appeared before the timeout`
|
|
323
|
+
: "the expected target never appeared before the timeout";
|
|
324
|
+
}
|
|
325
|
+
if (dom.visible === false) {
|
|
326
|
+
return failure.locator
|
|
327
|
+
? `${failure.locator} was found but stayed hidden before the timeout`
|
|
328
|
+
: "the target was found but stayed hidden before the timeout";
|
|
329
|
+
}
|
|
330
|
+
if (dom.enabled === false) {
|
|
331
|
+
return failure.locator
|
|
332
|
+
? `${failure.locator} was found but stayed disabled before the timeout`
|
|
333
|
+
: "the target was found but stayed disabled before the timeout";
|
|
334
|
+
}
|
|
335
|
+
if (dom.targetFound === true && dom.visible === true && failure.codeContext?.action) {
|
|
336
|
+
return failure.locator
|
|
337
|
+
? `${failure.codeContext.action} on ${failure.locator} never completed even though the target was present`
|
|
338
|
+
: `${failure.codeContext.action} never completed even though the target was present`;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
};
|
|
342
|
+
const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
|
|
343
|
+
const commitTouchedFailure = (commit, failure) => {
|
|
344
|
+
const reasons = [];
|
|
345
|
+
const touchedFiles = new Set();
|
|
346
|
+
let score = 0;
|
|
347
|
+
const likelyFileBase = basename(failure.likelyFile);
|
|
348
|
+
const likelyDir = dirnameToken(failure.likelyFile);
|
|
349
|
+
const likelyModule = (failure.likelyModule || "").toLowerCase();
|
|
350
|
+
const locatorToken = (failure.locator || failure.domCapture?.testId || "")
|
|
351
|
+
.toLowerCase()
|
|
352
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
353
|
+
.split(" ")
|
|
354
|
+
.find((token) => token.length >= 4) || null;
|
|
355
|
+
const apiToken = (failure.apiHint || "")
|
|
356
|
+
.toLowerCase()
|
|
357
|
+
.replace(/https?:\/\//g, "")
|
|
358
|
+
.replace(/[^a-z0-9/]+/g, " ")
|
|
359
|
+
.split(/[\s/]+/)
|
|
360
|
+
.find((token) => token.length >= 4) || null;
|
|
361
|
+
const titleToken = cleanTitleParts(failure.titlePath)
|
|
362
|
+
.join(" ")
|
|
363
|
+
.toLowerCase()
|
|
364
|
+
.split(/[^a-z0-9]+/)
|
|
365
|
+
.find((token) => token.length >= 4) || null;
|
|
366
|
+
if (commit.sha === currentSha()) {
|
|
367
|
+
score += 0.08;
|
|
368
|
+
reasons.push("current failing commit");
|
|
369
|
+
}
|
|
370
|
+
const exactFileMatches = commit.changedFiles.filter((item) => basename(item) === likelyFileBase);
|
|
371
|
+
if (exactFileMatches.length) {
|
|
372
|
+
score += 0.42;
|
|
373
|
+
exactFileMatches.forEach((file) => touchedFiles.add(file));
|
|
374
|
+
reasons.push(buildTouchedFileReason("touches likely file", exactFileMatches));
|
|
375
|
+
}
|
|
376
|
+
const dirMatches = likelyDir ? commit.changedFiles.filter((item) => normalizePath(item).includes(`/${likelyDir}/`) || normalizePath(item).endsWith(`/${likelyDir}`)) : [];
|
|
377
|
+
if (dirMatches.length) {
|
|
378
|
+
score += 0.18;
|
|
379
|
+
dirMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
|
|
380
|
+
reasons.push(buildTouchedFileReason("touches same area", dirMatches));
|
|
381
|
+
}
|
|
382
|
+
const moduleMatches = likelyModule ? commit.changedFiles.filter((item) => normalizePath(item).includes(likelyModule)) : [];
|
|
383
|
+
if (moduleMatches.length) {
|
|
384
|
+
score += 0.16;
|
|
385
|
+
moduleMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
|
|
386
|
+
reasons.push(buildTouchedFileReason("overlaps likely module", moduleMatches));
|
|
387
|
+
}
|
|
388
|
+
const locatorMatches = locatorToken ? commit.changedFiles.filter((item) => normalizePath(item).includes(locatorToken)) : [];
|
|
389
|
+
if (locatorMatches.length) {
|
|
390
|
+
score += 0.22;
|
|
391
|
+
locatorMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
|
|
392
|
+
reasons.push(buildTouchedFileReason(`overlaps selector token (${locatorToken})`, locatorMatches));
|
|
393
|
+
}
|
|
394
|
+
const apiMatches = apiToken ? commit.changedFiles.filter((item) => normalizePath(item).includes(apiToken)) : [];
|
|
395
|
+
if (apiMatches.length) {
|
|
396
|
+
score += 0.24;
|
|
397
|
+
apiMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
|
|
398
|
+
reasons.push(buildTouchedFileReason(`overlaps API token (${apiToken})`, apiMatches));
|
|
399
|
+
}
|
|
400
|
+
if (locatorToken && commit.message.toLowerCase().includes(locatorToken)) {
|
|
401
|
+
score += 0.12;
|
|
402
|
+
reasons.push("commit message overlaps the failing selector");
|
|
403
|
+
}
|
|
404
|
+
if (apiToken && commit.message.toLowerCase().includes(apiToken)) {
|
|
405
|
+
score += 0.12;
|
|
406
|
+
reasons.push("commit message overlaps the failing API");
|
|
407
|
+
}
|
|
408
|
+
if (titleToken && commit.changedFiles.some((item) => normalizePath(item).includes(titleToken))) {
|
|
409
|
+
score += 0.12;
|
|
410
|
+
reasons.push(`changed file overlaps the failing flow (${titleToken})`);
|
|
411
|
+
}
|
|
412
|
+
if (failure.signal === "network" && /api|request|fetch|backend|service|latency|timeout/i.test(commit.message)) {
|
|
413
|
+
score += 0.14;
|
|
414
|
+
reasons.push("commit message points to backend or network changes");
|
|
415
|
+
}
|
|
416
|
+
if (["assertion_mismatch", "locator_not_found", "actionability"].includes(failure.signal) && /ui|button|modal|page|auth|login|checkout|selector|form/i.test(commit.message)) {
|
|
417
|
+
score += 0.14;
|
|
418
|
+
reasons.push("commit message points to the failing UI flow");
|
|
419
|
+
}
|
|
420
|
+
if (failure.signal === "infra" && /browser|playwright|infra|ci|worker|build|docker|image/i.test(commit.message)) {
|
|
421
|
+
score += 0.16;
|
|
422
|
+
reasons.push("commit message points to CI or browser infrastructure changes");
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
commit,
|
|
426
|
+
score,
|
|
427
|
+
reasons: Array.from(new Set(reasons)).slice(0, 3),
|
|
428
|
+
touchedFiles: Array.from(touchedFiles).slice(0, 3)
|
|
429
|
+
};
|
|
430
|
+
};
|
|
431
|
+
const rankCommitsForFailure = (failure, window) => window.commits
|
|
432
|
+
.map((commit) => commitTouchedFailure(commit, failure))
|
|
433
|
+
.filter((entry) => entry.score > 0.2)
|
|
434
|
+
.sort((a, b) => b.score - a.score)
|
|
435
|
+
.slice(0, 2);
|
|
98
436
|
const flattenFailedCases = (node, titlePath = []) => {
|
|
99
437
|
const currentTitlePath = node.title ? [...titlePath, node.title] : titlePath;
|
|
100
|
-
const
|
|
438
|
+
const failures = [];
|
|
439
|
+
for (const child of node.suites || [])
|
|
440
|
+
failures.push(...flattenFailedCases(child, currentTitlePath));
|
|
441
|
+
for (const child of node.specs || [])
|
|
442
|
+
failures.push(...flattenFailedCases(child, currentTitlePath));
|
|
101
443
|
for (const test of node.tests || []) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
444
|
+
const results = Array.isArray(test.results) ? test.results : [];
|
|
445
|
+
const finalResult = results[results.length - 1];
|
|
446
|
+
if (!finalResult)
|
|
447
|
+
continue;
|
|
448
|
+
const finalStatus = normalizeStatus(finalResult.status);
|
|
449
|
+
if (finalStatus !== "failed")
|
|
450
|
+
continue;
|
|
451
|
+
const message = toMessage(finalResult);
|
|
452
|
+
const titleParts = cleanTitleParts([...currentTitlePath, test.title || "Unnamed test"]);
|
|
453
|
+
failures.push((0, exports.parseFailureFacts)(titleParts.join(" > "), titleParts, message, "failed", node.file || node.location?.file || test.location?.file || null, {
|
|
454
|
+
projectName: test.projectName || null,
|
|
455
|
+
timeoutBudgetMs: typeof test.timeout === "number" ? test.timeout : null,
|
|
456
|
+
codeContext: loadCodeContext(finalResult),
|
|
457
|
+
domCapture: loadDomCapture(finalResult),
|
|
458
|
+
errorLocation: finalResult.error?.location || finalResult.errors?.find(Boolean)?.location || null,
|
|
459
|
+
errorSnippet: finalResult.error?.snippet || null
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
return failures;
|
|
463
|
+
};
|
|
464
|
+
const checkFirst = (failure) => {
|
|
465
|
+
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
466
|
+
const file = basename(failure.codeContext?.file || failure.likelyFile);
|
|
467
|
+
const line = failure.codeContext?.line;
|
|
468
|
+
if (file && line) {
|
|
469
|
+
return `inspect the throw at ${file}:${line} before opening the trace`;
|
|
112
470
|
}
|
|
471
|
+
if (failure.codeContext?.focusLine) {
|
|
472
|
+
return `inspect ${failure.codeContext.focusLine.trim()} before opening the trace`;
|
|
473
|
+
}
|
|
474
|
+
return "inspect the shared retry or flaky helper before opening the trace";
|
|
475
|
+
}
|
|
476
|
+
if (failure.signal === "infra") {
|
|
477
|
+
return "inspect the browser crash or CI worker logs before digging into the test code";
|
|
113
478
|
}
|
|
114
|
-
|
|
115
|
-
|
|
479
|
+
if (failure.signal === "network") {
|
|
480
|
+
return failure.apiHint ? `inspect ${failure.apiHint}` : "inspect the failing request and backend response";
|
|
116
481
|
}
|
|
117
|
-
|
|
118
|
-
|
|
482
|
+
if (failure.signal === "timeout" && failure.codeContext?.action) {
|
|
483
|
+
if (failure.locator && failure.domCapture?.targetFound === false) {
|
|
484
|
+
return `inspect why ${failure.locator} never appeared before the ${failure.codeContext.action}`;
|
|
485
|
+
}
|
|
486
|
+
if (failure.locator && failure.domCapture?.visible === false) {
|
|
487
|
+
return `inspect why ${failure.locator} stayed hidden before the ${failure.codeContext.action}`;
|
|
488
|
+
}
|
|
489
|
+
if (failure.locator && failure.domCapture?.enabled === false) {
|
|
490
|
+
return `inspect why ${failure.locator} stayed disabled before the ${failure.codeContext.action}`;
|
|
491
|
+
}
|
|
492
|
+
if (failure.locator && failure.domCapture?.targetFound === true && failure.domCapture?.visible === true) {
|
|
493
|
+
return `inspect what blocked ${failure.codeContext.action} on ${failure.locator} (overlay or pointer interception is likely)`;
|
|
494
|
+
}
|
|
495
|
+
return `inspect the blocked ${failure.codeContext.action} step before the timeout`;
|
|
119
496
|
}
|
|
120
|
-
|
|
497
|
+
if (failure.locator)
|
|
498
|
+
return `verify ${failure.locator}`;
|
|
499
|
+
if (failure.codeContext?.focusLine)
|
|
500
|
+
return `inspect ${failure.codeContext.focusLine.trim()}`;
|
|
501
|
+
if (failure.likelyFile)
|
|
502
|
+
return `inspect ${basename(failure.likelyFile)}`;
|
|
503
|
+
return "open the failing trace step";
|
|
121
504
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
505
|
+
const confidenceLabel = (score) => {
|
|
506
|
+
if (score >= 0.62)
|
|
507
|
+
return "High";
|
|
508
|
+
if (score >= 0.34)
|
|
509
|
+
return "Medium";
|
|
510
|
+
return "Low";
|
|
125
511
|
};
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
512
|
+
const compactCommitLine = (match) => {
|
|
513
|
+
if (!match)
|
|
514
|
+
return null;
|
|
515
|
+
return `Suspected commit: ${match.commit.sha.slice(0, 7)} "${match.commit.message}"`;
|
|
516
|
+
};
|
|
517
|
+
const compactWhyLine = (match) => {
|
|
518
|
+
if (!match?.reasons.length)
|
|
519
|
+
return null;
|
|
520
|
+
return match.reasons.join("; ");
|
|
521
|
+
};
|
|
522
|
+
const alternateCommitLine = (match) => {
|
|
523
|
+
if (!match)
|
|
524
|
+
return null;
|
|
525
|
+
return `Also changed: ${match.commit.sha.slice(0, 7)} "${match.commit.message}"`;
|
|
526
|
+
};
|
|
527
|
+
const rootCauseLabel = (failure) => {
|
|
528
|
+
switch (failure.signal) {
|
|
529
|
+
case "assertion_mismatch":
|
|
530
|
+
return "UI assertion mismatch";
|
|
531
|
+
case "locator_not_found":
|
|
532
|
+
return "Locator or render regression";
|
|
533
|
+
case "actionability":
|
|
534
|
+
return "Actionability regression";
|
|
535
|
+
case "network":
|
|
536
|
+
return "Backend or network failure";
|
|
537
|
+
case "runtime":
|
|
538
|
+
return /retry|flaky/i.test(failure.message) ? "Same test-side throw before the app flow completed" : "Runtime error during the flow";
|
|
539
|
+
case "timeout":
|
|
540
|
+
return "Timeout waiting for state change";
|
|
541
|
+
case "infra":
|
|
542
|
+
return "Browser or CI infrastructure failure";
|
|
543
|
+
default:
|
|
544
|
+
return "Failure pattern";
|
|
137
545
|
}
|
|
138
546
|
};
|
|
139
|
-
exports.collectFailureFacts = collectFailureFacts;
|
|
140
|
-
const parseFailureFacts = (title, titlePath, message, status) => ({
|
|
141
|
-
title,
|
|
142
|
-
titlePath,
|
|
143
|
-
message,
|
|
144
|
-
signal: classifySignal(message),
|
|
145
|
-
locator: extractLocator(message),
|
|
146
|
-
expected: extractExpected(message),
|
|
147
|
-
received: extractReceived(message),
|
|
148
|
-
timeoutMs: extractTimeoutMs(message),
|
|
149
|
-
lastUrl: extractLastUrl(message),
|
|
150
|
-
status
|
|
151
|
-
});
|
|
152
|
-
exports.parseFailureFacts = parseFailureFacts;
|
|
153
547
|
const describeFailure = (failure) => {
|
|
154
548
|
if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
|
|
155
|
-
return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}"
|
|
549
|
+
return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}".`;
|
|
156
550
|
}
|
|
157
551
|
if (failure.signal === "locator_not_found" && failure.locator) {
|
|
158
552
|
return `${failure.locator} was not found when the test expected it to be available.`;
|
|
@@ -161,68 +555,390 @@ const describeFailure = (failure) => {
|
|
|
161
555
|
return `${failure.locator} was found but was not actionable when the interaction ran.`;
|
|
162
556
|
}
|
|
163
557
|
if (failure.signal === "network") {
|
|
164
|
-
return
|
|
558
|
+
return failure.apiHint
|
|
559
|
+
? `A network or API request around ${failure.apiHint} did not complete successfully.`
|
|
560
|
+
: `A network or API request did not complete successfully.`;
|
|
165
561
|
}
|
|
166
562
|
if (failure.signal === "timeout") {
|
|
167
|
-
|
|
563
|
+
const domState = describeDomState(failure);
|
|
564
|
+
if (domState)
|
|
565
|
+
return `${domState}.`;
|
|
566
|
+
return failure.timeoutMs
|
|
567
|
+
? `The expected UI or network condition did not complete before the ${failure.timeoutMs}ms timeout.`
|
|
568
|
+
: `The expected UI or network condition did not complete before timeout.`;
|
|
569
|
+
}
|
|
570
|
+
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
571
|
+
return `The test code threw a retry or flaky guard error before the app flow completed.`;
|
|
168
572
|
}
|
|
169
573
|
if (failure.signal === "runtime") {
|
|
170
|
-
return `A
|
|
574
|
+
return `A runtime error interrupted the test flow before the expected state was reached.`;
|
|
575
|
+
}
|
|
576
|
+
if (failure.signal === "infra") {
|
|
577
|
+
return `The test failed because the browser or CI worker became unstable before the flow completed.`;
|
|
171
578
|
}
|
|
172
579
|
return `The failure signal could not be classified cleanly from the captured error.`;
|
|
173
580
|
};
|
|
174
581
|
exports.describeFailure = describeFailure;
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
582
|
+
const describeCluster = (cluster) => {
|
|
583
|
+
const failure = cluster.sample;
|
|
584
|
+
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
585
|
+
const location = buildLocationLine(failure);
|
|
586
|
+
const origin = location ? ` The same helper threw at ${location.replace(/^Error location: /, '').replace(/^Likely file: /, '')}.` : "";
|
|
587
|
+
return `${cluster.count} tests hit the same test-side throw before the app flow completed.${origin}`;
|
|
588
|
+
}
|
|
589
|
+
if (cluster.count > 1 && failure.signal === "assertion_mismatch") {
|
|
590
|
+
return `${cluster.count} tests hit the same UI assertion mismatch, which usually means one shared UI regression.`;
|
|
591
|
+
}
|
|
592
|
+
if (cluster.count > 1 && failure.signal === "network") {
|
|
593
|
+
return `${cluster.count} tests failed behind the same network or API signal.`;
|
|
594
|
+
}
|
|
595
|
+
if (cluster.count > 1 && failure.signal === "timeout") {
|
|
596
|
+
return `${cluster.count} tests timed out behind the same blocked state transition.`;
|
|
597
|
+
}
|
|
598
|
+
if (cluster.count > 1 && failure.signal === "infra") {
|
|
599
|
+
return `${cluster.count} tests failed behind the same browser or CI instability signal.`;
|
|
600
|
+
}
|
|
601
|
+
return (0, exports.describeFailure)(failure);
|
|
602
|
+
};
|
|
603
|
+
const clusterCheckFirst = (cluster) => {
|
|
604
|
+
const failure = cluster.sample;
|
|
605
|
+
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
606
|
+
const file = basename(failure.codeContext?.file || failure.likelyFile);
|
|
607
|
+
const line = failure.codeContext?.line;
|
|
608
|
+
if (file && line) {
|
|
609
|
+
return `inspect the throw at ${file}:${line} before opening the trace`;
|
|
610
|
+
}
|
|
611
|
+
return "inspect the shared retry/flaky helper or intentional throw in these tests before opening the trace";
|
|
612
|
+
}
|
|
613
|
+
return checkFirst(failure);
|
|
614
|
+
};
|
|
615
|
+
const formatAffectedTests = (titles) => {
|
|
616
|
+
const unique = Array.from(new Set(titles.map((title) => shortenTitle(title)))).slice(0, 3);
|
|
617
|
+
if (!unique.length)
|
|
618
|
+
return null;
|
|
619
|
+
return `Affected tests: ${unique.join("; ")}`;
|
|
620
|
+
};
|
|
621
|
+
const buildLocationLine = (failure) => {
|
|
622
|
+
const file = failure.codeContext?.file || failure.likelyFile;
|
|
623
|
+
const line = failure.codeContext?.line;
|
|
624
|
+
const column = failure.codeContext?.column;
|
|
625
|
+
if (!file)
|
|
626
|
+
return null;
|
|
627
|
+
if (line && column)
|
|
628
|
+
return `Error location: ${basename(file)}:${line}:${column}`;
|
|
629
|
+
if (line)
|
|
630
|
+
return `Error location: ${basename(file)}:${line}`;
|
|
631
|
+
return `Likely file: ${basename(file)}`;
|
|
632
|
+
};
|
|
633
|
+
const compactLocation = (failure) => {
|
|
634
|
+
const file = failure.codeContext?.file || failure.likelyFile;
|
|
635
|
+
const line = failure.codeContext?.line;
|
|
636
|
+
if (!file)
|
|
637
|
+
return null;
|
|
638
|
+
return line ? `${basename(file)}:${line}` : basename(file);
|
|
639
|
+
};
|
|
640
|
+
const buildClusterLocationLine = (failures) => {
|
|
641
|
+
const locations = Array.from(new Set(failures.map((failure) => compactLocation(failure)).filter(Boolean)));
|
|
642
|
+
if (!locations.length)
|
|
643
|
+
return null;
|
|
644
|
+
const label = locations.length > 1 ? 'Error locations' : 'Error location';
|
|
645
|
+
return `${label}: ${locations.slice(0, 3).join('; ')}`;
|
|
646
|
+
};
|
|
647
|
+
const buildEvidenceLines = (failure) => {
|
|
648
|
+
const lines = [];
|
|
649
|
+
const locationLine = buildLocationLine(failure);
|
|
650
|
+
if (locationLine)
|
|
651
|
+
lines.push(locationLine);
|
|
652
|
+
if (failure.codeContext?.focusLine)
|
|
653
|
+
lines.push(`Failing code: ${failure.codeContext.focusLine.trim()}`);
|
|
654
|
+
if (failure.codeContext?.action)
|
|
655
|
+
lines.push(`Failing step: ${failure.codeContext.action}`);
|
|
180
656
|
if (failure.locator)
|
|
181
|
-
lines.push(`
|
|
657
|
+
lines.push(`Selector: ${failure.locator}`);
|
|
658
|
+
if (failure.signal === "timeout" && failure.domCapture) {
|
|
659
|
+
if (failure.domCapture.targetFound === false || failure.domCapture.matchedCount === 0) {
|
|
660
|
+
lines.push(`Target state: locator never appeared`);
|
|
661
|
+
}
|
|
662
|
+
else if (failure.domCapture.visible === false) {
|
|
663
|
+
lines.push(`Target state: found but hidden`);
|
|
664
|
+
}
|
|
665
|
+
else if (failure.domCapture.enabled === false) {
|
|
666
|
+
lines.push(`Target state: found but disabled`);
|
|
667
|
+
}
|
|
668
|
+
else if (failure.domCapture.targetFound === true && failure.domCapture.visible === true) {
|
|
669
|
+
lines.push(`Target state: found and visible before timeout`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (failure.apiHint)
|
|
673
|
+
lines.push(`API: ${failure.apiHint}`);
|
|
674
|
+
return lines.slice(0, 4);
|
|
675
|
+
};
|
|
676
|
+
const withoutPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length).trim() : value;
|
|
677
|
+
const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
|
|
678
|
+
.filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
|
|
679
|
+
.slice(0, 3);
|
|
680
|
+
const compactRootCauseSummary = (cluster) => {
|
|
681
|
+
const failure = cluster.sample;
|
|
682
|
+
if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
|
|
683
|
+
return "Same test-side throw before the app flow completed";
|
|
684
|
+
}
|
|
685
|
+
if (failure.signal === "network") {
|
|
686
|
+
return failure.apiHint
|
|
687
|
+
? `Same network/API failure around ${failure.apiHint}`
|
|
688
|
+
: "Same network/API failure across these tests";
|
|
689
|
+
}
|
|
690
|
+
if (failure.signal === "timeout") {
|
|
691
|
+
return "Same blocked state transition timed out across these tests";
|
|
692
|
+
}
|
|
693
|
+
if (failure.signal === "assertion_mismatch") {
|
|
694
|
+
return "Same UI assertion mismatch across these tests";
|
|
695
|
+
}
|
|
696
|
+
if (failure.signal === "locator_not_found") {
|
|
697
|
+
return "Same missing or changed locator across these tests";
|
|
698
|
+
}
|
|
699
|
+
if (failure.signal === "actionability") {
|
|
700
|
+
return "Same element actionability problem across these tests";
|
|
701
|
+
}
|
|
702
|
+
if (failure.signal === "infra") {
|
|
703
|
+
return "Same browser/CI instability affected these tests";
|
|
704
|
+
}
|
|
705
|
+
return describeCluster(cluster);
|
|
706
|
+
};
|
|
707
|
+
const compactErrorLine = (failure) => {
|
|
708
|
+
if (!failure.firstErrorLine)
|
|
709
|
+
return null;
|
|
710
|
+
return withoutPrefix(failure.firstErrorLine, "Error:");
|
|
711
|
+
};
|
|
712
|
+
const parseFailureFacts = (title, titlePath, message, status, file = null, options) => {
|
|
713
|
+
const signal = classifySignal(message);
|
|
714
|
+
const fallbackLocation = options?.errorLocation || extractStackLocation(message);
|
|
715
|
+
const fallbackCodeContext = fallbackLocation || options?.errorSnippet
|
|
716
|
+
? {
|
|
717
|
+
file: fallbackLocation?.file || file || null,
|
|
718
|
+
line: typeof fallbackLocation?.line === "number" ? fallbackLocation.line : null,
|
|
719
|
+
column: typeof fallbackLocation?.column === "number" ? fallbackLocation.column : null,
|
|
720
|
+
action: null,
|
|
721
|
+
locator: null,
|
|
722
|
+
expectedText: null,
|
|
723
|
+
timeoutMs: null,
|
|
724
|
+
apiCall: null,
|
|
725
|
+
assertion: null,
|
|
726
|
+
methodName: null,
|
|
727
|
+
focusLine: extractFocusLineFromSnippet(options?.errorSnippet),
|
|
728
|
+
previousActionLine: null,
|
|
729
|
+
found: Boolean(fallbackLocation?.file || options?.errorSnippet)
|
|
730
|
+
}
|
|
731
|
+
: null;
|
|
732
|
+
const codeContext = options?.codeContext
|
|
733
|
+
? {
|
|
734
|
+
...fallbackCodeContext,
|
|
735
|
+
...options.codeContext,
|
|
736
|
+
file: options.codeContext.file || fallbackCodeContext?.file || null,
|
|
737
|
+
line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
|
|
738
|
+
column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
|
|
739
|
+
focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || null,
|
|
740
|
+
found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
|
|
741
|
+
}
|
|
742
|
+
: fallbackCodeContext || null;
|
|
743
|
+
const domCapture = options?.domCapture || null;
|
|
744
|
+
const locator = extractLocator(message) || codeContext?.locator || domCapture?.locator || null;
|
|
745
|
+
const expected = extractExpected(message) || codeContext?.expectedText || domCapture?.expectedText || null;
|
|
746
|
+
const received = extractReceived(message) || domCapture?.observedText || domCapture?.textContent || null;
|
|
747
|
+
const likelyFile = inferLikelyFile(file, codeContext);
|
|
748
|
+
const likelyModule = inferLikelyModule(file, locator, codeContext);
|
|
749
|
+
const apiHint = extractApiHint(message, codeContext);
|
|
750
|
+
return {
|
|
751
|
+
title,
|
|
752
|
+
titlePath,
|
|
753
|
+
projectName: options?.projectName || null,
|
|
754
|
+
message,
|
|
755
|
+
firstErrorLine: stripAnsi(message).split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null,
|
|
756
|
+
signal,
|
|
757
|
+
locator,
|
|
758
|
+
expected,
|
|
759
|
+
received,
|
|
760
|
+
timeoutMs: extractTimeoutMs(message) || codeContext?.timeoutMs || options?.timeoutBudgetMs || null,
|
|
761
|
+
timeoutBudgetMs: options?.timeoutBudgetMs || codeContext?.timeoutMs || null,
|
|
762
|
+
lastUrl: extractLastUrl(message),
|
|
763
|
+
status,
|
|
764
|
+
file,
|
|
765
|
+
likelyFile,
|
|
766
|
+
likelyModule,
|
|
767
|
+
apiHint,
|
|
768
|
+
codeContext,
|
|
769
|
+
domCapture
|
|
770
|
+
};
|
|
771
|
+
};
|
|
772
|
+
exports.parseFailureFacts = parseFailureFacts;
|
|
773
|
+
const collectFailureFacts = (playwrightJsonPath) => {
|
|
774
|
+
if (!node_fs_1.default.existsSync(playwrightJsonPath))
|
|
775
|
+
return [];
|
|
776
|
+
try {
|
|
777
|
+
const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
|
|
778
|
+
const parsed = JSON.parse(raw);
|
|
779
|
+
return flattenFailedCases(parsed);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
exports.collectFailureFacts = collectFailureFacts;
|
|
786
|
+
const buildDebugSummary = (failure) => {
|
|
787
|
+
const lines = [`Test: ${failure.title}`, `Diagnosis: ${(0, exports.describeFailure)(failure)}`];
|
|
788
|
+
for (const line of buildEvidenceLines(failure))
|
|
789
|
+
lines.push(line);
|
|
182
790
|
if (failure.expected)
|
|
183
791
|
lines.push(`Expected: ${failure.expected}`);
|
|
184
792
|
if (failure.received)
|
|
185
793
|
lines.push(`Observed: ${failure.received}`);
|
|
186
794
|
if (failure.timeoutMs)
|
|
187
795
|
lines.push(`Timeout: ${failure.timeoutMs}ms`);
|
|
188
|
-
if (failure.lastUrl)
|
|
189
|
-
lines.push(`URL: ${failure.lastUrl}`);
|
|
190
796
|
return lines.join("\n");
|
|
191
797
|
};
|
|
192
798
|
exports.buildDebugSummary = buildDebugSummary;
|
|
193
799
|
const buildSimilarityKey = (failure) => {
|
|
194
|
-
|
|
800
|
+
const locationKey = failure.codeContext?.line ? `${basename(failure.codeContext.file)}:${failure.codeContext.line}` : basename(failure.likelyFile) || "unknown-file";
|
|
801
|
+
if (failure.signal === 'runtime' || failure.signal === 'unknown') {
|
|
195
802
|
return [
|
|
196
803
|
failure.signal,
|
|
197
|
-
failure.
|
|
804
|
+
normalizeMessageFingerprint(failure.message),
|
|
805
|
+
locationKey
|
|
806
|
+
].join('|');
|
|
807
|
+
}
|
|
808
|
+
if (failure.locator || failure.expected || failure.received || failure.apiHint) {
|
|
809
|
+
return [
|
|
810
|
+
failure.signal,
|
|
811
|
+
failure.locator || failure.apiHint || "unknown-target",
|
|
198
812
|
failure.expected || "unknown-expected",
|
|
199
|
-
failure.received || "unknown-received"
|
|
813
|
+
failure.received || "unknown-received",
|
|
814
|
+
locationKey
|
|
200
815
|
].join("|");
|
|
201
816
|
}
|
|
202
|
-
return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}`;
|
|
817
|
+
return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}|${locationKey}`;
|
|
203
818
|
};
|
|
204
819
|
exports.buildSimilarityKey = buildSimilarityKey;
|
|
205
|
-
|
|
820
|
+
const summarizeSignal = (signal) => {
|
|
821
|
+
switch (signal) {
|
|
822
|
+
case "timeout":
|
|
823
|
+
return "timeout while waiting for UI or network conditions";
|
|
824
|
+
case "assertion_mismatch":
|
|
825
|
+
return "assertion mismatch between expected and rendered UI state";
|
|
826
|
+
case "locator_not_found":
|
|
827
|
+
return "missing or changed locator";
|
|
828
|
+
case "actionability":
|
|
829
|
+
return "target element was not actionable";
|
|
830
|
+
case "network":
|
|
831
|
+
return "network or API failure";
|
|
832
|
+
case "runtime":
|
|
833
|
+
return "runtime error thrown before the flow completed";
|
|
834
|
+
case "infra":
|
|
835
|
+
return "browser or CI infrastructure failure";
|
|
836
|
+
default:
|
|
837
|
+
return "failure signal could not be classified cleanly";
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
exports.summarizeSignal = summarizeSignal;
|
|
841
|
+
const buildClusterSuspects = (clusterFailures, window) => {
|
|
842
|
+
return window.commits
|
|
843
|
+
.map((commit) => {
|
|
844
|
+
const combined = clusterFailures.map((failure) => commitTouchedFailure(commit, failure));
|
|
845
|
+
const score = combined.reduce((sum, item) => sum + item.score, 0);
|
|
846
|
+
const reasons = Array.from(new Set(combined.flatMap((item) => item.reasons))).slice(0, 3);
|
|
847
|
+
const touchedFiles = Array.from(new Set(combined.flatMap((item) => item.touchedFiles))).slice(0, 3);
|
|
848
|
+
return { commit, score, reasons, touchedFiles };
|
|
849
|
+
})
|
|
850
|
+
.filter((entry) => entry.score > 0.2)
|
|
851
|
+
.sort((a, b) => b.score - a.score)
|
|
852
|
+
.slice(0, 2);
|
|
853
|
+
};
|
|
206
854
|
const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
207
855
|
const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
|
|
208
856
|
if (!failures.length)
|
|
209
857
|
return null;
|
|
858
|
+
const commitWindow = getCommitWindow();
|
|
210
859
|
if (failures.length === 1) {
|
|
211
860
|
const failed = failures[0];
|
|
861
|
+
const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
|
|
862
|
+
const top = suspects[0];
|
|
863
|
+
const alt = suspects[1];
|
|
864
|
+
const lines = [`${formatTitle(failed.title)} failed.`, `What broke: ${(0, exports.describeFailure)(failed)}`];
|
|
865
|
+
const primaryLocation = buildLocationLine(failed);
|
|
866
|
+
if (primaryLocation)
|
|
867
|
+
lines.push(`Where: ${withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")}`);
|
|
868
|
+
if (failed.firstErrorLine)
|
|
869
|
+
lines.push(`Error: ${failed.firstErrorLine}`);
|
|
870
|
+
for (const evidence of buildSecondaryEvidenceLines(failed))
|
|
871
|
+
lines.push(evidence);
|
|
872
|
+
if (top) {
|
|
873
|
+
lines.push(compactCommitLine(top));
|
|
874
|
+
lines.push(`Why Sentinel picked it: ${compactWhyLine(top)}.`);
|
|
875
|
+
if (top.touchedFiles.length) {
|
|
876
|
+
lines.push(`Touched files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
|
|
877
|
+
}
|
|
878
|
+
if (alt)
|
|
879
|
+
lines.push(alternateCommitLine(alt));
|
|
880
|
+
}
|
|
881
|
+
lines.push(`Check first: ${checkFirst(failed)}.`);
|
|
212
882
|
return {
|
|
213
|
-
lines
|
|
883
|
+
lines,
|
|
884
|
+
footer: [
|
|
885
|
+
...(top ? [`Confidence: ${confidenceLabel(top.score)}`] : []),
|
|
886
|
+
...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
|
|
887
|
+
]
|
|
214
888
|
};
|
|
215
889
|
}
|
|
216
|
-
const
|
|
217
|
-
for (const
|
|
218
|
-
|
|
890
|
+
const clusterMap = new Map();
|
|
891
|
+
for (const failure of failures) {
|
|
892
|
+
const key = (0, exports.buildSimilarityKey)(failure);
|
|
893
|
+
const bucket = clusterMap.get(key) || [];
|
|
894
|
+
bucket.push(failure);
|
|
895
|
+
clusterMap.set(key, bucket);
|
|
896
|
+
}
|
|
897
|
+
const clusters = Array.from(clusterMap.entries())
|
|
898
|
+
.map(([key, clusterFailures]) => ({
|
|
899
|
+
key,
|
|
900
|
+
count: clusterFailures.length,
|
|
901
|
+
sample: clusterFailures[0],
|
|
902
|
+
titles: clusterFailures.map((item) => item.title),
|
|
903
|
+
suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : []
|
|
904
|
+
}))
|
|
905
|
+
.sort((a, b) => b.count - a.count)
|
|
906
|
+
.slice(0, 2);
|
|
907
|
+
const topCluster = clusters[0];
|
|
908
|
+
const lines = [
|
|
909
|
+
`${failures.length} tests failed.`,
|
|
910
|
+
`Collapsed into ${clusters.length} likely root cause${clusters.length === 1 ? "" : "s"}:`
|
|
911
|
+
];
|
|
912
|
+
for (const [index, cluster] of clusters.entries()) {
|
|
913
|
+
const clusterFailures = clusterMap.get(cluster.key) || [cluster.sample];
|
|
914
|
+
const clusterLocation = buildClusterLocationLine(clusterFailures);
|
|
915
|
+
const top = cluster.suspects[0];
|
|
916
|
+
const locationValue = clusterLocation
|
|
917
|
+
? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
|
|
918
|
+
: null;
|
|
919
|
+
const rootCause = compactRootCauseSummary(cluster);
|
|
920
|
+
const errorLine = compactErrorLine(cluster.sample);
|
|
921
|
+
lines.push(`[${index + 1}] ${rootCauseLabel(cluster.sample)} (${cluster.count} tests)`);
|
|
922
|
+
lines.push(` Root cause: ${rootCause}`);
|
|
923
|
+
if (errorLine) {
|
|
924
|
+
lines.push(` Error: ${errorLine}`);
|
|
925
|
+
}
|
|
926
|
+
if (locationValue) {
|
|
927
|
+
lines.push(` Where: ${locationValue}`);
|
|
928
|
+
}
|
|
929
|
+
if (top && top.touchedFiles.length) {
|
|
930
|
+
lines.push(` Changed files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
|
|
931
|
+
}
|
|
932
|
+
lines.push(` Check first: ${clusterCheckFirst(cluster)}.`);
|
|
219
933
|
}
|
|
220
|
-
const topSignal = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
|
|
221
934
|
return {
|
|
222
|
-
lines
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
935
|
+
lines,
|
|
936
|
+
footer: topCluster
|
|
937
|
+
? [
|
|
938
|
+
...(topCluster.suspects[0] ? [`Top confidence: ${confidenceLabel(topCluster.suspects[0].score)}`] : []),
|
|
939
|
+
...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
|
|
940
|
+
]
|
|
941
|
+
: []
|
|
226
942
|
};
|
|
227
943
|
};
|
|
228
944
|
exports.buildQuickDiagnosis = buildQuickDiagnosis;
|