@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.
- package/README.md +142 -165
- package/dist/localReport.js +812 -34
- package/dist/mode.d.ts +2 -0
- package/dist/mode.js +16 -0
- package/dist/quickDiagnosis.d.ts +42 -2
- package/dist/quickDiagnosis.js +1243 -98
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +102 -25
- package/dist/runHistory.d.ts +3 -0
- package/dist/runHistory.js +11 -1
- package/dist/terminalSummary.js +44 -12
- package/package.json +1 -1
package/dist/localReport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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>
|
|
1637
|
+
<h2>Why It Failed</h2>
|
|
1268
1638
|
</div>
|
|
1269
|
-
<p>
|
|
1270
|
-
${
|
|
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>
|
|
1645
|
+
<h2>Explanation</h2>
|
|
1276
1646
|
<div class="failed-count">${failedTests.length} failed</div>
|
|
1277
1647
|
</div>
|
|
1278
|
-
<p>
|
|
1279
|
-
${
|
|
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>
|
|
1285
|
-
<div class="failed-count">${similarGroups.length} groups</div>
|
|
1654
|
+
<h2>History</h2>
|
|
1286
1655
|
</div>
|
|
1287
|
-
<p>
|
|
1288
|
-
${
|
|
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>
|
|
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
|
|
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 {
|