@sentinelqa/playwright-reporter 0.1.53 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,11 +1,29 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  const node_1 = require("@sentinelqa/uploader/node");
6
+ const node_path_1 = __importDefault(require("node:path"));
7
+ const node_url_1 = require("node:url");
3
8
  const env_1 = require("./env");
4
9
  const quickDiagnosis_1 = require("./quickDiagnosis");
10
+ const localReport_1 = require("./localReport");
5
11
  const terminalSummary_1 = require("./terminalSummary");
6
12
  const runHistory_1 = require("./runHistory");
7
13
  const telemetry_1 = require("./telemetry");
8
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
+ };
9
27
  const colorize = (value, code) => {
10
28
  if (!process.stdout.isTTY)
11
29
  return value;
@@ -15,9 +33,10 @@ const styleCritical = (value) => colorize(value, "1;31");
15
33
  const styleWarning = (value) => colorize(value, "1;33");
16
34
  const styleAction = (value) => colorize(value, "1;36");
17
35
  const stylePrimary = (value) => colorize(value, "1;97");
36
+ const styleImportant = (value) => colorize(value, "97");
18
37
  const styleSecondary = (value) => colorize(value, "2");
19
38
  const divider = () => "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
20
- const LABEL_WIDTH = 10;
39
+ const LABEL_WIDTH = 19;
21
40
  const renderRow = (label, value, valueStyle, indent = "") => {
22
41
  const paddedLabel = `${label}:`.padEnd(LABEL_WIDTH);
23
42
  const styledValue = valueStyle ? valueStyle(value) : value;
@@ -31,8 +50,8 @@ const highlightCounts = (value) => value
31
50
  .replace(/\b(\d+\s+passing\s+runs?)\b/gi, (_, match) => styleSecondary(match))
32
51
  .replace(/\b(\d+\s+newly\s+failing)\b/gi, (_, match) => styleWarning(match))
33
52
  .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));
