@sentinelqa/playwright-reporter 0.1.50 → 0.1.53

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 CHANGED
@@ -40,6 +40,46 @@ Debugging Playwright failures usually means downloading traces, screenshots, and
40
40
 
41
41
  Reporter uploads those artifacts into a single hosted Sentinel run page so you can open one link, inspect failures fast, and share that link with the rest of the team.
42
42
 
43
+ ## CLI diagnosis
44
+
45
+ Sentinel is not just a report link.
46
+
47
+ On failed runs, it prints a compact terminal diagnosis that answers:
48
+
49
+ 1. what broke
50
+ 2. why it broke
51
+ 3. where to look
52
+ 4. what changed
53
+ 5. what to do next
54
+
55
+ The goal is simple:
56
+
57
+ you should not need to open logs first.
58
+
59
+ Typical CLI output:
60
+
61
+ ```text
62
+ Sentinel diagnosis
63
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
64
+
65
+ NEW FAILURE after 8 passing runs
66
+
67
+ What broke: 10 tests failed
68
+ Why: collapsed into 2 real issues
69
+
70
+ Issue 1: UI assertion mismatch (6 tests)
71
+ Why: getByTestId('metric-pass-rate') showed "82%" instead of "88%"
72
+ Where: app.spec.ts:43, app.spec.ts:69
73
+ Expected: 88%
74
+ Received: 82%
75
+ What changed: "update analytics cards"
76
+ Next: verify getByTestId('metric-pass-rate')
77
+ Impact: 6 tests failing with same root cause
78
+ Clears: fixing this likely clears 6 of 10 failures
79
+ ```
80
+
81
+ On passed runs, Sentinel stays short and only prints a warning if there is a strong risk signal worth caring about.
82
+
43
83
  ## Why teams use the free version
44
84
 
45
85
  - Drop one wrapper into `playwright.config.ts` and keep running `npx playwright test`
@@ -1,5 +1,5 @@
1
1
  type DiagnosisSignal = "timeout" | "assertion_mismatch" | "locator_not_found" | "actionability" | "network" | "runtime" | "infra" | "unknown";
