@sentinelqa/playwright-reporter 0.1.53 → 0.1.54

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.
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
9
9
  const crypto_1 = __importDefault(require("crypto"));
10
10
  const child_process_1 = require("child_process");
11
11
  const quickDiagnosis_1 = require("./quickDiagnosis");
12
+ const runHistory_1 = require("./runHistory");
12
13
  const DEFAULT_REPORT_DIR = "sentinel-report";
13
14
  const DEFAULT_REPORT_FILE = "index.html";
14
15
  const DEFAULT_REDIRECT_FILE = "sentinel-debug.html";
@@ -100,6 +101,10 @@ const cleanTitleParts = (parts) => {
100
101
  const withoutUnnamed = normalized.filter((part) => part !== "Unnamed test");
101
102
  return withoutUnnamed.length ? withoutUnnamed : normalized;
102
103
  };
104
+ const shortenTitle = (title) => {
105
+ const parts = cleanTitleParts(title.split(" > "));
106
+ return parts[parts.length - 1] || title;
107
+ };
103
108
  const buildTitlePath = (baseTitles, test) => {
104
109
  const title = typeof test?.title === "string" ? test.title : null;
105
110
  const next = !title || baseTitles[baseTitles.length - 1] === title
@@ -555,7 +560,7 @@ const renderArtifactGroups = (artifacts) => {
555
560
  `)
556
561
  .join("\n");
557
562
  };
558
- const renderTestCard = (test) => {
563
+ const renderTestCard = (test, groupedIssue) => {
559
564
  const statusClass = test.status === "passed" ? "status-passed" : "status-failed";
560
565
  const fileLine = test.file ? `<div class="meta-item">${escapeHtml(test.file)}</div>` : "";
561
566
  const projectLine = test.projectName
@@ -582,8 +587,47 @@ const renderTestCard = (test) => {
582
587
  : `<pre>No error message was attached to this result.</pre>`;
583
588
  const artifactMarkup = renderArtifactGroups(test.artifacts);
584
589
  const diagnosis = test.diagnosis;
585
- const diagnosisMarkup = diagnosis
590
+ const diagnosisMarkup = groupedIssue
586
591
  ? `
592
+ <div class="diagnosis-shell">
593
+ <div>
594
+ <span class="artifact-kind">Grouped root cause</span>
595
+ <p class="diagnosis-copy"><strong>${escapeHtml(groupedIssue.title)}</strong></p>
596
+ <p class="diagnosis-copy">${escapeHtml(groupedIssue.cause)}</p>
597
+ </div>
598
+ <button
599
+ type="button"
600
+ class="copy-button"
601
+ data-copy-summary="${escapeHtml([
602
+ `Test: ${test.title}`,
603
+ `Issue: ${groupedIssue.title}`,
604
+ `Cause: ${groupedIssue.cause}`,
605
+ groupedIssue.where ? `Where: ${groupedIssue.where}` : null,
606
+ groupedIssue.failingStep ? `Failing step: ${groupedIssue.failingStep}` : null,
607
+ groupedIssue.expected ? `Expected: ${groupedIssue.expected}` : null,
608
+ groupedIssue.received ? `Received: ${groupedIssue.received}` : null,
609
+ `Likely fix: ${groupedIssue.next}`
610
+ ]
611
+ .filter(Boolean)
612
+ .join("\n"))}"
613
+ aria-label="Copy grouped diagnosis"
614
+ >
615
+ Copy diagnosis
616
+ </button>
617
+ </div>
618
+ <div class="fact-row">
619
+ ${groupedIssue.where ? `<span class="fact-chip">Where: ${escapeHtml(groupedIssue.where)}</span>` : ""}
620
+ ${groupedIssue.failingStep ? `<span class="fact-chip">Failing step: ${escapeHtml(groupedIssue.failingStep)}</span>` : ""}
621
+ ${groupedIssue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(groupedIssue.selector)}</span>` : ""}
622
+ ${groupedIssue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(groupedIssue.blocker)}</span>` : ""}
623
+ ${groupedIssue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(groupedIssue.targetState)}</span>` : ""}
624
+ ${groupedIssue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(groupedIssue.expected)}</span>` : ""}
625
+ ${groupedIssue.received ? `<span class="fact-chip">Received: ${escapeHtml(groupedIssue.received)}</span>` : ""}
626
+ </div>
627
+ <p class="diagnosis-copy"><strong>Likely fix:</strong> ${escapeHtml(groupedIssue.next)}</p>
628
+ `
629
+ : diagnosis
630
+ ? `
587
631
  <div class="diagnosis-shell">
