@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.
@@ -290,33 +290,6 @@ const extractLocator = (message) => {
290
290
  const callLine = message.match(/(getByTestId|getByRole|getByText|getByLabel|getByPlaceholder|getByTitle|locator)\([^)]+\)/);
291
291
  return callLine?.[0] || null;
292
292
  };
293
- const normalizedUrlPath = (value) => {
294
- if (!value)
295
- return null;
296
- try {
297
- const parsed = new URL(value);
298
- return parsed.pathname || null;
299
- }
300
- catch {
301
- return value.replace(/^https?:\/\/[^/]+/i, "").split("?")[0] || value;
302
- }
303
- };
304
- const canonicalLocatorForFailure = (failure) => failure.domCapture?.normalizedLocator ||
305
- failure.locator ||
306
- failure.codeContext?.locator ||
307
- null;
308
- const pageErrorFingerprint = (failure) => normalizeMessageFingerprint(failure.domCapture?.recentPageErrors?.[0] ||
309
- failure.domCapture?.recentConsoleErrors?.[0]?.text ||
310
- "");
311
- const requestFingerprint = (failure) => {
312
- const request = failedRequestFromDomCapture(failure.domCapture);
313
- if (!request)
314
- return null;
315
- const method = request.method || "REQUEST";
316
- const url = normalizedUrlPath(request.url) || request.url || "unknown-request";
317
- const status = typeof request.status === "number" ? String(request.status) : request.failure || "failed";
318
- return `${method} ${url} ${status}`;
319
- };
320
293
  const extractExpected = (message) => {
321
294
  const match = message.match(/Expected substring:\s*"([^"]+)"/i) ||
322
295
  message.match(/Expected string:\s*"([^"]+)"/i) ||
@@ -627,26 +600,6 @@ const blockedActionSubtype = (failure, verdict) => {
627
600
  return "disabled";
628
601
  return "generic";
629
602
  };
