@sentinelqa/playwright-reporter 0.1.53 → 0.1.56

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
@@ -239,6 +244,23 @@ const readAttachmentJson = (attachments, name, baseDirs) => {
239
244
  return null;
240
245
  }
241
246
  };
247
+ const stripInternalInstructionsFromErrorContextMarkdown = (value) => {
248
+ // Playwright generates `error-context.md`. Any internal guidance must not appear in user-visible reports.
249
+ const start = value.match(/^# Instructions[ \t]*\r?\n/m);
250
+ if (!start)
251
+ return value;
252
+ const startIndex = start.index ?? -1;
253
+ if (startIndex < 0)
254
+ return value;
255
+ const rest = value.slice(startIndex);
256
+ const nextHeading = rest.match(/^# (?!Instructions\b).*$/m);
257
+ const endIndex = nextHeading && typeof nextHeading.index === "number"
258
+ ? startIndex + nextHeading.index
259
+ : value.length;
260
+ const before = value.slice(0, startIndex);
261
+ const after = value.slice(endIndex);
262
+ return `${before.trimEnd()}\n\n${after.trimStart()}`.trim() + "\n";
263
+ };
242
264
  const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) => {
243
265
  const hash = crypto_1.default
244
266
  .createHash("sha1")
@@ -256,7 +278,20 @@ const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) =>
256
278
  usedRelativePaths.add(relativePath);
257
279
  const destination = path_1.default.join(reportDir, relativePath);
258
280
  ensureDir(path_1.default.dirname(destination));
259
- fs_1.default.copyFileSync(sourcePath, destination);
281
+ const baseName = path_1.default.basename(sourcePath).toLowerCase();
282
+ if (baseName === "error-context.md") {
283
+ try {
284
+ const raw = fs_1.default.readFileSync(sourcePath, "utf8");
285
+ const cleaned = stripInternalInstructionsFromErrorContextMarkdown(raw);
286
+ fs_1.default.writeFileSync(destination, cleaned, "utf8");
287
+ }
288
+ catch {
289
+ fs_1.default.copyFileSync(sourcePath, destination);
290
+ }
291
+ }
292
+ else {
293
+ fs_1.default.copyFileSync(sourcePath, destination);
294
+ }
260
295
  return {
261
296
  sourcePath,
262
297
  fileName: path_1.default.basename(destination),
@@ -452,7 +487,10 @@ const buildRunDiff = (tests, snapshot) => {
452
487
  const currentById = new Map(tests.map((test) => [test.id, test]));
453
488
  const currentByMatchKey = new Map(tests.map((test) => [test.matchKey, test]));
454
489
  const currentFailures = getFailureTests(tests);
455
- const previousFailures = previous.tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
490
+ const previousTests = Array.isArray(previous.tests) ? previous.tests : [];
491
+ if (previousTests.length === 0)
492
+ return null;
493
+ const previousFailures = previousTests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test?.status));
456
494
  const previousFailureIds = new Set(previousFailures.map((test) => test.id));
457
495
  const previousFailureMatchKeys = new Set(previousFailures.map((test) => (typeof test.matchKey === "string" ? test.matchKey : test.id)));
458
496
  return {
@@ -555,7 +593,7 @@ const renderArtifactGroups = (artifacts) => {
555
593
  `)
556
594
  .join("\n");
557
595
  };
558
- const renderTestCard = (test) => {
596
+ const renderTestCard = (test, groupedIssue) => {
559
597
  const statusClass = test.status === "passed" ? "status-passed" : "status-failed";
560
598
  const fileLine = test.file ? `<div class="meta-item">${escapeHtml(test.file)}</div>` : "";
561
599
  const projectLine = test.projectName
@@ -582,8 +620,47 @@ const renderTestCard = (test) => {
582
620
  : `<pre>No error message was attached to this result.</pre>`;
583
621
  const artifactMarkup = renderArtifactGroups(test.artifacts);
584
622
  const diagnosis = test.diagnosis;
585
- const diagnosisMarkup = diagnosis
623
+ const diagnosisMarkup = groupedIssue
586
624
  ? `
625
+ <div class="diagnosis-shell">
626
+ <div>
627
+ <span class="artifact-kind">Grouped root cause</span>
628
+ <p class="diagnosis-copy"><strong>${escapeHtml(groupedIssue.title)}</strong></p>
629
+ <p class="diagnosis-copy">${escapeHtml(groupedIssue.cause)}</p>
630
+ </div>
631
+ <button
632
+ type="button"
633
+ class="copy-button"
634
+ data-copy-summary="${escapeHtml([
635
+ `Test: ${test.title}`,
636
+ `Issue: ${groupedIssue.title}`,
637
+ `Cause: ${groupedIssue.cause}`,
638
+ groupedIssue.where ? `Where: ${groupedIssue.where}` : null,
639
+ groupedIssue.failingStep ? `Failing step: ${groupedIssue.failingStep}` : null,
640
+ groupedIssue.expected ? `Expected: ${groupedIssue.expected}` : null,
641
+ groupedIssue.received ? `Received: ${groupedIssue.received}` : null,
642
+ `Likely fix: ${groupedIssue.next}`
643
+ ]
644
+ .filter(Boolean)
645
+ .join("\n"))}"
646
+ aria-label="Copy grouped diagnosis"
647
+ >
648
+ Copy diagnosis
649
+ </button>
650
+ </div>
651
+ <div class="fact-row">
652
+ ${groupedIssue.where ? `<span class="fact-chip">Where: ${escapeHtml(groupedIssue.where)}</span>` : ""}
653
+ ${groupedIssue.failingStep ? `<span class="fact-chip">Failing step: ${escapeHtml(groupedIssue.failingStep)}</span>` : ""}
654
+ ${groupedIssue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(groupedIssue.selector)}</span>` : ""}
655
+ ${groupedIssue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(groupedIssue.blocker)}</span>` : ""}
656
+ ${groupedIssue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(groupedIssue.targetState)}</span>` : ""}
657
+ ${groupedIssue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(groupedIssue.expected)}</span>` : ""}
658
+ ${groupedIssue.received ? `<span class="fact-chip">Received: ${escapeHtml(groupedIssue.received)}</span>` : ""}
659
+ </div>
660
+ <p class="diagnosis-copy"><strong>Likely fix:</strong> ${escapeHtml(groupedIssue.next)}</p>
661
+ `
662
+ : diagnosis
663
+ ? `
587
664
  <div class="diagnosis-shell">
588
665
  <div>
589
666
  <span class="artifact-kind">Quick diagnosis</span>
