@sentinelqa/playwright-reporter 0.1.27 → 0.1.29
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 +13 -5
- package/dist/localReport.d.ts +46 -0
- package/dist/localReport.js +410 -23
- package/dist/quickDiagnosis.d.ts +19 -0
- package/dist/quickDiagnosis.js +134 -24
- package/dist/reporter.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Playwright Reporter
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-

|
|
5
|
-

|
|
3
|
+
[](https://www.npmjs.com/package/@sentinelqa/playwright-reporter)
|
|
4
|
+
[](https://www.npmjs.com/package/@sentinelqa/playwright-reporter)
|
|
5
|
+
[](./LICENSE)
|
|
6
6
|
|
|
7
7
|
A Playwright reporter that aggregates traces, screenshots, videos, and logs
|
|
8
8
|
into a single debugging report for failed tests.
|
|
@@ -17,6 +17,11 @@ Optionally upload runs to Sentinel Cloud for CI history and AI failure analysis.
|
|
|
17
17
|
|
|
18
18
|
- Aggregates Playwright traces, screenshots, videos, and logs
|
|
19
19
|
- Generates a local HTML debugging report
|
|
20
|
+
- Prints a deterministic quick diagnosis in the terminal after failed runs
|
|
21
|
+
- Adds a failure digest to the local HTML report
|
|
22
|
+
- Groups similar failures so repeated symptoms are easy to spot
|
|
23
|
+
- Lets you copy debug summaries for Slack, Jira, and GitHub issues
|
|
24
|
+
- Compares the current run to the previous run on the same branch
|
|
20
25
|
- Works with existing Playwright reporter setup
|
|
21
26
|
- Optional Sentinel Cloud integration
|
|
22
27
|
- CI run history and AI debugging summaries in cloud mode
|
|
@@ -26,7 +31,7 @@ Optionally upload runs to Sentinel Cloud for CI history and AI failure analysis.
|
|
|
26
31
|
Debugging Playwright CI failures often means downloading traces,
|
|
27
32
|
screenshots, and videos separately.
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
Reporter aggregates everything into one debugging report
|
|
30
35
|
so you can quickly understand what failed.
|
|
31
36
|
|
|
32
37
|
## Requirements
|
|
@@ -87,6 +92,9 @@ If tests fail and `SENTINEL_TOKEN` is not set, Sentinel generates:
|
|
|
87
92
|
|
|
88
93
|
Open the report to inspect:
|
|
89
94
|
|
|
95
|
+
- failure digest
|
|
96
|
+
- similar failure groups
|
|
97
|
+
- run-to-run diff
|
|
90
98
|
- failed tests
|
|
91
99
|
- screenshots
|
|
92
100
|
- videos
|
package/dist/localReport.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FailureFacts } from "./quickDiagnosis";
|
|
1
2
|
type ReporterSourcePaths = {
|
|
2
3
|
playwrightJsonPath: string;
|
|
3
4
|
playwrightReportDir: string;
|
|
@@ -9,6 +10,28 @@ type LocalReportOptions = ReporterSourcePaths & {
|
|
|
9
10
|
reportFileName?: string;
|
|
10
11
|
redirectFileName?: string;
|
|
11
12
|
};
|
|
13
|
+
type ArtifactKind = "trace" | "screenshot" | "video" | "log" | "network" | "report" | "attachment";
|
|
14
|
+
type CopiedArtifact = {
|
|
15
|
+
sourcePath: string;
|
|
16
|
+
fileName: string;
|
|
17
|
+
relativePath: string;
|
|
18
|
+
kind: ArtifactKind;
|
|
19
|
+
label: string;
|
|
20
|
+
testId: string | null;
|
|
21
|
+
};
|
|
22
|
+
type ReportTest = {
|
|
23
|
+
id: string;
|
|
24
|
+
matchKey: string;
|
|
25
|
+
title: string;
|
|
26
|
+
titlePath: string[];
|
|
27
|
+
file: string | null;
|
|
28
|
+
projectName: string | null;
|
|
29
|
+
status: string;
|
|
30
|
+
duration: number;
|
|
31
|
+
errors: string[];
|
|
32
|
+
diagnosis: FailureFacts | null;
|
|
33
|
+
artifacts: CopiedArtifact[];
|
|
34
|
+
};
|
|
12
35
|
type LocalReportSummary = {
|
|
13
36
|
total: number;
|
|
14
37
|
failed: number;
|
|
@@ -20,6 +43,29 @@ type LocalReportResult = {
|
|
|
20
43
|
redirectPath: string;
|
|
21
44
|
artifactCount: number;
|
|
22
45
|
summary: LocalReportSummary;
|
|
46
|
+
runDiff: RunDiffSummary | null;
|
|
47
|
+
};
|
|
48
|
+
type RunSnapshot = {
|
|
49
|
+
generatedAt: string;
|
|
50
|
+
branch: string;
|
|
51
|
+
gitSha: string;
|
|
52
|
+
totals: LocalReportSummary;
|
|
53
|
+
tests: Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
matchKey: string;
|
|
56
|
+
title: string;
|
|
57
|
+
status: string;
|
|
58
|
+
signal: string | null;
|
|
59
|
+
locator: string | null;
|
|
60
|
+
expected: string | null;
|
|
61
|
+
received: string | null;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
type RunDiffSummary = {
|
|
65
|
+
label: string;
|
|
66
|
+
newFailures: ReportTest[];
|
|
67
|
+
fixedTests: RunSnapshot["tests"];
|
|
68
|
+
stillFailing: ReportTest[];
|
|
23
69
|
};
|
|
24
70
|
export declare function generateLocalDebugReport(options: LocalReportOptions): LocalReportResult;
|
|
25
71
|
export {};
|
package/dist/localReport.js
CHANGED
|
@@ -7,10 +7,13 @@ exports.generateLocalDebugReport = generateLocalDebugReport;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const quickDiagnosis_1 = require("./quickDiagnosis");
|
|
10
12
|
const DEFAULT_REPORT_DIR = "sentinel-report";
|
|
11
13
|
const DEFAULT_REPORT_FILE = "index.html";
|
|
12
14
|
const DEFAULT_REDIRECT_FILE = "sentinel-debug.html";
|
|
13
15
|
const SENTINEL_URL = "https://sentinelqa.com";
|
|
16
|
+
const SENTINEL_HISTORY_DIR = path_1.default.join(".sentinel", "history");
|
|
14
17
|
const ARTIFACT_EXTENSIONS = {
|
|
15
18
|
trace: [".zip"],
|
|
16
19
|
screenshot: [".png", ".jpg", ".jpeg", ".webp", ".gif"],
|
|
@@ -92,6 +95,23 @@ const safeSlug = (value) => {
|
|
|
92
95
|
.replace(/^-+|-+$/g, "")
|
|
93
96
|
.slice(0, 64) || "artifact");
|
|
94
97
|
};
|
|
98
|
+
const buildTitlePath = (baseTitles, test) => {
|
|
99
|
+
const title = typeof test?.title === "string" ? test.title : null;
|
|
100
|
+
if (!title)
|
|
101
|
+
return baseTitles.filter(Boolean);
|
|
102
|
+
if (baseTitles[baseTitles.length - 1] === title)
|
|
103
|
+
return baseTitles.filter(Boolean);
|
|
104
|
+
return [...baseTitles, title].filter(Boolean);
|
|
105
|
+
};
|
|
106
|
+
const buildTestIdentity = (test, titlePath) => {
|
|
107
|
+
const file = test?.location?.file || "unknown";
|
|
108
|
+
const project = test?.projectName || "default";
|
|
109
|
+
const joined = titlePath.join(" > ");
|
|
110
|
+
return {
|
|
111
|
+
id: [file, project, joined].join("::"),
|
|
112
|
+
matchKey: [file, joined].join("::")
|
|
113
|
+
};
|
|
114
|
+
};
|
|
95
115
|
const formatDuration = (durationMs) => {
|
|
96
116
|
if (!Number.isFinite(durationMs) || durationMs <= 0)
|
|
97
117
|
return "0 ms";
|
|
@@ -99,6 +119,39 @@ const formatDuration = (durationMs) => {
|
|
|
99
119
|
return `${Math.round(durationMs)} ms`;
|
|
100
120
|
return `${(durationMs / 1000).toFixed(durationMs >= 10000 ? 0 : 1)} s`;
|
|
101
121
|
};
|
|
122
|
+
const pluralize = (count, singular, plural) => count === 1 ? singular : plural;
|
|
123
|
+
const sanitizeFileSegment = (value) => value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
124
|
+
const getCurrentBranch = () => {
|
|
125
|
+
const envBranch = process.env.GITHUB_HEAD_REF ||
|
|
126
|
+
process.env.GITHUB_REF_NAME ||
|
|
127
|
+
process.env.VERCEL_GIT_COMMIT_REF ||
|
|
128
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
129
|
+
process.env.BRANCH_NAME;
|
|
130
|
+
if (envBranch)
|
|
131
|
+
return sanitizeFileSegment(envBranch);
|
|
132
|
+
try {
|
|
133
|
+
return sanitizeFileSegment((0, child_process_1.execSync)("git rev-parse --abbrev-ref HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").trim());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return "unknown";
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const getCurrentGitSha = () => {
|
|
140
|
+
const envSha = process.env.GITHUB_SHA ||
|
|
141
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
142
|
+
process.env.CI_COMMIT_SHA ||
|
|
143
|
+
process.env.COMMIT_SHA;
|
|
144
|
+
if (envSha)
|
|
145
|
+
return envSha.slice(0, 12);
|
|
146
|
+
try {
|
|
147
|
+
return (0, child_process_1.execSync)("git rev-parse --short=12 HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
|
148
|
+
.toString("utf8")
|
|
149
|
+
.trim();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return "unknown";
|
|
153
|
+
}
|
|
154
|
+
};
|
|
102
155
|
const relativeFromCwd = (targetPath) => {
|
|
103
156
|
const relative = path_1.default.relative(process.cwd(), targetPath).replace(/\\/g, "/");
|
|
104
157
|
if (!relative || relative === "")
|
|
@@ -206,20 +259,22 @@ const createReportTest = (test, titlePath) => {
|
|
|
206
259
|
.filter(Boolean)
|
|
207
260
|
: []);
|
|
208
261
|
const duration = results.reduce((total, result) => total + (Number(result?.duration) || 0), 0);
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
titlePath.join(" > ")
|
|
213
|
-
].join("::");
|
|
262
|
+
const identity = buildTestIdentity(test, titlePath);
|
|
263
|
+
const status = normalizeTestStatus(test?.status || lastResult?.status || "unknown");
|
|
264
|
+
const primaryError = errors[0] || "";
|
|
214
265
|
return {
|
|
215
|
-
id,
|
|
266
|
+
id: identity.id,
|
|
267
|
+
matchKey: identity.matchKey,
|
|
216
268
|
title: test?.title || titlePath[titlePath.length - 1] || "Untitled test",
|
|
217
269
|
titlePath,
|
|
218
270
|
file: test?.location?.file || null,
|
|
219
271
|
projectName: test?.projectName || null,
|
|
220
|
-
status
|
|
272
|
+
status,
|
|
221
273
|
duration,
|
|
222
274
|
errors,
|
|
275
|
+
diagnosis: ["failed", "timedOut", "interrupted"].includes(status) && primaryError
|
|
276
|
+
? (0, quickDiagnosis_1.parseFailureFacts)(test?.title || titlePath[titlePath.length - 1] || "Untitled test", titlePath, primaryError, status)
|
|
277
|
+
: null,
|
|
223
278
|
artifacts: []
|
|
224
279
|
};
|
|
225
280
|
};
|
|
@@ -228,7 +283,7 @@ const collectTests = (node, parentTitles = []) => {
|
|
|
228
283
|
const collected = [];
|
|
229
284
|
if (Array.isArray(node?.tests)) {
|
|
230
285
|
for (const test of node.tests) {
|
|
231
|
-
collected.push(createReportTest(test,
|
|
286
|
+
collected.push(createReportTest(test, buildTitlePath(nextTitles, test)));
|
|
232
287
|
}
|
|
233
288
|
}
|
|
234
289
|
if (Array.isArray(node?.specs)) {
|
|
@@ -236,7 +291,7 @@ const collectTests = (node, parentTitles = []) => {
|
|
|
236
291
|
const specTitles = [...nextTitles, spec?.title].filter(Boolean);
|
|
237
292
|
const specTests = Array.isArray(spec?.tests) ? spec.tests : [];
|
|
238
293
|
for (const test of specTests) {
|
|
239
|
-
collected.push(createReportTest(test, specTitles));
|
|
294
|
+
collected.push(createReportTest(test, buildTitlePath(specTitles, test)));
|
|
240
295
|
}
|
|
241
296
|
}
|
|
242
297
|
}
|
|
@@ -252,12 +307,8 @@ const collectTestRefs = (node, parentTitles = []) => {
|
|
|
252
307
|
const refs = [];
|
|
253
308
|
if (Array.isArray(node?.tests)) {
|
|
254
309
|
for (const test of node.tests) {
|
|
255
|
-
const titlePath =
|
|
256
|
-
const id =
|
|
257
|
-
test?.location?.file || "unknown",
|
|
258
|
-
test?.projectName || "default",
|
|
259
|
-
titlePath.join(" > ")
|
|
260
|
-
].join("::");
|
|
310
|
+
const titlePath = buildTitlePath(nextTitles, test);
|
|
311
|
+
const id = buildTestIdentity(test, titlePath).id;
|
|
261
312
|
refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
|
|
262
313
|
}
|
|
263
314
|
}
|
|
@@ -265,11 +316,7 @@ const collectTestRefs = (node, parentTitles = []) => {
|
|
|
265
316
|
for (const spec of node.specs) {
|
|
266
317
|
const titlePath = [...nextTitles, spec?.title].filter(Boolean);
|
|
267
318
|
for (const test of Array.isArray(spec?.tests) ? spec.tests : []) {
|
|
268
|
-
const id =
|
|
269
|
-
test?.location?.file || "unknown",
|
|
270
|
-
test?.projectName || "default",
|
|
271
|
-
titlePath.join(" > ")
|
|
272
|
-
].join("::");
|
|
319
|
+
const id = buildTestIdentity(test, buildTitlePath(titlePath, test)).id;
|
|
273
320
|
refs.push({ id, resultList: Array.isArray(test?.results) ? test.results : [] });
|
|
274
321
|
}
|
|
275
322
|
}
|
|
@@ -294,6 +341,94 @@ const summarizeTests = (tests) => {
|
|
|
294
341
|
return summary;
|
|
295
342
|
}, { total: 0, failed: 0, passed: 0, skipped: 0 });
|
|
296
343
|
};
|
|
344
|
+
const getFailureTests = (tests) => tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
345
|
+
const groupSimilarFailures = (tests) => {
|
|
346
|
+
const groups = new Map();
|
|
347
|
+
for (const test of getFailureTests(tests)) {
|
|
348
|
+
const diagnosis = test.diagnosis;
|
|
349
|
+
const key = diagnosis ? (0, quickDiagnosis_1.buildSimilarityKey)(diagnosis) : `unknown|${test.title}`;
|
|
350
|
+
if (!groups.has(key)) {
|
|
351
|
+
groups.set(key, {
|
|
352
|
+
key,
|
|
353
|
+
signal: diagnosis ? (0, quickDiagnosis_1.summarizeSignal)(diagnosis.signal) : "uncategorized failure",
|
|
354
|
+
summary: diagnosis ? (0, quickDiagnosis_1.describeFailure)(diagnosis) : "The failure could not be grouped from captured evidence.",
|
|
355
|
+
locator: diagnosis?.locator || null,
|
|
356
|
+
tests: []
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
groups.get(key).tests.push(test);
|
|
360
|
+
}
|
|
361
|
+
return Array.from(groups.values())
|
|
362
|
+
.filter((group) => group.tests.length > 1)
|
|
363
|
+
.sort((a, b) => b.tests.length - a.tests.length);
|
|
364
|
+
};
|
|
365
|
+
const buildRunSnapshot = (tests, summary) => ({
|
|
366
|
+
generatedAt: new Date().toISOString(),
|
|
367
|
+
branch: getCurrentBranch(),
|
|
368
|
+
gitSha: getCurrentGitSha(),
|
|
369
|
+
totals: summary,
|
|
370
|
+
tests: tests.map((test) => ({
|
|
371
|
+
id: test.id,
|
|
372
|
+
matchKey: test.matchKey,
|
|
373
|
+
title: test.titlePath.join(" > ") || test.title,
|
|
374
|
+
status: test.status,
|
|
375
|
+
signal: test.diagnosis?.signal || null,
|
|
376
|
+
locator: test.diagnosis?.locator || null,
|
|
377
|
+
expected: test.diagnosis?.expected || null,
|
|
378
|
+
received: test.diagnosis?.received || null
|
|
379
|
+
}))
|
|
380
|
+
});
|
|
381
|
+
const getPointerPaths = (branch) => [
|
|
382
|
+
path_1.default.join(".sentinel", "latest.json"),
|
|
383
|
+
path_1.default.join(".sentinel", `latest-${branch}.json`),
|
|
384
|
+
...(branch === "main" ? [path_1.default.join(".sentinel", "latest-main.json")] : [])
|
|
385
|
+
];
|
|
386
|
+
const readSnapshot = (filePath) => {
|
|
387
|
+
if (!fs_1.default.existsSync(filePath))
|
|
388
|
+
return null;
|
|
389
|
+
try {
|
|
390
|
+
return JSON.parse(fs_1.default.readFileSync(filePath, "utf8"));
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const writeRunHistory = (snapshot) => {
|
|
397
|
+
ensureDir(path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR));
|
|
398
|
+
ensureDir(path_1.default.resolve(process.cwd(), ".sentinel"));
|
|
399
|
+
const fileName = `${snapshot.generatedAt.replace(/[:.]/g, "-")}-${snapshot.gitSha}.json`;
|
|
400
|
+
const historyPath = path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR, fileName);
|
|
401
|
+
fs_1.default.writeFileSync(historyPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
402
|
+
for (const pointerPath of getPointerPaths(snapshot.branch)) {
|
|
403
|
+
fs_1.default.writeFileSync(path_1.default.resolve(process.cwd(), pointerPath), JSON.stringify({
|
|
404
|
+
path: relativeFromCwd(historyPath),
|
|
405
|
+
...snapshot
|
|
406
|
+
}, null, 2), "utf8");
|
|
407
|
+
}
|
|
408
|
+
return historyPath;
|
|
409
|
+
};
|
|
410
|
+
const buildRunDiff = (tests, snapshot) => {
|
|
411
|
+
const previous = readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", `latest-${snapshot.branch}.json`))
|
|
412
|
+
|| readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", "latest.json"));
|
|
413
|
+
if (!previous || previous.generatedAt === snapshot.generatedAt)
|
|
414
|
+
return null;
|
|
415
|
+
const currentById = new Map(tests.map((test) => [test.id, test]));
|
|
416
|
+
const currentByMatchKey = new Map(tests.map((test) => [test.matchKey, test]));
|
|
417
|
+
const currentFailures = getFailureTests(tests);
|
|
418
|
+
const previousFailures = previous.tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
419
|
+
const previousFailureIds = new Set(previousFailures.map((test) => test.id));
|
|
420
|
+
const previousFailureMatchKeys = new Set(previousFailures.map((test) => (typeof test.matchKey === "string" ? test.matchKey : test.id)));
|
|
421
|
+
return {
|
|
422
|
+
label: previous.branch === snapshot.branch ? `Compared to previous ${snapshot.branch} run` : "Compared to previous run",
|
|
423
|
+
newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id) && !previousFailureMatchKeys.has(test.matchKey)),
|
|
424
|
+
fixedTests: previousFailures.filter((test) => {
|
|
425
|
+
const current = currentById.get(test.id) ||
|
|
426
|
+
currentByMatchKey.get(typeof test.matchKey === "string" ? test.matchKey : test.id);
|
|
427
|
+
return current && !["failed", "timedOut", "interrupted"].includes(current.status);
|
|
428
|
+
}),
|
|
429
|
+
stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id) || previousFailureMatchKeys.has(test.matchKey))
|
|
430
|
+
};
|
|
431
|
+
};
|
|
297
432
|
const renderArtifact = (artifact) => {
|
|
298
433
|
const href = escapeHtml(artifact.relativePath);
|
|
299
434
|
const label = escapeHtml(artifact.label);
|
|
@@ -409,6 +544,31 @@ const renderTestCard = (test) => {
|
|
|
409
544
|
})()
|
|
410
545
|
: `<pre>No error message was attached to this result.</pre>`;
|
|
411
546
|
const artifactMarkup = renderArtifactGroups(test.artifacts);
|
|
547
|
+
const diagnosis = test.diagnosis;
|
|
548
|
+
const diagnosisMarkup = diagnosis
|
|
549
|
+
? `
|
|
550
|
+
<div class="diagnosis-shell">
|
|
551
|
+
<div>
|
|
552
|
+
<span class="artifact-kind">Quick diagnosis</span>
|
|
553
|
+
<p class="diagnosis-copy">${escapeHtml((0, quickDiagnosis_1.describeFailure)(diagnosis))}</p>
|
|
554
|
+
</div>
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
class="copy-button"
|
|
558
|
+
data-copy-summary="${escapeHtml((0, quickDiagnosis_1.buildDebugSummary)(diagnosis))}"
|
|
559
|
+
aria-label="Copy debug summary"
|
|
560
|
+
>
|
|
561
|
+
Copy debug summary
|
|
562
|
+
</button>
|
|
563
|
+
</div>
|
|
564
|
+
<div class="fact-row">
|
|
565
|
+
${diagnosis.locator ? `<span class="fact-chip">Locator: ${escapeHtml(diagnosis.locator)}</span>` : ""}
|
|
566
|
+
${diagnosis.expected ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span>` : ""}
|
|
567
|
+
${diagnosis.received ? `<span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>` : ""}
|
|
568
|
+
${diagnosis.timeoutMs ? `<span class="fact-chip">Timeout: ${diagnosis.timeoutMs}ms</span>` : ""}
|
|
569
|
+
</div>
|
|
570
|
+
`
|
|
571
|
+
: "";
|
|
412
572
|
return `
|
|
413
573
|
<details class="test-card">
|
|
414
574
|
<summary class="test-summary">
|
|
@@ -423,6 +583,7 @@ const renderTestCard = (test) => {
|
|
|
423
583
|
</div>
|
|
424
584
|
</summary>
|
|
425
585
|
<div class="panel">
|
|
586
|
+
${diagnosisMarkup}
|
|
426
587
|
<h4>Error</h4>
|
|
427
588
|
${errorBlock}
|
|
428
589
|
</div>
|
|
@@ -435,6 +596,121 @@ const renderTestCard = (test) => {
|
|
|
435
596
|
</details>
|
|
436
597
|
`;
|
|
437
598
|
};
|
|
599
|
+
const renderFailureDigest = (tests) => {
|
|
600
|
+
const failedTests = getFailureTests(tests);
|
|
601
|
+
if (!failedTests.length) {
|
|
602
|
+
return `<div class="empty-state">No failed tests were detected in this run.</div>`;
|
|
603
|
+
}
|
|
604
|
+
return `
|
|
605
|
+
<div class="digest-grid">
|
|
606
|
+
${failedTests
|
|
607
|
+
.map((test) => {
|
|
608
|
+
const diagnosis = test.diagnosis;
|
|
609
|
+
const title = escapeHtml(test.title);
|
|
610
|
+
const summary = escapeHtml(diagnosis
|
|
611
|
+
? (0, quickDiagnosis_1.describeFailure)(diagnosis)
|
|
612
|
+
: (test.errors[0]?.split(/\r?\n/)[0]?.trim() || "Open the failure details to inspect the exact Playwright error."));
|
|
613
|
+
const debugSummary = escapeHtml(diagnosis
|
|
614
|
+
? (0, quickDiagnosis_1.buildDebugSummary)(diagnosis)
|
|
615
|
+
: `Test: ${test.title}\nDiagnosis: Review trace and error details in the expanded card.`);
|
|
616
|
+
return `
|
|
617
|
+
<article class="digest-card">
|
|
618
|
+
<div class="digest-head">
|
|
619
|
+
<div>
|
|
620
|
+
<span class="artifact-kind">${escapeHtml(test.status)}</span>
|
|
621
|
+
<h3>${title}</h3>
|
|
622
|
+
</div>
|
|
623
|
+
<button
|
|
624
|
+
type="button"
|
|
625
|
+
class="copy-button"
|
|
626
|
+
data-copy-summary="${debugSummary}"
|
|
627
|
+
aria-label="Copy debug summary"
|
|
628
|
+
>
|
|
629
|
+
Copy summary
|
|
630
|
+
</button>
|
|
631
|
+
</div>
|
|
632
|
+
<p>${summary}</p>
|
|
633
|
+
<div class="fact-row">
|
|
634
|
+
${diagnosis?.locator ? `<span class="fact-chip">Locator: ${escapeHtml(diagnosis.locator)}</span>` : ""}
|
|
635
|
+
${diagnosis?.expected ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span>` : ""}
|
|
636
|
+
${diagnosis?.received ? `<span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>` : ""}
|
|
637
|
+
</div>
|
|
638
|
+
</article>
|
|
639
|
+
`;
|
|
640
|
+
})
|
|
641
|
+
.join("\n")}
|
|
642
|
+
</div>
|
|
643
|
+
`;
|
|
644
|
+
};
|
|
645
|
+
const renderSimilarFailureGroups = (groups) => {
|
|
646
|
+
if (!groups.length) {
|
|
647
|
+
return `<div class="empty-state">No repeated failure fingerprint was detected in this run.</div>`;
|
|
648
|
+
}
|
|
649
|
+
return groups
|
|
650
|
+
.map((group) => `
|
|
651
|
+
<article class="group-card">
|
|
652
|
+
<div class="failed-list-head">
|
|
653
|
+
<div>
|
|
654
|
+
<h3>${escapeHtml(group.signal)}</h3>
|
|
655
|
+
<p>${escapeHtml(group.summary)}</p>
|
|
656
|
+
</div>
|
|
657
|
+
<div class="group-count">${group.tests.length} ${pluralize(group.tests.length, "test", "tests")}</div>
|
|
658
|
+
</div>
|
|
659
|
+
${group.locator
|
|
660
|
+
? `<div class="fact-row"><span class="fact-chip">Common locator: ${escapeHtml(group.locator)}</span></div>`
|
|
661
|
+
: ""}
|
|
662
|
+
<ul class="group-list">
|
|
663
|
+
${group.tests
|
|
664
|
+
.slice(0, 6)
|
|
665
|
+
.map((test) => `<li>${escapeHtml(test.title)}</li>`)
|
|
666
|
+
.join("\n")}
|
|
667
|
+
</ul>
|
|
668
|
+
</article>
|
|
669
|
+
`)
|
|
670
|
+
.join("\n");
|
|
671
|
+
};
|
|
672
|
+
const renderRunDiff = (runDiff) => {
|
|
673
|
+
if (!runDiff) {
|
|
674
|
+
return `<div class="empty-state">No previous run snapshot was available for comparison.</div>`;
|
|
675
|
+
}
|
|
676
|
+
return `
|
|
677
|
+
<div class="diff-label">${escapeHtml(runDiff.label)}</div>
|
|
678
|
+
<div class="summary-grid">
|
|
679
|
+
<div class="summary-card">
|
|
680
|
+
<span class="summary-label">New failures</span>
|
|
681
|
+
<span class="summary-value">${runDiff.newFailures.length}</span>
|
|
682
|
+
</div>
|
|
683
|
+
<div class="summary-card">
|
|
684
|
+
<span class="summary-label">Fixed since last run</span>
|
|
685
|
+
<span class="summary-value">${runDiff.fixedTests.length}</span>
|
|
686
|
+
</div>
|
|
687
|
+
<div class="summary-card">
|
|
688
|
+
<span class="summary-label">Still failing</span>
|
|
689
|
+
<span class="summary-value">${runDiff.stillFailing.length}</span>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
<div class="diff-grid">
|
|
693
|
+
<article class="diff-card">
|
|
694
|
+
<h3>New failures</h3>
|
|
695
|
+
${runDiff.newFailures.length
|
|
696
|
+
? `<ul class="group-list">${runDiff.newFailures.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
697
|
+
: `<div class="empty-state">No new failures in this run.</div>`}
|
|
698
|
+
</article>
|
|
699
|
+
<article class="diff-card">
|
|
700
|
+
<h3>Fixed since last run</h3>
|
|
701
|
+
${runDiff.fixedTests.length
|
|
702
|
+
? `<ul class="group-list">${runDiff.fixedTests.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
703
|
+
: `<div class="empty-state">No previously failing tests were fixed in this run.</div>`}
|
|
704
|
+
</article>
|
|
705
|
+
<article class="diff-card">
|
|
706
|
+
<h3>Still failing</h3>
|
|
707
|
+
${runDiff.stillFailing.length
|
|
708
|
+
? `<ul class="group-list">${runDiff.stillFailing.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
709
|
+
: `<div class="empty-state">No tests remained failing across both runs.</div>`}
|
|
710
|
+
</article>
|
|
711
|
+
</div>
|
|
712
|
+
`;
|
|
713
|
+
};
|
|
438
714
|
const renderAdditionalArtifacts = (artifacts) => {
|
|
439
715
|
if (artifacts.length === 0) {
|
|
440
716
|
return "";
|
|
@@ -462,8 +738,9 @@ const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRe
|
|
|
462
738
|
}
|
|
463
739
|
}
|
|
464
740
|
};
|
|
465
|
-
const buildHtml = (tests, summary, extraArtifacts) => {
|
|
741
|
+
const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
|
|
466
742
|
const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
743
|
+
const similarGroups = groupSimilarFailures(tests);
|
|
467
744
|
const generatedAt = new Date().toLocaleString();
|
|
468
745
|
return `<!doctype html>
|
|
469
746
|
<html lang="en">
|
|
@@ -776,6 +1053,60 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
776
1053
|
.section-shell li {
|
|
777
1054
|
margin-top: 6px;
|
|
778
1055
|
}
|
|
1056
|
+
.digest-grid, .diff-grid {
|
|
1057
|
+
display: grid;
|
|
1058
|
+
gap: 16px;
|
|
1059
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
1060
|
+
margin-top: 18px;
|
|
1061
|
+
}
|
|
1062
|
+
.digest-card, .group-card, .diff-card {
|
|
1063
|
+
border: 1px solid rgba(125, 211, 252, 0.16);
|
|
1064
|
+
border-radius: 16px;
|
|
1065
|
+
background: rgba(9, 13, 20, 0.72);
|
|
1066
|
+
padding: 16px;
|
|
1067
|
+
}
|
|
1068
|
+
.digest-head, .diagnosis-shell {
|
|
1069
|
+
display: flex;
|
|
1070
|
+
justify-content: space-between;
|
|
1071
|
+
gap: 12px;
|
|
1072
|
+
align-items: flex-start;
|
|
1073
|
+
}
|
|
1074
|
+
.digest-card p,
|
|
1075
|
+
.group-card p,
|
|
1076
|
+
.diff-card p,
|
|
1077
|
+
.diagnosis-copy {
|
|
1078
|
+
margin: 10px 0 0;
|
|
1079
|
+
color: var(--muted);
|
|
1080
|
+
line-height: 1.6;
|
|
1081
|
+
}
|
|
1082
|
+
.fact-row {
|
|
1083
|
+
display: flex;
|
|
1084
|
+
flex-wrap: wrap;
|
|
1085
|
+
gap: 8px;
|
|
1086
|
+
margin-top: 14px;
|
|
1087
|
+
}
|
|
1088
|
+
.fact-chip {
|
|
1089
|
+
display: inline-flex;
|
|
1090
|
+
align-items: center;
|
|
1091
|
+
padding: 6px 10px;
|
|
1092
|
+
border-radius: 999px;
|
|
1093
|
+
border: 1px solid rgba(125, 211, 252, 0.2);
|
|
1094
|
+
background: rgba(125, 211, 252, 0.06);
|
|
1095
|
+
color: #cdefff;
|
|
1096
|
+
font-size: 12px;
|
|
1097
|
+
}
|
|
1098
|
+
.group-count, .diff-label {
|
|
1099
|
+
color: var(--accent);
|
|
1100
|
+
font-size: 13px;
|
|
1101
|
+
font-weight: 600;
|
|
1102
|
+
}
|
|
1103
|
+
.group-list {
|
|
1104
|
+
margin: 14px 0 0 18px;
|
|
1105
|
+
color: var(--text);
|
|
1106
|
+
}
|
|
1107
|
+
.group-list li {
|
|
1108
|
+
margin-top: 8px;
|
|
1109
|
+
}
|
|
779
1110
|
.empty-state {
|
|
780
1111
|
color: var(--muted);
|
|
781
1112
|
border: 1px dashed rgba(39, 48, 66, 0.9);
|
|
@@ -880,6 +1211,32 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
880
1211
|
</div>
|
|
881
1212
|
</header>
|
|
882
1213
|
|
|
1214
|
+
<section class="section-shell">
|
|
1215
|
+
<div class="failed-list-head">
|
|
1216
|
+
<h2>Run-to-Run Diff</h2>
|
|
1217
|
+
</div>
|
|
1218
|
+
<p>Compare this run with the latest saved snapshot from the same branch.</p>
|
|
1219
|
+
${renderRunDiff(runDiff)}
|
|
1220
|
+
</section>
|
|
1221
|
+
|
|
1222
|
+
<section class="section-shell">
|
|
1223
|
+
<div class="failed-list-head">
|
|
1224
|
+
<h2>Failure Digest</h2>
|
|
1225
|
+
<div class="failed-count">${failedTests.length} failed</div>
|
|
1226
|
+
</div>
|
|
1227
|
+
<p>Deterministic summaries built from the Playwright error, locator, assertion text, and timeout.</p>
|
|
1228
|
+
${renderFailureDigest(tests)}
|
|
1229
|
+
</section>
|
|
1230
|
+
|
|
1231
|
+
<section class="section-shell">
|
|
1232
|
+
<div class="failed-list-head">
|
|
1233
|
+
<h2>Similar Failures</h2>
|
|
1234
|
+
<div class="failed-count">${similarGroups.length} groups</div>
|
|
1235
|
+
</div>
|
|
1236
|
+
<p>Failures are grouped only when they share the same fingerprint, so one repeated issue is easier to spot.</p>
|
|
1237
|
+
${renderSimilarFailureGroups(similarGroups)}
|
|
1238
|
+
</section>
|
|
1239
|
+
|
|
883
1240
|
<section class="section-shell">
|
|
884
1241
|
<div class="failed-list-head">
|
|
885
1242
|
<h2>Failed Tests</h2>
|
|
@@ -973,6 +1330,32 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
973
1330
|
});
|
|
974
1331
|
});
|
|
975
1332
|
|
|
1333
|
+
document.querySelectorAll("[data-copy-summary]").forEach(function (button) {
|
|
1334
|
+
button.addEventListener("click", async function () {
|
|
1335
|
+
var rawSummary = button.getAttribute("data-copy-summary");
|
|
1336
|
+
if (!rawSummary) return;
|
|
1337
|
+
var text = rawSummary
|
|
1338
|
+
.replace(/"/g, '"')
|
|
1339
|
+
.replace(/'/g, "'")
|
|
1340
|
+
.replace(/</g, "<")
|
|
1341
|
+
.replace(/>/g, ">")
|
|
1342
|
+
.replace(/&/g, "&");
|
|
1343
|
+
try {
|
|
1344
|
+
await navigator.clipboard.writeText(text);
|
|
1345
|
+
var previousText = button.textContent;
|
|
1346
|
+
button.textContent = "Copied";
|
|
1347
|
+
setTimeout(function () {
|
|
1348
|
+
button.textContent = previousText || "Copy summary";
|
|
1349
|
+
}, 1200);
|
|
1350
|
+
} catch (_error) {
|
|
1351
|
+
button.textContent = "Copy failed";
|
|
1352
|
+
setTimeout(function () {
|
|
1353
|
+
button.textContent = previousText || "Copy summary";
|
|
1354
|
+
}, 1200);
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
|
|
976
1359
|
var overlay = document.getElementById("preview-overlay");
|
|
977
1360
|
var previewImage = document.getElementById("preview-image");
|
|
978
1361
|
var previewClose = document.getElementById("preview-close");
|
|
@@ -1072,13 +1455,17 @@ function generateLocalDebugReport(options) {
|
|
|
1072
1455
|
extraArtifacts.push(artifact);
|
|
1073
1456
|
}
|
|
1074
1457
|
const summary = summarizeTests(tests);
|
|
1075
|
-
const
|
|
1458
|
+
const snapshot = buildRunSnapshot(tests, summary);
|
|
1459
|
+
const runDiff = buildRunDiff(tests, snapshot);
|
|
1460
|
+
writeRunHistory(snapshot);
|
|
1461
|
+
const html = buildHtml(tests, summary, extraArtifacts, runDiff);
|
|
1076
1462
|
fs_1.default.writeFileSync(reportHtmlPath, html, "utf8");
|
|
1077
1463
|
fs_1.default.writeFileSync(redirectPath, `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=${relativeFromCwd(reportHtmlPath)}" /><title>Sentinel Playwright Reporter</title></head><body><p>Open <a href="${relativeFromCwd(reportHtmlPath)}">${relativeFromCwd(reportHtmlPath)}</a>.</p></body></html>`, "utf8");
|
|
1078
1464
|
return {
|
|
1079
1465
|
htmlPath: reportHtmlPath,
|
|
1080
1466
|
redirectPath,
|
|
1081
1467
|
artifactCount: claimedSourcePaths.size,
|
|
1082
|
-
summary
|
|
1468
|
+
summary,
|
|
1469
|
+
runDiff
|
|
1083
1470
|
};
|
|
1084
1471
|
}
|
package/dist/quickDiagnosis.d.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
type DiagnosisSignal = "timeout" | "assertion_mismatch" | "locator_not_found" | "actionability" | "network" | "runtime" | "unknown";
|
|
1
2
|
type QuickDiagnosis = {
|
|
2
3
|
lines: string[];
|
|
3
4
|
};
|
|
5
|
+
export type FailureFacts = {
|
|
6
|
+
title: string;
|
|
7
|
+
titlePath: string[];
|
|
8
|
+
message: string;
|
|
9
|
+
signal: DiagnosisSignal;
|
|
10
|
+
locator: string | null;
|
|
11
|
+
expected: string | null;
|
|
12
|
+
received: string | null;
|
|
13
|
+
timeoutMs: number | null;
|
|
14
|
+
lastUrl: string | null;
|
|
15
|
+
status: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const collectFailureFacts: (playwrightJsonPath: string) => FailureFacts[];
|
|
18
|
+
export declare const parseFailureFacts: (title: string, titlePath: string[], message: string, status: string) => FailureFacts;
|
|
19
|
+
export declare const describeFailure: (failure: FailureFacts) => string;
|
|
20
|
+
export declare const buildDebugSummary: (failure: FailureFacts) => string;
|
|
21
|
+
export declare const buildSimilarityKey: (failure: FailureFacts) => string;
|
|
22
|
+
export declare const summarizeSignal: (signal: DiagnosisSignal) => "timeout while waiting for UI or network conditions" | "assertion mismatch between expected and rendered UI state" | "missing or changed locator" | "target element was not actionable" | "network or API failure" | "frontend runtime error" | "failure signal could not be classified cleanly";
|
|
4
23
|
export declare const buildQuickDiagnosis: (playwrightJsonPath: string) => QuickDiagnosis | null;
|
|
5
24
|
export {};
|
package/dist/quickDiagnosis.js
CHANGED
|
@@ -3,8 +3,18 @@ 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 = void 0;
|
|
6
|
+
exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.describeFailure = exports.parseFailureFacts = exports.collectFailureFacts = void 0;
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const normalizeMessageFingerprint = (message) => stripAnsi(message)
|
|
9
|
+
.split(/\r?\n/)
|
|
10
|
+
.map((line) => line.trim())
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.slice(0, 3)
|
|
13
|
+
.join(" | ")
|
|
14
|
+
.replace(/\b\d+ms\b/gi, "<ms>")
|
|
15
|
+
.replace(/:\d+:\d+/g, ":<line>:<col>")
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.slice(0, 200);
|
|
8
18
|
const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
|
|
9
19
|
const toMessage = (result) => {
|
|
10
20
|
const direct = result.error?.message ||
|
|
@@ -36,6 +46,37 @@ const classifySignal = (message) => {
|
|
|
36
46
|
return "runtime";
|
|
37
47
|
return "unknown";
|
|
38
48
|
};
|
|
49
|
+
const extractLocator = (message) => {
|
|
50
|
+
const locatorLine = message.match(/Locator:\s*(.+)/i);
|
|
51
|
+
if (locatorLine?.[1])
|
|
52
|
+
return locatorLine[1].trim();
|
|
53
|
+
const callLine = message.match(/(getByTestId|getByRole|getByText|locator)\([^)]+\)/);
|
|
54
|
+
return callLine?.[0] || null;
|
|
55
|
+
};
|
|
56
|
+
const extractExpected = (message) => {
|
|
57
|
+
const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
|
|
58
|
+
message.match(/Expected string:\s*"([^"]+)"/i) ||
|
|
59
|
+
message.match(/Expected:\s*"([^"]+)"/i);
|
|
60
|
+
return match?.[1] || null;
|
|
61
|
+
};
|
|
62
|
+
const extractReceived = (message) => {
|
|
63
|
+
const match = message.match(/Received string:\s*"([^"]+)"/i) ||
|
|
64
|
+
message.match(/Received:\s*"([^"]+)"/i);
|
|
65
|
+
return match?.[1] || null;
|
|
66
|
+
};
|
|
67
|
+
const extractTimeoutMs = (message) => {
|
|
68
|
+
const match = message.match(/Timeout:\s*(\d+)\s*ms/i) ||
|
|
69
|
+
message.match(/timeout(?: of)?\s*(\d+)\s*ms/i) ||
|
|
70
|
+
message.match(/(\d+)\s*ms/i);
|
|
71
|
+
if (!match)
|
|
72
|
+
return null;
|
|
73
|
+
const value = Number(match[1]);
|
|
74
|
+
return Number.isFinite(value) ? value : null;
|
|
75
|
+
};
|
|
76
|
+
const extractLastUrl = (message) => {
|
|
77
|
+
const match = message.match(/https?:\/\/[^\s)"']+/i);
|
|
78
|
+
return match?.[0] || null;
|
|
79
|
+
};
|
|
39
80
|
const signalSummary = (signal) => {
|
|
40
81
|
switch (signal) {
|
|
41
82
|
case "timeout":
|
|
@@ -82,37 +123,106 @@ const shortenTitle = (value) => {
|
|
|
82
123
|
const parts = value.split(" > ").filter(Boolean);
|
|
83
124
|
return parts[parts.length - 1] || value;
|
|
84
125
|
};
|
|
85
|
-
const
|
|
126
|
+
const collectFailureFacts = (playwrightJsonPath) => {
|
|
86
127
|
if (!node_fs_1.default.existsSync(playwrightJsonPath))
|
|
87
|
-
return
|
|
128
|
+
return [];
|
|
88
129
|
try {
|
|
89
130
|
const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
|
|
90
131
|
const parsed = JSON.parse(raw);
|
|
91
132
|
const failedCases = flattenFailedCases(parsed);
|
|
92
|
-
|
|
93
|
-
return null;
|
|
94
|
-
if (failedCases.length === 1) {
|
|
95
|
-
const failed = failedCases[0];
|
|
96
|
-
return {
|
|
97
|
-
lines: [
|
|
98
|
-
`Test "${shortenTitle(failed.title)}" likely failed due to ${signalSummary(failed.signal)}.`
|
|
99
|
-
]
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
const counts = new Map();
|
|
103
|
-
for (const failed of failedCases) {
|
|
104
|
-
counts.set(failed.signal, (counts.get(failed.signal) || 0) + 1);
|
|
105
|
-
}
|
|
106
|
-
const topSignal = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
|
|
107
|
-
return {
|
|
108
|
-
lines: [
|
|
109
|
-
`${failedCases.length} tests failed.`,
|
|
110
|
-
`Most common signal: ${signalSummary(topSignal)}.`
|
|
111
|
-
]
|
|
112
|
-
};
|
|
133
|
+
return failedCases.map((failed) => (0, exports.parseFailureFacts)(shortenTitle(failed.title), failed.title.split(" > ").filter(Boolean), failed.message, "failed"));
|
|
113
134
|
}
|
|
114
135
|
catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
};
|
|
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
|
+
const describeFailure = (failure) => {
|
|
154
|
+
if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
|
|
155
|
+
return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}" before timeout.`;
|
|
156
|
+
}
|
|
157
|
+
if (failure.signal === "locator_not_found" && failure.locator) {
|
|
158
|
+
return `${failure.locator} was not found when the test expected it to be available.`;
|
|
159
|
+
}
|
|
160
|
+
if (failure.signal === "actionability" && failure.locator) {
|
|
161
|
+
return `${failure.locator} was found but was not actionable when the interaction ran.`;
|
|
162
|
+
}
|
|
163
|
+
if (failure.signal === "network") {
|
|
164
|
+
return `The test likely failed because a network or API request did not complete successfully.`;
|
|
165
|
+
}
|
|
166
|
+
if (failure.signal === "timeout") {
|
|
167
|
+
return `The expected UI or network condition did not complete before timeout.`;
|
|
168
|
+
}
|
|
169
|
+
if (failure.signal === "runtime") {
|
|
170
|
+
return `A frontend runtime error interrupted the test flow.`;
|
|
171
|
+
}
|
|
172
|
+
return `The failure signal could not be classified cleanly from the captured error.`;
|
|
173
|
+
};
|
|
174
|
+
exports.describeFailure = describeFailure;
|
|
175
|
+
const buildDebugSummary = (failure) => {
|
|
176
|
+
const lines = [
|
|
177
|
+
`Test: ${failure.title}`,
|
|
178
|
+
`Diagnosis: ${(0, exports.describeFailure)(failure)}`
|
|
179
|
+
];
|
|
180
|
+
if (failure.locator)
|
|
181
|
+
lines.push(`Locator: ${failure.locator}`);
|
|
182
|
+
if (failure.expected)
|
|
183
|
+
lines.push(`Expected: ${failure.expected}`);
|
|
184
|
+
if (failure.received)
|
|
185
|
+
lines.push(`Observed: ${failure.received}`);
|
|
186
|
+
if (failure.timeoutMs)
|
|
187
|
+
lines.push(`Timeout: ${failure.timeoutMs}ms`);
|
|
188
|
+
if (failure.lastUrl)
|
|
189
|
+
lines.push(`URL: ${failure.lastUrl}`);
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
};
|
|
192
|
+
exports.buildDebugSummary = buildDebugSummary;
|
|
193
|
+
const buildSimilarityKey = (failure) => {
|
|
194
|
+
if (failure.locator || failure.expected || failure.received) {
|
|
195
|
+
return [
|
|
196
|
+
failure.signal,
|
|
197
|
+
failure.locator || "unknown-locator",
|
|
198
|
+
failure.expected || "unknown-expected",
|
|
199
|
+
failure.received || "unknown-received"
|
|
200
|
+
].join("|");
|
|
201
|
+
}
|
|
202
|
+
return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}`;
|
|
203
|
+
};
|
|
204
|
+
exports.buildSimilarityKey = buildSimilarityKey;
|
|
205
|
+
exports.summarizeSignal = signalSummary;
|
|
206
|
+
const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
207
|
+
const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
|
|
208
|
+
if (!failures.length)
|
|
115
209
|
return null;
|
|
210
|
+
if (failures.length === 1) {
|
|
211
|
+
const failed = failures[0];
|
|
212
|
+
return {
|
|
213
|
+
lines: [`Test "${failed.title}" likely failed due to ${signalSummary(failed.signal)}.`]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const counts = new Map();
|
|
217
|
+
for (const failed of failures) {
|
|
218
|
+
counts.set(failed.signal, (counts.get(failed.signal) || 0) + 1);
|
|
116
219
|
}
|
|
220
|
+
const topSignal = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
|
|
221
|
+
return {
|
|
222
|
+
lines: [
|
|
223
|
+
`${failures.length} tests failed.`,
|
|
224
|
+
`Most common signal: ${signalSummary(topSignal)}.`
|
|
225
|
+
]
|
|
226
|
+
};
|
|
117
227
|
};
|
|
118
228
|
exports.buildQuickDiagnosis = buildQuickDiagnosis;
|
package/dist/reporter.js
CHANGED
|
@@ -59,7 +59,8 @@ class SentinelReporter {
|
|
|
59
59
|
this.failedCount += 1;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
printLocalReport(
|
|
62
|
+
printLocalReport(localReport) {
|
|
63
|
+
const localReportPath = localReport.htmlPath;
|
|
63
64
|
const relativeReportPath = path_1.default
|
|
64
65
|
.relative(process.cwd(), localReportPath)
|
|
65
66
|
.replace(/\\/g, "/");
|
|
@@ -85,6 +86,13 @@ class SentinelReporter {
|
|
|
85
86
|
}
|
|
86
87
|
console.log("");
|
|
87
88
|
}
|
|
89
|
+
if (localReport.runDiff) {
|
|
90
|
+
console.log(yellow("Run-to-run diff"));
|
|
91
|
+
console.log(` ${dim(`New failures: ${localReport.runDiff.newFailures.length}`)}`);
|
|
92
|
+
console.log(` ${dim(`Fixed since last run: ${localReport.runDiff.fixedTests.length}`)}`);
|
|
93
|
+
console.log(` ${dim(`Still failing: ${localReport.runDiff.stillFailing.length}`)}`);
|
|
94
|
+
console.log("");
|
|
95
|
+
}
|
|
88
96
|
console.log(yellow("Tip"));
|
|
89
97
|
console.log(` ${dim("Want full AI analysis, shareable run links, and CI history?")}`);
|
|
90
98
|
console.log(` ${dim("Try Sentinel Cloud Beta free:")} ${cyan(formatTerminalLink("https://sentinelqa.com", "https://sentinelqa.com"))}`);
|
|
@@ -107,7 +115,7 @@ class SentinelReporter {
|
|
|
107
115
|
reportFileName: this.options.localReportFileName,
|
|
108
116
|
redirectFileName: this.options.localRedirectFileName
|
|
109
117
|
});
|
|
110
|
-
this.printLocalReport(localReport
|
|
118
|
+
this.printLocalReport(localReport);
|
|
111
119
|
console.log("");
|
|
112
120
|
if (hasSentinelToken && !hasCiEnv && !localUploadEnabled) {
|
|
113
121
|
console.log("Sentinel upload skipped for this local run.");
|