2
- type QuickDiagnosis = {
2
+ export type QuickDiagnosis = {
3
3
  lines: string[];
4
4
  footer?: string[];
5
5
  };
@@ -162,6 +162,23 @@ const extractFocusLineFromSnippet = (snippet) => {
162
162
  .replace(/^\s*\d+\s*\|\s*/, "")
163
163
  .trim() || null;
164
164
  };
165
+ const extractFocusLineFromMessage = (message) => {
166
+ if (!message)
167
+ return null;
168
+ const lines = stripAnsi(message).split(/\r?\n/);
169
+ const marked = lines.find((line) => /^\s*>\s*\d+\s*\|/.test(line));
170
+ if (marked) {
171
+ return marked
172
+ .replace(/^\s*>\s*/, "")
173
+ .replace(/^\s*\d+\s*\|\s*/, "")
174
+ .trim() || null;
175
+ }
176
+ const expectLine = lines.find((line) => /\b(await\s+)?expect\(/.test(line));
177
+ if (expectLine)
178
+ return expectLine.trim();
179
+ const stepLine = lines.find((line) => /\b(getBy|locator\(|page\.)/.test(line));
180
+ return stepLine?.trim() || null;
181
+ };
165
182
  const parseCommitLine = (line) => {
166
183
  const [sha, author, message] = line.split("\u001f");
167
184
  if (!sha)
@@ -208,12 +225,28 @@ const normalizeStatus = (status) => {
208
225
  return "passed";
209
226
  return "skipped";
210
227
  };
228
+ const errorRichness = (error) => {
229
+ if (!error)
230
+ return -1;
231
+ const message = String(error.message || error.stack || error.value || "");
232
+ let score = message.length;
233
+ if (error.location?.file)
234
+ score += 200;
235
+ if (/locator\.|Call log:|intercepts pointer events|waiting for|getBy|at .*:\d+:\d+/i.test(message))
236
+ score += 400;
237
+ if (/Test timeout of \d+ms exceeded\.$/i.test(stripAnsi(message).trim()))
238
+ score -= 300;
239
+ return score;
240
+ };
241
+ const selectBestError = (result) => {
242
+ const candidates = [result.error, ...(result.errors || [])].filter(Boolean);
243
+ if (!candidates.length)
244
+ return null;
245
+ return candidates.sort((a, b) => errorRichness(b) - errorRichness(a))[0] || null;
246
+ };
211
247
  const toMessage = (result) => {
212
- const direct = result.error?.message || result.error?.stack || result.error?.value || null;
213
- if (direct)
214
- return stripAnsi(String(direct));
215
- const first = result.errors?.find(Boolean);
216
- return first ? stripAnsi(String(first.message || first.stack || first.value || "")) : "";
248
+ const best = selectBestError(result);
249
+ return best ? stripAnsi(String(best.message || best.stack || best.value || "")) : "";
217
250
  };
218
251
  const classifySignal = (message) => {
219
252
  const lower = message.toLowerCase();
@@ -339,6 +372,56 @@ const describeDomState = (failure) => {
339
372
  }
340
373
  return null;
341
374
  };
375
+ const dominantValue = (values) => {
376
+ const counts = new Map();
377
+ for (const value of values) {
378
+ const normalized = (value || "").trim();
379
+ if (!normalized)
380
+ continue;
381
+ counts.set(normalized, (counts.get(normalized) || 0) + 1);
382
+ }
383
+ const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
384
+ if (!sorted.length)
385
+ return null;
386
+ return sorted[0][0];
387
+ };
388
+ const dominantFocusLine = (failures) => dominantValue(failures.map((failure) => failure.codeContext?.focusLine || null));
389
+ const timeoutState = (failure) => {
390
+ const dom = failure.domCapture;
391
+ if (!dom)
392
+ return "unknown";
393
+ if (dom.targetFound === false || dom.matchedCount === 0)
394
+ return "missing";
395
+ if (dom.visible === false)
396
+ return "hidden";
397
+ if (dom.enabled === false)
398
+ return "disabled";
399
+ if (dom.targetFound === true && dom.visible === true)
400
+ return "present";
401
+ return "unknown";
402
+ };
403
+ const sharedTimeoutEvidence = (failures) => {
404
+ const timeoutFailures = failures.filter((failure) => failure.signal === "timeout");
405
+ return {
406
+ action: dominantValue(timeoutFailures.map((failure) => failure.codeContext?.action || null)),
407
+ locator: dominantValue(timeoutFailures.map((failure) => failure.locator || null)),
408
+ state: dominantValue(timeoutFailures.map((failure) => timeoutState(failure))) || "unknown"
409
+ };
410
+ };
411
+ const timeoutStateLabel = (state) => {
412
+ switch (state) {
413
+ case "missing":
414
+ return "locator never appeared";
415
+ case "hidden":
416
+ return "found but hidden";
417
+ case "disabled":
418
+ return "found but disabled";
419
+ case "present":
420
+ return "found and visible before timeout";
421
+ default:
422
+ return null;
423
+ }
424
+ };
342
425
  const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
343
426
  const commitTouchedFailure = (commit, failure) => {
344
427
  const reasons = [];
@@ -455,8 +538,12 @@ const flattenFailedCases = (node, titlePath = []) => {
455
538
  timeoutBudgetMs: typeof test.timeout === "number" ? test.timeout : null,
456
539
  codeContext: loadCodeContext(finalResult),
457
540
  domCapture: loadDomCapture(finalResult),
458
- errorLocation: finalResult.error?.location || finalResult.errors?.find(Boolean)?.location || null,
459
- errorSnippet: finalResult.error?.snippet || null
541
+ errorLocation: selectBestError(finalResult)?.location ||
542
+ finalResult.error?.location ||
543
+ finalResult.errors?.find((item) => item?.location)?.location ||
544
+ test.location ||
545
+ null,
546
+ errorSnippet: selectBestError(finalResult)?.snippet || finalResult.error?.snippet || null
460
547
  }));
461
548
  }
462
549
  return failures;
@@ -546,13 +633,13 @@ const rootCauseLabel = (failure) => {
546
633
  };
547
634
  const describeFailure = (failure) => {
548
635
  if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
549
- return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}".`;
636
+ return `${compactLocator(failure.locator)} showed "${truncateValue(failure.received, 72)}" instead of "${truncateValue(failure.expected, 40)}".`;
550
637
  }
551
638
  if (failure.signal === "locator_not_found" && failure.locator) {
552
- return `${failure.locator} was not found when the test expected it to be available.`;
639
+ return `${compactLocator(failure.locator)} was not found when the test expected it to be available.`;
553
640
  }
554
641
  if (failure.signal === "actionability" && failure.locator) {
555
- return `${failure.locator} was found but was not actionable when the interaction ran.`;
642
+ return `${compactLocator(failure.locator)} was found but was not actionable when the interaction ran.`;
556
643
  }
557
644
  if (failure.signal === "network") {
558
645
  return failure.apiHint
@@ -593,7 +680,25 @@ const describeCluster = (cluster) => {
593
680
  return `${cluster.count} tests failed behind the same network or API signal.`;
594
681
  }
595
682
  if (cluster.count > 1 && failure.signal === "timeout") {
596
- return `${cluster.count} tests timed out behind the same blocked state transition.`;
683
+ const evidence = sharedTimeoutEvidence(cluster.failures);
684
+ const action = evidence.action ? evidence.action.trim() : "the expected interaction";
685
+ const locator = evidence.locator ? compactLocator(evidence.locator) : "the target element";
686
+ if (evidence.state === "missing") {
687
+ return `${cluster.count} tests timed out because ${locator} never appeared before ${action}.`;
688
+ }
689
+ if (evidence.state === "hidden") {
690
+ return `${cluster.count} tests timed out because ${locator} stayed hidden and blocked ${action}.`;
691
+ }
692
+ if (evidence.state === "disabled") {
693
+ return `${cluster.count} tests timed out because ${locator} stayed disabled and blocked ${action}.`;
694
+ }
695
+ if (evidence.state === "present" && evidence.action) {
696
+ return `${cluster.count} tests timed out because ${action} on ${locator} never completed even though the target was present.`;
697
+ }
698
+ if (evidence.action && evidence.locator) {
699
+ return `${cluster.count} tests timed out on the same ${action} step for ${locator}.`;
700
+ }
701
+ return `${cluster.count} tests timed out while waiting for the same UI state change to complete.`;
597
702
  }
598
703
  if (cluster.count > 1 && failure.signal === "infra") {
599
704
  return `${cluster.count} tests failed behind the same browser or CI instability signal.`;
@@ -610,8 +715,57 @@ const clusterCheckFirst = (cluster) => {
610
715
  }
611
716
  return "inspect the shared retry/flaky helper or intentional throw in these tests before opening the trace";
612
717
  }
718
+ if (failure.signal === "timeout") {
719
+ const evidence = sharedTimeoutEvidence(cluster.failures);
720
+ const locator = evidence.locator ? compactLocator(evidence.locator) : "the target element";
721
+ if (evidence.state === "missing") {
722
+ return `make ${locator} render before the blocked step`;
723
+ }
724
+ if (evidence.state === "hidden") {
725
+ return `remove the condition keeping ${locator} hidden before the action`;
726
+ }
727
+ if (evidence.state === "disabled") {
728
+ return `enable ${locator} before the action runs`;
729
+ }
730
+ if (evidence.state === "present" && evidence.action && evidence.locator) {
731
+ return `fix what blocks ${evidence.action} on ${compactLocator(evidence.locator)} (overlay or pointer interception is likely)`;
732
+ }
733
+ if (failure.codeContext?.focusLine) {
734
+ return `inspect the waiting assertion or step: ${failure.codeContext.focusLine.trim()}`;
735
+ }
736
+ if (failure.likelyFile) {
737
+ return `inspect the waiting assertion or blocked state transition in ${basename(failure.likelyFile)}`;
738
+ }
739
+ }
613
740
  return checkFirst(failure);
614
741
  };
742
+ const buildClusterEvidenceLines = (cluster) => {
743
+ const failure = cluster.sample;
744
+ const lines = [];
745
+ if (failure.signal === "timeout") {
746
+ const evidence = sharedTimeoutEvidence(cluster.failures);
747
+ const focusLine = dominantFocusLine(cluster.failures);
748
+ if (focusLine)
749
+ lines.push(`Failing code: ${focusLine.trim()}`);
750
+ if (evidence.action)
751
+ lines.push(`Failing step: ${evidence.action}`);
752
+ if (evidence.locator)
753
+ lines.push(`Selector: ${compactLocator(evidence.locator)}`);
754
+ const stateLabel = timeoutStateLabel(evidence.state);
755
+ if (stateLabel)
756
+ lines.push(`Target state: ${stateLabel}`);
757
+ return lines.slice(0, 4);
758
+ }
759
+ const focusLine = dominantFocusLine(cluster.failures);
760
+ if (focusLine)
761
+ lines.push(`Failing code: ${focusLine.trim()}`);
762
+ for (const line of buildSecondaryEvidenceLines(failure)) {
763
+ if (lines.includes(line))
764
+ continue;
765
+ lines.push(line);
766
+ }
767
+ return lines.slice(0, 4);
768
+ };
615
769
  const formatAffectedTests = (titles) => {
616
770
  const unique = Array.from(new Set(titles.map((title) => shortenTitle(title)))).slice(0, 3);
617
771
  if (!unique.length)
@@ -674,6 +828,18 @@ const buildEvidenceLines = (failure) => {
674
828
  return lines.slice(0, 4);
675
829
  };
676
830
  const withoutPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length).trim() : value;
831
+ const truncateValue = (value, max = 96) => {
832
+ if (!value)
833
+ return null;
834
+ const compact = value.replace(/\s+/g, " ").trim();
835
+ if (compact.length <= max)
836
+ return compact;
837
+ return `${compact.slice(0, max - 1)}…`;
838
+ };
839
+ const compactLocator = (value) => {
840
+ const compact = truncateValue(value, 40);
841
+ return compact || "target element";
842
+ };
677
843
  const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
678
844
  .filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
679
845
  .slice(0, 3);
@@ -704,6 +870,65 @@ const compactRootCauseSummary = (cluster) => {
704
870
  }
705
871
  return describeCluster(cluster);
706
872
  };