588
632
  <div>
589
633
  <span class="artifact-kind">Quick diagnosis</span>
@@ -605,9 +649,9 @@ const renderTestCard = (test) => {
605
649
  ${diagnosis.timeoutMs ? `<span class="fact-chip">Timeout: ${diagnosis.timeoutMs}ms</span>` : ""}
606
650
  </div>
607
651
  `
608
- : "";
652
+ : "";
609
653
  return `
610
- <details class="test-card">
654
+ <details class="test-card" id="test-${safeSlug(test.id)}">
611
655
  <summary class="test-summary">
612
656
  <div>
613
657
  <div class="status-pill ${statusClass}">${escapeHtml(test.status)}</div>
@@ -693,6 +737,237 @@ const renderFailureDigest = (tests) => {
693
737
  </div>
694
738
  `;
695
739
  };
740
+ const renderDiagnosisOverview = (summary) => {
741
+ if (!summary) {
742
+ return `<div class="empty-state">No structured diagnosis was available for this run.</div>`;
743
+ }
744
+ const copyText = escapeHtml([
745
+ summary.headline || "",
746
+ summary.failureCountLine,
747
+ summary.collapseLine || "",
748
+ ...summary.issues.flatMap((issue, index) => {
749
+ const lines = [
750
+ `Issue ${index + 1}: ${issue.title}`,
751
+ `Cause: ${issue.cause}`
752
+ ];
753
+ if (issue.where)
754
+ lines.push(`Where: ${issue.where}`);
755
+ if (issue.failingStep)
756
+ lines.push(`Failing step: ${issue.failingStep}`);
757
+ if (issue.blocker)
758
+ lines.push(`Blocker: ${issue.blocker}`);
759
+ if (issue.targetState)
760
+ lines.push(`Target state: ${issue.targetState}`);
761
+ if (issue.expected)
762
+ lines.push(`Expected: ${issue.expected}`);
763
+ if (issue.received)
764
+ lines.push(`Received: ${issue.received}`);
765
+ if (issue.whatChanged)
766
+ lines.push(`What changed: ${issue.whatChanged}`);
767
+ if (issue.reason)
768
+ lines.push(`Reason: ${issue.reason}`);
769
+ lines.push(`Next: ${issue.next}`);
770
+ lines.push(`Impact: ${issue.impact}`);
771
+ return lines;
772
+ }),
773
+ ...summary.footer
774
+ ].filter(Boolean).join("\n"));
775
+ return `
776
+ <div class="diagnosis-overview">
777
+ <div class="diagnosis-shell">
778
+ <div>
779
+ <span class="artifact-kind">Sentinel diagnosis</span>
780
+ ${summary.headline ? `<h2 class="diagnosis-headline">${escapeHtml(summary.headline)}</h2>` : ""}
781
+ <p class="diagnosis-kicker">${escapeHtml(summary.failureCountLine)}</p>
782
+ ${summary.collapseLine ? `<p class="diagnosis-kicker diagnosis-kicker-secondary">${escapeHtml(summary.collapseLine)}</p>` : ""}
783
+ </div>
784
+ <button
785
+ type="button"
786
+ class="copy-button"
787
+ data-copy-summary="${copyText}"
788
+ aria-label="Copy diagnosis"
789
+ >
790
+ Copy diagnosis
791
+ </button>
792
+ </div>
793
+ ${summary.footer.length ? `<p class="diagnosis-copy">${escapeHtml(summary.footer.join(" · "))}</p>` : ""}
794
+ </div>
795
+ `;
796
+ };
797
+ const renderRecurringInsight = (historySummary) => {
798
+ if (!historySummary?.isDominantRecurringIssue || !historySummary.dominantRecurringIssueTitle)
799
+ return "";
800
+ return `
801
+ <p class="diagnosis-copy">
802
+ <strong>Recurring signal:</strong>
803
+ Seen in ${escapeHtml(String(historySummary.dominantRecurringIssueCount))} recorded failed runs.
804
+ This is the most common recent failure in local history.
805
+ </p>
806
+ `;
807
+ };
808
+ const renderDiagnosisIssueCards = (summary) => {
809
+ if (!summary || !summary.issues.length) {
810
+ return `<div class="empty-state">No grouped diagnosis issues were available for this run.</div>`;
811
+ }
812
+ return `
813
+ <div class="digest-grid">
814
+ ${summary.issues.map((issue, index) => `
815
+ <article class="digest-card">
816
+ <div class="digest-head">
817
+ <div>
818
+ <span class="artifact-kind">Issue ${index + 1}</span>
819
+ <h3>${escapeHtml(issue.title)}</h3>
820
+ </div>
821
+ </div>
822
+ <p class="diagnosis-copy">${escapeHtml(issue.cause)}</p>
823
+ <div class="fact-row">
824
+ ${issue.where ? `<span class="fact-chip">Where: ${escapeHtml(issue.where)}</span>` : ""}
825
+ ${issue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(issue.selector)}</span>` : ""}
826
+ ${issue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(issue.targetState)}</span>` : ""}
827
+ ${issue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(issue.blocker)}</span>` : ""}
828
+ </div>
829
+ <div class="fact-row">
830
+ ${issue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(issue.expected)}</span>` : ""}
831
+ ${issue.received ? `<span class="fact-chip">Received: ${escapeHtml(issue.received)}</span>` : ""}
832
+ </div>
833
+ ${issue.failingCode ? `<pre>${escapeHtml(issue.failingCode)}</pre>` : ""}
834
+ ${issue.failingStep ? `<p class="diagnosis-copy"><strong>Failing step:</strong> ${escapeHtml(issue.failingStep)}</p>` : ""}
835
+ ${issue.whatChanged ? `<p class="diagnosis-copy"><strong>What changed:</strong> ${escapeHtml(issue.whatChanged)}</p>` : ""}
836
+ ${issue.reason ? `<p class="diagnosis-copy"><strong>Reason:</strong> ${escapeHtml(issue.reason)}</p>` : ""}
837
+ <p class="diagnosis-copy"><strong>Next:</strong> ${escapeHtml(issue.next)}</p>
838
+ <p class="diagnosis-copy"><strong>Clears:</strong> ${escapeHtml(issue.clears)}</p>
839
+ <p class="diagnosis-copy"><strong>Impact:</strong> ${escapeHtml(issue.impact)}</p>
840
+ </article>
841
+ `).join("\n")}
842
+ </div>
843
+ `;
844
+ };
845
+ const renderDiagnosisIssueCardsForTests = (summary, tests) => {
846
+ if (!summary || !summary.issues.length) {
847
+ return `<div class="empty-state">No grouped diagnosis issues were available for this run.</div>`;
848
+ }
849
+ return `
850
+ <div class="digest-grid">
851
+ ${summary.issues.map((issue, index) => {
852
+ const representative = pickRepresentativeTestForIssue(tests, issue);
853
+ return `
854
+ <article class="digest-card ${index === 0 ? "digest-card-primary" : ""}">
855
+ <div class="digest-head">
856
+ <div>
857
+ <span class="artifact-kind">${index === 0 ? "Top issue" : `Issue ${index + 1}`}</span>
858
+ <h3>${escapeHtml(issue.title)}</h3>
859
+ </div>
860
+ </div>
861
+ <p class="diagnosis-copy">${escapeHtml(issue.cause)}</p>
862
+ <div class="fact-row">
863
+ ${issue.where ? `<span class="fact-chip">Where: ${escapeHtml(issue.where)}</span>` : ""}
864
+ ${issue.failingStep ? `<span class="fact-chip">Failing step: ${escapeHtml(issue.failingStep)}</span>` : ""}
865
+ ${issue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(issue.targetState)}</span>` : ""}
866
+ ${issue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(issue.blocker)}</span>` : ""}
867
+ </div>
868
+ <div class="fact-row">
869
+ ${issue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(issue.selector)}</span>` : ""}
870
+ ${issue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(issue.expected)}</span>` : ""}
871
+ ${issue.received ? `<span class="fact-chip">Received: ${escapeHtml(issue.received)}</span>` : ""}
872
+ </div>
873
+ ${issue.whatChanged ? `<p class="diagnosis-copy"><strong>What changed:</strong> ${escapeHtml(issue.whatChanged)}</p>` : ""}
874
+ ${issue.reason ? `<p class="diagnosis-copy"><strong>Reason:</strong> ${escapeHtml(issue.reason)}</p>` : ""}
875
+ <p class="diagnosis-copy"><strong>Likely fix:</strong> ${escapeHtml(issue.next)}</p>
876
+ <p class="diagnosis-copy"><strong>Impact:</strong> ${escapeHtml(issue.impact)}</p>
877
+ ${issue.affectedTitles.length > 1
878
+ ? `<ul class="group-list">${issue.affectedTitles
879
+ .slice(0, 6)
880
+ .map((title) => `<li>${escapeHtml(shortenTitle(title))}</li>`)
881
+ .join("\n")}</ul>`
882
+ : ""}
883
+ ${representative ? `<p class="diagnosis-copy"><strong>Representative test:</strong> ${escapeHtml(representative.title)}</p>` : ""}
884
+ ${renderIssueArtifactActions(representative)}
885
+ </article>
886
+ `;
887
+ }).join("\n")}
888
+ </div>
889
+ `;
890
+ };
891
+ const reportTestDisplayTitle = (test) => cleanTitleParts(test.titlePath).join(" > ") || test.title;
892
+ const findIssueForTest = (summary, test) => {
893
+ if (!summary?.issues.length)
894
+ return null;
895
+ const title = reportTestDisplayTitle(test);
896
+ return (summary.issues.find((issue) => issue.affectedTitles.some((affectedTitle) => cleanTitleParts(affectedTitle.split(" > ")).join(" > ") === title)) || null);
897
+ };
898
+ const extractLineHints = (value) => Array.from(new Set(Array.from((value || "").matchAll(/:([0-9]+)(?::[0-9]+)?/g))
899
+ .map((match) => Number.parseInt(match[1] || "", 10))
900
+ .filter((line) => Number.isFinite(line))));
901
+ const pickRepresentativeTestForIssue = (tests, issue) => {
902
+ const failedTests = getFailureTests(tests);
903
+ const affected = new Set(issue.affectedTitles.map((title) => cleanTitleParts(title.split(" > ")).join(" > ")));
904
+ const matches = failedTests.filter((test) => affected.has(reportTestDisplayTitle(test)));
905
+ if (!matches.length)
906
+ return null;
907
+ const issueFile = (issue.where || "").split(":")[0]?.trim().toLowerCase();
908
+ const issueLines = extractLineHints(issue.where);
909
+ const issueSelector = (issue.selector || "").trim().toLowerCase();
910
+ const issueBlocker = (issue.blocker || "").trim().toLowerCase();
911
+ const issueFailingCode = (issue.failingCode || "").trim();
912
+ const issueTargetState = (issue.targetState || "").trim().toLowerCase();
913
+ const score = (test) => {
914
+ const diagnosis = test.diagnosis;
915
+ const diagnosisFile = (diagnosis?.likelyFile || diagnosis?.file || "").trim().toLowerCase();
916
+ const diagnosisSelector = (diagnosis?.locator || "").trim().toLowerCase();
917
+ const diagnosisLine = diagnosis?.codeContext?.line || null;
918
+ const diagnosisFocusLine = (diagnosis?.codeContext?.focusLine || "").trim();
919
+ const diagnosisMessage = (diagnosis?.message || "").toLowerCase();
920
+ const traceScore = test.artifacts.some((artifact) => artifact.kind === "trace") ? 50 : 0;
921
+ const screenshotScore = test.artifacts.some((artifact) => artifact.kind === "screenshot") ? 30 : 0;
922
+ const videoScore = test.artifacts.some((artifact) => artifact.kind === "video") ? 20 : 0;
923
+ const selectorScore = issueSelector && diagnosisSelector && issueSelector === diagnosisSelector ? 40 : 0;
924
+ const fileScore = issueFile && diagnosisFile && diagnosisFile.endsWith(issueFile) ? 20 : 0;
925
+ const lineScore = diagnosisLine && issueLines.includes(diagnosisLine) ? 35 : 0;
926
+ const focusScore = issueFailingCode && diagnosisFocusLine && issueFailingCode === diagnosisFocusLine ? 35 : 0;
927
+ const blockerScore = issueBlocker && diagnosisMessage.includes(issueBlocker) ? 25 : 0;
928
+ const targetStateScore = issueTargetState && diagnosis?.domCapture
929
+ ? (() => {
930
+ const dom = diagnosis.domCapture;
931
+ const targetState = dom.targetFound === false || dom.matchedCount === 0
932
+ ? "missing"
933
+ : dom.visible === false
934
+ ? "hidden"
935
+ : dom.enabled === false
936
+ ? "disabled"
937
+ : dom.targetFound === true && dom.visible === true
938
+ ? "visible_blocked"
939
+ : "";
940
+ return targetState === issueTargetState ? 15 : 0;
941
+ })()
942
+ : 0;
943
+ const lineAwareScore = diagnosisLine ? 10 : 0;
944
+ return traceScore + screenshotScore + videoScore + selectorScore + fileScore + lineScore + focusScore + blockerScore + targetStateScore + lineAwareScore;
945
+ };
946
+ return matches.sort((a, b) => score(b) - score(a))[0] || matches[0];
947
+ };
948
+ const renderIssueArtifactActions = (test) => {
949
+ if (!test)
950
+ return "";
951
+ const trace = test.artifacts.find((artifact) => artifact.kind === "trace");
952
+ const screenshot = test.artifacts.find((artifact) => artifact.kind === "screenshot");
953
+ const video = test.artifacts.find((artifact) => artifact.kind === "video");
954
+ const jumpTarget = `#test-${safeSlug(test.id)}`;
955
+ const actions = [
956
+ trace
957
+ ? `<a class="trace-button" href="${escapeHtml(trace.relativePath)}" data-trace-path="${escapeHtml(trace.relativePath)}" target="_blank" rel="noreferrer">Best trace</a>`
958
+ : "",
959
+ screenshot
960
+ ? `<a class="trace-button" href="${escapeHtml(screenshot.relativePath)}" target="_blank" rel="noreferrer">Open screenshot</a>`
961
+ : "",
962
+ video
963
+ ? `<a class="trace-button" href="${escapeHtml(video.relativePath)}" target="_blank" rel="noreferrer">Open video</a>`
964
+ : "",
965
+ `<a class="trace-button" href="${escapeHtml(jumpTarget)}">Jump to test</a>`
966
+ ].filter(Boolean);
967
+ if (!actions.length)
968
+ return "";
969
+ return `<div class="issue-actions">${actions.join("\n")}</div>`;
970
+ };
696
971
  const renderSimilarFailureGroups = (groups) => {
697
972
  if (!groups.length) {
698
973
  return `<div class="empty-state">No repeated failure fingerprint was detected in this run.</div>`;
@@ -762,6 +1037,33 @@ const renderRunDiff = (runDiff) => {
762
1037
  </div>
763
1038
  `;
764
1039
  };
1040
+ const renderHistoryOverview = (historySummary, runDiff) => {
1041
+ if (!historySummary && !runDiff) {
1042
+ return `<div class="empty-state">No prior run history was available for this failure yet.</div>`;
1043
+ }
1044
+ return `
1045
+ ${renderRecurringInsight(historySummary)}
1046
+ ${runDiff
1047
+ ? `
1048
+ <div class="summary-grid">
1049
+ <div class="summary-card">
1050
+ <span class="summary-label">New failures</span>
1051
+ <span class="summary-value">${runDiff.newFailures.length}</span>
1052
+ </div>
1053
+ <div class="summary-card">
1054
+ <span class="summary-label">Still failing</span>
1055
+ <span class="summary-value">${runDiff.stillFailing.length}</span>
1056
+ </div>
1057
+ <div class="summary-card">
1058
+ <span class="summary-label">Fixed since last run</span>
1059
+ <span class="summary-value">${runDiff.fixedTests.length}</span>
1060
+ </div>
1061
+ </div>
1062
+ <p class="diagnosis-copy">Compared with the latest saved run on the same branch: ${escapeHtml(runDiff.label)}</p>
1063
+ `
1064
+ : ""}
1065
+ `;
1066
+ };
765
1067
  const renderAdditionalArtifacts = (artifacts) => {
766
1068
  if (artifacts.length === 0) {
767
1069
  return "";
@@ -789,7 +1091,7 @@ const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRe
789
1091
  }
790
1092
  }
791
1093
  };
792
- const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1094
+ const buildHtml = (tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory) => {
793
1095
  const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
794
1096
  const similarGroups = groupSimilarFailures(tests);
795
1097
  const generatedAt = new Date().toLocaleString();
@@ -1116,6 +1418,11 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1116
1418
  background: rgba(9, 13, 20, 0.72);
1117
1419
  padding: 16px;
1118
1420
  }
1421
+ .digest-card-primary {
1422
+ border-color: rgba(125, 211, 252, 0.38);
1423
+ box-shadow: 0 12px 36px rgba(4, 14, 26, 0.26);
1424
+ background: linear-gradient(180deg, rgba(14, 22, 34, 0.92), rgba(9, 13, 20, 0.78));
1425
+ }
1119
1426
  .digest-head, .diagnosis-shell {
1120
1427
  display: flex;
1121
1428
  justify-content: space-between;
@@ -1130,12 +1437,42 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1130
1437
  color: var(--muted);
1131
1438
  line-height: 1.6;
1132
1439
  }
1440
+ .diagnosis-overview {
1441
+ display: grid;
1442
+ gap: 12px;
1443
+ margin-top: 18px;
1444
+ }
1445
+ .diagnosis-headline {
1446
+ margin-top: 10px;
1447
+ font-size: 28px;
1448
+ line-height: 1.1;
1449
+ }
1450
+ .diagnosis-kicker {
1451
+ margin: 12px 0 0;
1452
+ color: var(--text);
1453
+ font-size: 20px;
1454
+ font-weight: 700;
1455
+ }
1456
+ .diagnosis-kicker-secondary {
1457
+ color: var(--accent);
1458
+ font-size: 16px;
1459
+ font-weight: 600;
1460
+ }
1133
1461
  .fact-row {
1134
1462
  display: flex;
1135
1463
  flex-wrap: wrap;
1136
1464
  gap: 8px;
1137
1465
  margin-top: 14px;
1138
1466
  }
1467
+ .issue-actions {
1468
+ display: flex;
1469
+ flex-wrap: wrap;
1470
+ gap: 10px;
1471
+ margin-top: 14px;
1472
+ }
1473
+ .section-shell-secondary {
1474
+ opacity: 0.9;
1475
+ }
1139
1476
  .fact-chip {
1140
1477
  display: inline-flex;
1141
1478
  align-items: center;
@@ -1264,37 +1601,39 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1264
1601
 
1265
1602
  <section class="section-shell">
1266
1603
  <div class="failed-list-head">
1267
- <h2>Run-to-Run Diff</h2>
1604
+ <h2>Why It Failed</h2>
1268
1605
  </div>
1269
- <p>Compare this run with the latest saved snapshot from the same branch.</p>
1270
- ${renderRunDiff(runDiff)}
1606
+ <p>The same grouped explanation shown in the CLI, with the most useful evidence first.</p>
1607
+ ${renderDiagnosisOverview(diagnosisSummary)}
1271
1608
  </section>
1272
1609
 
1273
1610
  <section class="section-shell">
1274
1611
  <div class="failed-list-head">
1275
- <h2>Failure Digest</h2>
1612
+ <h2>Explanation</h2>
1276
1613
  <div class="failed-count">${failedTests.length} failed</div>
1277
1614
  </div>
1278
- <p>Deterministic summaries built from the Playwright error, locator, assertion text, and timeout.</p>
1279
- ${renderFailureDigest(tests)}
1615
+ <p>Grouped root causes, likely fixes, and direct links to the best artifacts for each issue.</p>
1616
+ ${renderDiagnosisIssueCardsForTests(diagnosisSummary, tests)}
1280
1617
  </section>
1281
1618
 
1282
- <section class="section-shell">
1619
+ <section class="section-shell section-shell-secondary">
1283
1620
  <div class="failed-list-head">
1284
- <h2>Similar Failures</h2>
1285
- <div class="failed-count">${similarGroups.length} groups</div>
1621
+ <h2>History</h2>
1286
1622
  </div>
1287
- <p>Failures are grouped only when they share the same fingerprint, so one repeated issue is easier to spot.</p>
1288
- ${renderSimilarFailureGroups(similarGroups)}
1623
+ <p>Recent failure history and how this run changed from the previous saved run.</p>
1624
+ ${renderHistoryOverview(failedRunHistory, runDiff)}
1289
1625
  </section>
1290
1626
 
1291
1627
  <section class="section-shell">
1292
1628
  <div class="failed-list-head">
1293
- <h2>Failed Tests</h2>
1629
+ <h2>Artifacts</h2>
1294
1630
  <div class="failed-count">${failedTests.length} failed</div>
1295
1631
  </div>
1632
+ <p>Failed tests with trace, screenshot, video, logs, and raw error details.</p>
1296
1633
  ${failedTests.length > 0
1297
- ? failedTests.map((test) => renderTestCard(test)).join("\n")
1634
+ ? failedTests
1635
+ .map((test) => renderTestCard(test, findIssueForTest(diagnosisSummary, test)))
1636
+ .join("\n")
1298
1637
  : `<div class="empty-state">No failed tests were found in this run. The local report still includes collected artifacts below.</div>`}
1299
1638
  </section>
1300
1639
 
@@ -1306,19 +1645,6 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1306
1645
  : `<div class="empty-state">All detected artifacts were mapped onto failed tests.</div>`}
1307
1646
  </section>
1308
1647
 
1309
- <section class="section-shell">
1310
- <h2>Optional: Sentinel Cloud</h2>
1311
- <p>Upload runs to Sentinel Cloud for:</p>
1312
- <ul>
1313
- <li>CI history</li>
1314
- <li>shareable run links</li>
1315
- <li>AI failure summaries</li>
1316
- </ul>
1317
- <p>
1318
- <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">More on sentinelqa.com</a>
1319
- </p>
1320
- </section>
1321
-
1322
1648
  <footer>
1323
1649
  Generated by <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">Sentinel Playwright Reporter</a>.
1324
1650
  </footer>
@@ -1508,8 +1834,10 @@ function generateLocalDebugReport(options) {
1508
1834
  const summary = summarizeTests(tests);
1509
1835
  const snapshot = buildRunSnapshot(tests, summary);
1510
1836
  const runDiff = buildRunDiff(tests, snapshot);
1837
+ const diagnosisSummary = (0, quickDiagnosis_1.buildQuickDiagnosisStructured)(options.playwrightJsonPath);
1838
+ const failedRunHistory = summary.failed > 0 ? (0, runHistory_1.buildFailedRunHistorySummary)(options.playwrightJsonPath) : null;
1511
1839
  writeRunHistory(snapshot);
1512
- const html = buildHtml(tests, summary, extraArtifacts, runDiff);
1840
+ const html = buildHtml(tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory);
1513
1841
  fs_1.default.writeFileSync(reportHtmlPath, html, "utf8");
1514
1842
  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");
1515
1843
  return {
@@ -1,8 +1,34 @@
1
- type DiagnosisSignal = "timeout" | "assertion_mismatch" | "locator_not_found" | "actionability" | "network" | "runtime" | "infra" | "unknown";
1
+ type DiagnosisSignal = "setup" | "timeout" | "assertion_mismatch" | "locator_not_found" | "actionability" | "network" | "runtime" | "infra" | "unknown";
2
2
  export type QuickDiagnosis = {
3
3
  lines: string[];
4
4
  footer?: string[];
5
5
  };
6
+ export type DiagnosisIssue = {
7
+ title: string;
8
+ cause: string;
9
+ affectedTitles: string[];
10
+ where: string | null;
11
+ failingCode: string | null;
12
+ failingStep: string | null;
13
+ selector: string | null;
14
+ blocker: string | null;
15
+ targetState: string | null;
16
+ expected: string | null;
17
+ received: string | null;
18
+ whatChanged: string | null;
19
+ reason: string | null;
20
+ next: string;
21
+ impact: string;
22
+ clears: string;
23
+ };
24
+ export type DiagnosisSummary = {
25
+ mode: "new_failure" | "recurring_failure" | "failure";
26
+ headline: string | null;
27
+ failureCountLine: string;
28
+ collapseLine: string | null;
29
+ issues: DiagnosisIssue[];
30
+ footer: string[];
31
+ };
6
32
  type CodeContextCapture = {
7
33
  file?: string | null;
8
34
  line?: number | null;
@@ -20,6 +46,7 @@ type CodeContextCapture = {
20
46
  };
21
47
  type DomCapture = {
22
48
  locator?: string | null;
49
+ normalizedLocator?: string | null;
23
50
  expectedText?: string | null;
24
51
  observedText?: string | null;
25
52
  captureSource?: "live_page" | "error_fallback";
@@ -37,6 +64,7 @@ type DomCapture = {
37
64
  placeholder?: string | null;
38
65
  ariaLabel?: string | null;
39
66
  textAlternatives?: string[] | null;
67
+ ariaSnapshot?: string | null;
40
68
  matchedElements?: Array<{
41
69
  index: number;
42
70
  role: string | null;
@@ -45,6 +73,17 @@ type DomCapture = {
45
73
  enabled: boolean | null;
46
74
  text: string | null;
47
75
  }> | null;
76
+ recentConsoleErrors?: Array<{
77
+ type: string;
78
+ text: string;
79
+ }> | null;
80
+ recentPageErrors?: string[] | null;
81
+ recentRequests?: Array<{
82
+ method: string | null;
83
+ url: string | null;
84
+ status: number | null;
85
+ failure: string | null;
86
+ }> | null;
48
87
  };
49
88
  export type FailureFacts = {
50
89
  title: string;
@@ -83,6 +122,7 @@ export declare const parseFailureFacts: (title: string, titlePath: string[], mes
83
122
  export declare const collectFailureFacts: (playwrightJsonPath: string) => FailureFacts[];
84
123
  export declare const buildDebugSummary: (failure: FailureFacts) => string;
85
124
  export declare const buildSimilarityKey: (failure: FailureFacts) => string;
86
- 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" | "runtime error thrown before the flow completed" | "browser or CI infrastructure failure" | "failure signal could not be classified cleanly";
125
+ export declare const summarizeSignal: (signal: DiagnosisSignal) => "Playwright setup or bootstrap error" | "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" | "runtime error thrown before the flow completed" | "browser or CI infrastructure failure" | "failure signal could not be classified cleanly";
126
+ export declare const buildQuickDiagnosisStructured: (playwrightJsonPath: string) => DiagnosisSummary | null;
87
127
  export declare const buildQuickDiagnosis: (playwrightJsonPath: string) => QuickDiagnosis | null;
88
128
  export {};