@sentinelqa/playwright-reporter 0.1.47 → 0.1.51
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/dist/localReport.js +34 -11
- package/dist/quickDiagnosis.d.ts +69 -5
- package/dist/quickDiagnosis.js +802 -97
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +89 -48
- package/dist/runHistory.d.ts +11 -0
- package/dist/runHistory.js +124 -3
- package/dist/terminalSummary.d.ts +27 -2
- package/dist/terminalSummary.js +202 -44
- package/package.json +2 -2
package/dist/terminalSummary.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
75
|
-
|
|
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"]
|
|
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
|
|
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,148 @@ 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
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
if (
|
|
200
|
-
|
|
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;
|
|
346
|
+
const lines = ["All tests passed", `${snapshot.passedCount} tests in ${formatDuration(displayedRunDurationMs)}`];
|
|
347
|
+
if (hasActiveRisk && topNearFailures[0]) {
|
|
348
|
+
lines.push(`At risk: ${topNearFailures[0].title} ${topNearFailures[0].primaryReason}`);
|
|
349
|
+
lines.push(topNearFailures[0].retries > 0 || topNearFailures[0].historicalRetries >= 2
|
|
350
|
+
? "Why this matters: Similar timing and retry patterns often turn into flaky failures"
|
|
351
|
+
: "Why this matters: Performance regressions often lead to flaky failures");
|
|
352
|
+
if (lastFailureRunsAgo !== null)
|
|
353
|
+
lines.push(`Last failure: ${lastFailureRunsAgo} runs ago`);
|
|
354
|
+
lines.push(topNearFailures[0].retries > 0
|
|
355
|
+
? "Recommendation: inspect retry behavior or timing around this test"
|
|
356
|
+
: "Recommendation: monitor the next runs or investigate the slowdown");
|
|
201
357
|
}
|
|
202
|
-
|
|
203
|
-
|
|
358
|
+
else {
|
|
359
|
+
lines.push("No anomalies detected");
|
|
360
|
+
if (lastFailureRunsAgo !== null)
|
|
361
|
+
lines.push(`Last failure: ${lastFailureRunsAgo} runs ago`);
|
|
362
|
+
lines.push("Status: Stable");
|
|
204
363
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return { lines, risks };
|
|
364
|
+
lines.push("Artifacts ready: traces, screenshots, video");
|
|
365
|
+
return { lines };
|
|
208
366
|
};
|
|
209
367
|
exports.buildPassingRunSummary = buildPassingRunSummary;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentinelqa/playwright-reporter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.51",
|
|
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": "
|
|
42
|
+
"@sentinelqa/uploader": "^0.1.35"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^20.19.32",
|