@sentinelqa/playwright-reporter 0.1.47 → 0.1.50

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.
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.buildPassingRunSummary = void 0;
6
+ exports.buildPassingRunSummary = exports.recordReporterHistorySnapshot = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
@@ -39,7 +39,7 @@ const getCurrentGitSha = () => {
39
39
  const normalizeStatus = (status) => {
40
40
  if (status === "failed" || status === "timedOut" || status === "interrupted")
41
41
  return "failed";
42
- if (status === "passed")
42
+ if (status === "passed" || status === "flaky")
43
43
  return "passed";
44
44
  return "skipped";
45
45
  };
@@ -51,6 +51,13 @@ const formatDuration = (durationMs) => {
51
51
  return `${seconds}s`;
52
52
  return `${minutes}m ${seconds}s`;
53
53
  };
54
+ const formatShortDuration = (durationMs) => {
55
+ if (durationMs === null || !Number.isFinite(durationMs))
56
+ return null;
57
+ if (durationMs < 1000)
58
+ return `${Math.round(durationMs)}ms`;
59
+ return `${(durationMs / 1000).toFixed(durationMs >= 10000 ? 0 : 1)}s`;
60
+ };
54
61
  const median = (values) => {
55
62
  if (values.length === 0)
56
63
  return null;
@@ -60,7 +67,50 @@ const median = (values) => {
60
67
  return (sorted[mid - 1] + sorted[mid]) / 2;
61
68
  return sorted[mid];
62
69
  };
63
- const buildMatchKey = (file, projectName, titlePath) => [file || "unknown", projectName || "default", titlePath.join(" > ")].join("::");
70
+ const medianAbsoluteDeviation = (values) => {
71
+ const med = median(values);
72
+ if (med === null)
73
+ return null;
74
+ return median(values.map((value) => Math.abs(value - med)));
75
+ };
76
+ const cleanTitlePath = (parts) => {
77
+ const normalized = parts.map((part) => part.trim()).filter(Boolean);
78
+ const withoutUnnamed = normalized.filter((part) => part !== "Unnamed test");
79
+ return withoutUnnamed.length ? withoutUnnamed : normalized;
80
+ };
81
+ const formatHistoryTitle = (value) => {
82
+ if (!value)
83
+ return null;
84
+ return cleanTitlePath(value.split(" > ")).join(" > ") || value;
85
+ };
86
+ const buildMatchKey = (file, projectName, titlePath) => [file || "unknown", projectName || "default", cleanTitlePath(titlePath).join(" > ")].join("::");
87
+ const collectWallDurationMs = (node) => {
88
+ let minStart = Number.POSITIVE_INFINITY;
89
+ let maxEnd = 0;
90
+ const walk = (current) => {
91
+ if (!current)
92
+ return;
93
+ for (const child of current.suites || [])
94
+ walk(child);
95
+ for (const child of current.specs || [])
96
+ walk(child);
97
+ for (const test of current.tests || []) {
98
+ for (const result of test.results || []) {
99
+ if (!result?.startTime || typeof result.duration !== 'number')
100
+ continue;
101
+ const start = Date.parse(result.startTime);
102
+ if (!Number.isFinite(start))
103
+ continue;
104
+ minStart = Math.min(minStart, start);
105
+ maxEnd = Math.max(maxEnd, start + result.duration);
106
+ }
107
+ }
108
+ };
109
+ walk(node);
110
+ if (!Number.isFinite(minStart) || maxEnd <= minStart)
111
+ return null;
112
+ return maxEnd - minStart;
113
+ };
64
114
  const collectTests = (node, ancestors = []) => {
65
115
  const currentTitlePath = node.title ? [...ancestors, node.title] : ancestors;
66
116
  const tests = [];
@@ -71,17 +121,15 @@ const collectTests = (node, ancestors = []) => {
71
121
  const specTests = Array.isArray(node.tests) ? node.tests : [];
72
122
  for (const test of specTests) {
73
123
  const results = Array.isArray(test.results) ? test.results : [];
74
- let finalStatus = normalizeStatus(test.results?.[test.results.length - 1]?.status);
75
- let durationMs = 0;
124
+ const finalStatus = normalizeStatus(test.results?.[test.results.length - 1]?.status);
125
+ const finalDurationMs = typeof results[results.length - 1]?.duration === "number" ? results[results.length - 1].duration : 0;
126
+ let durationMs = finalDurationMs;
76
127
  let retries = 0;
77
128
  for (const result of results) {
78
- durationMs += typeof result.duration === "number" ? result.duration : 0;
79
129
  if (typeof result.retry === "number")
80
130
  retries = Math.max(retries, result.retry);
81
- if (normalizeStatus(result.status) === "failed")
82
- finalStatus = "failed";
83
131
  }
84
- const titlePath = [...currentTitlePath, test.title || "Unnamed test"].filter(Boolean);
132
+ const titlePath = cleanTitlePath([...currentTitlePath, test.title || "Unnamed test"]);
85
133
  const file = node.file || node.location?.file || test.location?.file || "";
86
134
  const projectName = test.projectName || "";
87
135
  tests.push({
@@ -89,7 +137,8 @@ const collectTests = (node, ancestors = []) => {
89
137
  title: titlePath.join(" > "),
90
138
  status: finalStatus,
91
139
  durationMs,
92
- retries
140
+ retries,
141
+ timeoutMs: typeof test.timeout === "number" ? test.timeout : null
93
142
  });
94
143
  }
95
144
  return tests;
@@ -106,6 +155,7 @@ const buildSnapshot = (playwrightJsonPath) => {
106
155
  generatedAt: new Date().toISOString(),
107
156
  branch: getCurrentBranch(),
108
157
  gitSha: getCurrentGitSha(),
158
+ wallDurationMs: collectWallDurationMs(parsed),
109
159
  totalTests: tests.length,
110
160
  passedCount,
111
161
  failedCount: failedTests.length,
@@ -132,17 +182,24 @@ const writeSnapshot = (snapshot) => {
132
182
  const fileName = `${snapshot.generatedAt.replace(/[:.]/g, "-")}-${snapshot.gitSha}.json`;
133
183
  node_fs_1.default.writeFileSync(node_path_1.default.resolve(process.cwd(), HISTORY_DIR, fileName), JSON.stringify(snapshot, null, 2), "utf8");
134
184
  };
135
- const buildPassingRunSummary = (playwrightJsonPath) => {
185
+ const recordReporterHistorySnapshot = (playwrightJsonPath) => {
186
+ const snapshot = buildSnapshot(playwrightJsonPath);
187
+ if (!snapshot)
188
+ return null;
189
+ writeSnapshot(snapshot);
190
+ return snapshot;
191
+ };
192
+ exports.recordReporterHistorySnapshot = recordReporterHistorySnapshot;
193
+ const buildPassingRunSummary = (playwrightJsonPath, options) => {
136
194
  const snapshot = buildSnapshot(playwrightJsonPath);
137
195
  if (!snapshot)
138
196
  return null;
139
197
  const previousRuns = listSnapshots(snapshot.branch);
140
198
  writeSnapshot(snapshot);
141
- if (snapshot.failedCount > 0)
199
+ if (snapshot.failedCount > 0 || snapshot.totalTests === 0 || snapshot.passedCount === 0)
142
200
  return null;
143
201
  let passStreak = 1;
144
202
  let lastFailureRunsAgo = null;
145
- let lastFailureTitle = null;
146
203
  for (let i = 0; i < previousRuns.length; i += 1) {
147
204
  const previous = previousRuns[i];
148
205
  if (previous.failedCount === 0) {
@@ -150,11 +207,12 @@ const buildPassingRunSummary = (playwrightJsonPath) => {
150
207
  continue;
151
208
  }
152
209
  lastFailureRunsAgo = i + 1;
153
- lastFailureTitle = previous.failures[0] || null;
154
210
  break;
155
211
  }
156
212
  const retryPassedCount = snapshot.retryPassedCount;
157
213
  const durationSamples = new Map();
214
+ const recentDurationSamples = new Map();
215
+ const slowPassCounts = new Map();
158
216
  for (const previous of previousRuns.slice(0, 20)) {
159
217
  for (const test of previous.tests || []) {
160
218
  if (test.status !== "passed" || test.durationMs <= 0)
@@ -162,48 +220,179 @@ const buildPassingRunSummary = (playwrightJsonPath) => {
162
220
  const bucket = durationSamples.get(test.matchKey) || [];
163
221
  bucket.push(test.durationMs);
164
222
  durationSamples.set(test.matchKey, bucket);
223
+ if (durationSamples.get(test.matchKey).length <= 5) {
224
+ const recentBucket = recentDurationSamples.get(test.matchKey) || [];
225
+ recentBucket.push(test.durationMs);
226
+ recentDurationSamples.set(test.matchKey, recentBucket);
227
+ }
165
228
  }
166
229
  }
167
230
  let slowestRisk = null;
231
+ const historicalRetryCounts = new Map();
232
+ const historicalFailureCounts = new Map();
233
+ for (const previous of previousRuns.slice(0, 10)) {
234
+ for (const test of previous.tests || []) {
235
+ if (test.status === "passed" && test.retries > 0) {
236
+ historicalRetryCounts.set(test.matchKey, (historicalRetryCounts.get(test.matchKey) || 0) + 1);
237
+ }
238
+ if (test.status === "failed") {
239
+ historicalFailureCounts.set(test.matchKey, (historicalFailureCounts.get(test.matchKey) || 0) + 1);
240
+ }
241
+ if (test.status === "passed" && test.durationMs > 0) {
242
+ const all = durationSamples.get(test.matchKey) || [];
243
+ const historicalMedian = median(all);
244
+ if (historicalMedian && test.durationMs / historicalMedian >= 1.35) {
245
+ slowPassCounts.set(test.matchKey, (slowPassCounts.get(test.matchKey) || 0) + 1);
246
+ }
247
+ }
248
+ }
249
+ }
250
+ const nearFailureCandidates = [];
168
251
  for (const test of snapshot.tests) {
169
252
  if (test.status !== "passed" || test.durationMs <= 0)
170
253
  continue;
171
254
  const samples = durationSamples.get(test.matchKey) || [];
172
255
  const med = median(samples);
173
- if (!med || med < 1000)
174
- continue;
175
- const ratio = test.durationMs / med;
176
- if (ratio < 1.8)
177
- continue;
178
- if (!slowestRisk || ratio > slowestRisk.ratio) {
256
+ const variability = medianAbsoluteDeviation(samples);
257
+ const recentMedian = median(recentDurationSamples.get(test.matchKey) || []);
258
+ const hasReliableMedian = samples.length >= 5 && Boolean(med) && (med || 0) >= 250;
259
+ const hasReliableRecentMedian = (recentDurationSamples.get(test.matchKey) || []).length >= 3 && Boolean(recentMedian) && Boolean(med) && (med || 0) >= 250;
260
+ const varianceFloor = Math.max(400, variability ? variability * 3 : 400);
261
+ const absoluteDelta = hasReliableMedian && med ? test.durationMs - med : null;
262
+ const ratio = hasReliableMedian && med && absoluteDelta !== null && absoluteDelta >= varianceFloor ? test.durationMs / med : null;
263
+ const recentTrendDelta = hasReliableRecentMedian && recentMedian && med ? recentMedian - med : null;
264
+ const recentTrendRatio = hasReliableRecentMedian &&
265
+ recentMedian &&
266
+ med &&
267
+ recentTrendDelta !== null &&
268
+ recentTrendDelta >= varianceFloor
269
+ ? recentMedian / med
270
+ : null;
271
+ if (ratio && ratio >= 1.8 && (!slowestRisk || ratio > slowestRisk.ratio)) {
179
272
  slowestRisk = { title: test.title, ratio };
180
273
  }
274
+ const historicalRetries = historicalRetryCounts.get(test.matchKey) || 0;
275
+ const historicalFailures = historicalFailureCounts.get(test.matchKey) || 0;
276
+ const repeatedSlowPasses = slowPassCounts.get(test.matchKey) || 0;
277
+ const timeoutUtilization = test.timeoutMs && test.timeoutMs > 0 ? test.durationMs / test.timeoutMs : null;
278
+ const recentFailurePressure = historicalFailures >= 5 && lastFailureRunsAgo !== null && lastFailureRunsAgo <= 3;
279
+ const currentRunSignal = Boolean(test.retries > 0 ||
280
+ (ratio && ratio >= 1.8) ||
281
+ (timeoutUtilization && timeoutUtilization >= 0.9));
282
+ const persistentPassSignal = Boolean(historicalRetries >= 2 ||
283
+ repeatedSlowPasses >= 3 ||
284
+ (recentTrendRatio && recentTrendRatio >= 1.5));
285
+ const score = (test.retries > 0 ? 4 : 0) +
286
+ (ratio && ratio >= 1.8 ? 2 : 0) +
287
+ (ratio && ratio >= 2.2 ? 1 : 0) +
288
+ (recentTrendRatio && recentTrendRatio >= 1.5 ? 1 : 0) +
289
+ (repeatedSlowPasses >= 2 ? 2 : repeatedSlowPasses > 0 ? 1 : 0) +
290
+ (timeoutUtilization && timeoutUtilization >= 0.65 ? 1 : 0) +
291
+ (timeoutUtilization && timeoutUtilization >= 0.85 ? 1 : 0) +
292
+ (historicalRetries >= 2 ? 2 : historicalRetries > 0 ? 1 : 0) +
293
+ (recentFailurePressure ? 1 : 0);
294
+ const strongSignal = Boolean(currentRunSignal ||
295
+ persistentPassSignal);
296
+ const primaryReason = test.retries > 0
297
+ ? `passed after ${test.retries} retr${test.retries === 1 ? 'y' : 'ies'} in this run`
298
+ : historicalRetries >= 2
299
+ ? `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`
310
+ : `has weak instability signals`;
311
+ if (score > 0) {
312
+ nearFailureCandidates.push({
313
+ title: test.title,
314
+ ratio,
315
+ recentTrendRatio,
316
+ retries: test.retries,
317
+ historicalRetries,
318
+ historicalFailures,
319
+ repeatedSlowPasses,
320
+ timeoutUtilization,
321
+ medianDurationMs: med,
322
+ currentDurationMs: test.durationMs,
323
+ score,
324
+ strongSignal,
325
+ currentRunSignal,
326
+ primaryReason
327
+ });
328
+ }
181
329
  }
182
- const flakyLookingCount = retryPassedCount + (slowestRisk ? 1 : 0);
330
+ nearFailureCandidates.sort((left, right) => Number(right.currentRunSignal) - Number(left.currentRunSignal) ||
331
+ right.score - left.score ||
332
+ (right.historicalFailures || 0) - (left.historicalFailures || 0) ||
333
+ (right.timeoutUtilization || 0) - (left.timeoutUtilization || 0) ||
334
+ (right.ratio || 0) - (left.ratio || 0));
335
+ const strongNearFailures = nearFailureCandidates.filter((candidate) => candidate.strongSignal &&
336
+ (candidate.currentRunSignal ||
337
+ candidate.historicalRetries >= 2 ||
338
+ candidate.repeatedSlowPasses >= 3 ||
339
+ (candidate.recentTrendRatio !== null && candidate.recentTrendRatio >= 1.5)) &&
340
+ candidate.score >= 3);
341
+ const topNearFailures = strongNearFailures.slice(0, 2);
342
+ const flakyLookingCount = strongNearFailures.length;
343
+ const hasActiveRisk = flakyLookingCount > 0;
344
+ const totalDurationMs = snapshot.tests.reduce((sum, test) => sum + test.durationMs, 0);
345
+ const displayedRunDurationMs = options?.observedRunDurationMs || snapshot.wallDurationMs || totalDurationMs;
183
346
  const lines = [
184
- `- ${snapshot.passedCount} tests passed in ${formatDuration(snapshot.tests.reduce((sum, test) => sum + test.durationMs, 0))}`
347
+ `- ${snapshot.passedCount} tests passed in ${formatDuration(displayedRunDurationMs)} of wall time`
185
348
  ];
186
- if (retryPassedCount > 0)
187
- lines.push(`- Retries detected: ${retryPassedCount}`);
188
- if (flakyLookingCount > 0)
349
+ if (hasActiveRisk) {
350
+ if (retryPassedCount > 0)
351
+ lines.push(`- Retries detected: ${retryPassedCount}`);
189
352
  lines.push(`- Flaky-looking tests: ${flakyLookingCount}`);
190
- if (passStreak > 1)
191
- lines.push(`- Pass streak: ${passStreak} runs`);
192
- if (lastFailureRunsAgo !== null)
193
- lines.push(`- Last failure: ${lastFailureRunsAgo} runs ago`);
194
- lines.push(`- Debug readiness: traces, screenshots, video enabled`);
195
- const risks = [];
196
- if (slowestRisk) {
197
- risks.push(`- ${slowestRisk.title} is ${slowestRisk.ratio.toFixed(1)}x slower than its recent median`);
353
+ if (topNearFailures[0]) {
354
+ lines.push(`- Most at risk next: ${topNearFailures[0].title} (${topNearFailures[0].primaryReason})`);
355
+ }
356
+ if (passStreak > 1)
357
+ lines.push(`- Pass streak: ${passStreak} runs`);
358
+ if (lastFailureRunsAgo !== null)
359
+ lines.push(`- Last failure: ${lastFailureRunsAgo} runs ago`);
198
360
  }
199
- if (retryPassedCount > 0) {
200
- risks.push(`- ${retryPassedCount} tests passed after retry`);
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`);
201
367
  }
202
- if (lastFailureTitle) {
203
- risks.push(`- Last failure was in ${lastFailureTitle}`);
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)`);
373
+ }
374
+ if (candidate.retries > 0) {
375
+ risks.push(`- ${candidate.title} passed after ${candidate.retries} retr${candidate.retries === 1 ? 'y' : 'ies'}`);
376
+ }
377
+ if (candidate.historicalRetries >= 2) {
378
+ risks.push(`- ${candidate.title} has needed retries in ${candidate.historicalRetries} of the last 10 passing runs`);
379
+ }
380
+ if (candidate.repeatedSlowPasses >= 3) {
381
+ risks.push(`- ${candidate.title} has been unusually slow in ${candidate.repeatedSlowPasses} of the last 10 passing runs`);
382
+ }
383
+ if (candidate.recentTrendRatio && candidate.recentTrendRatio >= 1.5) {
384
+ risks.push(`- ${candidate.title} is trending slower over recent runs`);
385
+ }
386
+ if (candidate.timeoutUtilization && candidate.timeoutUtilization >= 0.65) {
387
+ risks.push(`- ${candidate.title} used ${(candidate.timeoutUtilization * 100).toFixed(0)}% of its timeout budget`);
388
+ }
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`);
394
+ }
204
395
  }
205
- if (lines.length <= 2 && risks.length === 0)
206
- return null;
207
- return { lines, risks };
396
+ return { lines, risks: Array.from(new Set(risks)).slice(0, 4) };
208
397
  };
209
398
  exports.buildPassingRunSummary = buildPassingRunSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentinelqa/playwright-reporter",
3
- "version": "0.1.47",
3
+ "version": "0.1.50",
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": "file:../SentinelQA_skeleton/packages/uploader"
42
+ "@sentinelqa/uploader": "^0.1.35"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^20.19.32",