873
+ const compactIssueTitle = (cluster) => {
874
+ const failure = cluster.sample;
875
+ const locator = compactLocator(failure.locator);
876
+ switch (failure.signal) {
877
+ case "assertion_mismatch":
878
+ return failure.locator ? `Assertion mismatch (${locator})` : "Assertion mismatch";
879
+ case "locator_not_found":
880
+ return failure.locator ? `Missing locator (${locator})` : "Missing locator";
881
+ case "actionability":
882
+ return failure.locator ? `Blocked interaction (${locator})` : "Blocked interaction";
883
+ case "network":
884
+ return failure.apiHint ? `Network/API failure (${truncateValue(failure.apiHint, 28)})` : "Network/API failure";
885
+ case "timeout":
886
+ return failure.locator ? `Timeout waiting on ${locator}` : "Timeout waiting for state change";
887
+ case "runtime":
888
+ return /retry|flaky/i.test(failure.message) ? "Test-side throw" : "Runtime error";
889
+ case "infra":
890
+ return "Browser/CI instability";
891
+ default:
892
+ return rootCauseLabel(failure);
893
+ }
894
+ };
895
+ const clusterCauseLine = (cluster) => {
896
+ const failure = cluster.sample;
897
+ if (failure.signal === "assertion_mismatch") {
898
+ return failure.locator
899
+ ? `Same assertion mismatch on ${compactLocator(failure.locator)}`
900
+ : "Same assertion mismatch across these tests";
901
+ }
902
+ if (failure.signal === "locator_not_found") {
903
+ return failure.locator
904
+ ? `${compactLocator(failure.locator)} is missing or changed in each failure`
905
+ : "Same missing or changed locator across these tests";
906
+ }
907
+ if (failure.signal === "actionability") {
908
+ return failure.locator
909
+ ? `${compactLocator(failure.locator)} is present but blocked in each failure`
910
+ : "Same actionability problem across these tests";
911
+ }
912
+ return compactRootCauseSummary(cluster);
913
+ };
914
+ const strongerClusterNext = (cluster) => {
915
+ const failure = cluster.sample;
916
+ if (failure.signal === "assertion_mismatch") {
917
+ if (failure.locator && failure.apiHint) {
918
+ return `check ${compactLocator(failure.locator)} or the data returned by ${truncateValue(failure.apiHint, 36)}`;
919
+ }
920
+ if (failure.locator) {
921
+ return `check ${compactLocator(failure.locator)} or the data source behind it`;
922
+ }
923
+ }
924
+ if (failure.signal === "locator_not_found" && failure.locator) {
925
+ return `check whether ${compactLocator(failure.locator)} changed or no longer renders`;
926
+ }
927
+ if (failure.signal === "actionability" && failure.locator) {
928
+ return `check what blocks ${compactLocator(failure.locator)} from becoming actionable`;
929
+ }
930
+ return clusterCheckFirst(cluster);
931
+ };
707
932
  const compactErrorLine = (failure) => {
708
933
  if (!failure.firstErrorLine)
709
934
  return null;
@@ -736,10 +961,29 @@ const parseFailureFacts = (title, titlePath, message, status, file = null, optio
736
961
  file: options.codeContext.file || fallbackCodeContext?.file || null,
737
962
  line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
738
963
  column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
739
- focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || null,
964
+ focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || extractFocusLineFromMessage(message) || null,
740
965
  found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
741
966
  }
742
- : fallbackCodeContext || null;
967
+ : fallbackCodeContext
968
+ ? {
969
+ ...fallbackCodeContext,
970
+ focusLine: fallbackCodeContext.focusLine || extractFocusLineFromMessage(message) || null
971
+ }
972
+ : {
973
+ file: fallbackLocation?.file || file || null,
974
+ line: typeof fallbackLocation?.line === "number" ? fallbackLocation.line : null,
975
+ column: typeof fallbackLocation?.column === "number" ? fallbackLocation.column : null,
976
+ action: null,
977
+ locator: null,
978
+ expectedText: null,
979
+ timeoutMs: null,
980
+ apiCall: null,
981
+ assertion: null,
982
+ methodName: null,
983
+ focusLine: extractFocusLineFromMessage(message),
984
+ previousActionLine: null,
985
+ found: Boolean(fallbackLocation?.file || extractFocusLineFromMessage(message))
986
+ };
743
987
  const domCapture = options?.domCapture || null;
744
988
  const locator = extractLocator(message) || codeContext?.locator || domCapture?.locator || null;
745
989
  const expected = extractExpected(message) || codeContext?.expectedText || domCapture?.expectedText || null;
@@ -805,6 +1049,27 @@ const buildSimilarityKey = (failure) => {
805
1049
  locationKey
806
1050
  ].join('|');
807
1051
  }
