@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.
- package/README.md +116 -130
- package/dist/localReport.js +453 -3
- package/dist/mode.d.ts +2 -0
- package/dist/mode.js +16 -0
- package/dist/quickDiagnosis.js +92 -161
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +56 -14
- package/package.json +2 -2
package/dist/quickDiagnosis.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
2079
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
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
|
-
|
|
2108
|
-
failure.
|
|
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
|
-
|
|
2116
|
-
|
|
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
|
-
|
|
1986
|
+
failure.locator || failure.apiHint || "unknown-target",
|
|
2123
1987
|
failure.expected || "unknown-expected",
|
|
2124
|
-
failure.received ||
|
|
2125
|
-
|
|
1988
|
+
failure.received || "unknown-received",
|
|
1989
|
+
locationKey
|
|
2126
1990
|
].join("|");
|
|
2127
1991
|
}
|
|
2128
|
-
return `${failure.signal}|${
|
|
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) =>
|
|
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:
|
|
2305
|
-
failingStep:
|
|
2306
|
-
selector:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
2353
|
+
issues: mergedIssues,
|
|
2423
2354
|
footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
|
|
2424
2355
|
};
|
|
2425
2356
|
};
|
package/dist/reporter.d.ts
CHANGED
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.
|
|
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.
|
|
42
|
+
"@sentinelqa/uploader": "^0.1.35"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^20.19.32",
|