@sentinelqa/playwright-reporter 0.1.26 → 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 CHANGED
@@ -1,8 +1,8 @@
1
- # Sentinel Playwright Reporter
1
+ # Playwright Reporter
2
2
 
3
- ![npm](https://img.shields.io/npm/v/@sentinelqa/playwright-reporter)
4
- ![downloads](https://img.shields.io/npm/dm/@sentinelqa/playwright-reporter)
5
- ![license](https://img.shields.io/npm/l/@sentinelqa/playwright-reporter)
3
+ [![npm](https://img.shields.io/npm/v/@sentinelqa/playwright-reporter)](https://www.npmjs.com/package/@sentinelqa/playwright-reporter)
4
+ [![downloads](https://img.shields.io/npm/dm/@sentinelqa/playwright-reporter)](https://www.npmjs.com/package/@sentinelqa/playwright-reporter)
5
+ [![license](https://img.shields.io/npm/l/@sentinelqa/playwright-reporter)](./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
- Sentinel Reporter aggregates everything into one debugging report
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
@@ -195,10 +203,3 @@ Sentinel Cloud adds:
195
203
 
196
204
  Free for up to 100 CI runs per month.
197
205
  Create an account at [sentinelqa.com](https://sentinelqa.com).
198
-
199
- ## Publish Checklist
200
-
201
- - Confirm `repository`, `homepage`, and `bugs` point at this repo
202
- - Run `npm run build`
203
- - Run `npm pack --dry-run`
204
- - Verify `dist/` is included in the published tarball
@@ -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 {};
@@ -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(/&quot;/g, '"')
1329
+ .replace(/&#39;/g, "'")
1330
+ .replace(/&lt;/g, "<")
1331
+ .replace(/&gt;/g, ">")
1332
+ .replace(/&amp;/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 tests = collectTests(reportRoot);
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 html = buildHtml(tests, summary, extraArtifacts);
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
  }
@@ -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 {};
@@ -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 buildQuickDiagnosis = (playwrightJsonPath) => {
116
+ const collectFailureFacts = (playwrightJsonPath) => {
86
117
  if (!node_fs_1.default.existsSync(playwrightJsonPath))
87
- return null;
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
- if (!failedCases.length)
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
- };
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(localReportPath) {
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.htmlPath);
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.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",