1052
+ if (failure.signal === "assertion_mismatch") {
1053
+ return [
1054
+ failure.signal,
1055
+ failure.locator || failure.likelyModule || basename(failure.likelyFile) || "unknown-target",
1056
+ basename(failure.likelyFile) || "unknown-file"
1057
+ ].join("|");
1058
+ }
1059
+ if (failure.signal === "timeout" || failure.signal === "actionability" || failure.signal === "locator_not_found") {
1060
+ return [
1061
+ failure.signal,
1062
+ failure.locator || failure.codeContext?.action || failure.likelyModule || "unknown-target",
1063
+ basename(failure.likelyFile) || "unknown-file"
1064
+ ].join("|");
1065
+ }
1066
+ if (failure.signal === "network") {
1067
+ return [
1068
+ failure.signal,
1069
+ failure.apiHint || failure.likelyModule || "unknown-api",
1070
+ basename(failure.likelyFile) || "unknown-file"
1071
+ ].join("|");
1072
+ }
808
1073
  if (failure.locator || failure.expected || failure.received || failure.apiHint) {
809
1074
  return [
810
1075
  failure.signal,
@@ -860,31 +1125,29 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
860
1125
  const failed = failures[0];
861
1126
  const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
862
1127
  const top = suspects[0];
863
- const alt = suspects[1];
864
- const lines = [`${formatTitle(failed.title)} failed.`, `What broke: ${(0, exports.describeFailure)(failed)}`];
1128
+ const lines = [`What broke: ${shortenTitle(failed.title)}`, `Why: ${(0, exports.describeFailure)(failed)}`];
865
1129
  const primaryLocation = buildLocationLine(failed);
1130
+ const confidence = top ? confidenceLabel(top.score).toLowerCase() : "medium";
866
1131
  if (primaryLocation)
867
1132
  lines.push(`Where: ${withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")}`);
868
- if (failed.firstErrorLine)
869
- lines.push(`Error: ${failed.firstErrorLine}`);
870
- for (const evidence of buildSecondaryEvidenceLines(failed))
871
- lines.push(evidence);
872
- if (top) {
873
- lines.push(compactCommitLine(top));
874
- lines.push(`Why Sentinel picked it: ${compactWhyLine(top)}.`);
875
- if (top.touchedFiles.length) {
876
- lines.push(`Touched files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
1133
+ if (failed.codeContext?.action)
1134
+ lines.push(`Failing step: ${failed.codeContext.action}`);
1135
+ if (failed.expected)
1136
+ lines.push(`Expected: ${truncateValue(failed.expected)}`);
1137
+ if (failed.received)
1138
+ lines.push(`Received: ${truncateValue(failed.received)}`);
1139
+ if (top && top.score >= 0.62) {
1140
+ lines.push(`What changed: "${top.commit.message}"`);
1141
+ if (top.reasons.length) {
1142
+ lines.push(`Reason: ${compactWhyLine(top)}.`);
877
1143
  }
878
- if (alt)
879
- lines.push(alternateCommitLine(alt));
880
1144
  }
881
- lines.push(`Check first: ${checkFirst(failed)}.`);
1145
+ lines.push(`Confidence: ${confidence}`);
1146
+ lines.push("Next:");
1147
+ lines.push(`- ${checkFirst(failed)}`);
882
1148
  return {
883
1149
  lines,
884
- footer: [
885
- ...(top ? [`Confidence: ${confidenceLabel(top.score)}`] : []),
886
- ...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
887
- ]
1150
+ footer: []
888
1151
  };
889
1152
  }
890
1153
  const clusterMap = new Map();
@@ -900,14 +1163,17 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
900
1163
  count: clusterFailures.length,
901
1164
  sample: clusterFailures[0],
902
1165
  titles: clusterFailures.map((item) => item.title),
903
- suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : []
1166
+ suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : [],
1167
+ failures: clusterFailures
904
1168
  }))
905
- .sort((a, b) => b.count - a.count)
1169
+ .sort((a, b) => b.count - a.count ||
1170
+ ((b.suspects[0]?.score || 0) - (a.suspects[0]?.score || 0)) ||
1171
+ (b.sample.signal === "assertion_mismatch" ? 1 : 0) - (a.sample.signal === "assertion_mismatch" ? 1 : 0))
906
1172
  .slice(0, 2);
907
1173
  const topCluster = clusters[0];
908
1174
  const lines = [
909
- `${failures.length} tests failed.`,
910
- `Collapsed into ${clusters.length} likely root cause${clusters.length === 1 ? "" : "s"}:`
1175
+ `${failures.length} tests failed`,
1176
+ `Collapsed into ${clusters.length} real issue${clusters.length === 1 ? "" : "s"}`
911
1177
  ];
912
1178
  for (const [index, cluster] of clusters.entries()) {
913
1179
  const clusterFailures = clusterMap.get(cluster.key) || [cluster.sample];
@@ -916,29 +1182,36 @@ const buildQuickDiagnosis = (playwrightJsonPath) => {
916
1182
  const locationValue = clusterLocation
917
1183
  ? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
918
1184
  : null;
919
- const rootCause = compactRootCauseSummary(cluster);
920
- const errorLine = compactErrorLine(cluster.sample);
921
- lines.push(`[${index + 1}] ${rootCauseLabel(cluster.sample)} (${cluster.count} tests)`);
922
- lines.push(` Root cause: ${rootCause}`);
923
- if (errorLine) {
924
- lines.push(` Error: ${errorLine}`);
925
- }
1185
+ const rootCause = cluster.count === 1 ? (0, exports.describeFailure)(cluster.sample) : clusterCauseLine(cluster);
1186
+ lines.push(`Issue ${index + 1}: ${compactIssueTitle(cluster)} (${cluster.count} test${cluster.count === 1 ? "" : "s"})`);
1187
+ lines.push(` Cause: ${rootCause}`);
926
1188
  if (locationValue) {
927
- lines.push(` Where: ${locationValue}`);
1189
+ lines.push(` Where: ${locationValue}`);
1190
+ }
1191
+ for (const evidenceLine of buildClusterEvidenceLines(cluster)) {
1192
+ lines.push(` ${evidenceLine}`);
928
1193
  }
929
- if (top && top.touchedFiles.length) {
930
- lines.push(` Changed files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
1194
+ if (cluster.sample.expected) {
1195
+ lines.push(` Expected: ${truncateValue(cluster.sample.expected)}`);
1196
+ }
1197
+ if (cluster.sample.received) {
1198
+ lines.push(` Received: ${truncateValue(cluster.sample.received)}`);
1199
+ }
1200
+ if (top && top.score >= 0.62) {
1201
+ lines.push(` What changed: "${top.commit.message}"`);
1202
+ if (top.reasons.length) {
1203
+ lines.push(` Reason: ${compactWhyLine(top)}.`);
1204
+ }
931
1205
  }
932
- lines.push(` Check first: ${clusterCheckFirst(cluster)}.`);
1206
+ lines.push(` Next: ${strongerClusterNext(cluster)}.`);
1207
+ lines.push(` Impact: ${cluster.count} test${cluster.count === 1 ? "" : "s"} failing with ${cluster.count === 1 ? "this" : "same"} root cause`);
1208
+ lines.push(` Clears: fixing this likely clears ${cluster.count} of ${failures.length} failures`);
1209
+ if (index < clusters.length - 1)
1210
+ lines.push("");
933
1211
  }
934
1212
  return {
935
1213
  lines,
936
- footer: topCluster
937
- ? [
938
- ...(topCluster.suspects[0] ? [`Top confidence: ${confidenceLabel(topCluster.suspects[0].score)}`] : []),
939
- ...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
940
- ]
941
- : []
1214
+ footer: topCluster?.suspects[0] ? [`Confidence: ${confidenceLabel(topCluster.suspects[0].score).toLowerCase()}`] : []
942
1215
  };
943
1216
  };
944
1217
  exports.buildQuickDiagnosis = buildQuickDiagnosis;
package/dist/reporter.js CHANGED
@@ -11,9 +11,104 @@ const colorize = (value, code) => {
11
11
  return value;
12
12
  return `\u001b[${code}m${value}\u001b[0m`;
13
13
  };
14
- const green = (value) => colorize(value, "32");
15
- const yellow = (value) => colorize(value, "33");
16
- const dim = (value) => colorize(value, "2");
14
+ const styleCritical = (value) => colorize(value, "1;31");
15
+ const styleWarning = (value) => colorize(value, "1;33");
16
+ const styleAction = (value) => colorize(value, "1;36");
17
+ const stylePrimary = (value) => colorize(value, "1;97");
18
+ const styleSecondary = (value) => colorize(value, "2");
19
+ const divider = () => "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
20
+ const LABEL_WIDTH = 10;
21
+ const renderRow = (label, value, valueStyle, indent = "") => {
22
+ const paddedLabel = `${label}:`.padEnd(LABEL_WIDTH);
23
+ const styledValue = valueStyle ? valueStyle(value) : value;
24
+ return `${indent}${stylePrimary(paddedLabel)} ${styledValue}`;
25
+ };
26
+ const highlightCounts = (value) => value
27
+ .replace(/\b(\d+\s+tests?\s+failed)\b/gi, (_, match) => styleCritical(match))
28
+ .replace(/\b(\d+\s+real\s+issues?)\b/gi, (_, match) => styleWarning(match))
29
+ .replace(/\b(\d+\s+tests?)\b/gi, (_, match) => styleWarning(match))
30
+ .replace(/\b(\d+\s+previous\s+runs?)\b/gi, (_, match) => styleSecondary(match))
31
+ .replace(/\b(\d+\s+passing\s+runs?)\b/gi, (_, match) => styleSecondary(match))
32
+ .replace(/\b(\d+\s+newly\s+failing)\b/gi, (_, match) => styleWarning(match))
33
+ .replace(/\b(\d+\s+tests?\s+still\s+failing)\b/gi, (_, match) => styleWarning(match));
34
+ const styleIssueTitle = (value) => value.replace(/\((\d+\s+tests?)\)/i, (_, match) => `(${styleWarning(match)})`);
35
+ const styleWhereValue = (value) => value.replace(/\b([A-Za-z0-9_.-]+\.[cm]?[jt]sx?:\d+(?::\d+)?)\b/g, (_, match) => stylePrimary(match));
36
+ const formatCliLine = (line) => {
37
+ if (!line.trim())
38
+ return line;
39
+ if (line === divider())
40
+ return styleSecondary(line);
41
+ if (/^NEW FAILURE/.test(line))
42
+ return styleCritical(line);
43
+ if (/^RECURRING FAILURE/.test(line)) {
44
+ return line.replace(/RECURRING FAILURE/, styleWarning("RECURRING FAILURE")).replace(/\(([^)]+)\)/, (_, inner) => `(${styleSecondary(inner)})`);
45
+ }
46
+ if (/^All tests passed$/.test(line))
47
+ return stylePrimary(line);
48
+ if (/^\d+\s+tests?\s+failed$/.test(line))
49
+ return styleCritical(line);
50
+ if (/^Collapsed into \d+\s+real issue/.test(line))
51
+ return highlightCounts(line);
52
+ if (/^\d+\s+tests?\s+in\s+/.test(line))
53
+ return line;
54
+ if (/^Issue \d+:/.test(line)) {
55
+ const [head, rest] = line.split(": ", 2);
56
+ return `${stylePrimary(head)}: ${styleIssueTitle(rest || "")}`;
57
+ }
58
+ const rowMatch = line.match(/^(\s*)(What broke|Why|Cause|Where|What changed|Next|Expected|Received|Confidence|Impact|At risk|Why this matters|Recommendation|Last failure|Status|Artifacts ready|Failing step|Failing code|Selector|Target state|Clears|Report):\s*(.*)$/);
59
+ if (!rowMatch)
60
+ return line;
61
+ const [, indent, label, rawValue] = rowMatch;
62
+ const value = highlightCounts(rawValue);
63
+ switch (label) {
64
+ case "What broke":
65
+ return renderRow(label, value, undefined, indent);
66
+ case "Why":
67
+ return renderRow(label, value, undefined, indent);
68
+ case "Cause":
69
+ return renderRow(label, value, undefined, indent);
70
+ case "Where":
71
+ return renderRow(label, rawValue, styleWhereValue, indent);
72
+ case "What changed":
73
+ return renderRow(label, rawValue, styleWarning, indent);
74
+ case "Next":
75
+ return renderRow(label, rawValue, styleAction, indent);
76
+ case "Report":
77
+ return renderRow(label, rawValue, styleAction, indent);
78
+ case "Expected":
79
+ return renderRow(label, rawValue, stylePrimary, indent);
80
+ case "Received":
81
+ return renderRow(label, rawValue, styleCritical, indent);
82
+ case "Confidence":
83
+ return renderRow(label, rawValue, /high/i.test(rawValue) ? styleAction : /medium/i.test(rawValue) ? styleWarning : styleSecondary, indent);
84
+ case "Impact":
85
+ return renderRow(label, highlightCounts(rawValue), undefined, indent);
86
+ case "At risk":
87
+ return renderRow(label, rawValue, styleWarning, indent);
88
+ case "Why this matters":
89
+ return renderRow(label, rawValue, undefined, indent);
90
+ case "Recommendation":
91
+ return renderRow(label, rawValue, styleAction, indent);
92
+ case "Last failure":
93
+ return renderRow(label, rawValue, styleSecondary, indent);
94
+ case "Status":
95
+ return renderRow(label, rawValue, styleSecondary, indent);
96
+ case "Artifacts ready":
97
+ return renderRow(label, rawValue, styleSecondary, indent);
98
+ case "Failing step":
99
+ return renderRow(label, rawValue, stylePrimary, indent);
100
+ case "Failing code":
101
+ return renderRow(label, rawValue, stylePrimary, indent);
102
+ case "Selector":
103
+ return renderRow(label, rawValue, stylePrimary, indent);
104
+ case "Target state":
105
+ return renderRow(label, rawValue, styleWarning, indent);
106
+ case "Clears":
107
+ return renderRow(label, highlightCounts(rawValue), styleWarning, indent);
108
+ default:
109
+ return line;
110
+ }
111
+ };
17
112
  const readFinalFailedCount = (playwrightJsonPath) => {
18
113
  try {
19
114
  const parsed = JSON.parse(require("node:fs").readFileSync(playwrightJsonPath, "utf8"));
@@ -93,22 +188,15 @@ class SentinelReporter {
93
188
  }
94
189
  console.log("");
95
190
  if (passingSummary) {
96
- console.log(green("Sentinel run summary"));
191
+ console.log(stylePrimary("Sentinel run summary"));
192
+ console.log(styleSecondary(divider()));
193
+ console.log("");
97
194
  for (const line of passingSummary.lines) {
98
- console.log(` ${line}`);
99
- }
100
- if (passingSummary.risks.length > 0) {
101
- console.log("");
102
- console.log(yellow("Potential risks"));
103
- for (const line of passingSummary.risks) {
104
- console.log(` ${line}`);
105
- }
106
- }
107
- if (!hasWorkspaceToken) {
108
- console.log("");
109
- console.log("Activate a free workspace at sentinelqa.com/register");
195
+ console.log(formatCliLine(line));
110
196
  }
111
197
  console.log("");
198
+ console.log(divider());
199
+ console.log("");
112
200
  }
113
201
  if (effectiveFailedCount > 0) {
114
202
  (0, telemetry_1.emitFailedRunTelemetry)();
@@ -118,31 +206,38 @@ class SentinelReporter {
118
206
  return;
119
207
  }
120
208
  if (hasWorkspaceToken && !hasCiEnv && !localUploadEnabled) {
121
- console.log([
122
- "Sentinel: Upload skipped.",
123
- "Reason: Local workspace uploads require SENTINEL_UPLOAD_LOCAL=1.",
124
- "",
125
- "Next step:",
126
- "- Set SENTINEL_UPLOAD_LOCAL=1 to allow a local workspace upload.",
127
- "- Or remove SENTINEL_TOKEN to generate a free public hosted report instead."
128
- ].join("\n"));
209
+ console.log("Uploading debug report skipped");
210
+ console.log("Set SENTINEL_UPLOAD_LOCAL=1 for local workspace uploads.");
129
211
  return;
130
212
  }
131
- if (hasWorkspaceToken) {
213
+ if (quickDiagnosis?.lines.length) {
214
+ console.log(stylePrimary("Sentinel diagnosis"));
215
+ console.log(styleSecondary(divider()));
132
216
  console.log("");
133
- console.log(green("✔ Artifacts collected"));
134
- }
135
- console.log("");
136
- if (hasWorkspaceToken) {
137
- console.log("Uploading hosted debugging report to Sentinel...");
138
- }
139
- else if (usingImplicitLocalPublicMode) {
140
- console.log("Creating free hosted debug report with Sentinel...");
141
- console.log(dim("No API key detected. Using the free public report flow for this local run."));
142
- }
143
- else {
144
- console.log("Creating free hosted debug report with Sentinel...");
217
+ if (failedRunHistory?.passStreakBeforeFailure && failedRunHistory.passStreakBeforeFailure > 0) {
218
+ console.log(formatCliLine(`NEW FAILURE after ${failedRunHistory.passStreakBeforeFailure} passing runs`));
219
+ console.log("");
220
+ }
221
+ else if ((failedRunHistory?.recurringCount || 0) > 0) {
222
+ console.log(formatCliLine(`RECURRING FAILURE (${failedRunHistory?.recurringCount} previous runs)`));
223
+ console.log("");
224
+ }
225
+ for (const line of quickDiagnosis.lines) {
226
+ console.log(formatCliLine(line));
227
+ }
228
+ console.log("");
229
+ console.log(styleSecondary(divider()));
230
+ console.log("");
231
+ if (failedRunHistory?.newFailures && failedRunHistory.newFailures > 0) {
232
+ console.log(formatCliLine(`Impact: ${failedRunHistory.newFailures} newly failing in this run`));
233
+ console.log("");
234
+ }
235
+ else if (failedRunHistory?.stillFailing && failedRunHistory.stillFailing > 0) {
236
+ console.log(formatCliLine(`Impact: ${failedRunHistory.stillFailing} tests still failing`));
237
+ console.log("");
238
+ }
145
239
  }
240
+ console.log(styleSecondary("Uploading debug report..."));
146
241
  console.log("");
147
242
  const upload = (await (0, node_1.runSentinelUpload)({
148
243
  playwrightJsonPath: this.options.playwrightJsonPath,
@@ -159,42 +254,21 @@ class SentinelReporter {
159
254
  if (upload.exitCode !== 0) {
160
255
  throw new Error(`Sentinel upload failed with exit code ${upload.exitCode}`);
161
256
  }
162
- const backendReady = upload.diagnosis?.status === "ready" && Boolean(upload.diagnosis?.lines?.length);
163
- const diagnosis = backendReady ? upload.diagnosis : quickDiagnosis;
164
- if (diagnosis?.lines.length) {
257
+ if (!quickDiagnosis?.lines.length && upload.diagnosis?.lines.length) {
258
+ console.log(stylePrimary("Sentinel diagnosis"));
259
+ console.log(styleSecondary(divider()));
165
260
  console.log("");
166
- console.log(yellow("Sentinel diagnosis"));
167
- for (const line of diagnosis.lines) {
168
- console.log(` ${dim(line)}`);
169
- }
170
- if (diagnosis.footer?.length) {
171
- console.log("");
172
- for (const line of diagnosis.footer) {
173
- console.log(` ${dim(line)}`);
174
- }
261
+ for (const line of upload.diagnosis.lines) {
262
+ console.log(formatCliLine(line));
175
263
  }
176
- }
177
- const historyLines = failedRunHistory?.lines || [];
178
- const normalizedHistory = backendReady
179
- ? historyLines.filter((line) => !line.includes("Seen before:"))
180
- : historyLines;
181
- if (normalizedHistory.length) {
182
264
  console.log("");
183
- console.log(yellow("Run context"));
184
- for (const line of normalizedHistory) {
185
- console.log(` ${dim(line)}`);
186
- }
265
+ console.log(styleSecondary(divider()));
187
266
  }
188
267
  console.log("");
189
- console.log("View full debug report");
190
- console.log(` ${upload.shareRunUrl || upload.internalRunUrl}`);
268
+ console.log(styleAction("Debug report ready"));
269
+ console.log(formatCliLine(`Next: ${upload.shareRunUrl || upload.internalRunUrl}`));
191
270
  if (upload.shareLabel) {
192
- console.log(` ${dim(upload.shareLabel)}`);
193
- }
194
- if (!hasWorkspaceToken) {
195
- console.log("");
196
- console.log("Create a free workspace to keep reports private, compare runs, and unlock deeper AI debugging");
197
- console.log(` ${dim("https://sentinelqa.com/register")}`);
271
+ console.log(` ${styleSecondary(upload.shareLabel)}`);
198
272
  }
199
273
  }
200
274
  }
@@ -5,6 +5,13 @@ type RunDiffSummary = {
5
5
  };
6
6
  export type FailedRunHistorySummary = {
7
7
  lines: string[];
8
+ passStreakBeforeFailure: number;
9
+ previousWasGreen: boolean;
10
+ newFailures: number;
11
+ fixedTests: number;
12
+ stillFailing: number;
13
+ recurringCount: number;
14
+ recurringTitle: string | null;
8
15
  };
9
16
  export declare const buildRunDiffSummary: (playwrightJsonPath: string) => RunDiffSummary | null;
10
17
  export declare const buildFailedRunHistorySummary: (playwrightJsonPath: string) => FailedRunHistorySummary | null;
@@ -221,6 +221,17 @@ const buildFailedRunHistorySummary = (playwrightJsonPath) => {
221
221
  if (topRecurring) {
222
222
  lines.push(`- Recurring across ${topRecurring.occurrences + 1} recorded failed runs in local history (${topRecurring.failure.title})`);
223
223
  }
224
- return lines.length ? { lines } : null;
224
+ return lines.length
225
+ ? {
226
+ lines,
227
+ passStreakBeforeFailure,
228
+ previousWasGreen: Boolean(previousRun && (previousRun.failedCount || 0) === 0),
229
+ newFailures,
230
+ fixedTests,
231
+ stillFailing,
232
+ recurringCount: topRecurring ? topRecurring.occurrences : 0,
233
+ recurringTitle: topRecurring?.failure.title || null
234
+ }
235
+ : null;
225
236
  };
226
237
  exports.buildFailedRunHistorySummary = buildFailedRunHistorySummary;
@@ -21,7 +21,6 @@ type HistorySnapshot = {
21
21
  };
22
22
  export type PassingRunSummary = {
23
23
  lines: string[];
24
- risks: string[];
25
24
  };
26
25
  type PassingRunSummaryOptions = {
27
26
  observedRunDurationMs?: number | null;
@@ -73,6 +73,37 @@ const medianAbsoluteDeviation = (values) => {
73
73
  return null;
74
74
  return median(values.map((value) => Math.abs(value - med)));
75
75
  };
76
+ const getPrimaryRiskKind = (candidate) => {
77
+ if (candidate.primaryReason.startsWith("failed in "))
78
+ return "historical_failures";
79
+ if (candidate.primaryReason.startsWith("passed after "))
80
+ return "retry";
81
+ if (candidate.primaryReason.startsWith("needed retries in "))
82
+ return "historical_retries";
83
+ if (candidate.primaryReason.startsWith("took "))
84
+ return "slowdown";
85
+ if (candidate.primaryReason.startsWith("has been unusually slow in "))
86
+ return "repeated_slow";
87
+ if (candidate.primaryReason.startsWith("used "))
88
+ return "timeout_pressure";
89
+ if (candidate.primaryReason.startsWith("is trending slower"))
90
+ return "trend";
91
+ if (candidate.retries > 0)
92
+ return "retry";
93
+ if (candidate.historicalRetries >= 2)
94
+ return "historical_retries";
95
+ if (candidate.ratio !== null && candidate.ratio >= 1.8)
96
+ return "slowdown";
97
+ if (candidate.repeatedSlowPasses >= 3)
98
+ return "repeated_slow";
99
+ if (candidate.recentTrendRatio !== null && candidate.recentTrendRatio >= 1.5)
100
+ return "trend";
101
+ if (candidate.timeoutUtilization !== null && candidate.timeoutUtilization >= 0.85)
102
+ return "timeout_pressure";
103
+ if (candidate.historicalFailures >= 5)
104
+ return "historical_failures";
105
+ return "generic";
106
+ };
76
107
  const cleanTitlePath = (parts) => {
77
108
  const normalized = parts.map((part) => part.trim()).filter(Boolean);
78
109
  const withoutUnnamed = normalized.filter((part) => part !== "Unnamed test");
@@ -297,16 +328,16 @@ const buildPassingRunSummary = (playwrightJsonPath, options) => {
297
328
  ? `passed after ${test.retries} retr${test.retries === 1 ? 'y' : 'ies'} in this run`
298
329
  : historicalRetries >= 2
299
330
  ? `needed retries in ${historicalRetries} of the last 10 passing runs`
300
- : recentFailurePressure
301
- ? `failed in ${historicalFailures} of the last 10 runs`
302
- : ratio && ratio >= 1.8
303
- ? `took ${formatShortDuration(test.durationMs)} vs ${formatShortDuration(med)} recent median (${ratio.toFixed(1)}x)`
304
- : repeatedSlowPasses >= 2
305
- ? `has been unusually slow in ${repeatedSlowPasses} of the last 10 passing runs`
306
- : timeoutUtilization && timeoutUtilization >= 0.85
307
- ? `used ${(timeoutUtilization * 100).toFixed(0)}% of its timeout budget`
308
- : recentTrendRatio && recentTrendRatio >= 1.5
309
- ? `is trending slower over recent runs`
331
+ : ratio && ratio >= 1.8
332
+ ? `took ${formatShortDuration(test.durationMs)} vs ${formatShortDuration(med)} recent median (${ratio.toFixed(1)}x)`
333
+ : repeatedSlowPasses >= 2
334
+ ? `has been unusually slow in ${repeatedSlowPasses} of the last 10 passing runs`
335
+ : timeoutUtilization && timeoutUtilization >= 0.85
336
+ ? `used ${(timeoutUtilization * 100).toFixed(0)}% of its timeout budget`
337
+ : recentTrendRatio && recentTrendRatio >= 1.5
338
+ ? `is trending slower over recent runs`
339
+ : recentFailurePressure
340
+ ? `failed in ${historicalFailures} of the last 10 runs`
310
341
  : `has weak instability signals`;
311
342
  if (score > 0) {
312
343
  nearFailureCandidates.push({
@@ -343,56 +374,50 @@ const buildPassingRunSummary = (playwrightJsonPath, options) => {
343
374
  const hasActiveRisk = flakyLookingCount > 0;
344
375
  const totalDurationMs = snapshot.tests.reduce((sum, test) => sum + test.durationMs, 0);
345
376
  const displayedRunDurationMs = options?.observedRunDurationMs || snapshot.wallDurationMs || totalDurationMs;
346
- const lines = [
347
- `- ${snapshot.passedCount} tests passed in ${formatDuration(displayedRunDurationMs)} of wall time`
348
- ];
349
- if (hasActiveRisk) {
350
- if (retryPassedCount > 0)
351
- lines.push(`- Retries detected: ${retryPassedCount}`);
352
- lines.push(`- Flaky-looking tests: ${flakyLookingCount}`);
353
- if (topNearFailures[0]) {
354
- lines.push(`- Most at risk next: ${topNearFailures[0].title} (${topNearFailures[0].primaryReason})`);
377
+ const lines = ["All tests passed", `${snapshot.passedCount} tests in ${formatDuration(displayedRunDurationMs)}`];
378
+ if (hasActiveRisk && topNearFailures[0]) {
379
+ const riskKind = getPrimaryRiskKind(topNearFailures[0]);
380
+ lines.push(`At risk: ${topNearFailures[0].title} ${topNearFailures[0].primaryReason}`);
381
+ if (riskKind === "retry" || riskKind === "historical_retries") {
382
+ lines.push("Why this matters: Similar retry patterns often turn into flaky failures");
355
383
  }
356
- if (passStreak > 1)
357
- lines.push(`- Pass streak: ${passStreak} runs`);
358
- if (lastFailureRunsAgo !== null)
359
- lines.push(`- Last failure: ${lastFailureRunsAgo} runs ago`);
360
- }
361
- else {
362
- lines.push(`- Pipeline health: Stable`);
363
- if (passStreak > 1)
364
- lines.push(`- Pass streak: ${passStreak} runs`);
365
- if (lastFailureRunsAgo !== null)
366
- lines.push(`- Last failure: ${lastFailureRunsAgo} runs ago`);
367
- }
368
- lines.push(`- Sentinel active: traces, screenshots, video enabled`);
369
- const risks = [];
370
- for (const candidate of topNearFailures) {
371
- if (candidate.ratio && candidate.ratio >= 1.8) {
372
- risks.push(`- ${candidate.title} took ${formatShortDuration(candidate.currentDurationMs)} vs ${formatShortDuration(candidate.medianDurationMs)} recent median (${candidate.ratio.toFixed(1)}x)`);
384
+ else if (riskKind === "slowdown" || riskKind === "repeated_slow" || riskKind === "trend") {
385
+ lines.push("Why this matters: Performance regressions often lead to flaky failures");
373
386
  }
374
- if (candidate.retries > 0) {
375
- risks.push(`- ${candidate.title} passed after ${candidate.retries} retr${candidate.retries === 1 ? 'y' : 'ies'}`);
387
+ else if (riskKind === "historical_failures") {
388
+ lines.push("Why this matters: This test has been failing repeatedly and is likely to regress again");
376
389
  }
377
- if (candidate.historicalRetries >= 2) {
378
- risks.push(`- ${candidate.title} has needed retries in ${candidate.historicalRetries} of the last 10 passing runs`);
390
+ else if (riskKind === "timeout_pressure") {
391
+ lines.push("Why this matters: Tests that run close to their timeout budget often become flaky");
379
392
  }
380
- if (candidate.repeatedSlowPasses >= 3) {
381
- risks.push(`- ${candidate.title} has been unusually slow in ${candidate.repeatedSlowPasses} of the last 10 passing runs`);
393
+ else {
394
+ lines.push("Why this matters: This instability pattern often turns into a future failure");
382
395
  }
383
- if (candidate.recentTrendRatio && candidate.recentTrendRatio >= 1.5) {
384
- risks.push(`- ${candidate.title} is trending slower over recent runs`);
396
+ if (lastFailureRunsAgo !== null)
397
+ lines.push(`Last failure: ${lastFailureRunsAgo} runs ago`);
398
+ if (riskKind === "retry" || riskKind === "historical_retries") {
399
+ lines.push("Recommendation: inspect retry behavior or timing around this test");
400
+ }
401
+ else if (riskKind === "slowdown" || riskKind === "repeated_slow" || riskKind === "trend") {
402
+ lines.push("Recommendation: monitor the next runs or investigate the slowdown");
403
+ }
404
+ else if (riskKind === "historical_failures") {
405
+ lines.push("Recommendation: rerun this test locally and inspect the last failing behavior");
385
406
  }
386
- if (candidate.timeoutUtilization && candidate.timeoutUtilization >= 0.65) {
387
- risks.push(`- ${candidate.title} used ${(candidate.timeoutUtilization * 100).toFixed(0)}% of its timeout budget`);
407
+ else if (riskKind === "timeout_pressure") {
408
+ lines.push("Recommendation: inspect timeout pressure or waiting logic in this test");
388
409
  }
389
- if (candidate.historicalFailures >= 5 &&
390
- lastFailureRunsAgo !== null &&
391
- lastFailureRunsAgo <= 3 &&
392
- (candidate.currentRunSignal || candidate.historicalRetries >= 2 || candidate.repeatedSlowPasses >= 3)) {
393
- risks.push(`- ${candidate.title} failed in ${candidate.historicalFailures} of the last 10 runs`);
410
+ else {
411
+ lines.push("Recommendation: monitor the next runs and inspect this test if the pattern repeats");
394
412
  }
395
413
  }
396
- return { lines, risks: Array.from(new Set(risks)).slice(0, 4) };
414
+ else {
415
+ lines.push("No anomalies detected");
416
+ if (lastFailureRunsAgo !== null)
417
+ lines.push(`Last failure: ${lastFailureRunsAgo} runs ago`);
418
+ lines.push("Status: Stable");
419
+ }
420
+ lines.push("Artifacts ready: traces, screenshots, video");
421
+ return { lines };
397
422
  };
398
423
  exports.buildPassingRunSummary = buildPassingRunSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.50",
3
+ "version": "0.1.53",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",