53
+ const styleIssueTitle = (value) => stylePrimary(value.replace(/\((\d+\s+tests?)\)/i, (_, match) => `(${styleWarning(match)})`));
54
+ const styleWhereValue = (value) => value.replace(/\b([A-Za-z0-9_.-]+\.[cm]?[jt]sx?:\d+(?::\d+)?)\b/g, (_, match) => styleImportant(match));
36
55
  const formatCliLine = (line) => {
37
56
  if (!line.trim())
38
57
  return line;
@@ -45,17 +64,19 @@ const formatCliLine = (line) => {
45
64
  }
46
65
  if (/^All tests passed$/.test(line))
47
66
  return stylePrimary(line);
67
+ if (/^❌\s+\d+\s+failures?/.test(line))
68
+ return styleCritical(line);
48
69
  if (/^\d+\s+tests?\s+failed$/.test(line))
49
70
  return styleCritical(line);
50
71
  if (/^Collapsed into \d+\s+real issue/.test(line))
51
72
  return highlightCounts(line);
52
73
  if (/^\d+\s+tests?\s+in\s+/.test(line))
53
74
  return line;
54
- if (/^Issue \d+:/.test(line)) {
75
+ if (/^Issue(?:(?: \d+)?):/.test(line)) {
55
76
  const [head, rest] = line.split(": ", 2);
56
77
  return `${stylePrimary(head)}: ${styleIssueTitle(rest || "")}`;
57
78
  }
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*(.*)$/);
79
+ const rowMatch = line.match(/^(\s*)(What broke|Why|Cause|Where|What changed|Changes since last run|Next|Likely fix|Expected|Received|Confidence|Impact|At risk|Why this matters|Recommendation|Last failure|Status|Artifacts ready|Failing step|Selector|Target state|Report|Blocker):\s*(.*)$/);
59
80
  if (!rowMatch)
60
81
  return line;
61
82
  const [, indent, label, rawValue] = rowMatch;
@@ -66,17 +87,19 @@ const formatCliLine = (line) => {
66
87
  case "Why":
67
88
  return renderRow(label, value, undefined, indent);
68
89
  case "Cause":
69
- return renderRow(label, value, undefined, indent);
90
+ return renderRow(label, rawValue, undefined, indent);
70
91
  case "Where":
71
92
  return renderRow(label, rawValue, styleWhereValue, indent);
72
93
  case "What changed":
94
+ case "Changes since last run":
73
95
  return renderRow(label, rawValue, styleWarning, indent);
74
96
  case "Next":
97
+ case "Likely fix":
75
98
  return renderRow(label, rawValue, styleAction, indent);
76
99
  case "Report":
77
100
  return renderRow(label, rawValue, styleAction, indent);
78
101
  case "Expected":
79
- return renderRow(label, rawValue, stylePrimary, indent);
102
+ return renderRow(label, rawValue, styleImportant, indent);
80
103
  case "Received":
81
104
  return renderRow(label, rawValue, styleCritical, indent);
82
105
  case "Confidence":
@@ -96,15 +119,13 @@ const formatCliLine = (line) => {
96
119
  case "Artifacts ready":
97
120
  return renderRow(label, rawValue, styleSecondary, indent);
98
121
  case "Failing step":
99
- return renderRow(label, rawValue, stylePrimary, indent);
100
- case "Failing code":
101
- return renderRow(label, rawValue, stylePrimary, indent);
122
+ return renderRow(label, rawValue, styleImportant, indent);
102
123
  case "Selector":
103
- return renderRow(label, rawValue, stylePrimary, indent);
124
+ return renderRow(label, rawValue, styleImportant, indent);
125
+ case "Blocker":
126
+ return renderRow(label, rawValue, styleCritical, indent);
104
127
  case "Target state":
105
128
  return renderRow(label, rawValue, styleWarning, indent);
106
- case "Clears":
107
- return renderRow(label, highlightCounts(rawValue), styleWarning, indent);
108
129
  default:
109
130
  return line;
110
131
  }
@@ -142,6 +163,7 @@ class SentinelReporter {
142
163
  this.startedAt = Date.now();
143
164
  (0, env_1.loadSentinelEnv)();
144
165
  this.options = options;
166
+ this.sentinelMode = readSentinelMode(process.env);
145
167
  }
146
168
  onBegin(config, suite) {
147
169
  this.startedAt = Date.now();
@@ -170,6 +192,37 @@ class SentinelReporter {
170
192
  }
171
193
  }
172
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
+ };
173
226
  const hasWorkspaceToken = Boolean(process.env.SENTINEL_TOKEN);
174
227
  const hasCiEnv = (0, node_1.hasSupportedCiEnv)(process.env);
175
228
  const localUploadEnabled = (0, node_1.isLocalUploadEnabled)(process.env);
@@ -177,12 +230,15 @@ class SentinelReporter {
177
230
  !hasCiEnv &&
178
231
  !localUploadEnabled;
179
232
  const quickDiagnosis = (0, quickDiagnosis_1.buildQuickDiagnosis)(this.options.playwrightJsonPath);
233
+ const isSetupFailureDiagnosis = Boolean(quickDiagnosis?.lines.some((line) => /^Issue(?:(?: \d+)?): Playwright setup error/.test(line)));
180
234
  const finalFailedCount = readFinalFailedCount(this.options.playwrightJsonPath);
181
235
  const effectiveFailedCount = typeof finalFailedCount === "number" ? finalFailedCount : this.failedCount;
182
236
  const passingSummary = effectiveFailedCount === 0
183
237
  ? (0, terminalSummary_1.buildPassingRunSummary)(this.options.playwrightJsonPath, { observedRunDurationMs: Date.now() - this.startedAt })
184
238
  : null;
185
- const failedRunHistory = effectiveFailedCount > 0 ? (0, runHistory_1.buildFailedRunHistorySummary)(this.options.playwrightJsonPath) : null;
239
+ const failedRunHistory = effectiveFailedCount > 0 && !isSetupFailureDiagnosis
240
+ ? (0, runHistory_1.buildFailedRunHistorySummary)(this.options.playwrightJsonPath)
241
+ : null;
186
242
  if (effectiveFailedCount > 0) {
187
243
  (0, terminalSummary_1.recordReporterHistorySnapshot)(this.options.playwrightJsonPath);
188
244
  }
@@ -208,35 +264,52 @@ class SentinelReporter {
208
264
  if (hasWorkspaceToken && !hasCiEnv && !localUploadEnabled) {
209
265
  console.log("Uploading debug report skipped");
210
266
  console.log("Set SENTINEL_UPLOAD_LOCAL=1 for local workspace uploads.");
267
+ console.log("");
268
+ emitLocalReport();
211
269
  return;
212
270
  }
213
271
  if (quickDiagnosis?.lines.length) {
214
272
  console.log(stylePrimary("Sentinel diagnosis"));
215
273
  console.log(styleSecondary(divider()));
216
274
  console.log("");
217
- if (failedRunHistory?.passStreakBeforeFailure && failedRunHistory.passStreakBeforeFailure > 0) {
275
+ if (!isSetupFailureDiagnosis && failedRunHistory?.passStreakBeforeFailure && failedRunHistory.passStreakBeforeFailure > 0) {
218
276
  console.log(formatCliLine(`NEW FAILURE after ${failedRunHistory.passStreakBeforeFailure} passing runs`));
219
277
  console.log("");
220
278
  }
221
- else if ((failedRunHistory?.recurringCount || 0) > 0) {
279
+ else if (!isSetupFailureDiagnosis && (failedRunHistory?.recurringCount || 0) > 0) {
222
280
  console.log(formatCliLine(`RECURRING FAILURE (${failedRunHistory?.recurringCount} previous runs)`));
281
+ if (failedRunHistory?.isDominantRecurringIssue && failedRunHistory?.dominantRecurringIssueTitle) {
282
+ console.log(formatCliLine(`Impact: ${failedRunHistory.dominantRecurringIssueTitle} is the most common recent failure in local history`));
283
+ }
223
284
  console.log("");
224
285
  }
225
286
  for (const line of quickDiagnosis.lines) {
226
287
  console.log(formatCliLine(line));
227
288
  }
228
289
  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`));
290
+ if (!isSetupFailureDiagnosis) {
291
+ console.log(styleSecondary(divider()));
237
292
  console.log("");
293
+ if (failedRunHistory?.newFailures && failedRunHistory.newFailures > 0) {
294
+ console.log(formatCliLine(`Impact: ${failedRunHistory.newFailures} newly failing in this run`));
295
+ console.log("");
296
+ }
297
+ else if (failedRunHistory?.stillFailing && failedRunHistory.stillFailing > 0) {
298
+ console.log(formatCliLine(`Impact: ${failedRunHistory.stillFailing} tests still failing`));
299
+ if (failedRunHistory?.isDominantRecurringIssue && failedRunHistory?.dominantRecurringIssueCount > 0) {
300
+ console.log(formatCliLine(`Impact: Seen in ${failedRunHistory.dominantRecurringIssueCount} recorded failed runs`));
301
+ }
302
+ console.log("");
303
+ }
238
304
  }
239
305
  }
306
+ if (isSetupFailureDiagnosis) {
307
+ return;
308
+ }
309
+ if (offlineMode) {
310
+ emitLocalReport();
311
+ return;
312
+ }
240
313
  console.log(styleSecondary("Uploading debug report..."));
241
314
  console.log("");
242
315
  const upload = (await (0, node_1.runSentinelUpload)({
@@ -252,7 +325,11 @@ class SentinelReporter {
252
325
  }
253
326
  }));
254
327
  if (upload.exitCode !== 0) {
255
- throw new Error(`Sentinel upload failed with exit code ${upload.exitCode}`);
328
+ console.log(styleWarning("Hosted upload failed"));
329
+ console.log(styleSecondary("Falling back to a local report."));
330
+ console.log("");
331
+ emitLocalReport();
332
+ return;
256
333
  }
257
334
  if (!quickDiagnosis?.lines.length && upload.diagnosis?.lines.length) {
258
335
  console.log(stylePrimary("Sentinel diagnosis"));
@@ -12,6 +12,9 @@ export type FailedRunHistorySummary = {
12
12
  stillFailing: number;
13
13
  recurringCount: number;
14
14
  recurringTitle: string | null;
15
+ dominantRecurringIssueTitle: string | null;
16
+ dominantRecurringIssueCount: number;
17
+ isDominantRecurringIssue: boolean;
15
18
  };
16
19
  export declare const buildRunDiffSummary: (playwrightJsonPath: string) => RunDiffSummary | null;
17
20
  export declare const buildFailedRunHistorySummary: (playwrightJsonPath: string) => FailedRunHistorySummary | null;
@@ -204,6 +204,10 @@ const buildFailedRunHistorySummary = (playwrightJsonPath) => {
204
204
  }))
205
205
  .sort((a, b) => b.occurrences - a.occurrences);
206
206
  const topRecurring = recurringFailures.find((item) => item.occurrences > 0) || null;
207
+ const topRecurringTotal = topRecurring ? topRecurring.occurrences + 1 : 0;
208
+ const isDominantRecurringIssue = Boolean(topRecurring &&
209
+ topRecurringTotal >= 3 &&
210
+ topRecurringTotal >= Math.ceil(Math.max(failingReporterRuns.length, 1) / 2));
207
211
  writeSnapshot(snapshot);
208
212
  const lines = [];
209
213
  if (passStreakBeforeFailure > 0) {
@@ -220,6 +224,9 @@ const buildFailedRunHistorySummary = (playwrightJsonPath) => {
220
224
  }
221
225
  if (topRecurring) {
222
226
  lines.push(`- Recurring across ${topRecurring.occurrences + 1} recorded failed runs in local history (${topRecurring.failure.title})`);
227
+ if (isDominantRecurringIssue) {
228
+ lines.push(`- This is the most common recent failure in local history`);
229
+ }
223
230
  }
224
231
  return lines.length
225
232
  ? {
@@ -230,7 +237,10 @@ const buildFailedRunHistorySummary = (playwrightJsonPath) => {
230
237
  fixedTests,
231
238
  stillFailing,
232
239
  recurringCount: topRecurring ? topRecurring.occurrences : 0,
233
- recurringTitle: topRecurring?.failure.title || null
240
+ recurringTitle: topRecurring?.failure.title || null,
241
+ dominantRecurringIssueTitle: topRecurring?.failure.title || null,
242
+ dominantRecurringIssueCount: topRecurringTotal,
243
+ isDominantRecurringIssue
234
244
  }
235
245
  : null;
236
246
  };
@@ -104,6 +104,25 @@ const getPrimaryRiskKind = (candidate) => {
104
104
  return "historical_failures";
105
105
  return "generic";
106
106
  };
107
+ const isStrongPassRisk = (candidate, lastFailureRunsAgo) => {
108
+ if (candidate.retries > 0)
109
+ return true;
110
+ if (candidate.ratio !== null && candidate.ratio >= 2.0)
111
+ return true;
112
+ if (candidate.repeatedSlowPasses >= 3)
113
+ return true;
114
+ if (candidate.timeoutUtilization !== null && candidate.timeoutUtilization >= 0.9)
115
+ return true;
116
+ if (candidate.historicalRetries >= 2)
117
+ return true;
118
+ if (candidate.historicalFailures >= 6 &&
119
+ lastFailureRunsAgo !== null &&
120
+ lastFailureRunsAgo <= 2 &&
121
+ (candidate.ratio !== null || candidate.historicalRetries > 0 || candidate.repeatedSlowPasses > 0)) {
122
+ return true;
123
+ }
124
+ return false;
125
+ };
107
126
  const cleanTitlePath = (parts) => {
108
127
  const normalized = parts.map((part) => part.trim()).filter(Boolean);
109
128
  const withoutUnnamed = normalized.filter((part) => part !== "Unnamed test");
@@ -339,6 +358,22 @@ const buildPassingRunSummary = (playwrightJsonPath, options) => {
339
358
  : recentFailurePressure
340
359
  ? `failed in ${historicalFailures} of the last 10 runs`
341
360
  : `has weak instability signals`;
361
+ const riskKind = getPrimaryRiskKind({
362
+ title: test.title,
363
+ ratio,
364
+ recentTrendRatio,
365
+ retries: test.retries,
366
+ historicalRetries,
367
+ historicalFailures,
368
+ repeatedSlowPasses,
369
+ timeoutUtilization,
370
+ medianDurationMs: med,
371
+ currentDurationMs: test.durationMs,
372
+ score,
373
+ strongSignal,
374
+ currentRunSignal,
375
+ primaryReason
376
+ });
342
377
  if (score > 0) {
343
378
  nearFailureCandidates.push({
344
379
  title: test.title,
@@ -354,7 +389,8 @@ const buildPassingRunSummary = (playwrightJsonPath, options) => {
354
389
  score,
355
390
  strongSignal,
356
391
  currentRunSignal,
357
- primaryReason
392
+ primaryReason,
393
+ riskKind
358
394
  });
359
395
  }
360
396
  }
@@ -364,20 +400,16 @@ const buildPassingRunSummary = (playwrightJsonPath, options) => {
364
400
  (right.timeoutUtilization || 0) - (left.timeoutUtilization || 0) ||
365
401
  (right.ratio || 0) - (left.ratio || 0));
366
402
  const strongNearFailures = nearFailureCandidates.filter((candidate) => candidate.strongSignal &&
367
- (candidate.currentRunSignal ||
368
- candidate.historicalRetries >= 2 ||
369
- candidate.repeatedSlowPasses >= 3 ||
370
- (candidate.recentTrendRatio !== null && candidate.recentTrendRatio >= 1.5)) &&
371
- candidate.score >= 3);
372
- const topNearFailures = strongNearFailures.slice(0, 2);
373
- const flakyLookingCount = strongNearFailures.length;
374
- const hasActiveRisk = flakyLookingCount > 0;
403
+ candidate.score >= 3 &&
404
+ isStrongPassRisk(candidate, lastFailureRunsAgo));
405
+ const topRisk = strongNearFailures[0] || null;
406
+ const hasActiveRisk = Boolean(topRisk);
375
407
  const totalDurationMs = snapshot.tests.reduce((sum, test) => sum + test.durationMs, 0);
376
408
  const displayedRunDurationMs = options?.observedRunDurationMs || snapshot.wallDurationMs || totalDurationMs;
377
409
  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}`);
410
+ if (hasActiveRisk && topRisk) {
411
+ const riskKind = topRisk.riskKind;
412
+ lines.push(`At risk: ${topRisk.title} ${topRisk.primaryReason}`);
381
413
  if (riskKind === "retry" || riskKind === "historical_retries") {
382
414
  lines.push("Why this matters: Similar retry patterns often turn into flaky failures");
383
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.53",
3
+ "version": "0.1.56",
4
4
  "private": false,
5
5
  "description": "Playwright reporter for CI debugging with optional Sentinel cloud dashboards",
6
6
  "license": "MIT",