@sentinelqa/playwright-reporter 0.1.27 → 0.1.28
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 +44 -0
- package/dist/localReport.js +387 -9
- package/dist/quickDiagnosis.d.ts +18 -0
- package/dist/quickDiagnosis.js +116 -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,27 @@ 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
|
+
title: string;
|
|
25
|
+
titlePath: string[];
|
|
26
|
+
file: string | null;
|
|
27
|
+
projectName: string | null;
|
|
28
|
+
status: string;
|
|
29
|
+
duration: number;
|
|
30
|
+
errors: string[];
|
|
31
|
+
diagnosis: FailureFacts | null;
|
|
32
|
+
artifacts: CopiedArtifact[];
|
|
33
|
+
};
|
|
12
34
|
type LocalReportSummary = {
|
|
13
35
|
total: number;
|
|
14
36
|
failed: number;
|
|
@@ -20,6 +42,28 @@ type LocalReportResult = {
|
|
|
20
42
|
redirectPath: string;
|
|
21
43
|
artifactCount: number;
|
|
22
44
|
summary: LocalReportSummary;
|
|
45
|
+
runDiff: RunDiffSummary | null;
|
|
46
|
+
};
|
|
47
|
+
type RunSnapshot = {
|
|
48
|
+
generatedAt: string;
|
|
49
|
+
branch: string;
|
|
50
|
+
gitSha: string;
|
|
51
|
+
totals: LocalReportSummary;
|
|
52
|
+
tests: Array<{
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
status: string;
|
|
56
|
+
signal: string | null;
|
|
57
|
+
locator: string | null;
|
|
58
|
+
expected: string | null;
|
|
59
|
+
received: string | null;
|
|
60
|
+
}>;
|
|
61
|
+
};
|
|
62
|
+
type RunDiffSummary = {
|
|
63
|
+
label: string;
|
|
64
|
+
newFailures: ReportTest[];
|
|
65
|
+
fixedTests: RunSnapshot["tests"];
|
|
66
|
+
stillFailing: ReportTest[];
|
|
23
67
|
};
|
|
24
68
|
export declare function generateLocalDebugReport(options: LocalReportOptions): LocalReportResult;
|
|
25
69
|
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"],
|
|
@@ -99,6 +102,39 @@ const formatDuration = (durationMs) => {
|
|
|
99
102
|
return `${Math.round(durationMs)} ms`;
|
|
100
103
|
return `${(durationMs / 1000).toFixed(durationMs >= 10000 ? 0 : 1)} s`;
|
|
101
104
|
};
|
|
105
|
+
const pluralize = (count, singular, plural) => count === 1 ? singular : plural;
|
|
106
|
+
const sanitizeFileSegment = (value) => value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
107
|
+
const getCurrentBranch = () => {
|
|
108
|
+
const envBranch = process.env.GITHUB_HEAD_REF ||
|
|
109
|
+
process.env.GITHUB_REF_NAME ||
|
|
110
|
+
process.env.VERCEL_GIT_COMMIT_REF ||
|
|
111
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
112
|
+
process.env.BRANCH_NAME;
|
|
113
|
+
if (envBranch)
|
|
114
|
+
return sanitizeFileSegment(envBranch);
|
|
115
|
+
try {
|
|
116
|
+
return sanitizeFileSegment((0, child_process_1.execSync)("git rev-parse --abbrev-ref HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").trim());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return "unknown";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const getCurrentGitSha = () => {
|
|
123
|
+
const envSha = process.env.GITHUB_SHA ||
|
|
124
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
125
|
+
process.env.CI_COMMIT_SHA ||
|
|
126
|
+
process.env.COMMIT_SHA;
|
|
127
|
+
if (envSha)
|
|
128
|
+
return envSha.slice(0, 12);
|
|
129
|
+
try {
|
|
130
|
+
return (0, child_process_1.execSync)("git rev-parse --short=12 HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
|
131
|
+
.toString("utf8")
|
|
132
|
+
.trim();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
};
|
|
102
138
|
const relativeFromCwd = (targetPath) => {
|
|
103
139
|
const relative = path_1.default.relative(process.cwd(), targetPath).replace(/\\/g, "/");
|
|
104
140
|
if (!relative || relative === "")
|
|
@@ -197,7 +233,7 @@ const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) =>
|
|
|
197
233
|
testId
|
|
198
234
|
};
|
|
199
235
|
};
|
|
200
|
-
const createReportTest = (test, titlePath) => {
|
|
236
|
+
const createReportTest = (test, titlePath, diagnosisById) => {
|
|
201
237
|
const results = Array.isArray(test?.results) ? test.results : [];
|
|
202
238
|
const lastResult = results.length > 0 ? results[results.length - 1] : null;
|
|
203
239
|
const errors = results.flatMap((result) => Array.isArray(result?.errors)
|
|
@@ -220,15 +256,16 @@ const createReportTest = (test, titlePath) => {
|
|
|
220
256
|
status: normalizeTestStatus(test?.status || lastResult?.status || "unknown"),
|
|
221
257
|
duration,
|
|
222
258
|
errors,
|
|
259
|
+
diagnosis: diagnosisById.get(titlePath.join(" > ")) || null,
|
|
223
260
|
artifacts: []
|
|
224
261
|
};
|
|
225
262
|
};
|
|
226
|
-
const collectTests = (node, parentTitles = []) => {
|
|
263
|
+
const collectTests = (node, diagnosisById, parentTitles = []) => {
|
|
227
264
|
const nextTitles = node?.title ? [...parentTitles, node.title] : parentTitles;
|
|
228
265
|
const collected = [];
|
|
229
266
|
if (Array.isArray(node?.tests)) {
|
|
230
267
|
for (const test of node.tests) {
|
|
231
|
-
collected.push(createReportTest(test, [...nextTitles, test?.title].filter(Boolean)));
|
|
268
|
+
collected.push(createReportTest(test, [...nextTitles, test?.title].filter(Boolean), diagnosisById));
|
|
232
269
|
}
|
|
233
270
|
}
|
|
234
271
|
if (Array.isArray(node?.specs)) {
|
|
@@ -236,13 +273,13 @@ const collectTests = (node, parentTitles = []) => {
|
|
|
236
273
|
const specTitles = [...nextTitles, spec?.title].filter(Boolean);
|
|
237
274
|
const specTests = Array.isArray(spec?.tests) ? spec.tests : [];
|
|
238
275
|
for (const test of specTests) {
|
|
239
|
-
collected.push(createReportTest(test, specTitles));
|
|
276
|
+
collected.push(createReportTest(test, specTitles, diagnosisById));
|
|
240
277
|
}
|
|
241
278
|
}
|
|
242
279
|
}
|
|
243
280
|
if (Array.isArray(node?.suites)) {
|
|
244
281
|
for (const suite of node.suites) {
|
|
245
|
-
collected.push(...collectTests(suite, nextTitles));
|
|
282
|
+
collected.push(...collectTests(suite, diagnosisById, nextTitles));
|
|
246
283
|
}
|
|
247
284
|
}
|
|
248
285
|
return collected;
|
|
@@ -294,6 +331,96 @@ const summarizeTests = (tests) => {
|
|
|
294
331
|
return summary;
|
|
295
332
|
}, { total: 0, failed: 0, passed: 0, skipped: 0 });
|
|
296
333
|
};
|
|
334
|
+
const buildDiagnosisMap = (playwrightJsonPath) => {
|
|
335
|
+
const failures = (0, quickDiagnosis_1.collectFailureFacts)(playwrightJsonPath);
|
|
336
|
+
const map = new Map();
|
|
337
|
+
for (const failure of failures) {
|
|
338
|
+
map.set(failure.titlePath.join(" > "), failure);
|
|
339
|
+
}
|
|
340
|
+
return map;
|
|
341
|
+
};
|
|
342
|
+
const getFailureTests = (tests) => tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
343
|
+
const groupSimilarFailures = (tests) => {
|
|
344
|
+
const groups = new Map();
|
|
345
|
+
for (const test of getFailureTests(tests)) {
|
|
346
|
+
const diagnosis = test.diagnosis;
|
|
347
|
+
const key = diagnosis ? (0, quickDiagnosis_1.buildSimilarityKey)(diagnosis) : `unknown|${test.title}`;
|
|
348
|
+
if (!groups.has(key)) {
|
|
349
|
+
groups.set(key, {
|
|
350
|
+
key,
|
|
351
|
+
signal: diagnosis ? (0, quickDiagnosis_1.summarizeSignal)(diagnosis.signal) : "uncategorized failure",
|
|
352
|
+
summary: diagnosis ? (0, quickDiagnosis_1.describeFailure)(diagnosis) : "The failure could not be grouped from captured evidence.",
|
|
353
|
+
locator: diagnosis?.locator || null,
|
|
354
|
+
tests: []
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
groups.get(key).tests.push(test);
|
|
358
|
+
}
|
|
359
|
+
return Array.from(groups.values()).sort((a, b) => b.tests.length - a.tests.length);
|
|
360
|
+
};
|
|
361
|
+
const buildRunSnapshot = (tests, summary) => ({
|
|
362
|
+
generatedAt: new Date().toISOString(),
|
|
363
|
+
branch: getCurrentBranch(),
|
|
364
|
+
gitSha: getCurrentGitSha(),
|
|
365
|
+
totals: summary,
|
|
366
|
+
tests: tests.map((test) => ({
|
|
367
|
+
id: test.id,
|
|
368
|
+
title: test.titlePath.join(" > ") || test.title,
|
|
369
|
+
status: test.status,
|
|
370
|
+
signal: test.diagnosis?.signal || null,
|
|
371
|
+
locator: test.diagnosis?.locator || null,
|
|
372
|
+
expected: test.diagnosis?.expected || null,
|
|
373
|
+
received: test.diagnosis?.received || null
|
|
374
|
+
}))
|
|
375
|
+
});
|
|
376
|
+
const getPointerPaths = (branch) => [
|
|
377
|
+
path_1.default.join(".sentinel", "latest.json"),
|
|
378
|
+
path_1.default.join(".sentinel", `latest-${branch}.json`),
|
|
379
|
+
...(branch === "main" ? [path_1.default.join(".sentinel", "latest-main.json")] : [])
|
|
380
|
+
];
|
|
381
|
+
const readSnapshot = (filePath) => {
|
|
382
|
+
if (!fs_1.default.existsSync(filePath))
|
|
383
|
+
return null;
|
|
384
|
+
try {
|
|
385
|
+
return JSON.parse(fs_1.default.readFileSync(filePath, "utf8"));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
const writeRunHistory = (snapshot) => {
|
|
392
|
+
ensureDir(path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR));
|
|
393
|
+
ensureDir(path_1.default.resolve(process.cwd(), ".sentinel"));
|
|
394
|
+
const fileName = `${snapshot.generatedAt.replace(/[:.]/g, "-")}-${snapshot.gitSha}.json`;
|
|
395
|
+
const historyPath = path_1.default.resolve(process.cwd(), SENTINEL_HISTORY_DIR, fileName);
|
|
396
|
+
fs_1.default.writeFileSync(historyPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
397
|
+
for (const pointerPath of getPointerPaths(snapshot.branch)) {
|
|
398
|
+
fs_1.default.writeFileSync(path_1.default.resolve(process.cwd(), pointerPath), JSON.stringify({
|
|
399
|
+
path: relativeFromCwd(historyPath),
|
|
400
|
+
...snapshot
|
|
401
|
+
}, null, 2), "utf8");
|
|
402
|
+
}
|
|
403
|
+
return historyPath;
|
|
404
|
+
};
|
|
405
|
+
const buildRunDiff = (tests, snapshot) => {
|
|
406
|
+
const previous = readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", `latest-${snapshot.branch}.json`))
|
|
407
|
+
|| readSnapshot(path_1.default.resolve(process.cwd(), ".sentinel", "latest.json"));
|
|
408
|
+
if (!previous || previous.generatedAt === snapshot.generatedAt)
|
|
409
|
+
return null;
|
|
410
|
+
const previousById = new Map(previous.tests.map((test) => [test.id, test]));
|
|
411
|
+
const currentFailures = getFailureTests(tests);
|
|
412
|
+
const previousFailures = previous.tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
413
|
+
const previousFailureIds = new Set(previousFailures.map((test) => test.id));
|
|
414
|
+
return {
|
|
415
|
+
label: previous.branch === snapshot.branch ? `Compared to previous ${snapshot.branch} run` : "Compared to previous run",
|
|
416
|
+
newFailures: currentFailures.filter((test) => !previousFailureIds.has(test.id)),
|
|
417
|
+
fixedTests: previousFailures.filter((test) => {
|
|
418
|
+
const current = tests.find((entry) => entry.id === test.id);
|
|
419
|
+
return current && !["failed", "timedOut", "interrupted"].includes(current.status);
|
|
420
|
+
}),
|
|
421
|
+
stillFailing: currentFailures.filter((test) => previousFailureIds.has(test.id))
|
|
422
|
+
};
|
|
423
|
+
};
|
|
297
424
|
const renderArtifact = (artifact) => {
|
|
298
425
|
const href = escapeHtml(artifact.relativePath);
|
|
299
426
|
const label = escapeHtml(artifact.label);
|
|
@@ -409,6 +536,31 @@ const renderTestCard = (test) => {
|
|
|
409
536
|
})()
|
|
410
537
|
: `<pre>No error message was attached to this result.</pre>`;
|
|
411
538
|
const artifactMarkup = renderArtifactGroups(test.artifacts);
|
|
539
|
+
const diagnosis = test.diagnosis;
|
|
540
|
+
const diagnosisMarkup = diagnosis
|
|
541
|
+
? `
|
|
542
|
+
<div class="diagnosis-shell">
|
|
543
|
+
<div>
|
|
544
|
+
<span class="artifact-kind">Quick diagnosis</span>
|
|
545
|
+
<p class="diagnosis-copy">${escapeHtml((0, quickDiagnosis_1.describeFailure)(diagnosis))}</p>
|
|
546
|
+
</div>
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
class="copy-button"
|
|
550
|
+
data-copy-summary="${escapeHtml((0, quickDiagnosis_1.buildDebugSummary)(diagnosis))}"
|
|
551
|
+
aria-label="Copy debug summary"
|
|
552
|
+
>
|
|
553
|
+
Copy debug summary
|
|
554
|
+
</button>
|
|
555
|
+
</div>
|
|
556
|
+
<div class="fact-row">
|
|
557
|
+
${diagnosis.locator ? `<span class="fact-chip">Locator: ${escapeHtml(diagnosis.locator)}</span>` : ""}
|
|
558
|
+
${diagnosis.expected ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span>` : ""}
|
|
559
|
+
${diagnosis.received ? `<span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>` : ""}
|
|
560
|
+
${diagnosis.timeoutMs ? `<span class="fact-chip">Timeout: ${diagnosis.timeoutMs}ms</span>` : ""}
|
|
561
|
+
</div>
|
|
562
|
+
`
|
|
563
|
+
: "";
|
|
412
564
|
return `
|
|
413
565
|
<details class="test-card">
|
|
414
566
|
<summary class="test-summary">
|
|
@@ -423,6 +575,7 @@ const renderTestCard = (test) => {
|
|
|
423
575
|
</div>
|
|
424
576
|
</summary>
|
|
425
577
|
<div class="panel">
|
|
578
|
+
${diagnosisMarkup}
|
|
426
579
|
<h4>Error</h4>
|
|
427
580
|
${errorBlock}
|
|
428
581
|
</div>
|
|
@@ -435,6 +588,119 @@ const renderTestCard = (test) => {
|
|
|
435
588
|
</details>
|
|
436
589
|
`;
|
|
437
590
|
};
|
|
591
|
+
const renderFailureDigest = (tests) => {
|
|
592
|
+
const failedTests = getFailureTests(tests);
|
|
593
|
+
if (!failedTests.length) {
|
|
594
|
+
return `<div class="empty-state">No failed tests were detected in this run.</div>`;
|
|
595
|
+
}
|
|
596
|
+
return `
|
|
597
|
+
<div class="digest-grid">
|
|
598
|
+
${failedTests
|
|
599
|
+
.map((test) => {
|
|
600
|
+
const diagnosis = test.diagnosis;
|
|
601
|
+
const title = escapeHtml(test.title);
|
|
602
|
+
const summary = escapeHtml(diagnosis ? (0, quickDiagnosis_1.describeFailure)(diagnosis) : "The failure could not be summarized from the captured evidence.");
|
|
603
|
+
const debugSummary = escapeHtml(diagnosis
|
|
604
|
+
? (0, quickDiagnosis_1.buildDebugSummary)(diagnosis)
|
|
605
|
+
: `Test: ${test.title}\nDiagnosis: Review trace and error details in the expanded card.`);
|
|
606
|
+
return `
|
|
607
|
+
<article class="digest-card">
|
|
608
|
+
<div class="digest-head">
|
|
609
|
+
<div>
|
|
610
|
+
<span class="artifact-kind">${escapeHtml(test.status)}</span>
|
|
611
|
+
<h3>${title}</h3>
|
|
612
|
+
</div>
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
class="copy-button"
|
|
616
|
+
data-copy-summary="${debugSummary}"
|
|
617
|
+
aria-label="Copy debug summary"
|
|
618
|
+
>
|
|
619
|
+
Copy summary
|
|
620
|
+
</button>
|
|
621
|
+
</div>
|
|
622
|
+
<p>${summary}</p>
|
|
623
|
+
<div class="fact-row">
|
|
624
|
+
${diagnosis?.locator ? `<span class="fact-chip">Locator: ${escapeHtml(diagnosis.locator)}</span>` : ""}
|
|
625
|
+
${diagnosis?.expected ? `<span class="fact-chip">Expected: ${escapeHtml(diagnosis.expected)}</span>` : ""}
|
|
626
|
+
${diagnosis?.received ? `<span class="fact-chip">Observed: ${escapeHtml(diagnosis.received)}</span>` : ""}
|
|
627
|
+
</div>
|
|
628
|
+
</article>
|
|
629
|
+
`;
|
|
630
|
+
})
|
|
631
|
+
.join("\n")}
|
|
632
|
+
</div>
|
|
633
|
+
`;
|
|
634
|
+
};
|
|
635
|
+
const renderSimilarFailureGroups = (groups) => {
|
|
636
|
+
if (!groups.length) {
|
|
637
|
+
return `<div class="empty-state">No failure groups were detected for this run.</div>`;
|
|
638
|
+
}
|
|
639
|
+
return groups
|
|
640
|
+
.map((group) => `
|
|
641
|
+
<article class="group-card">
|
|
642
|
+
<div class="failed-list-head">
|
|
643
|
+
<div>
|
|
644
|
+
<h3>${escapeHtml(group.signal)}</h3>
|
|
645
|
+
<p>${escapeHtml(group.summary)}</p>
|
|
646
|
+
</div>
|
|
647
|
+
<div class="group-count">${group.tests.length} ${pluralize(group.tests.length, "test", "tests")}</div>
|
|
648
|
+
</div>
|
|
649
|
+
${group.locator
|
|
650
|
+
? `<div class="fact-row"><span class="fact-chip">Common locator: ${escapeHtml(group.locator)}</span></div>`
|
|
651
|
+
: ""}
|
|
652
|
+
<ul class="group-list">
|
|
653
|
+
${group.tests
|
|
654
|
+
.slice(0, 6)
|
|
655
|
+
.map((test) => `<li>${escapeHtml(test.title)}</li>`)
|
|
656
|
+
.join("\n")}
|
|
657
|
+
</ul>
|
|
658
|
+
</article>
|
|
659
|
+
`)
|
|
660
|
+
.join("\n");
|
|
661
|
+
};
|
|
662
|
+
const renderRunDiff = (runDiff) => {
|
|
663
|
+
if (!runDiff) {
|
|
664
|
+
return `<div class="empty-state">No previous run snapshot was available for comparison.</div>`;
|
|
665
|
+
}
|
|
666
|
+
return `
|
|
667
|
+
<div class="diff-label">${escapeHtml(runDiff.label)}</div>
|
|
668
|
+
<div class="summary-grid">
|
|
669
|
+
<div class="summary-card">
|
|
670
|
+
<span class="summary-label">New failures</span>
|
|
671
|
+
<span class="summary-value">${runDiff.newFailures.length}</span>
|
|
672
|
+
</div>
|
|
673
|
+
<div class="summary-card">
|
|
674
|
+
<span class="summary-label">Fixed since last run</span>
|
|
675
|
+
<span class="summary-value">${runDiff.fixedTests.length}</span>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="summary-card">
|
|
678
|
+
<span class="summary-label">Still failing</span>
|
|
679
|
+
<span class="summary-value">${runDiff.stillFailing.length}</span>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
<div class="diff-grid">
|
|
683
|
+
<article class="diff-card">
|
|
684
|
+
<h3>New failures</h3>
|
|
685
|
+
${runDiff.newFailures.length
|
|
686
|
+
? `<ul class="group-list">${runDiff.newFailures.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
687
|
+
: `<div class="empty-state">No new failures in this run.</div>`}
|
|
688
|
+
</article>
|
|
689
|
+
<article class="diff-card">
|
|
690
|
+
<h3>Fixed since last run</h3>
|
|
691
|
+
${runDiff.fixedTests.length
|
|
692
|
+
? `<ul class="group-list">${runDiff.fixedTests.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
693
|
+
: `<div class="empty-state">No previously failing tests were fixed in this run.</div>`}
|
|
694
|
+
</article>
|
|
695
|
+
<article class="diff-card">
|
|
696
|
+
<h3>Still failing</h3>
|
|
697
|
+
${runDiff.stillFailing.length
|
|
698
|
+
? `<ul class="group-list">${runDiff.stillFailing.map((test) => `<li>${escapeHtml(test.title)}</li>`).join("\n")}</ul>`
|
|
699
|
+
: `<div class="empty-state">No tests remained failing across both runs.</div>`}
|
|
700
|
+
</article>
|
|
701
|
+
</div>
|
|
702
|
+
`;
|
|
703
|
+
};
|
|
438
704
|
const renderAdditionalArtifacts = (artifacts) => {
|
|
439
705
|
if (artifacts.length === 0) {
|
|
440
706
|
return "";
|
|
@@ -462,8 +728,9 @@ const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRe
|
|
|
462
728
|
}
|
|
463
729
|
}
|
|
464
730
|
};
|
|
465
|
-
const buildHtml = (tests, summary, extraArtifacts) => {
|
|
731
|
+
const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
|
|
466
732
|
const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
|
|
733
|
+
const similarGroups = groupSimilarFailures(tests);
|
|
467
734
|
const generatedAt = new Date().toLocaleString();
|
|
468
735
|
return `<!doctype html>
|
|
469
736
|
<html lang="en">
|
|
@@ -776,6 +1043,60 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
776
1043
|
.section-shell li {
|
|
777
1044
|
margin-top: 6px;
|
|
778
1045
|
}
|
|
1046
|
+
.digest-grid, .diff-grid {
|
|
1047
|
+
display: grid;
|
|
1048
|
+
gap: 16px;
|
|
1049
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
1050
|
+
margin-top: 18px;
|
|
1051
|
+
}
|
|
1052
|
+
.digest-card, .group-card, .diff-card {
|
|
1053
|
+
border: 1px solid rgba(125, 211, 252, 0.16);
|
|
1054
|
+
border-radius: 16px;
|
|
1055
|
+
background: rgba(9, 13, 20, 0.72);
|
|
1056
|
+
padding: 16px;
|
|
1057
|
+
}
|
|
1058
|
+
.digest-head, .diagnosis-shell {
|
|
1059
|
+
display: flex;
|
|
1060
|
+
justify-content: space-between;
|
|
1061
|
+
gap: 12px;
|
|
1062
|
+
align-items: flex-start;
|
|
1063
|
+
}
|
|
1064
|
+
.digest-card p,
|
|
1065
|
+
.group-card p,
|
|
1066
|
+
.diff-card p,
|
|
1067
|
+
.diagnosis-copy {
|
|
1068
|
+
margin: 10px 0 0;
|
|
1069
|
+
color: var(--muted);
|
|
1070
|
+
line-height: 1.6;
|
|
1071
|
+
}
|
|
1072
|
+
.fact-row {
|
|
1073
|
+
display: flex;
|
|
1074
|
+
flex-wrap: wrap;
|
|
1075
|
+
gap: 8px;
|
|
1076
|
+
margin-top: 14px;
|
|
1077
|
+
}
|
|
1078
|
+
.fact-chip {
|
|
1079
|
+
display: inline-flex;
|
|
1080
|
+
align-items: center;
|
|
1081
|
+
padding: 6px 10px;
|
|
1082
|
+
border-radius: 999px;
|
|
1083
|
+
border: 1px solid rgba(125, 211, 252, 0.2);
|
|
1084
|
+
background: rgba(125, 211, 252, 0.06);
|
|
1085
|
+
color: #cdefff;
|
|
1086
|
+
font-size: 12px;
|
|
1087
|
+
}
|
|
1088
|
+
.group-count, .diff-label {
|
|
1089
|
+
color: var(--accent);
|
|
1090
|
+
font-size: 13px;
|
|
1091
|
+
font-weight: 600;
|
|
1092
|
+
}
|
|
1093
|
+
.group-list {
|
|
1094
|
+
margin: 14px 0 0 18px;
|
|
1095
|
+
color: var(--text);
|
|
1096
|
+
}
|
|
1097
|
+
.group-list li {
|
|
1098
|
+
margin-top: 8px;
|
|
1099
|
+
}
|
|
779
1100
|
.empty-state {
|
|
780
1101
|
color: var(--muted);
|
|
781
1102
|
border: 1px dashed rgba(39, 48, 66, 0.9);
|
|
@@ -880,6 +1201,32 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
880
1201
|
</div>
|
|
881
1202
|
</header>
|
|
882
1203
|
|
|
1204
|
+
<section class="section-shell">
|
|
1205
|
+
<div class="failed-list-head">
|
|
1206
|
+
<h2>Run-to-Run Diff</h2>
|
|
1207
|
+
</div>
|
|
1208
|
+
<p>Compare this run with the latest saved snapshot from the same branch.</p>
|
|
1209
|
+
${renderRunDiff(runDiff)}
|
|
1210
|
+
</section>
|
|
1211
|
+
|
|
1212
|
+
<section class="section-shell">
|
|
1213
|
+
<div class="failed-list-head">
|
|
1214
|
+
<h2>Failure Digest</h2>
|
|
1215
|
+
<div class="failed-count">${failedTests.length} failed</div>
|
|
1216
|
+
</div>
|
|
1217
|
+
<p>Deterministic summaries built from the Playwright error, locator, assertion text, and timeout.</p>
|
|
1218
|
+
${renderFailureDigest(tests)}
|
|
1219
|
+
</section>
|
|
1220
|
+
|
|
1221
|
+
<section class="section-shell">
|
|
1222
|
+
<div class="failed-list-head">
|
|
1223
|
+
<h2>Similar Failures</h2>
|
|
1224
|
+
<div class="failed-count">${similarGroups.length} groups</div>
|
|
1225
|
+
</div>
|
|
1226
|
+
<p>Failures are grouped by the most likely common cause so you can separate one bug from many symptoms.</p>
|
|
1227
|
+
${renderSimilarFailureGroups(similarGroups)}
|
|
1228
|
+
</section>
|
|
1229
|
+
|
|
883
1230
|
<section class="section-shell">
|
|
884
1231
|
<div class="failed-list-head">
|
|
885
1232
|
<h2>Failed Tests</h2>
|
|
@@ -973,6 +1320,32 @@ const buildHtml = (tests, summary, extraArtifacts) => {
|
|
|
973
1320
|
});
|
|
974
1321
|
});
|
|
975
1322
|
|
|
1323
|
+
document.querySelectorAll("[data-copy-summary]").forEach(function (button) {
|
|
1324
|
+
button.addEventListener("click", async function () {
|
|
1325
|
+
var rawSummary = button.getAttribute("data-copy-summary");
|
|
1326
|
+
if (!rawSummary) return;
|
|
1327
|
+
var text = rawSummary
|
|
1328
|
+
.replace(/"/g, '"')
|
|
1329
|
+
.replace(/'/g, "'")
|
|
1330
|
+
.replace(/</g, "<")
|
|
1331
|
+
.replace(/>/g, ">")
|
|
1332
|
+
.replace(/&/g, "&");
|
|
1333
|
+
try {
|
|
1334
|
+
await navigator.clipboard.writeText(text);
|
|
1335
|
+
var previousText = button.textContent;
|
|
1336
|
+
button.textContent = "Copied";
|
|
1337
|
+
setTimeout(function () {
|
|
1338
|
+
button.textContent = previousText || "Copy summary";
|
|
1339
|
+
}, 1200);
|
|
1340
|
+
} catch (_error) {
|
|
1341
|
+
button.textContent = "Copy failed";
|
|
1342
|
+
setTimeout(function () {
|
|
1343
|
+
button.textContent = previousText || "Copy summary";
|
|
1344
|
+
}, 1200);
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
|
|
976
1349
|
var overlay = document.getElementById("preview-overlay");
|
|
977
1350
|
var previewImage = document.getElementById("preview-image");
|
|
978
1351
|
var previewClose = document.getElementById("preview-close");
|
|
@@ -1027,7 +1400,8 @@ function generateLocalDebugReport(options) {
|
|
|
1027
1400
|
const reportJsonRaw = fs_1.default.readFileSync(options.playwrightJsonPath, "utf8");
|
|
1028
1401
|
const reportJson = JSON.parse(reportJsonRaw);
|
|
1029
1402
|
const reportRoot = { suites: reportJson?.suites || [] };
|
|
1030
|
-
const
|
|
1403
|
+
const diagnosisById = buildDiagnosisMap(options.playwrightJsonPath);
|
|
1404
|
+
const tests = collectTests(reportRoot, diagnosisById);
|
|
1031
1405
|
const testsById = new Map(tests.map((test) => [test.id, test]));
|
|
1032
1406
|
const claimedSourcePaths = new Set();
|
|
1033
1407
|
const attachArtifactToTest = (sourcePath, testId) => {
|
|
@@ -1072,13 +1446,17 @@ function generateLocalDebugReport(options) {
|
|
|
1072
1446
|
extraArtifacts.push(artifact);
|
|
1073
1447
|
}
|
|
1074
1448
|
const summary = summarizeTests(tests);
|
|
1075
|
-
const
|
|
1449
|
+
const snapshot = buildRunSnapshot(tests, summary);
|
|
1450
|
+
const runDiff = buildRunDiff(tests, snapshot);
|
|
1451
|
+
writeRunHistory(snapshot);
|
|
1452
|
+
const html = buildHtml(tests, summary, extraArtifacts, runDiff);
|
|
1076
1453
|
fs_1.default.writeFileSync(reportHtmlPath, html, "utf8");
|
|
1077
1454
|
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
1455
|
return {
|
|
1079
1456
|
htmlPath: reportHtmlPath,
|
|
1080
1457
|
redirectPath,
|
|
1081
1458
|
artifactCount: claimedSourcePaths.size,
|
|
1082
|
-
summary
|
|
1459
|
+
summary,
|
|
1460
|
+
runDiff
|
|
1083
1461
|
};
|
|
1084
1462
|
}
|
package/dist/quickDiagnosis.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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 describeFailure: (failure: FailureFacts) => string;
|
|
19
|
+
export declare const buildDebugSummary: (failure: FailureFacts) => string;
|
|
20
|
+
export declare const buildSimilarityKey: (failure: FailureFacts) => string;
|
|
21
|
+
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
22
|
export declare const buildQuickDiagnosis: (playwrightJsonPath: string) => QuickDiagnosis | null;
|
|
5
23
|
export {};
|
package/dist/quickDiagnosis.js
CHANGED
|
@@ -3,7 +3,7 @@ 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.collectFailureFacts = void 0;
|
|
7
7
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
8
|
const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
|
|
9
9
|
const toMessage = (result) => {
|
|
@@ -36,6 +36,37 @@ const classifySignal = (message) => {
|
|
|
36
36
|
return "runtime";
|
|
37
37
|
return "unknown";
|
|
38
38
|
};
|
|
39
|
+
const extractLocator = (message) => {
|
|
40
|
+
const locatorLine = message.match(/Locator:\s*(.+)/i);
|
|
41
|
+
if (locatorLine?.[1])
|
|
42
|
+
return locatorLine[1].trim();
|
|
43
|
+
const callLine = message.match(/(getByTestId|getByRole|getByText|locator)\([^)]+\)/);
|
|
44
|
+
return callLine?.[0] || null;
|
|
45
|
+
};
|
|
46
|
+
const extractExpected = (message) => {
|
|
47
|
+
const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
|
|
48
|
+
message.match(/Expected string:\s*"([^"]+)"/i) ||
|
|
49
|
+
message.match(/Expected:\s*"([^"]+)"/i);
|
|
50
|
+
return match?.[1] || null;
|
|
51
|
+
};
|
|
52
|
+
const extractReceived = (message) => {
|
|
53
|
+
const match = message.match(/Received string:\s*"([^"]+)"/i) ||
|
|
54
|
+
message.match(/Received:\s*"([^"]+)"/i);
|
|
55
|
+
return match?.[1] || null;
|
|
56
|
+
};
|
|
57
|
+
const extractTimeoutMs = (message) => {
|
|
58
|
+
const match = message.match(/Timeout:\s*(\d+)\s*ms/i) ||
|
|
59
|
+
message.match(/timeout(?: of)?\s*(\d+)\s*ms/i) ||
|
|
60
|
+
message.match(/(\d+)\s*ms/i);
|
|
61
|
+
if (!match)
|
|
62
|
+
return null;
|
|
63
|
+
const value = Number(match[1]);
|
|
64
|
+
return Number.isFinite(value) ? value : null;
|
|
65
|
+
};
|
|
66
|
+
const extractLastUrl = (message) => {
|
|
67
|
+
const match = message.match(/https?:\/\/[^\s)"']+/i);
|
|
68
|
+
return match?.[0] || null;
|
|
69
|
+
};
|
|
39
70
|
const signalSummary = (signal) => {
|
|
40
71
|
switch (signal) {
|
|
41
72
|
case "timeout":
|
|
@@ -82,37 +113,98 @@ const shortenTitle = (value) => {
|
|
|
82
113
|
const parts = value.split(" > ").filter(Boolean);
|
|
83
114
|
return parts[parts.length - 1] || value;
|
|
84
115
|
};
|
|
85
|
-
const
|
|
116
|
+
const collectFailureFacts = (playwrightJsonPath) => {
|
|
86
117
|
if (!node_fs_1.default.existsSync(playwrightJsonPath))
|
|
87
|
-
return
|
|
118
|
+
return [];
|
|
88
119
|
try {
|
|
89
120
|
const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
|
|
90
121
|
const parsed = JSON.parse(raw);
|
|
91
122
|
const failedCases = flattenFailedCases(parsed);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
};
|
|
123
|
+
return failedCases.map((failed) => ({
|
|
124
|
+
title: shortenTitle(failed.title),
|
|
125
|
+
titlePath: failed.title.split(" > ").filter(Boolean),
|
|
126
|
+
message: failed.message,
|
|
127
|
+
signal: failed.signal,
|
|
128
|
+
locator: extractLocator(failed.message),
|
|
129
|
+
expected: extractExpected(failed.message),
|
|
130
|
+
received: extractReceived(failed.message),
|
|
131
|
+
timeoutMs: extractTimeoutMs(failed.message),
|
|
132
|
+
lastUrl: extractLastUrl(failed.message),
|
|
133
|
+
status: "failed"
|
|
134
|
+
}));
|
|
113
135
|
}
|
|
114
136
|
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
exports.collectFailureFacts = collectFailureFacts;
|
|
141
|
+
const describeFailure = (failure) => {
|
|
142
|
+
if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
|
|
143
|
+
return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}" before timeout.`;
|
|
144
|
+
}
|
|
145
|
+
if (failure.signal === "locator_not_found" && failure.locator) {
|
|
146
|
+
return `${failure.locator} was not found when the test expected it to be available.`;
|
|
147
|
+
}
|
|
148
|
+
if (failure.signal === "actionability" && failure.locator) {
|
|
149
|
+
return `${failure.locator} was found but was not actionable when the interaction ran.`;
|
|
150
|
+
}
|
|
151
|
+
if (failure.signal === "network") {
|
|
152
|
+
return `The test likely failed because a network or API request did not complete successfully.`;
|
|
153
|
+
}
|
|
154
|
+
if (failure.signal === "timeout") {
|
|
155
|
+
return `The expected UI or network condition did not complete before timeout.`;
|
|
156
|
+
}
|
|
157
|
+
if (failure.signal === "runtime") {
|
|
158
|
+
return `A frontend runtime error interrupted the test flow.`;
|
|
159
|
+
}
|
|
160
|
+
return `The failure signal could not be classified cleanly from the captured error.`;
|
|
161
|
+
};
|
|
162
|
+
exports.describeFailure = describeFailure;
|
|
163
|
+
const buildDebugSummary = (failure) => {
|
|
164
|
+
const lines = [
|
|
165
|
+
`Test: ${failure.title}`,
|
|
166
|
+
`Diagnosis: ${(0, exports.describeFailure)(failure)}`
|
|
167
|
+
];
|
|
168
|
+
if (failure.locator)
|
|
169
|
+
lines.push(`Locator: ${failure.locator}`);
|
|
170
|
+
if (failure.expected)
|
|
171
|
+
lines.push(`Expected: ${failure.expected}`);
|
|
172
|
+
if (failure.received)
|
|
173
|
+
lines.push(`Observed: ${failure.received}`);
|
|
174
|
+
if (failure.timeoutMs)
|
|
175
|
+
lines.push(`Timeout: ${failure.timeoutMs}ms`);
|
|
176
|
+
if (failure.lastUrl)
|
|
177
|
+
lines.push(`URL: ${failure.lastUrl}`);
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
};
|
|
180
|
+
exports.buildDebugSummary = buildDebugSummary;
|
|
181
|
+
const buildSimilarityKey = (failure) => {
|
|
182
|
+
const locator = failure.locator || "unknown-locator";
|
|
183
|
+
const expected = failure.expected || "unknown-expected";
|
|
184
|
+
return `${failure.signal}|${locator}|${expected}`;
|
|
185
|
+
};
|
|
186
|
+
exports.buildSimilarityKey = buildSimilarityKey;
|
|
187
|
+
exports.summarizeSignal = signalSummary;
|
|
188
|
+
const buildQuickDiagnosis = (playwrightJsonPath) => {
|
|
189
|
+
const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
|
|
190
|
+
if (!failures.length)
|
|
115
191
|
return null;
|
|
192
|
+
if (failures.length === 1) {
|
|
193
|
+
const failed = failures[0];
|
|
194
|
+
return {
|
|
195
|
+
lines: [`Test "${failed.title}" likely failed due to ${signalSummary(failed.signal)}.`]
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const counts = new Map();
|
|
199
|
+
for (const failed of failures) {
|
|
200
|
+
counts.set(failed.signal, (counts.get(failed.signal) || 0) + 1);
|
|
116
201
|
}
|
|
202
|
+
const topSignal = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
|
|
203
|
+
return {
|
|
204
|
+
lines: [
|
|
205
|
+
`${failures.length} tests failed.`,
|
|
206
|
+
`Most common signal: ${signalSummary(topSignal)}.`
|
|
207
|
+
]
|
|
208
|
+
};
|
|
117
209
|
};
|
|
118
210
|
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.");
|