@sentinelqa/playwright-reporter 0.1.45 → 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.
- package/dist/localReport.js +34 -11
- package/dist/quickDiagnosis.d.ts +68 -4
- package/dist/quickDiagnosis.js +813 -97
- package/dist/reporter.d.ts +1 -0
- package/dist/reporter.js +103 -12
- package/dist/runHistory.d.ts +4 -0
- package/dist/runHistory.js +113 -3
- package/dist/telemetry.d.ts +3 -0
- package/dist/telemetry.js +173 -0
- package/dist/terminalSummary.d.ts +31 -0
- package/dist/terminalSummary.js +398 -0
- package/package.json +2 -2
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildPassingRunSummary = exports.recordReporterHistorySnapshot = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
|
|
10
|
+
const ensureDir = (dirPath) => {
|
|
11
|
+
if (!node_fs_1.default.existsSync(dirPath))
|
|
12
|
+
node_fs_1.default.mkdirSync(dirPath, { recursive: true });
|
|
13
|
+
};
|
|
14
|
+
const readJson = (filePath) => {
|
|
15
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const getCurrentBranch = () => {
|
|
25
|
+
const fromEnv = process.env.GITHUB_REF_NAME ||
|
|
26
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
27
|
+
process.env.CI_COMMIT_BRANCH ||
|
|
28
|
+
process.env.BRANCH_NAME ||
|
|
29
|
+
null;
|
|
30
|
+
return fromEnv && fromEnv.trim() ? fromEnv.trim() : "main";
|
|
31
|
+
};
|
|
32
|
+
const getCurrentGitSha = () => {
|
|
33
|
+
const fromEnv = process.env.GITHUB_SHA ||
|
|
34
|
+
process.env.CI_COMMIT_SHA ||
|
|
35
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
36
|
+
null;
|
|
37
|
+
return fromEnv && fromEnv.trim() ? fromEnv.trim() : "unknown";
|
|
38
|
+
};
|
|
39
|
+
const normalizeStatus = (status) => {
|
|
40
|
+
if (status === "failed" || status === "timedOut" || status === "interrupted")
|
|
41
|
+
return "failed";
|
|
42
|
+
if (status === "passed" || status === "flaky")
|
|
43
|
+
return "passed";
|
|
44
|
+
return "skipped";
|
|
45
|
+
};
|
|
46
|
+
const formatDuration = (durationMs) => {
|
|
47
|
+
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
48
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
49
|
+
const seconds = totalSeconds % 60;
|
|
50
|
+
if (minutes === 0)
|
|
51
|
+
return `${seconds}s`;
|
|
52
|
+
return `${minutes}m ${seconds}s`;
|
|
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
|
+
};
|
|
61
|
+
const median = (values) => {
|
|
62
|
+
if (values.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
65
|
+
const mid = Math.floor(sorted.length / 2);
|
|
66
|
+
if (sorted.length % 2 === 0)
|
|
67
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
68
|
+
return sorted[mid];
|
|
69
|
+
};
|
|
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
|
+
};
|
|
114
|
+
const collectTests = (node, ancestors = []) => {
|
|
115
|
+
const currentTitlePath = node.title ? [...ancestors, node.title] : ancestors;
|
|
116
|
+
const tests = [];
|
|
117
|
+
for (const child of node.suites || [])
|
|
118
|
+
tests.push(...collectTests(child, currentTitlePath));
|
|
119
|
+
for (const child of node.specs || [])
|
|
120
|
+
tests.push(...collectTests(child, currentTitlePath));
|
|
121
|
+
const specTests = Array.isArray(node.tests) ? node.tests : [];
|
|
122
|
+
for (const test of specTests) {
|
|
123
|
+
const results = Array.isArray(test.results) ? test.results : [];
|
|
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;
|
|
127
|
+
let retries = 0;
|
|
128
|
+
for (const result of results) {
|
|
129
|
+
if (typeof result.retry === "number")
|
|
130
|
+
retries = Math.max(retries, result.retry);
|
|
131
|
+
}
|
|
132
|
+
const titlePath = cleanTitlePath([...currentTitlePath, test.title || "Unnamed test"]);
|
|
133
|
+
const file = node.file || node.location?.file || test.location?.file || "";
|
|
134
|
+
const projectName = test.projectName || "";
|
|
135
|
+
tests.push({
|
|
136
|
+
matchKey: buildMatchKey(file, projectName, titlePath),
|
|
137
|
+
title: titlePath.join(" > "),
|
|
138
|
+
status: finalStatus,
|
|
139
|
+
durationMs,
|
|
140
|
+
retries,
|
|
141
|
+
timeoutMs: typeof test.timeout === "number" ? test.timeout : null
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return tests;
|
|
145
|
+
};
|
|
146
|
+
const buildSnapshot = (playwrightJsonPath) => {
|
|
147
|
+
const parsed = readJson(playwrightJsonPath);
|
|
148
|
+
if (!parsed)
|
|
149
|
+
return null;
|
|
150
|
+
const tests = collectTests(parsed);
|
|
151
|
+
const failedTests = tests.filter((test) => test.status === "failed");
|
|
152
|
+
const passedCount = tests.filter((test) => test.status === "passed").length;
|
|
153
|
+
const skippedCount = tests.filter((test) => test.status === "skipped").length;
|
|
154
|
+
return {
|
|
155
|
+
generatedAt: new Date().toISOString(),
|
|
156
|
+
branch: getCurrentBranch(),
|
|
157
|
+
gitSha: getCurrentGitSha(),
|
|
158
|
+
wallDurationMs: collectWallDurationMs(parsed),
|
|
159
|
+
totalTests: tests.length,
|
|
160
|
+
passedCount,
|
|
161
|
+
failedCount: failedTests.length,
|
|
162
|
+
skippedCount,
|
|
163
|
+
retryPassedCount: tests.filter((test) => test.status === "passed" && test.retries > 0).length,
|
|
164
|
+
failures: failedTests.map((test) => test.title),
|
|
165
|
+
tests
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const listSnapshots = (branch) => {
|
|
169
|
+
const historyDir = node_path_1.default.resolve(process.cwd(), HISTORY_DIR);
|
|
170
|
+
if (!node_fs_1.default.existsSync(historyDir))
|
|
171
|
+
return [];
|
|
172
|
+
return node_fs_1.default
|
|
173
|
+
.readdirSync(historyDir)
|
|
174
|
+
.filter((file) => file.endsWith(".json"))
|
|
175
|
+
.map((file) => readJson(node_path_1.default.join(historyDir, file)))
|
|
176
|
+
.filter((value) => Boolean(value))
|
|
177
|
+
.filter((snapshot) => snapshot.branch === branch)
|
|
178
|
+
.sort((a, b) => b.generatedAt.localeCompare(a.generatedAt));
|
|
179
|
+
};
|
|
180
|
+
const writeSnapshot = (snapshot) => {
|
|
181
|
+
ensureDir(node_path_1.default.resolve(process.cwd(), HISTORY_DIR));
|
|
182
|
+
const fileName = `${snapshot.generatedAt.replace(/[:.]/g, "-")}-${snapshot.gitSha}.json`;
|
|
183
|
+
node_fs_1.default.writeFileSync(node_path_1.default.resolve(process.cwd(), HISTORY_DIR, fileName), JSON.stringify(snapshot, null, 2), "utf8");
|
|
184
|
+
};
|
|
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) => {
|
|
194
|
+
const snapshot = buildSnapshot(playwrightJsonPath);
|
|
195
|
+
if (!snapshot)
|
|
196
|
+
return null;
|
|
197
|
+
const previousRuns = listSnapshots(snapshot.branch);
|
|
198
|
+
writeSnapshot(snapshot);
|
|
199
|
+
if (snapshot.failedCount > 0 || snapshot.totalTests === 0 || snapshot.passedCount === 0)
|
|
200
|
+
return null;
|
|
201
|
+
let passStreak = 1;
|
|
202
|
+
let lastFailureRunsAgo = null;
|
|
203
|
+
for (let i = 0; i < previousRuns.length; i += 1) {
|
|
204
|
+
const previous = previousRuns[i];
|
|
205
|
+
if (previous.failedCount === 0) {
|
|
206
|
+
passStreak += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
lastFailureRunsAgo = i + 1;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const retryPassedCount = snapshot.retryPassedCount;
|
|
213
|
+
const durationSamples = new Map();
|
|
214
|
+
const recentDurationSamples = new Map();
|
|
215
|
+
const slowPassCounts = new Map();
|
|
216
|
+
for (const previous of previousRuns.slice(0, 20)) {
|
|
217
|
+
for (const test of previous.tests || []) {
|
|
218
|
+
if (test.status !== "passed" || test.durationMs <= 0)
|
|
219
|
+
continue;
|
|
220
|
+
const bucket = durationSamples.get(test.matchKey) || [];
|
|
221
|
+
bucket.push(test.durationMs);
|
|
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
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
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 = [];
|
|
251
|
+
for (const test of snapshot.tests) {
|
|
252
|
+
if (test.status !== "passed" || test.durationMs <= 0)
|
|
253
|
+
continue;
|
|
254
|
+
const samples = durationSamples.get(test.matchKey) || [];
|
|
255
|
+
const med = median(samples);
|
|
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)) {
|
|
272
|
+
slowestRisk = { title: test.title, ratio };
|
|
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
|
+
}
|
|
329
|
+
}
|
|
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 = [
|
|
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})`);
|
|
355
|
+
}
|
|
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)`);
|
|
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
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return { lines, risks: Array.from(new Set(risks)).slice(0, 4) };
|
|
397
|
+
};
|
|
398
|
+
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.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": "^0.1.
|
|
42
|
+
"@sentinelqa/uploader": "^0.1.35"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^20.19.32",
|