@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 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
@@ -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 {};
@@ -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 id = [
210
- test?.location?.file || "unknown",
211
- test?.projectName || "default",
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: normalizeTestStatus(test?.status || lastResult?.status || "unknown"),
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, [...nextTitles, test?.title].filter(Boolean)));
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 = [...nextTitles, test?.title].filter(Boolean);
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(/&quot;/g, '"')
1339
+ .replace(/&#39;/g, "'")
1340
+ .replace(/&lt;/g, "<")
1341
+ .replace(/&gt;/g, ">")
1342
+ .replace(/&amp;/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 html = buildHtml(tests, summary, extraArtifacts);
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
  }
@@ -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 {};
@@ -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 buildQuickDiagnosis = (playwrightJsonPath) => {
126
+ const collectFailureFacts = (playwrightJsonPath) => {
86
127
  if (!node_fs_1.default.existsSync(playwrightJsonPath))
87
- return null;
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
- 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
- };
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(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.27",
3
+ "version": "0.1.29",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",