@@ -605,9 +682,9 @@ const renderTestCard = (test) => {
605
682
  ${diagnosis.timeoutMs ? `<span class="fact-chip">Timeout: ${diagnosis.timeoutMs}ms</span>` : ""}
606
683
  </div>
607
684
  `
608
- : "";
685
+ : "";
609
686
  return `
610
- <details class="test-card">
687
+ <details class="test-card" id="test-${safeSlug(test.id)}">
611
688
  <summary class="test-summary">
612
689
  <div>
613
690
  <div class="status-pill ${statusClass}">${escapeHtml(test.status)}</div>
@@ -693,6 +770,237 @@ const renderFailureDigest = (tests) => {
693
770
  </div>
694
771
  `;
695
772
  };
773
+ const renderDiagnosisOverview = (summary) => {
774
+ if (!summary) {
775
+ return `<div class="empty-state">No structured diagnosis was available for this run.</div>`;
776
+ }
777
+ const copyText = escapeHtml([
778
+ summary.headline || "",
779
+ summary.failureCountLine,
780
+ summary.collapseLine || "",
781
+ ...summary.issues.flatMap((issue, index) => {
782
+ const lines = [
783
+ `Issue ${index + 1}: ${issue.title}`,
784
+ `Cause: ${issue.cause}`
785
+ ];
786
+ if (issue.where)
787
+ lines.push(`Where: ${issue.where}`);
788
+ if (issue.failingStep)
789
+ lines.push(`Failing step: ${issue.failingStep}`);
790
+ if (issue.blocker)
791
+ lines.push(`Blocker: ${issue.blocker}`);
792
+ if (issue.targetState)
793
+ lines.push(`Target state: ${issue.targetState}`);
794
+ if (issue.expected)
795
+ lines.push(`Expected: ${issue.expected}`);
796
+ if (issue.received)
797
+ lines.push(`Received: ${issue.received}`);
798
+ if (issue.whatChanged)
799
+ lines.push(`What changed: ${issue.whatChanged}`);
800
+ if (issue.reason)
801
+ lines.push(`Reason: ${issue.reason}`);
802
+ lines.push(`Next: ${issue.next}`);
803
+ lines.push(`Impact: ${issue.impact}`);
804
+ return lines;
805
+ }),
806
+ ...summary.footer
807
+ ].filter(Boolean).join("\n"));
808
+ return `
809
+ <div class="diagnosis-overview">
810
+ <div class="diagnosis-shell">
811
+ <div>
812
+ <span class="artifact-kind">Sentinel diagnosis</span>
813
+ ${summary.headline ? `<h2 class="diagnosis-headline">${escapeHtml(summary.headline)}</h2>` : ""}
814
+ <p class="diagnosis-kicker">${escapeHtml(summary.failureCountLine)}</p>
815
+ ${summary.collapseLine ? `<p class="diagnosis-kicker diagnosis-kicker-secondary">${escapeHtml(summary.collapseLine)}</p>` : ""}
816
+ </div>
817
+ <button
818
+ type="button"
819
+ class="copy-button"
820
+ data-copy-summary="${copyText}"
821
+ aria-label="Copy diagnosis"
822
+ >
823
+ Copy diagnosis
824
+ </button>
825
+ </div>
826
+ ${summary.footer.length ? `<p class="diagnosis-copy">${escapeHtml(summary.footer.join(" · "))}</p>` : ""}
827
+ </div>
828
+ `;
829
+ };
830
+ const renderRecurringInsight = (historySummary) => {
831
+ if (!historySummary?.isDominantRecurringIssue || !historySummary.dominantRecurringIssueTitle)
832
+ return "";
833
+ return `
834
+ <p class="diagnosis-copy">
835
+ <strong>Recurring signal:</strong>
836
+ Seen in ${escapeHtml(String(historySummary.dominantRecurringIssueCount))} recorded failed runs.
837
+ This is the most common recent failure in local history.
838
+ </p>
839
+ `;
840
+ };
841
+ const renderDiagnosisIssueCards = (summary) => {
842
+ if (!summary || !summary.issues.length) {
843
+ return `<div class="empty-state">No grouped diagnosis issues were available for this run.</div>`;
844
+ }
845
+ return `
846
+ <div class="digest-grid">
847
+ ${summary.issues.map((issue, index) => `
848
+ <article class="digest-card">
849
+ <div class="digest-head">
850
+ <div>
851
+ <span class="artifact-kind">Issue ${index + 1}</span>
852
+ <h3>${escapeHtml(issue.title)}</h3>
853
+ </div>
854
+ </div>
855
+ <p class="diagnosis-copy">${escapeHtml(issue.cause)}</p>
856
+ <div class="fact-row">
857
+ ${issue.where ? `<span class="fact-chip">Where: ${escapeHtml(issue.where)}</span>` : ""}
858
+ ${issue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(issue.selector)}</span>` : ""}
859
+ ${issue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(issue.targetState)}</span>` : ""}
860
+ ${issue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(issue.blocker)}</span>` : ""}
861
+ </div>
862
+ <div class="fact-row">
863
+ ${issue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(issue.expected)}</span>` : ""}
864
+ ${issue.received ? `<span class="fact-chip">Received: ${escapeHtml(issue.received)}</span>` : ""}
865
+ </div>
866
+ ${issue.failingCode ? `<pre>${escapeHtml(issue.failingCode)}</pre>` : ""}
867
+ ${issue.failingStep ? `<p class="diagnosis-copy"><strong>Failing step:</strong> ${escapeHtml(issue.failingStep)}</p>` : ""}
868
+ ${issue.whatChanged ? `<p class="diagnosis-copy"><strong>What changed:</strong> ${escapeHtml(issue.whatChanged)}</p>` : ""}
869
+ ${issue.reason ? `<p class="diagnosis-copy"><strong>Reason:</strong> ${escapeHtml(issue.reason)}</p>` : ""}
870
+ <p class="diagnosis-copy"><strong>Next:</strong> ${escapeHtml(issue.next)}</p>
871
+ <p class="diagnosis-copy"><strong>Clears:</strong> ${escapeHtml(issue.clears)}</p>
872
+ <p class="diagnosis-copy"><strong>Impact:</strong> ${escapeHtml(issue.impact)}</p>
873
+ </article>
874
+ `).join("\n")}
875
+ </div>
876
+ `;
877
+ };
878
+ const renderDiagnosisIssueCardsForTests = (summary, tests) => {
879
+ if (!summary || !summary.issues.length) {
880
+ return `<div class="empty-state">No grouped diagnosis issues were available for this run.</div>`;
881
+ }
882
+ return `
883
+ <div class="digest-grid">
884
+ ${summary.issues.map((issue, index) => {
885
+ const representative = pickRepresentativeTestForIssue(tests, issue);
886
+ return `
887
+ <article class="digest-card ${index === 0 ? "digest-card-primary" : ""}">
888
+ <div class="digest-head">
889
+ <div>
890
+ <span class="artifact-kind">${index === 0 ? "Top issue" : `Issue ${index + 1}`}</span>
891
+ <h3>${escapeHtml(issue.title)}</h3>
892
+ </div>
893
+ </div>
894
+ <p class="diagnosis-copy">${escapeHtml(issue.cause)}</p>
895
+ <div class="fact-row">
896
+ ${issue.where ? `<span class="fact-chip">Where: ${escapeHtml(issue.where)}</span>` : ""}
897
+ ${issue.failingStep ? `<span class="fact-chip">Failing step: ${escapeHtml(issue.failingStep)}</span>` : ""}
898
+ ${issue.targetState ? `<span class="fact-chip">Target state: ${escapeHtml(issue.targetState)}</span>` : ""}
899
+ ${issue.blocker ? `<span class="fact-chip">Blocker: ${escapeHtml(issue.blocker)}</span>` : ""}
900
+ </div>
901
+ <div class="fact-row">
902
+ ${issue.selector ? `<span class="fact-chip">Selector: ${escapeHtml(issue.selector)}</span>` : ""}
903
+ ${issue.expected ? `<span class="fact-chip">Expected: ${escapeHtml(issue.expected)}</span>` : ""}
904
+ ${issue.received ? `<span class="fact-chip">Received: ${escapeHtml(issue.received)}</span>` : ""}
905
+ </div>
906
+ ${issue.whatChanged ? `<p class="diagnosis-copy"><strong>What changed:</strong> ${escapeHtml(issue.whatChanged)}</p>` : ""}
907
+ ${issue.reason ? `<p class="diagnosis-copy"><strong>Reason:</strong> ${escapeHtml(issue.reason)}</p>` : ""}
908
+ <p class="diagnosis-copy"><strong>Likely fix:</strong> ${escapeHtml(issue.next)}</p>
909
+ <p class="diagnosis-copy"><strong>Impact:</strong> ${escapeHtml(issue.impact)}</p>
910
+ ${issue.affectedTitles.length > 1
911
+ ? `<ul class="group-list">${issue.affectedTitles
912
+ .slice(0, 6)
913
+ .map((title) => `<li>${escapeHtml(shortenTitle(title))}</li>`)
914
+ .join("\n")}</ul>`
915
+ : ""}
916
+ ${representative ? `<p class="diagnosis-copy"><strong>Representative test:</strong> ${escapeHtml(representative.title)}</p>` : ""}
917
+ ${renderIssueArtifactActions(representative)}
918
+ </article>
919
+ `;
920
+ }).join("\n")}
921
+ </div>
922
+ `;
923
+ };
924
+ const reportTestDisplayTitle = (test) => cleanTitleParts(test.titlePath).join(" > ") || test.title;
925
+ const findIssueForTest = (summary, test) => {
926
+ if (!summary?.issues.length)
927
+ return null;
928
+ const title = reportTestDisplayTitle(test);
929
+ return (summary.issues.find((issue) => issue.affectedTitles.some((affectedTitle) => cleanTitleParts(affectedTitle.split(" > ")).join(" > ") === title)) || null);
930
+ };
931
+ const extractLineHints = (value) => Array.from(new Set(Array.from((value || "").matchAll(/:([0-9]+)(?::[0-9]+)?/g))
932
+ .map((match) => Number.parseInt(match[1] || "", 10))
933
+ .filter((line) => Number.isFinite(line))));
934
+ const pickRepresentativeTestForIssue = (tests, issue) => {
935
+ const failedTests = getFailureTests(tests);
936
+ const affected = new Set(issue.affectedTitles.map((title) => cleanTitleParts(title.split(" > ")).join(" > ")));
937
+ const matches = failedTests.filter((test) => affected.has(reportTestDisplayTitle(test)));
938
+ if (!matches.length)
939
+ return null;
940
+ const issueFile = (issue.where || "").split(":")[0]?.trim().toLowerCase();
941
+ const issueLines = extractLineHints(issue.where);
942
+ const issueSelector = (issue.selector || "").trim().toLowerCase();
943
+ const issueBlocker = (issue.blocker || "").trim().toLowerCase();
944
+ const issueFailingCode = (issue.failingCode || "").trim();
945
+ const issueTargetState = (issue.targetState || "").trim().toLowerCase();
946
+ const score = (test) => {
947
+ const diagnosis = test.diagnosis;
948
+ const diagnosisFile = (diagnosis?.likelyFile || diagnosis?.file || "").trim().toLowerCase();
949
+ const diagnosisSelector = (diagnosis?.locator || "").trim().toLowerCase();
950
+ const diagnosisLine = diagnosis?.codeContext?.line || null;
951
+ const diagnosisFocusLine = (diagnosis?.codeContext?.focusLine || "").trim();
952
+ const diagnosisMessage = (diagnosis?.message || "").toLowerCase();
953
+ const traceScore = test.artifacts.some((artifact) => artifact.kind === "trace") ? 50 : 0;
954
+ const screenshotScore = test.artifacts.some((artifact) => artifact.kind === "screenshot") ? 30 : 0;
955
+ const videoScore = test.artifacts.some((artifact) => artifact.kind === "video") ? 20 : 0;
956
+ const selectorScore = issueSelector && diagnosisSelector && issueSelector === diagnosisSelector ? 40 : 0;
957
+ const fileScore = issueFile && diagnosisFile && diagnosisFile.endsWith(issueFile) ? 20 : 0;
958
+ const lineScore = diagnosisLine && issueLines.includes(diagnosisLine) ? 35 : 0;
959
+ const focusScore = issueFailingCode && diagnosisFocusLine && issueFailingCode === diagnosisFocusLine ? 35 : 0;
960
+ const blockerScore = issueBlocker && diagnosisMessage.includes(issueBlocker) ? 25 : 0;
961
+ const targetStateScore = issueTargetState && diagnosis?.domCapture
962
+ ? (() => {
963
+ const dom = diagnosis.domCapture;
964
+ const targetState = dom.targetFound === false || dom.matchedCount === 0
965
+ ? "missing"
966
+ : dom.visible === false
967
+ ? "hidden"
968
+ : dom.enabled === false
969
+ ? "disabled"
970
+ : dom.targetFound === true && dom.visible === true
971
+ ? "visible_blocked"
972
+ : "";
973
+ return targetState === issueTargetState ? 15 : 0;
974
+ })()
975
+ : 0;
976
+ const lineAwareScore = diagnosisLine ? 10 : 0;
977
+ return traceScore + screenshotScore + videoScore + selectorScore + fileScore + lineScore + focusScore + blockerScore + targetStateScore + lineAwareScore;
978
+ };
979
+ return matches.sort((a, b) => score(b) - score(a))[0] || matches[0];
980
+ };
981
+ const renderIssueArtifactActions = (test) => {
982
+ if (!test)
983
+ return "";
984
+ const trace = test.artifacts.find((artifact) => artifact.kind === "trace");
985
+ const screenshot = test.artifacts.find((artifact) => artifact.kind === "screenshot");
986
+ const video = test.artifacts.find((artifact) => artifact.kind === "video");
987
+ const jumpTarget = `#test-${safeSlug(test.id)}`;
988
+ const actions = [
989
+ trace
990
+ ? `<a class="trace-button" href="${escapeHtml(trace.relativePath)}" data-trace-path="${escapeHtml(trace.relativePath)}" target="_blank" rel="noreferrer">Best trace</a>`
991
+ : "",
992
+ screenshot
993
+ ? `<a class="trace-button" href="${escapeHtml(screenshot.relativePath)}" target="_blank" rel="noreferrer">Open screenshot</a>`
994
+ : "",
995
+ video
996
+ ? `<a class="trace-button" href="${escapeHtml(video.relativePath)}" target="_blank" rel="noreferrer">Open video</a>`
997
+ : "",
998
+ `<a class="trace-button" href="${escapeHtml(jumpTarget)}">Jump to test</a>`
999
+ ].filter(Boolean);
1000
+ if (!actions.length)
1001
+ return "";
1002
+ return `<div class="issue-actions">${actions.join("\n")}</div>`;
1003
+ };
696
1004
  const renderSimilarFailureGroups = (groups) => {
697
1005
  if (!groups.length) {
698
1006
  return `<div class="empty-state">No repeated failure fingerprint was detected in this run.</div>`;
@@ -762,6 +1070,33 @@ const renderRunDiff = (runDiff) => {
762
1070
  </div>
763
1071
  `;
764
1072
  };
1073
+ const renderHistoryOverview = (historySummary, runDiff) => {
1074
+ if (!historySummary && !runDiff) {
1075
+ return `<div class="empty-state">No prior run history was available for this failure yet.</div>`;
1076
+ }
1077
+ return `
1078
+ ${renderRecurringInsight(historySummary)}
1079
+ ${runDiff
1080
+ ? `
1081
+ <div class="summary-grid">
1082
+ <div class="summary-card">
1083
+ <span class="summary-label">New failures</span>
1084
+ <span class="summary-value">${runDiff.newFailures.length}</span>
1085
+ </div>
1086
+ <div class="summary-card">
1087
+ <span class="summary-label">Still failing</span>
1088
+ <span class="summary-value">${runDiff.stillFailing.length}</span>
1089
+ </div>
1090
+ <div class="summary-card">
1091
+ <span class="summary-label">Fixed since last run</span>
1092
+ <span class="summary-value">${runDiff.fixedTests.length}</span>
1093
+ </div>
1094
+ </div>
1095
+ <p class="diagnosis-copy">Compared with the latest saved run on the same branch: ${escapeHtml(runDiff.label)}</p>
1096
+ `
1097
+ : ""}
1098
+ `;
1099
+ };
765
1100
  const renderAdditionalArtifacts = (artifacts) => {
766
1101
  if (artifacts.length === 0) {
767
1102
  return "";
@@ -789,7 +1124,7 @@ const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRe
789
1124
  }
790
1125
  }
791
1126
  };
792
- const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1127
+ const buildLegacyHtml = (tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory) => {
793
1128
  const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
794
1129
  const similarGroups = groupSimilarFailures(tests);
795
1130
  const generatedAt = new Date().toLocaleString();
@@ -1116,6 +1451,11 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1116
1451
  background: rgba(9, 13, 20, 0.72);
1117
1452
  padding: 16px;
1118
1453
  }
1454
+ .digest-card-primary {
1455
+ border-color: rgba(125, 211, 252, 0.38);
1456
+ box-shadow: 0 12px 36px rgba(4, 14, 26, 0.26);
1457
+ background: linear-gradient(180deg, rgba(14, 22, 34, 0.92), rgba(9, 13, 20, 0.78));
1458
+ }
1119
1459
  .digest-head, .diagnosis-shell {
1120
1460
  display: flex;
1121
1461
  justify-content: space-between;
@@ -1130,12 +1470,42 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1130
1470
  color: var(--muted);
1131
1471
  line-height: 1.6;
1132
1472
  }
1473
+ .diagnosis-overview {
1474
+ display: grid;
1475
+ gap: 12px;
1476
+ margin-top: 18px;
1477
+ }
1478
+ .diagnosis-headline {
1479
+ margin-top: 10px;
1480
+ font-size: 28px;
1481
+ line-height: 1.1;
1482
+ }
1483
+ .diagnosis-kicker {
1484
+ margin: 12px 0 0;
1485
+ color: var(--text);
1486
+ font-size: 20px;
1487
+ font-weight: 700;
1488
+ }
1489
+ .diagnosis-kicker-secondary {
1490
+ color: var(--accent);
1491
+ font-size: 16px;
1492
+ font-weight: 600;
1493
+ }
1133
1494
  .fact-row {
1134
1495
  display: flex;
1135
1496
  flex-wrap: wrap;
1136
1497
  gap: 8px;
1137
1498
  margin-top: 14px;
1138
1499
  }
1500
+ .issue-actions {
1501
+ display: flex;
1502
+ flex-wrap: wrap;
1503
+ gap: 10px;
1504
+ margin-top: 14px;
1505
+ }
1506
+ .section-shell-secondary {
1507
+ opacity: 0.9;
1508
+ }
1139
1509
  .fact-chip {
1140
1510
  display: inline-flex;
1141
1511
  align-items: center;
@@ -1264,37 +1634,39 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1264
1634
 
1265
1635
  <section class="section-shell">
1266
1636
  <div class="failed-list-head">
1267
- <h2>Run-to-Run Diff</h2>
1637
+ <h2>Why It Failed</h2>
1268
1638
  </div>
1269
- <p>Compare this run with the latest saved snapshot from the same branch.</p>
1270
- ${renderRunDiff(runDiff)}
1639
+ <p>The same grouped explanation shown in the CLI, with the most useful evidence first.</p>
1640
+ ${renderDiagnosisOverview(diagnosisSummary)}
1271
1641
  </section>
1272
1642
 
1273
1643
  <section class="section-shell">
1274
1644
  <div class="failed-list-head">
1275
- <h2>Failure Digest</h2>
1645
+ <h2>Explanation</h2>
1276
1646
  <div class="failed-count">${failedTests.length} failed</div>
1277
1647
  </div>
1278
- <p>Deterministic summaries built from the Playwright error, locator, assertion text, and timeout.</p>
1279
- ${renderFailureDigest(tests)}
1648
+ <p>Grouped root causes, likely fixes, and direct links to the best artifacts for each issue.</p>
1649
+ ${renderDiagnosisIssueCardsForTests(diagnosisSummary, tests)}
1280
1650
  </section>
1281
1651
 
1282
- <section class="section-shell">
1652
+ <section class="section-shell section-shell-secondary">
1283
1653
  <div class="failed-list-head">
1284
- <h2>Similar Failures</h2>
1285
- <div class="failed-count">${similarGroups.length} groups</div>
1654
+ <h2>History</h2>
1286
1655
  </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)}
1656
+ <p>Recent failure history and how this run changed from the previous saved run.</p>
1657
+ ${renderHistoryOverview(failedRunHistory, runDiff)}
1289
1658
  </section>
1290
1659
 
1291
1660
  <section class="section-shell">
1292
1661
  <div class="failed-list-head">
1293
- <h2>Failed Tests</h2>
1662
+ <h2>Artifacts</h2>
1294
1663
  <div class="failed-count">${failedTests.length} failed</div>
1295
1664
  </div>
1665
+ <p>Failed tests with trace, screenshot, video, logs, and raw error details.</p>
1296
1666
  ${failedTests.length > 0
1297
- ? failedTests.map((test) => renderTestCard(test)).join("\n")
1667
+ ? failedTests
1668
+ .map((test) => renderTestCard(test, findIssueForTest(diagnosisSummary, test)))
1669
+ .join("\n")
1298
1670
  : `<div class="empty-state">No failed tests were found in this run. The local report still includes collected artifacts below.</div>`}
1299
1671
  </section>
1300
1672
 
@@ -1306,19 +1678,6 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1306
1678
  : `<div class="empty-state">All detected artifacts were mapped onto failed tests.</div>`}
1307
1679
  </section>
1308
1680
 
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
1681
  <footer>
1323
1682
  Generated by <a href="${SENTINEL_URL}" target="_blank" rel="noreferrer">Sentinel Playwright Reporter</a>.
1324
1683
  </footer>
@@ -1440,6 +1799,423 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff) => {
1440
1799
  </body>
1441
1800
  </html>`;
1442
1801
  };
1802
+ const buildHtml = (tests, summary, extraArtifacts, _runDiff, diagnosisSummary, _failedRunHistory) => {
1803
+ // New default local report renderer: match the hosted public share report "Root Causes" UI.
1804
+ // Keep legacy renderer in the file as a fallback (and to avoid big diffs).
1805
+ const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
1806
+ // Hosted share reports prefer the reporter diagnosis summary (issues[]) for root-cause grouping.
1807
+ // Local reports must match the same canonical evidence and grouping, not re-cluster ad-hoc.
1808
+ const issueGroups = (() => {
1809
+ const issues = (diagnosisSummary?.issues || []).slice(0, 3);
1810
+ if (!issues.length)
1811
+ return [];
1812
+ const failures = getFailureTests(tests);
1813
+ return issues
1814
+ .map((issue) => {
1815
+ const affected = failures.filter((t) => {
1816
+ const matched = findIssueForTest(diagnosisSummary, t);
1817
+ return matched === issue;
1818
+ });
1819
+ return { issue, tests: affected };
1820
+ })
1821
+ .filter((group) => group.tests.length > 0);
1822
+ })();
1823
+ // Fallback: if diagnosis is missing, do a best-effort grouping based on quick per-test signals.
1824
+ const similarGroups = issueGroups.length > 0
1825
+ ? []
1826
+ : groupSimilarFailures(tests)
1827
+ .filter((group) => group.tests.some((t) => ["failed", "timedOut", "interrupted"].includes(t.status)))
1828
+ .map((group) => ({
1829
+ ...group,
1830
+ tests: group.tests.filter((t) => ["failed", "timedOut", "interrupted"].includes(t.status))
1831
+ }))
1832
+ .filter((group) => group.tests.length > 0);
1833
+ const formatDurationMs = (value) => {
1834
+ if (!Number.isFinite(value))
1835
+ return "-";
1836
+ if (value < 1000)
1837
+ return `${Math.round(value)} ms`;
1838
+ const seconds = Math.round(value / 1000);
1839
+ return `${seconds}s`;
1840
+ };
1841
+ const artifactCountByKind = (test) => {
1842
+ const by = {};
1843
+ for (const a of test.artifacts || [])
1844
+ by[a.kind] = (by[a.kind] || 0) + 1;
1845
+ return by;
1846
+ };
1847
+ const renderLocalArtifacts = (test) => {
1848
+ if (!test.artifacts?.length)
1849
+ return `<div class="muted">No artifacts captured.</div>`;
1850
+ return `<div class="artifact-grid">${test.artifacts.map((a) => renderArtifact(a)).join("\n")}</div>`;
1851
+ };
1852
+ const rootCauseTabs = issueGroups.length
1853
+ ? issueGroups
1854
+ .map((group) => {
1855
+ const count = group.tests.length;
1856
+ const label = escapeHtml(group.issue.title || "Root cause");
1857
+ return `<span class="pill pill-muted">${label}<span class="pill-sub">${count} tests</span></span>`;
1858
+ })
1859
+ .join("\n")
1860
+ : similarGroups.length
1861
+ ? similarGroups
1862
+ .map((group) => {
1863
+ const count = group.tests.length;
1864
+ const label = escapeHtml(group.signal || "Root cause");
1865
+ return `<span class="pill pill-muted">${label}<span class="pill-sub">${count} tests</span></span>`;
1866
+ })
1867
+ .join("\n")
1868
+ : `<span class="pill pill-muted">No failures</span>`;
1869
+ const renderIssueGroup = (group) => {
1870
+ const issue = group.issue;
1871
+ const title = escapeHtml(issue.title || "Root cause");
1872
+ const summaryText = escapeHtml(issue.cause || "Inspect failing artifacts and trace.");
1873
+ const evidenceFiles = issue.where ? escapeHtml(issue.where) : "";
1874
+ const evidenceBlocker = issue.blocker ? escapeHtml(issue.blocker) : null;
1875
+ const evidenceTargetState = issue.targetState ? escapeHtml(issue.targetState) : null;
1876
+ const evidenceExpected = issue.expected ? escapeHtml(issue.expected) : null;
1877
+ const evidenceReceived = issue.received ? escapeHtml(issue.received) : null;
1878
+ const evidenceFailingStep = issue.failingStep ? escapeHtml(issue.failingStep) : null;
1879
+ const rows = group.tests
1880
+ .map((test) => {
1881
+ const counts = artifactCountByKind(test);
1882
+ const videoCount = counts.video || 0;
1883
+ const screenshotCount = counts.screenshot || 0;
1884
+ const rowId = escapeHtml(test.id);
1885
+ const title = escapeHtml(test.title);
1886
+ const file = escapeHtml(test.file || "-");
1887
+ const duration = escapeHtml(formatDurationMs(test.duration));
1888
+ const desc = escapeHtml(test.errors?.[0] || "");
1889
+ return `
1890
+ <tr class="row" data-row="${rowId}">
1891
+ <td class="cell cell-test">
1892
+ <button class="chev" type="button" aria-label="Toggle details" data-toggle="${rowId}">›</button>
1893
+ <a class="test-link" href="#${rowId}" data-anchor="${rowId}">${title}</a>
1894
+ ${desc ? `<div class="muted tiny mt-6">${desc}</div>` : ""}
1895
+ <div class="muted tiny">${escapeHtml(test.projectName || "")}</div>
1896
+ </td>
1897
+ <td class="cell cell-file mono muted">${file}</td>
1898
+ <td class="cell">${duration}</td>
1899
+ <td class="cell">
1900
+ <div class="pill-row">
1901
+ ${videoCount > 0 ? `<span class="pill pill-muted">Video ${videoCount}</span>` : ""}
1902
+ ${screenshotCount > 0 ? `<span class="pill pill-muted">Screenshot ${screenshotCount}</span>` : ""}
1903
+ </div>
1904
+ </td>
1905
+ <td class="cell">
1906
+ <button class="btn btn-outline" type="button" data-copy="${rowId}">Copy link</button>
1907
+ </td>
1908
+ </tr>
1909
+ <tr class="row-details" data-details="${rowId}" hidden>
1910
+ <td class="cell details" colspan="5">
1911
+ <div class="details-inner">
1912
+ ${renderLocalArtifacts(test)}
1913
+ </div>
1914
+ </td>
1915
+ </tr>
1916
+ `;
1917
+ })
1918
+ .join("\n");
1919
+ return `
1920
+ <div class="group">
1921
+ <div class="group-head">
1922
+ <div class="kicker">${group.tests.length === 1 ? "1 test shares this root cause" : `${group.tests.length} tests share this root cause`}</div>
1923
+ <div class="group-title">${title}</div>
1924
+ <div class="group-summary">${summaryText}</div>
1925
+ <div class="group-meta muted tiny">
1926
+ <span>Impact: ${group.tests.length} tests affected</span>
1927
+ <span class="dot">·</span>
1928
+ <span>Inspect first: trace</span>
1929
+ </div>
1930
+ ${issue.next
1931
+ ? `<div class="group-meta muted tiny" style="margin-top:6px"><span>Likely fix: <span class="fg">${escapeHtml(issue.next)}</span></span></div>`
1932
+ : ""}
1933
+ <div class="evidence">
1934
+ <div class="ev-col">
1935
+ <div class="ev-title">Evidence</div>
1936
+ <div class="ev-lines muted tiny">
1937
+ ${evidenceFiles ? `<div>Where: <span class="fg">${evidenceFiles}</span></div>` : ""}
1938
+ ${evidenceFailingStep ? `<div>Failing step: <span class="fg">${evidenceFailingStep}</span></div>` : ""}
1939
+ ${evidenceBlocker ? `<div>Blocker: <span class="fg">${evidenceBlocker}</span></div>` : ""}
1940
+ ${evidenceTargetState ? `<div>Target state: <span class="fg">${evidenceTargetState}</span></div>` : ""}
1941
+ ${evidenceExpected ? `<div>Expected: <span class="fg">${evidenceExpected}</span></div>` : ""}
1942
+ ${evidenceReceived ? `<div>Received: <span class="fg">${evidenceReceived}</span></div>` : ""}
1943
+ </div>
1944
+ </div>
1945
+ <div class="ev-col">
1946
+ <div class="ev-title">Check first</div>
1947
+ <div class="ev-lines fg">
1948
+ <div>- Open trace at the failing step</div>
1949
+ </div>
1950
+ </div>
1951
+ </div>
1952
+ </div>
1953
+ <div class="table-wrap">
1954
+ <table class="table">
1955
+ <thead>
1956
+ <tr>
1957
+ <th>Test</th>
1958
+ <th>File</th>
1959
+ <th>Duration</th>
1960
+ <th>Artifacts</th>
1961
+ <th>Actions</th>
1962
+ </tr>
1963
+ </thead>
1964
+ <tbody>
1965
+ ${rows}
1966
+ </tbody>
1967
+ </table>
1968
+ </div>
1969
+ </div>
1970
+ `;
1971
+ };
1972
+ const groupTitle = (group) => {
1973
+ const headline = group.signal || "Root cause";
1974
+ const count = group.tests.length;
1975
+ return `${headline} (${count} ${count === 1 ? "test" : "tests"})`;
1976
+ };
1977
+ const groupSummary = (group) => {
1978
+ if (group.summary)
1979
+ return group.summary;
1980
+ const top = group.tests[0];
1981
+ const err = top?.errors?.[0];
1982
+ return err ? err : "Inspect failing artifacts and trace.";
1983
+ };
1984
+ const renderGroup = (group) => {
1985
+ const title = escapeHtml(groupTitle(group));
1986
+ const summaryText = escapeHtml(groupSummary(group));
1987
+ const evidenceFiles = group.tests
1988
+ .map((t) => t.file)
1989
+ .filter(Boolean)
1990
+ .slice(0, 3)
1991
+ .join("; ");
1992
+ const evidenceBlocker = group.locator ? escapeHtml(group.locator) : null;
1993
+ const rows = group.tests
1994
+ .map((test) => {
1995
+ const counts = artifactCountByKind(test);
1996
+ const videoCount = counts.video || 0;
1997
+ const screenshotCount = counts.screenshot || 0;
1998
+ const rowId = escapeHtml(test.id);
1999
+ const title = escapeHtml(test.title);
2000
+ const file = escapeHtml(test.file || "-");
2001
+ const duration = escapeHtml(formatDurationMs(test.duration));
2002
+ const desc = escapeHtml(test.errors?.[0] || "");
2003
+ return `
2004
+ <tr class="row" data-row="${rowId}">
2005
+ <td class="cell cell-test">
2006
+ <button class="chev" type="button" aria-label="Toggle details" data-toggle="${rowId}">›</button>
2007
+ <a class="test-link" href="#${rowId}" data-anchor="${rowId}">${title}</a>
2008
+ ${desc ? `<div class="muted tiny mt-6">${desc}</div>` : ""}
2009
+ <div class="muted tiny">${escapeHtml(test.projectName || "")}</div>
2010
+ </td>
2011
+ <td class="cell cell-file mono muted">${file}</td>
2012
+ <td class="cell">${duration}</td>
2013
+ <td class="cell">
2014
+ <div class="pill-row">
2015
+ ${videoCount > 0 ? `<span class="pill pill-muted">Video ${videoCount}</span>` : ""}
2016
+ ${screenshotCount > 0 ? `<span class="pill pill-muted">Screenshot ${screenshotCount}</span>` : ""}
2017
+ </div>
2018
+ </td>
2019
+ <td class="cell">
2020
+ <button class="btn btn-outline" type="button" data-copy="${rowId}">Copy link</button>
2021
+ </td>
2022
+ </tr>
2023
+ <tr class="row-details" data-details="${rowId}" hidden>
2024
+ <td class="cell details" colspan="5">
2025
+ <div class="details-inner">
2026
+ ${renderLocalArtifacts(test)}
2027
+ </div>
2028
+ </td>
2029
+ </tr>
2030
+ `;
2031
+ })
2032
+ .join("\n");
2033
+ return `
2034
+ <div class="group">
2035
+ <div class="group-head">
2036
+ <div class="kicker">${group.tests.length === 1 ? "Root cause" : `${group.tests.length} tests share this root cause`}</div>
2037
+ <div class="group-title">${title}</div>
2038
+ <div class="group-summary">${summaryText}</div>
2039
+ <div class="group-meta muted tiny">
2040
+ <span>Impact: ${group.tests.length} tests affected</span>
2041
+ <span class="dot">·</span>
2042
+ <span>Inspect first: trace</span>
2043
+ </div>
2044
+ <div class="evidence">
2045
+ <div class="ev-col">
2046
+ <div class="ev-title">Evidence</div>
2047
+ <div class="ev-lines muted tiny">
2048
+ ${evidenceFiles ? `<div>File: <span class="fg">${escapeHtml(evidenceFiles)}</span></div>` : ""}
2049
+ ${evidenceBlocker ? `<div>Blocker: <span class="fg">${evidenceBlocker}</span></div>` : ""}
2050
+ </div>
2051
+ </div>
2052
+ <div class="ev-col">
2053
+ <div class="ev-title">Check first</div>
2054
+ <div class="ev-lines fg">
2055
+ <div>- Open trace at the failing step</div>
2056
+ </div>
2057
+ </div>
2058
+ </div>
2059
+ </div>
2060
+ <div class="table-wrap">
2061
+ <table class="table">
2062
+ <thead>
2063
+ <tr>
2064
+ <th>Test</th>
2065
+ <th>File</th>
2066
+ <th>Duration</th>
2067
+ <th>Artifacts</th>
2068
+ <th>Actions</th>
2069
+ </tr>
2070
+ </thead>
2071
+ <tbody>
2072
+ ${rows}
2073
+ </tbody>
2074
+ </table>
2075
+ </div>
2076
+ </div>
2077
+ `;
2078
+ };
2079
+ const groupsHtml = issueGroups.length
2080
+ ? issueGroups.map((g) => renderIssueGroup(g)).join("\n")
2081
+ : similarGroups.length
2082
+ ? similarGroups.map((g) => renderGroup(g)).join("\n")
2083
+ : `<p class="muted">No failed tests.</p>`;
2084
+ const generatedAt = escapeHtml(new Date().toISOString());
2085
+ return `<!doctype html>
2086
+ <html lang="en">
2087
+ <head>
2088
+ <meta charset="utf-8" />
2089
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2090
+ <title>Sentinel Report (Local)</title>
2091
+ <style>
2092
+ :root{
2093
+ color-scheme: dark;
2094
+ --bg:#08090A;
2095
+ --fg:#F7F8F8;
2096
+ --muted:#8A8F98;
2097
+ --border:rgba(255,255,255,.10);
2098
+ --surface:rgba(255,255,255,.02);
2099
+ --surface-2:rgba(255,255,255,.03);
2100
+ --hover:#1a1a1a;
2101
+ --primary:#5E6AD2;
2102
+ }
2103
+ *{box-sizing:border-box}
2104
+ body{margin:0;background:var(--bg);color:var(--fg);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
2105
+ a{color:inherit;text-decoration:none}
2106
+ a:hover{text-decoration:underline}
2107
+ .page{max-width:1100px;margin:0 auto;padding:28px 20px 80px}
2108
+ .top{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:18px}
2109
+ h1{font-size:22px;margin:0;font-weight:650;letter-spacing:-.02em}
2110
+ .sub{color:var(--muted);font-size:13px;margin-top:6px}
2111
+ .pill-row{display:flex;flex-wrap:wrap;gap:8px}
2112
+ .pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--surface);padding:4px 10px;border-radius:999px;font-size:10px;font-weight:650;letter-spacing:.02em;color:var(--muted);white-space:nowrap}
2113
+ .pill-sub{margin-left:8px;color:var(--muted);font-weight:600}
2114
+ .wrap{border-top:1px solid var(--border);padding-top:18px}
2115
+ .card{background:var(--surface);border-radius:16px;padding:18px}
2116
+ .title{font-size:14px;font-weight:650}
2117
+ .groups{margin-top:14px;display:flex;flex-direction:column;gap:18px}
2118
+ .group{border-top:1px solid var(--border);padding-top:18px}
2119
+ .group:first-child{border-top:none;padding-top:0}
2120
+ .group-head{padding:0 0 14px}
2121
+ .kicker{font-size:11px;font-weight:650;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
2122
+ .group-title{margin-top:8px;font-size:18px;font-weight:650;color:var(--fg)}
2123
+ .group-summary{margin-top:6px;font-size:13px;color:var(--fg)}
2124
+ .group-meta{margin-top:6px;display:flex;flex-wrap:wrap;gap:10px}
2125
+ .dot{opacity:.6}
2126
+ .evidence{margin-top:14px;display:grid;grid-template-columns:1fr;gap:14px}
2127
+ @media(min-width:900px){.evidence{grid-template-columns:1fr 1fr}}
2128
+ .ev-col{border-left:1px solid var(--border);padding-left:14px}
2129
+ .ev-title{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:650;letter-spacing:.16em;text-transform:uppercase;color:var(--primary)}
2130
+ .ev-lines{margin-top:8px}
2131
+ .fg{color:var(--fg)}
2132
+ .muted{color:var(--muted)}
2133
+ .tiny{font-size:12px}
2134
+ .mt-6{margin-top:6px}
2135
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
2136
+ .table-wrap{overflow:auto;border-top:1px solid var(--border)}
2137
+ .table{width:100%;border-collapse:collapse}
2138
+ th{padding:12px 14px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
2139
+ td{padding:12px 14px;vertical-align:top;font-size:13px;color:var(--fg)}
2140
+ tbody tr.row:hover{background:var(--hover)}
2141
+ tbody tr.row-details td{padding-top:0}
2142
+ tbody{border-top:1px solid rgba(255,255,255,.06)}
2143
+ tbody tr+tr{border-top:1px solid rgba(255,255,255,.06)}
2144
+ .cell-test{min-width:420px}
2145
+ .test-link{font-weight:650}
2146
+ .chev{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:none;background:transparent;color:var(--muted);cursor:pointer;font-size:18px;line-height:1;margin-right:8px}
2147
+ .btn{display:inline-flex;align-items:center;justify-content:center;border-radius:10px;padding:8px 10px;font-size:12px;font-weight:650;cursor:pointer}
2148
+ .btn-outline{border:1px solid var(--border);background:transparent;color:var(--fg)}
2149
+ .btn-outline:hover{background:var(--surface-2)}
2150
+ .details{padding:0 14px 14px}
2151
+ .details-inner{border-left:1px solid var(--border);padding-left:14px}
2152
+ .artifact-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));margin-top:12px}
2153
+ .artifact-card,.artifact-link{border:1px solid rgba(255,255,255,.10);border-radius:12px;background:rgba(255,255,255,.01);padding:12px}
2154
+ .artifact-meta{display:flex;align-items:center;justify-content:space-between;gap:12px}
2155
+ .artifact-kind{display:inline-flex;align-items:center;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.02);border-radius:999px;padding:3px 8px;font-size:10px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
2156
+ .artifact-card img,.artifact-card video{width:100%;border-radius:10px;margin-top:10px;background:#05070b}
2157
+ </style>
2158
+ </head>
2159
+ <body>
2160
+ <div class="page">
2161
+ <div class="top">
2162
+ <div>
2163
+ <h1>Root Causes</h1>
2164
+ <div class="sub">Generated locally · ${generatedAt}</div>
2165
+ </div>
2166
+ <div class="pill-row">
2167
+ <span class="pill">Failed tests<span class="pill-sub">${failedTests.length}</span></span>
2168
+ </div>
2169
+ </div>
2170
+
2171
+ <div class="wrap">
2172
+ <div class="card">
2173
+ <div class="title">Root Causes</div>
2174
+ <div class="groups">
2175
+ <div class="pill-row">
2176
+ ${rootCauseTabs}
2177
+ </div>
2178
+ ${groupsHtml}
2179
+ </div>
2180
+ </div>
2181
+ </div>
2182
+ </div>
2183
+ <script>
2184
+ (function() {
2185
+ function baseUrl() {
2186
+ try { return window.location.href.split('#')[0]; } catch { return ''; }
2187
+ }
2188
+ document.querySelectorAll('[data-toggle]').forEach(function(btn) {
2189
+ btn.addEventListener('click', function(ev) {
2190
+ ev.preventDefault();
2191
+ ev.stopPropagation();
2192
+ var id = btn.getAttribute('data-toggle');
2193
+ if (!id) return;
2194
+ var details = document.querySelector('[data-details=\"' + id + '\"]');
2195
+ if (!details) return;
2196
+ var isHidden = details.hasAttribute('hidden');
2197
+ if (isHidden) details.removeAttribute('hidden');
2198
+ else details.setAttribute('hidden', '');
2199
+ btn.textContent = isHidden ? '⌄' : '›';
2200
+ });
2201
+ });
2202
+ document.querySelectorAll('[data-copy]').forEach(function(btn) {
2203
+ btn.addEventListener('click', function(ev) {
2204
+ ev.preventDefault();
2205
+ ev.stopPropagation();
2206
+ var id = btn.getAttribute('data-copy');
2207
+ if (!id) return;
2208
+ var link = baseUrl() + '#' + id;
2209
+ navigator.clipboard.writeText(link).catch(function(){});
2210
+ btn.textContent = 'Copied';
2211
+ setTimeout(function(){ btn.textContent = 'Copy link'; }, 1200);
2212
+ });
2213
+ });
2214
+ })();
2215
+ </script>
2216
+ </body>
2217
+ </html>`;
2218
+ };
1443
2219
  function generateLocalDebugReport(options) {
1444
2220
  const reportDir = path_1.default.resolve(process.cwd(), options.reportDir || DEFAULT_REPORT_DIR);
1445
2221
  const reportFileName = options.reportFileName || DEFAULT_REPORT_FILE;
@@ -1508,8 +2284,10 @@ function generateLocalDebugReport(options) {
1508
2284
  const summary = summarizeTests(tests);
1509
2285
  const snapshot = buildRunSnapshot(tests, summary);
1510
2286
  const runDiff = buildRunDiff(tests, snapshot);
2287
+ const diagnosisSummary = (0, quickDiagnosis_1.buildQuickDiagnosisStructured)(options.playwrightJsonPath);
2288
+ const failedRunHistory = summary.failed > 0 ? (0, runHistory_1.buildFailedRunHistorySummary)(options.playwrightJsonPath) : null;
1511
2289
  writeRunHistory(snapshot);
1512
- const html = buildHtml(tests, summary, extraArtifacts, runDiff);
2290
+ const html = buildHtml(tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory);
1513
2291
  fs_1.default.writeFileSync(reportHtmlPath, html, "utf8");
1514
2292
  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
2293
  return {