@sentinelqa/playwright-reporter 0.1.54 → 0.1.57

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.
@@ -244,6 +244,23 @@ const readAttachmentJson = (attachments, name, baseDirs) => {
244
244
  return null;
245
245
  }
246
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
+ };
247
264
  const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) => {
248
265
  const hash = crypto_1.default
249
266
  .createHash("sha1")
@@ -261,7 +278,20 @@ const copyArtifact = (sourcePath, kind, reportDir, usedRelativePaths, testId) =>
261
278
  usedRelativePaths.add(relativePath);
262
279
  const destination = path_1.default.join(reportDir, relativePath);
263
280
  ensureDir(path_1.default.dirname(destination));
264
- 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
+ }
265
295
  return {
266
296
  sourcePath,
267
297
  fileName: path_1.default.basename(destination),
@@ -457,7 +487,10 @@ const buildRunDiff = (tests, snapshot) => {
457
487
  const currentById = new Map(tests.map((test) => [test.id, test]));
458
488
  const currentByMatchKey = new Map(tests.map((test) => [test.matchKey, test]));
459
489
  const currentFailures = getFailureTests(tests);
460
- 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));
461
494
  const previousFailureIds = new Set(previousFailures.map((test) => test.id));
462
495
  const previousFailureMatchKeys = new Set(previousFailures.map((test) => (typeof test.matchKey === "string" ? test.matchKey : test.id)));
463
496
  return {
@@ -1091,7 +1124,7 @@ const tryMapRemainingArtifactsToTests = (tests, artifactPaths, reportDir, usedRe
1091
1124
  }
1092
1125
  }
1093
1126
  };
1094
- const buildHtml = (tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory) => {
1127
+ const buildLegacyHtml = (tests, summary, extraArtifacts, runDiff, diagnosisSummary, failedRunHistory) => {
1095
1128
  const failedTests = tests.filter((test) => ["failed", "timedOut", "interrupted"].includes(test.status));
1096
1129
  const similarGroups = groupSimilarFailures(tests);
1097
1130
  const generatedAt = new Date().toLocaleString();
@@ -1766,6 +1799,423 @@ const buildHtml = (tests, summary, extraArtifacts, runDiff, diagnosisSummary, fa
1766
1799
  </body>
1767
1800
  </html>`;
1768
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
+ };
1769
2219
  function generateLocalDebugReport(options) {
1770
2220
  const reportDir = path_1.default.resolve(process.cwd(), options.reportDir || DEFAULT_REPORT_DIR);
1771
2221
  const reportFileName = options.reportFileName || DEFAULT_REPORT_FILE;
package/dist/mode.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type SentinelMode = "hosted" | "offline";
2
+ export declare const readSentinelMode: (env?: NodeJS.ProcessEnv) => SentinelMode;
package/dist/mode.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readSentinelMode = void 0;
4
+ const readSentinelMode = (env = process.env) => {
5
+ const raw = env.SENTINEL_MODE?.trim();
6
+ if (!raw)
7
+ return "hosted";
8
+ if (raw === "hosted" || raw === "offline")
9
+ return raw;
10
+ throw new Error([
11
+ `Invalid SENTINEL_MODE value: "${raw}".`,
12
+ "Allowed values: hosted, offline.",
13
+ "Tip: remove SENTINEL_MODE to use the default hosted flow."
14
+ ].join(" "));
15
+ };
16
+ exports.readSentinelMode = readSentinelMode;