630
- const blockerGroupingKey = (failure, verdict) => {
631
- const subtype = blockedActionSubtype(failure, verdict);
632
- const blocker = (verdict?.blocker || "").toLowerCase().replace(/\s+/g, " ").trim();
633
- if (!blocker)
634
- return null;
635
- return `${subtype}|${blocker}`;
636
- };
637
- const sharedBlockedClusterSubtype = (cluster) => {
638
- const subtypes = cluster.failures
639
- .map((failure) => blockedActionSubtype(failure, blockedVerdictForFailure(failure)))
640
- .filter(Boolean);
641
- return dominantValue(subtypes) || null;
642
- };
643
- const isMultiSelectorBlockedCluster = (cluster) => {
644
- const selectors = new Set(cluster.failures.map((item) => compactLocator(item.locator)).filter(Boolean));
645
- if (selectors.size <= 1)
646
- return false;
647
- const subtype = sharedBlockedClusterSubtype(cluster);
648
- return subtype === "overlay" || subtype === "strict_mode" || subtype === "detached" || subtype === "offscreen" || subtype === "focus";
649
- };
650
603
  const extractStackLocation = (message) => {
651
604
  const lines = stripAnsi(message).split(/\r?\n/);
652
605
  for (const line of lines) {
@@ -1553,7 +1506,6 @@ const compactIssueTitle = (cluster) => {
1553
1506
  const locator = compactLocator(failure.locator);
1554
1507
  const blockedVerdict = blockedVerdictForCluster(cluster);
1555
1508
  const navigation = navigationVerdictForFailure(failure);
1556
- const multiSelectorBlocked = isMultiSelectorBlockedCluster(cluster);
1557
1509
  switch (failure.signal) {
1558
1510
  case "setup":
1559
1511
  return "Playwright setup error";
@@ -1570,19 +1522,6 @@ const compactIssueTitle = (cluster) => {
1570
1522
  case "actionability":
1571
1523
  if (blockedVerdict?.locator) {
1572
1524
  const subtype = blockedActionSubtype(failure, blockedVerdict);
1573
- if (multiSelectorBlocked) {
1574
- if (subtype === "overlay")
1575
- return "Overlay blocked interactions";
1576
- if (subtype === "strict_mode")
1577
- return "Strict-mode locator conflicts";
1578
- if (subtype === "detached")
1579
- return "Detached targets before interaction";
1580
- if (subtype === "offscreen")
1581
- return "Offscreen targets before interaction";
1582
- if (subtype === "focus")
1583
- return "Input targets blocked";
1584
- return "Blocked interactions";
1585
- }
1586
1525
  if (subtype === "strict_mode")
1587
1526
  return `Strict-mode locator conflict (${compactLocator(blockedVerdict.locator)})`;
1588
1527
  if (subtype === "detached")
@@ -1601,13 +1540,6 @@ const compactIssueTitle = (cluster) => {
1601
1540
  return "Navigation timeout";
1602
1541
  if (navigation?.kind === "blank_page")
1603
1542
  return "Blank page after navigation";
1604
- if (multiSelectorBlocked) {
1605
- const subtype = blockedActionSubtype(failure, blockedVerdict);
1606
- if (subtype === "overlay")
1607
- return "Overlay blocked interactions";
1608
- if (subtype === "strict_mode")
1609
- return "Strict-mode locator conflicts";
1610
- }
1611
1543
  if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1612
1544
  return `Blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
1613
1545
  }
@@ -1665,23 +1597,6 @@ const clusterCauseLine = (cluster) => {
1665
1597
  }
1666
1598
  if (failure.signal === "actionability") {
1667
1599
  const subtype = blockedActionSubtype(failure, blockedVerdict);
1668
- if (isMultiSelectorBlockedCluster(cluster)) {
1669
- if (subtype === "overlay" && blockedVerdict?.blocker) {
1670
- return `${blockedVerdict.blocker} blocked interactions across these tests`;
1671
- }
1672
- if (subtype === "strict_mode") {
1673
- return "The shared locator pattern matched multiple elements across these tests";
1674
- }
1675
- if (subtype === "detached") {
1676
- return "Targets detached from the DOM before the interaction completed across these tests";
1677
- }
1678
- if (subtype === "offscreen") {
1679
- return "Targets never became reachable in the viewport across these tests";
1680
- }
1681
- if (subtype === "focus") {
1682
- return "Targets could not receive focus or input when the interaction ran";
1683
- }
1684
- }
1685
1600
  if (subtype === "overlay" && blockedVerdict?.blocker && blockedVerdict?.locator) {
1686
1601
  return `${blockedVerdict.blocker} blocked ${blockedVerdict.action || "the action"} on ${compactLocator(blockedVerdict.locator)}`;
1687
1602
  }
@@ -1715,15 +1630,6 @@ const clusterCauseLine = (cluster) => {
1715
1630
  if (navigation?.kind === "blank_page") {
1716
1631
  return `The app ended on a blank page instead of the expected screen`;
1717
1632
  }
1718
- if (isMultiSelectorBlockedCluster(cluster)) {
1719
- const subtype = blockedActionSubtype(failure, blockedVerdict);
1720
- if (subtype === "overlay" && blockedVerdict?.blocker) {
1721
- return `${blockedVerdict.blocker} blocked interactions across these tests before the expected state changed`;
1722
- }
1723
- if (subtype === "strict_mode") {
1724
- return "The shared locator pattern matched multiple elements across these tests";
1725
- }
1726
- }
1727
1633
  if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1728
1634
  if (blockedVerdict.blocker) {
1729
1635
  return `${blockedVerdict.blocker} blocked ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`;
@@ -1798,23 +1704,6 @@ const strongerClusterNext = (cluster) => {
1798
1704
  }
1799
1705
  if (failure.signal === "actionability" && failure.locator) {
1800
1706
  const subtype = blockedActionSubtype(failure, blockedVerdict);
1801
- if (isMultiSelectorBlockedCluster(cluster)) {
1802
- if (subtype === "overlay" && blockedVerdict?.blocker) {
1803
- return `remove or dismiss ${blockedVerdict.blocker} before these interactions run`;
1804
- }
1805
- if (subtype === "strict_mode") {
1806
- return "narrow the shared locator pattern so each interaction matches exactly one element";
1807
- }
1808
- if (subtype === "detached") {
1809
- return "stabilize the DOM so the targets do not detach before the interactions run";
1810
- }
1811
- if (subtype === "offscreen") {
1812
- return "make the targets reachable in the viewport before the interactions run";
1813
- }
1814
- if (subtype === "focus") {
1815
- return "make the targets focusable/editable before the interactions run";
1816
- }
1817
- }
1818
1707
  if (subtype === "strict_mode" && blockedVerdict?.locator) {
1819
1708
  return `narrow ${compactLocator(blockedVerdict.locator)} so it matches exactly one element`;
1820
1709
  }
@@ -1845,15 +1734,6 @@ const strongerClusterNext = (cluster) => {
1845
1734
  if (navigation?.kind === "blank_page") {
1846
1735
  return "check why the app ended on a blank route after navigation";
1847
1736
  }
1848
- if (isMultiSelectorBlockedCluster(cluster)) {
1849
- const subtype = blockedActionSubtype(failure, blockedVerdict);
1850
- if (subtype === "overlay" && blockedVerdict?.blocker) {
1851
- return `remove or dismiss ${blockedVerdict.blocker} before these interactions run`;
1852
- }
1853
- if (subtype === "strict_mode") {
1854
- return "narrow the shared locator pattern so each interaction matches exactly one element";
1855
- }
1856
- }
1857
1737
  if (blockedVerdict?.targetState === "visible_blocked" && blockedVerdict.action && blockedVerdict.locator) {
1858
1738
  return blockedVerdict.blocker
1859
1739
  ? `remove or dismiss what blocks ${blockedVerdict.action} on ${compactLocator(blockedVerdict.locator)}`
@@ -1969,9 +1849,9 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
1969
1849
  found: Boolean(fallbackLocation?.file || assertionLine || extractFocusLineFromMessage(message))
1970
1850
  };
1971
1851
  const domCapture = options?.domCapture || null;
1972
- const locator = domCapture?.normalizedLocator ||
1973
- extractLocator(message) ||
1852
+ const locator = extractLocator(message) ||
1974
1853
  codeContext?.locator ||
1854
+ domCapture?.normalizedLocator ||
1975
1855
  domCapture?.locator ||
1976
1856
  null;
1977
1857
  const extractedExpected = extractExpected(message);
@@ -2069,63 +1949,47 @@ const buildDebugSummary = (failure) => {
2069
1949
  exports.buildDebugSummary = buildDebugSummary;
2070
1950
  const buildSimilarityKey = (failure) => {
2071
1951
  const locationKey = failure.codeContext?.line ? `${basename(failure.codeContext.file)}:${failure.codeContext.line}` : basename(failure.likelyFile) || "unknown-file";
2072
- const canonicalLocator = canonicalLocatorForFailure(failure);
2073
- const requestKey = requestFingerprint(failure);
2074
- const pageErrorKey = pageErrorFingerprint(failure);
2075
1952
  if (failure.signal === 'runtime' || failure.signal === 'unknown') {
2076
1953
  return [
2077
1954
  failure.signal,
2078
- pageErrorKey || normalizeMessageFingerprint(failure.message),
2079
- canonicalLocator || locationKey
1955
+ normalizeMessageFingerprint(failure.message),
1956
+ locationKey
2080
1957
  ].join('|');
2081
1958
  }
2082
1959
  if (failure.signal === "assertion_mismatch") {
2083
- const assertionKind = extractAssertionKind(failure.codeContext?.assertion || failure.codeContext?.focusLine || null);
2084
1960
  return [
2085
1961
  failure.signal,
2086
- canonicalLocator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
2087
- assertionKind,
2088
- failure.expected || "unknown-expected",
2089
- failure.received || pageErrorKey || "unknown-received"
1962
+ failure.locator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
1963
+ basename(failure.likelyFile) || "unknown-file"
2090
1964
  ].join("|");
2091
1965
  }
2092
1966
  if (failure.signal === "setup") {
2093
1967
  return [failure.signal, normalizeMessageFingerprint(failure.message)].join("|");
2094
1968
  }
2095
1969
  if (failure.signal === "timeout" || failure.signal === "actionability" || failure.signal === "locator_not_found") {
2096
- const verdict = blockedVerdictForFailure(failure);
2097
- const blockerKey = blockerGroupingKey(failure, verdict);
2098
- const subtype = blockedActionSubtype(failure, verdict);
2099
- if (blockerKey && (subtype === "overlay" || subtype === "strict_mode" || subtype === "detached" || subtype === "offscreen" || subtype === "focus")) {
2100
- return [
2101
- "blocked_action_family",
2102
- blockerKey
2103
- ].join("|");
2104
- }
2105
1970
  return [
2106
1971
  failure.signal,
2107
- canonicalLocator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
2108
- failure.codeContext?.action || "unknown-action",
2109
- pageErrorKey || locationKey
1972
+ failure.locator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
1973
+ basename(failure.likelyFile) || "unknown-file"
2110
1974
  ].join("|");
2111
1975
  }
2112
1976
  if (failure.signal === "network") {
2113
1977
  return [
2114
1978
  failure.signal,
2115
- requestKey || normalizedUrlPath(failure.apiHint) || failure.likelyModule || "unknown-api",
2116
- pageErrorKey || basename(failure.likelyFile) || "unknown-file"
1979
+ failure.apiHint || failure.likelyModule || "unknown-api",
1980
+ basename(failure.likelyFile) || "unknown-file"
2117
1981
  ].join("|");
2118
1982
  }
2119
1983
  if (failure.locator || failure.expected || failure.received || failure.apiHint) {
2120
1984
  return [
2121
1985
  failure.signal,
2122
- canonicalLocator || normalizedUrlPath(failure.apiHint) || "unknown-target",
1986
+ failure.locator || failure.apiHint || "unknown-target",
2123
1987
  failure.expected || "unknown-expected",
2124
- failure.received || requestKey || pageErrorKey || "unknown-received",
2125
- pageErrorKey || locationKey
1988
+ failure.received || "unknown-received",
1989
+ locationKey
2126
1990
  ].join("|");
2127
1991
  }
2128
- return `${failure.signal}|${pageErrorKey || normalizeMessageFingerprint(failure.message)}|${canonicalLocator || locationKey}`;
1992
+ return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}|${locationKey}`;
2129
1993
  };
2130
1994
  exports.buildSimilarityKey = buildSimilarityKey;
2131
1995
  const summarizeSignal = (signal) => {
@@ -2184,7 +2048,7 @@ const representativeClusterSelector = (cluster) => {
2184
2048
  .find((line) => line.startsWith("Selector:"));
2185
2049
  if (evidenceSelector)
2186
2050
  return withoutPrefix(evidenceSelector, "Selector:");
2187
- return representativeClusterValue(cluster.failures, (failure) => canonicalLocatorForFailure(failure));
2051
+ return representativeClusterValue(cluster.failures, (failure) => failure.locator);
2188
2052
  };
2189
2053
  const representativeClusterBlocker = (cluster) => {
2190
2054
  const evidenceBlocker = buildClusterEvidenceLines(cluster)
@@ -2295,18 +2159,17 @@ const buildSingleFailureIssue = (failed, top) => ({
2295
2159
  const buildClusterIssue = (cluster, totalFailures) => {
2296
2160
  const top = cluster.suspects[0];
2297
2161
  const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : clusterCauseLine(cluster);
2298
- const hideSelectorSpecificEvidence = isMultiSelectorBlockedCluster(cluster);
2299
2162
  return {
2300
2163
  title: `${compactIssueTitle(cluster)} (${cluster.count} test${cluster.count === 1 ? "" : "s"})`,
2301
2164
  cause: rootCause,
2302
2165
  affectedTitles: cluster.titles,
2303
2166
  where: locationValueForCluster(cluster),
2304
- failingCode: hideSelectorSpecificEvidence ? null : representativeClusterFailingCode(cluster)?.trim() || null,
2305
- failingStep: hideSelectorSpecificEvidence ? null : representativeClusterFailingStep(cluster)?.trim() || null,
2306
- selector: hideSelectorSpecificEvidence ? null : representativeClusterSelector(cluster)?.trim() || null,
2167
+ failingCode: representativeClusterFailingCode(cluster)?.trim() || null,
2168
+ failingStep: representativeClusterFailingStep(cluster)?.trim() || null,
2169
+ selector: representativeClusterSelector(cluster)?.trim() || null,
2307
2170
  blocker: representativeClusterBlocker(cluster)?.trim() || null,
2308
2171
  targetState: representativeClusterTargetState(cluster)?.trim() || null,
2309
- expected: hideSelectorSpecificEvidence ? null : representativeClusterExpected(cluster) ? truncateValue(representativeClusterExpected(cluster) || null) : null,
2172
+ expected: representativeClusterExpected(cluster) ? truncateValue(representativeClusterExpected(cluster) || null) : null,
2310
2173
  received: representativeClusterReceived(cluster) ? truncateValue(representativeClusterReceived(cluster) || null) : null,
2311
2174
  whatChanged: top && top.score >= 0.62 ? top.commit.message : null,
2312
2175
  reason: top && top.score >= 0.62 && top.reasons.length ? `${compactWhyLine(top)}.` : null,
@@ -2407,7 +2270,77 @@ const buildQuickDiagnosisStructured = (playwrightJsonPath) => {
2407
2270
  ((b.suspects[0]?.score || 0) - (a.suspects[0]?.score || 0)) ||
2408
2271
  (b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0));
2409
2272
  const topCluster = clusters[0];
2410
- const visibleClusters = clusters.slice(0, 3);
2273
+ const mergeRelatedIssues = (issues) => {
2274
+ // If multiple clusters share the same actionability blocker, treat them as one root cause.
2275
+ // This matches the user's expectation: the blocker is the canonical evidence, not the target locator.
2276
+ const merged = [];
2277
+ const used = new Set();
2278
+ const norm = (value) => (value || "").replace(/\s+/g, " ").trim().toLowerCase();
2279
+ const isBlockedInteraction = (issue) => issue.title.toLowerCase().startsWith("blocked interaction");
2280
+ const blockerKey = (issue) => {
2281
+ const blocker = norm(issue.blocker);
2282
+ if (!blocker)
2283
+ return null;
2284
+ const state = norm(issue.targetState);
2285
+ return `blocked_interaction::${blocker}::${state}`;
2286
+ };
2287
+ const buildMergedTitle = (blocker, count) => {
2288
+ const b = blocker.toLowerCase();
2289
+ const prefix = b.includes("overlay") ? "Overlay blocked interactions" : "Blocked interaction";
2290
+ return `${prefix} (${count} test${count === 1 ? "" : "s"})`;
2291
+ };
2292
+ for (let i = 0; i < issues.length; i += 1) {
2293
+ if (used.has(i))
2294
+ continue;
2295
+ const base = issues[i];
2296
+ const key = blockerKey(base);
2297
+ if (!key || !isBlockedInteraction(base)) {
2298
+ merged.push(base);
2299
+ used.add(i);
2300
+ continue;
2301
+ }
2302
+ const group = [base];
2303
+ used.add(i);
2304
+ for (let j = i + 1; j < issues.length; j += 1) {
2305
+ if (used.has(j))
2306
+ continue;
2307
+ const candidate = issues[j];
2308
+ if (!isBlockedInteraction(candidate))
2309
+ continue;
2310
+ if (blockerKey(candidate) !== key)
2311
+ continue;
2312
+ group.push(candidate);
2313
+ used.add(j);
2314
+ }
2315
+ if (group.length === 1) {
2316
+ merged.push(base);
2317
+ continue;
2318
+ }
2319
+ const allTitles = Array.from(new Set(group.flatMap((issue) => issue.affectedTitles || [])));
2320
+ const count = allTitles.length;
2321
+ const blocker = group.map((issue) => issue.blocker).find(Boolean) || base.blocker || "";
2322
+ const targetState = group.map((issue) => issue.targetState).find(Boolean) || base.targetState || null;
2323
+ const whereParts = group
2324
+ .map((issue) => issue.where)
2325
+ .filter(Boolean)
2326
+ .flatMap((where) => String(where).split(";").map((p) => p.trim()).filter(Boolean));
2327
+ const where = Array.from(new Set(whereParts)).slice(0, 12).join("; ") || null;
2328
+ merged.push({
2329
+ ...base,
2330
+ title: buildMergedTitle(blocker, count),
2331
+ // Canonical, normalized evidence: blocker + state. Avoid sample-selector specificity here.
2332
+ cause: `${blocker} blocked interactions across these tests`,
2333
+ affectedTitles: allTitles,
2334
+ where,
2335
+ selector: null,
2336
+ targetState,
2337
+ impact: `${count} tests failing with same root cause`,
2338
+ clears: `fixing this likely clears ${count} of ${failures.length} failures`
2339
+ });
2340
+ }
2341
+ return merged;
2342
+ };
2343
+ const mergedIssues = mergeRelatedIssues(clusters.map((cluster) => buildClusterIssue(cluster, failures.length)));
2411
2344
  return {
2412
2345
  mode: "failure",
2413
2346
  headline: null,
@@ -2415,11 +2348,9 @@ const buildQuickDiagnosisStructured = (playwrightJsonPath) => {
2415
2348
  ? `❌ ${failures.length} failures (grouped)`
2416
2349
  : `❌ ${failures.length} failures`,
2417
2350
  collapseLine: clusters.length < failures.length
2418
- ? clusters.length > visibleClusters.length
2419
- ? `Collapsed into ${clusters.length} real issues (showing top ${visibleClusters.length})`
2420
- : `Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
2351
+ ? `Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
2421
2352
  : null,
2422
- issues: visibleClusters.map((cluster) => buildClusterIssue(cluster, failures.length)),
2353
+ issues: mergedIssues,
2423
2354
  footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
2424
2355
  };
2425
2356
  };
@@ -11,6 +11,7 @@ declare class SentinelReporter {
11
11
  private totalCount;
12
12
  private startedAt;
13
13
  private options;
14
+ private sentinelMode;
14
15
  constructor(options: ReporterOptions);
15
16
  onBegin(config: any, suite: any): void;
16
17
  onTestEnd(test: any, result: any): Promise<void>;
package/dist/reporter.js CHANGED
@@ -3,14 +3,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  const node_1 = require("@sentinelqa/uploader/node");
6
- const node_fs_1 = __importDefault(require("node:fs"));
7
6
  const node_path_1 = __importDefault(require("node:path"));
7
+ const node_url_1 = require("node:url");
8
8
  const env_1 = require("./env");
9
9
  const quickDiagnosis_1 = require("./quickDiagnosis");
10
+ const localReport_1 = require("./localReport");
10
11
  const terminalSummary_1 = require("./terminalSummary");
11
12
  const runHistory_1 = require("./runHistory");
12
13
  const telemetry_1 = require("./telemetry");
13
14
  const { sentinelCaptureFailureContextFromReporter } = require("@sentinelqa/uploader/playwright");
15
+ const readSentinelMode = (env = process.env) => {
16
+ const raw = env.SENTINEL_MODE?.trim();
17
+ if (raw == null || raw === "")
18
+ return "hosted";
19
+ if (raw === "hosted" || raw === "offline")
20
+ return raw;
21
+ throw new Error([
22
+ "Invalid SENTINEL_MODE value: \"" + raw + "\".",
23
+ "Allowed values: hosted, offline.",
24
+ "Tip: remove SENTINEL_MODE to use the default hosted flow."
25
+ ].join(" "));
26
+ };
14
27
  const colorize = (value, code) => {
15
28
  if (!process.stdout.isTTY)
16
29
  return value;
@@ -150,6 +163,7 @@ class SentinelReporter {
150
163
  this.startedAt = Date.now();
151
164
  (0, env_1.loadSentinelEnv)();
152
165
  this.options = options;
166
+ this.sentinelMode = readSentinelMode(process.env);
153
167
  }
154
168
  onBegin(config, suite) {
155
169
  this.startedAt = Date.now();
@@ -178,6 +192,37 @@ class SentinelReporter {
178
192
  }
179
193
  }
180
194
  async onEnd() {
195
+ const offlineMode = this.sentinelMode === "offline";
196
+ const emitLocalReport = () => {
197
+ console.log(styleSecondary("Writing local debug report..."));
198
+ console.log("");
199
+ try {
200
+ const result = (0, localReport_1.generateLocalDebugReport)({
201
+ playwrightJsonPath: this.options.playwrightJsonPath,
202
+ playwrightReportDir: this.options.playwrightReportDir,
203
+ testResultsDir: this.options.testResultsDir,
204
+ artifactDirs: this.options.artifactDirs || [],
205
+ reportDir: process.env.SENTINEL_REPORT_DIR || undefined
206
+ });
207
+ const rel = node_path_1.default.relative(process.cwd(), result.htmlPath) || result.htmlPath;
208
+ const fileUrl = (0, node_url_1.pathToFileURL)(node_path_1.default.resolve(result.htmlPath)).toString();
209
+ console.log(styleAction("Local report ready"));
210
+ console.log(formatCliLine(`Next: ${rel}`));
211
+ console.log("");
212
+ console.log(styleAction("Shareable report:"));
213
+ console.log(` ${fileUrl}`);
214
+ }
215
+ catch (err) {
216
+ console.error(styleCritical("Sentinel: Local report generation failed."));
217
+ const message = err instanceof Error ? err.message : String(err);
218
+ if (message && message !== "[object Object]") {
219
+ console.error(styleSecondary(message));
220
+ }
221
+ if (process.env.SENTINEL_DEBUG && err instanceof Error && err.stack) {
222
+ console.error(styleSecondary(err.stack));
223
+ }
224
+ }
225
+ };
181
226
  const hasWorkspaceToken = Boolean(process.env.SENTINEL_TOKEN);
182
227
  const hasCiEnv = (0, node_1.hasSupportedCiEnv)(process.env);
183
228
  const localUploadEnabled = (0, node_1.isLocalUploadEnabled)(process.env);
@@ -185,7 +230,6 @@ class SentinelReporter {
185
230
  !hasCiEnv &&
186
231
  !localUploadEnabled;
187
232
  const quickDiagnosis = (0, quickDiagnosis_1.buildQuickDiagnosis)(this.options.playwrightJsonPath);
188
- const quickDiagnosisStructured = (0, quickDiagnosis_1.buildQuickDiagnosisStructured)(this.options.playwrightJsonPath);
189
233
  const isSetupFailureDiagnosis = Boolean(quickDiagnosis?.lines.some((line) => /^Issue(?:(?: \d+)?): Playwright setup error/.test(line)));
190
234
  const finalFailedCount = readFinalFailedCount(this.options.playwrightJsonPath);
191
235
  const effectiveFailedCount = typeof finalFailedCount === "number" ? finalFailedCount : this.failedCount;
@@ -220,6 +264,8 @@ class SentinelReporter {
220
264
  if (hasWorkspaceToken && !hasCiEnv && !localUploadEnabled) {
221
265
  console.log("Uploading debug report skipped");
222
266
  console.log("Set SENTINEL_UPLOAD_LOCAL=1 for local workspace uploads.");
267
+ console.log("");
268
+ emitLocalReport();
223
269
  return;
224
270
  }
225
271
  if (quickDiagnosis?.lines.length) {
@@ -260,19 +306,12 @@ class SentinelReporter {
260
306
  if (isSetupFailureDiagnosis) {
261
307
  return;
262
308
  }
309
+ if (offlineMode) {
310
+ emitLocalReport();
311
+ return;
312
+ }
263
313
  console.log(styleSecondary("Uploading debug report..."));
264
314
  console.log("");
265
- let structuredDiagnosisPath = null;
266
- if (quickDiagnosisStructured) {
267
- try {
268
- structuredDiagnosisPath = node_path_1.default.join(this.options.testResultsDir, "sentinel-structured-diagnosis.json");
269
- node_fs_1.default.mkdirSync(this.options.testResultsDir, { recursive: true });
270
- node_fs_1.default.writeFileSync(structuredDiagnosisPath, JSON.stringify(quickDiagnosisStructured, null, 2));
271
- }
272
- catch {
273
- structuredDiagnosisPath = null;
274
- }
275
- }
276
315
  const upload = (await (0, node_1.runSentinelUpload)({
277
316
  playwrightJsonPath: this.options.playwrightJsonPath,
278
317
  playwrightReportDir: this.options.playwrightReportDir,
@@ -282,11 +321,14 @@ class SentinelReporter {
282
321
  env: {
283
322
  SENTINEL_REPORTER_PROJECT: this.options.project || undefined,
284
323
  SENTINEL_REPORTER_SILENT: "1",
285
- SENTINEL_STRUCTURED_DIAGNOSIS_PATH: structuredDiagnosisPath || undefined,
286
324
  SENTINEL_UPLOAD_LOCAL: usingImplicitLocalPublicMode ? "1" : process.env.SENTINEL_UPLOAD_LOCAL
287
325
  }
288
326
  }));
289
327
  if (upload.exitCode !== 0) {
328
+ console.log(styleWarning("Hosted upload failed"));
329
+ console.log(styleSecondary("Falling back to a local report."));
330
+ console.log("");
331
+ emitLocalReport();
290
332
  return;
291
333
  }
292
334
  if (!quickDiagnosis?.lines.length && upload.diagnosis?.lines.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.54",
3
+ "version": "0.1.57",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "@playwright/test": ">=1.40.0"
40
40
  },
41
41
  "dependencies": {
42
- "@sentinelqa/uploader": "^0.1.38"
42
+ "@sentinelqa/uploader": "^0.1.35"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^20.19.32",