@rettangoli/vt 0.0.14 → 1.0.0-rc12
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 +164 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +405 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +119 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +94 -0
- package/src/cli/generate.js +118 -28
- package/src/cli/index.js +3 -1
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/screenshot.js +8 -0
- package/src/cli/service-runtime.js +116 -0
- package/src/cli/templates/default.html +5 -3
- package/src/cli/templates/index.html +9 -7
- package/src/cli/templates/report.html +8 -6
- package/src/common.js +124 -185
- package/src/createSteps.js +810 -44
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/section-page-key.js +14 -0
- package/src/selector-filter.js +140 -0
- package/src/step-commands.js +37 -0
- package/src/validation.js +636 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { createSteps } from "../createSteps.js";
|
|
5
|
+
import { formatScreenshotOrdinal } from "./screenshot-naming.js";
|
|
6
|
+
import { DEFAULT_VIEWPORT } from "../viewport.js";
|
|
7
|
+
|
|
8
|
+
function nowMs() {
|
|
9
|
+
return performance.now();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ensureDirectoryExists(dirPath) {
|
|
13
|
+
if (!existsSync(dirPath)) {
|
|
14
|
+
mkdirSync(dirPath, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectEnvVars(prefix) {
|
|
19
|
+
const envVars = {};
|
|
20
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
21
|
+
if (key.startsWith(prefix)) {
|
|
22
|
+
envVars[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return envVars;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class PlaywrightRunner {
|
|
29
|
+
constructor(options) {
|
|
30
|
+
const {
|
|
31
|
+
workerId,
|
|
32
|
+
browser,
|
|
33
|
+
screenshotsDir,
|
|
34
|
+
isolationMode,
|
|
35
|
+
screenshotWaitTime,
|
|
36
|
+
waitEvent,
|
|
37
|
+
waitSelector,
|
|
38
|
+
waitStrategy,
|
|
39
|
+
navigationTimeout,
|
|
40
|
+
readyTimeout,
|
|
41
|
+
screenshotTimeout,
|
|
42
|
+
envVarPrefix = "RTGL_VT_",
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
this.workerId = workerId;
|
|
46
|
+
this.browser = browser;
|
|
47
|
+
this.screenshotsDir = screenshotsDir;
|
|
48
|
+
this.isolationMode = isolationMode;
|
|
49
|
+
this.screenshotWaitTime = screenshotWaitTime;
|
|
50
|
+
this.waitEvent = waitEvent;
|
|
51
|
+
this.waitSelector = waitSelector;
|
|
52
|
+
this.waitStrategy = waitStrategy;
|
|
53
|
+
this.navigationTimeout = navigationTimeout;
|
|
54
|
+
this.readyTimeout = readyTimeout;
|
|
55
|
+
this.screenshotTimeout = screenshotTimeout;
|
|
56
|
+
this.envVarPrefix = envVarPrefix;
|
|
57
|
+
this.envVars = collectEnvVars(this.envVarPrefix);
|
|
58
|
+
this.sharedContext = null;
|
|
59
|
+
this.sharedPage = null;
|
|
60
|
+
this.sharedRegisteredReadyEvents = new Set();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async initialize() {
|
|
64
|
+
if (this.isolationMode === "fast") {
|
|
65
|
+
this.sharedContext = await this.createContext();
|
|
66
|
+
this.sharedPage = await this.sharedContext.newPage();
|
|
67
|
+
await this.configurePage(this.sharedPage);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async dispose() {
|
|
72
|
+
if (this.sharedContext) {
|
|
73
|
+
await this.sharedContext.close();
|
|
74
|
+
this.sharedContext = null;
|
|
75
|
+
this.sharedPage = null;
|
|
76
|
+
this.sharedRegisteredReadyEvents.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async recycleSharedContext() {
|
|
81
|
+
if (this.isolationMode !== "fast") {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await this.dispose();
|
|
85
|
+
this.sharedContext = await this.createContext();
|
|
86
|
+
this.sharedPage = await this.sharedContext.newPage();
|
|
87
|
+
await this.configurePage(this.sharedPage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async createContext() {
|
|
91
|
+
const context = await this.browser.newContext();
|
|
92
|
+
await context.addInitScript(() => {
|
|
93
|
+
const styleId = "__rtgl_vt_rendering_style";
|
|
94
|
+
const styleContent = `
|
|
95
|
+
* {
|
|
96
|
+
-webkit-font-smoothing: antialiased !important;
|
|
97
|
+
-moz-osx-font-smoothing: grayscale !important;
|
|
98
|
+
text-rendering: geometricPrecision !important;
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const installStyle = () => {
|
|
103
|
+
if (document.getElementById(styleId)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const style = document.createElement("style");
|
|
107
|
+
style.id = styleId;
|
|
108
|
+
style.textContent = styleContent;
|
|
109
|
+
const parent = document.head || document.documentElement;
|
|
110
|
+
parent.appendChild(style);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (document.readyState === "loading") {
|
|
114
|
+
document.addEventListener("DOMContentLoaded", installStyle, { once: true });
|
|
115
|
+
} else {
|
|
116
|
+
installStyle();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (Object.keys(this.envVars).length > 0) {
|
|
120
|
+
await context.addInitScript((vars) => {
|
|
121
|
+
Object.assign(window, vars);
|
|
122
|
+
}, this.envVars);
|
|
123
|
+
}
|
|
124
|
+
return context;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async configurePage(page, viewport = DEFAULT_VIEWPORT) {
|
|
128
|
+
await page.setViewportSize({
|
|
129
|
+
width: viewport.width,
|
|
130
|
+
height: viewport.height,
|
|
131
|
+
});
|
|
132
|
+
await page.setDefaultNavigationTimeout(this.navigationTimeout);
|
|
133
|
+
await page.setDefaultTimeout(this.readyTimeout);
|
|
134
|
+
await page.emulateMedia({
|
|
135
|
+
colorScheme: "light",
|
|
136
|
+
reducedMotion: "reduce",
|
|
137
|
+
forcedColors: "none",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async acquireSession() {
|
|
142
|
+
if (this.isolationMode === "strict") {
|
|
143
|
+
const context = await this.createContext();
|
|
144
|
+
const page = await context.newPage();
|
|
145
|
+
await this.configurePage(page);
|
|
146
|
+
return {
|
|
147
|
+
page,
|
|
148
|
+
resetSession: async () => 0,
|
|
149
|
+
registeredReadyEvents: new Set(),
|
|
150
|
+
cleanup: async () => {
|
|
151
|
+
await context.close();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!this.sharedContext) {
|
|
157
|
+
this.sharedContext = await this.createContext();
|
|
158
|
+
}
|
|
159
|
+
if (!this.sharedPage || this.sharedPage.isClosed()) {
|
|
160
|
+
this.sharedPage = await this.sharedContext.newPage();
|
|
161
|
+
await this.configurePage(this.sharedPage);
|
|
162
|
+
}
|
|
163
|
+
const resetSession = async () => {
|
|
164
|
+
const resetStart = nowMs();
|
|
165
|
+
// Clear origin-scoped runtime state before switching away.
|
|
166
|
+
await this.sharedPage.evaluate(async () => {
|
|
167
|
+
try {
|
|
168
|
+
localStorage.clear();
|
|
169
|
+
} catch {}
|
|
170
|
+
try {
|
|
171
|
+
sessionStorage.clear();
|
|
172
|
+
} catch {}
|
|
173
|
+
try {
|
|
174
|
+
if ("caches" in globalThis) {
|
|
175
|
+
const cacheNames = await caches.keys();
|
|
176
|
+
await Promise.allSettled(cacheNames.map((cacheName) => caches.delete(cacheName)));
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
try {
|
|
180
|
+
if ("navigator" in globalThis && "serviceWorker" in navigator) {
|
|
181
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
182
|
+
await Promise.allSettled(registrations.map((registration) => registration.unregister()));
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
});
|
|
186
|
+
await this.sharedPage.goto("about:blank", { waitUntil: "domcontentloaded" });
|
|
187
|
+
await this.sharedContext.clearCookies();
|
|
188
|
+
await this.sharedContext.clearPermissions();
|
|
189
|
+
await this.configurePage(this.sharedPage);
|
|
190
|
+
return nowMs() - resetStart;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
page: this.sharedPage,
|
|
195
|
+
resetSession,
|
|
196
|
+
registeredReadyEvents: this.sharedRegisteredReadyEvents,
|
|
197
|
+
cleanup: async () => {},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async ensureEventInitScript(page, waitEvent, registeredReadyEvents) {
|
|
202
|
+
if (registeredReadyEvents.has(waitEvent)) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await page.addInitScript((eventName) => {
|
|
206
|
+
window.__vtReadyState = window.__vtReadyState || {};
|
|
207
|
+
window.__vtReadyState[eventName] = false;
|
|
208
|
+
window.addEventListener(eventName, () => {
|
|
209
|
+
window.__vtReadyState[eventName] = true;
|
|
210
|
+
}, { once: true });
|
|
211
|
+
}, waitEvent);
|
|
212
|
+
registeredReadyEvents.add(waitEvent);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async navigateToReadyState(page, task, registeredReadyEvents) {
|
|
216
|
+
const strategy = task.waitStrategy || this.waitStrategy;
|
|
217
|
+
const waitEvent = task.waitEvent || this.waitEvent;
|
|
218
|
+
const waitSelector = task.waitSelector || this.waitSelector;
|
|
219
|
+
|
|
220
|
+
const navigationStart = nowMs();
|
|
221
|
+
if (strategy === "networkidle") {
|
|
222
|
+
await page.goto(task.url, {
|
|
223
|
+
waitUntil: "networkidle",
|
|
224
|
+
timeout: this.navigationTimeout,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
strategy,
|
|
228
|
+
navigationMs: nowMs() - navigationStart,
|
|
229
|
+
readyMs: 0,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (strategy === "event") {
|
|
234
|
+
if (!waitEvent) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`waitStrategy "event" requires waitEvent for ${task.path}.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
await this.ensureEventInitScript(page, waitEvent, registeredReadyEvents);
|
|
240
|
+
await page.goto(task.url, {
|
|
241
|
+
waitUntil: "load",
|
|
242
|
+
});
|
|
243
|
+
const readyStart = nowMs();
|
|
244
|
+
await page.waitForFunction(
|
|
245
|
+
(eventName) => Boolean(window.__vtReadyState && window.__vtReadyState[eventName] === true),
|
|
246
|
+
waitEvent,
|
|
247
|
+
);
|
|
248
|
+
return {
|
|
249
|
+
strategy,
|
|
250
|
+
navigationMs: nowMs() - navigationStart,
|
|
251
|
+
readyMs: nowMs() - readyStart,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (strategy === "selector") {
|
|
256
|
+
if (!waitSelector) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`waitStrategy "selector" requires waitSelector for ${task.path}.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
await page.goto(task.url, {
|
|
262
|
+
waitUntil: "load",
|
|
263
|
+
});
|
|
264
|
+
const readyStart = nowMs();
|
|
265
|
+
await page.waitForSelector(waitSelector, {
|
|
266
|
+
state: "attached",
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
strategy,
|
|
270
|
+
navigationMs: nowMs() - navigationStart,
|
|
271
|
+
readyMs: nowMs() - readyStart,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (strategy === "load") {
|
|
276
|
+
await page.goto(task.url, {
|
|
277
|
+
waitUntil: "load",
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
strategy,
|
|
281
|
+
navigationMs: nowMs() - navigationStart,
|
|
282
|
+
readyMs: 0,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Unsupported wait strategy "${strategy}" for ${task.path}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async takeAndSaveScreenshot(page, basePath, suffix) {
|
|
292
|
+
const finalPath = `${basePath}-${suffix}`;
|
|
293
|
+
const tempPngPath = join(this.screenshotsDir, `${finalPath}.png`);
|
|
294
|
+
const screenshotPath = join(this.screenshotsDir, `${finalPath}.webp`);
|
|
295
|
+
ensureDirectoryExists(dirname(screenshotPath));
|
|
296
|
+
|
|
297
|
+
const hasCustomScreenshot = await page.evaluate(
|
|
298
|
+
() => typeof window.takeVtScreenshotBase64 === "function",
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (hasCustomScreenshot) {
|
|
302
|
+
let base64Data = await page.evaluate(() => window.takeVtScreenshotBase64());
|
|
303
|
+
if (base64Data.includes(",")) {
|
|
304
|
+
base64Data = base64Data.split(",")[1];
|
|
305
|
+
}
|
|
306
|
+
const pngBuffer = Buffer.from(base64Data, "base64");
|
|
307
|
+
await sharp(pngBuffer).webp({ quality: 85 }).toFile(screenshotPath);
|
|
308
|
+
return screenshotPath;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await page.screenshot({
|
|
312
|
+
path: tempPngPath,
|
|
313
|
+
fullPage: true,
|
|
314
|
+
timeout: this.screenshotTimeout,
|
|
315
|
+
});
|
|
316
|
+
await sharp(tempPngPath).webp({ quality: 85 }).toFile(screenshotPath);
|
|
317
|
+
|
|
318
|
+
if (existsSync(tempPngPath)) {
|
|
319
|
+
unlinkSync(tempPngPath);
|
|
320
|
+
}
|
|
321
|
+
return screenshotPath;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async runTask(task, attempt) {
|
|
325
|
+
const overallStart = nowMs();
|
|
326
|
+
const sessionStart = nowMs();
|
|
327
|
+
const session = await this.acquireSession();
|
|
328
|
+
const sessionMs = nowMs() - sessionStart;
|
|
329
|
+
const { page, resetSession, registeredReadyEvents } = session;
|
|
330
|
+
|
|
331
|
+
let strategy = task.waitStrategy || this.waitStrategy;
|
|
332
|
+
let resetMs = 0;
|
|
333
|
+
let navigationMs = 0;
|
|
334
|
+
let readyMs = 0;
|
|
335
|
+
let settleMs = 0;
|
|
336
|
+
let initialScreenshotMs = 0;
|
|
337
|
+
let stepsMs = 0;
|
|
338
|
+
let screenshotCount = 0;
|
|
339
|
+
let screenshotIndex = 0;
|
|
340
|
+
|
|
341
|
+
const wrappedScreenshot = async (activePage, basePath = task.baseName) => {
|
|
342
|
+
screenshotIndex += 1;
|
|
343
|
+
const suffix = formatScreenshotOrdinal(screenshotIndex);
|
|
344
|
+
screenshotCount += 1;
|
|
345
|
+
return this.takeAndSaveScreenshot(activePage, basePath, suffix);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
resetMs = await resetSession();
|
|
350
|
+
await this.configurePage(page, task.viewport ?? DEFAULT_VIEWPORT);
|
|
351
|
+
const readyState = await this.navigateToReadyState(page, task, registeredReadyEvents);
|
|
352
|
+
strategy = readyState.strategy;
|
|
353
|
+
navigationMs = readyState.navigationMs;
|
|
354
|
+
readyMs = readyState.readyMs;
|
|
355
|
+
|
|
356
|
+
const settleStart = nowMs();
|
|
357
|
+
if (this.screenshotWaitTime > 0) {
|
|
358
|
+
await page.waitForTimeout(this.screenshotWaitTime);
|
|
359
|
+
}
|
|
360
|
+
settleMs = nowMs() - settleStart;
|
|
361
|
+
|
|
362
|
+
if (!task.frontMatter?.skipInitialScreenshot) {
|
|
363
|
+
const firstScreenshotStart = nowMs();
|
|
364
|
+
const firstScreenshotPath = await wrappedScreenshot(page, task.baseName);
|
|
365
|
+
initialScreenshotMs = nowMs() - firstScreenshotStart;
|
|
366
|
+
console.log(`Screenshot saved: ${firstScreenshotPath}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const stepsStart = nowMs();
|
|
370
|
+
const stepsExecutor = createSteps(page, {
|
|
371
|
+
baseName: task.baseName,
|
|
372
|
+
takeAndSaveScreenshot: wrappedScreenshot,
|
|
373
|
+
});
|
|
374
|
+
for (const step of task.steps) {
|
|
375
|
+
await stepsExecutor.executeStep(step);
|
|
376
|
+
}
|
|
377
|
+
stepsMs = nowMs() - stepsStart;
|
|
378
|
+
|
|
379
|
+
const totalMs = nowMs() - overallStart;
|
|
380
|
+
return {
|
|
381
|
+
workerId: this.workerId,
|
|
382
|
+
attempt,
|
|
383
|
+
strategy,
|
|
384
|
+
screenshotCount,
|
|
385
|
+
timings: {
|
|
386
|
+
totalMs,
|
|
387
|
+
sessionMs,
|
|
388
|
+
resetMs,
|
|
389
|
+
navigationMs,
|
|
390
|
+
readyMs,
|
|
391
|
+
settleMs,
|
|
392
|
+
initialScreenshotMs,
|
|
393
|
+
stepsMs,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
} catch (error) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Worker ${this.workerId} failed "${task.path}": ${error.message}`,
|
|
399
|
+
{ cause: error },
|
|
400
|
+
);
|
|
401
|
+
} finally {
|
|
402
|
+
await session.cleanup();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
function average(values) {
|
|
5
|
+
if (!values.length) return 0;
|
|
6
|
+
const sum = values.reduce((acc, value) => acc + value, 0);
|
|
7
|
+
return sum / values.length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function percentile(values, p) {
|
|
11
|
+
if (!values.length) return 0;
|
|
12
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
13
|
+
const index = Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p));
|
|
14
|
+
return sorted[index];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function summarizeMetric(items, metricName) {
|
|
18
|
+
const values = items
|
|
19
|
+
.map((item) => item.timings?.[metricName])
|
|
20
|
+
.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
avgMs: Number(average(values).toFixed(2)),
|
|
24
|
+
p50Ms: Number(percentile(values, 0.5).toFixed(2)),
|
|
25
|
+
p95Ms: Number(percentile(values, 0.95).toFixed(2)),
|
|
26
|
+
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function summarizeValues(values) {
|
|
31
|
+
const validValues = values
|
|
32
|
+
.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
avgMs: Number(average(validValues).toFixed(2)),
|
|
36
|
+
p50Ms: Number(percentile(validValues, 0.5).toFixed(2)),
|
|
37
|
+
p95Ms: Number(percentile(validValues, 0.95).toFixed(2)),
|
|
38
|
+
maxMs: Number((validValues.length ? Math.max(...validValues) : 0).toFixed(2)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ResultCollector {
|
|
43
|
+
constructor(options) {
|
|
44
|
+
const {
|
|
45
|
+
totalTasks,
|
|
46
|
+
metricsPath,
|
|
47
|
+
workerCount,
|
|
48
|
+
isolationMode,
|
|
49
|
+
maxRetries,
|
|
50
|
+
adaptivePolicy,
|
|
51
|
+
schedulingPolicy,
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
this.totalTasks = totalTasks;
|
|
55
|
+
this.metricsPath = metricsPath;
|
|
56
|
+
this.workerCount = workerCount;
|
|
57
|
+
this.isolationMode = isolationMode;
|
|
58
|
+
this.maxRetries = maxRetries;
|
|
59
|
+
this.adaptivePolicy = adaptivePolicy;
|
|
60
|
+
this.schedulingPolicy = schedulingPolicy;
|
|
61
|
+
|
|
62
|
+
this.startedAt = Date.now();
|
|
63
|
+
this.completed = 0;
|
|
64
|
+
this.successes = [];
|
|
65
|
+
this.failures = [];
|
|
66
|
+
this.retries = [];
|
|
67
|
+
this.recycles = [];
|
|
68
|
+
this.attempts = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recordSuccess(task, result, meta = {}) {
|
|
72
|
+
this.completed += 1;
|
|
73
|
+
this.successes.push({
|
|
74
|
+
path: task.path,
|
|
75
|
+
attempt: result.attempt,
|
|
76
|
+
workerId: result.workerId,
|
|
77
|
+
strategy: result.strategy,
|
|
78
|
+
screenshotCount: result.screenshotCount,
|
|
79
|
+
timings: result.timings,
|
|
80
|
+
queueType: meta.queueType,
|
|
81
|
+
queueWaitMs: meta.queueWaitMs,
|
|
82
|
+
attemptMs: meta.attemptMs,
|
|
83
|
+
});
|
|
84
|
+
this.attempts.push({
|
|
85
|
+
path: task.path,
|
|
86
|
+
attempt: result.attempt,
|
|
87
|
+
workerId: meta.workerId ?? result.workerId,
|
|
88
|
+
outcome: "success",
|
|
89
|
+
queueType: meta.queueType,
|
|
90
|
+
queueWaitMs: meta.queueWaitMs,
|
|
91
|
+
attemptMs: meta.attemptMs,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const timing = result.timings?.totalMs ? `${result.timings.totalMs.toFixed(0)}ms` : "n/a";
|
|
95
|
+
console.log(`[${this.completed}/${this.totalTasks}] Captured ${task.path} (${timing})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
recordRetry(task, attempt, errorMessage, meta = {}) {
|
|
99
|
+
this.retries.push({
|
|
100
|
+
path: task.path,
|
|
101
|
+
attempt,
|
|
102
|
+
error: errorMessage,
|
|
103
|
+
workerId: meta.workerId,
|
|
104
|
+
queueType: meta.queueType,
|
|
105
|
+
queueWaitMs: meta.queueWaitMs,
|
|
106
|
+
attemptMs: meta.attemptMs,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
this.attempts.push({
|
|
110
|
+
path: task.path,
|
|
111
|
+
attempt,
|
|
112
|
+
workerId: meta.workerId,
|
|
113
|
+
outcome: "retry",
|
|
114
|
+
queueType: meta.queueType,
|
|
115
|
+
queueWaitMs: meta.queueWaitMs,
|
|
116
|
+
attemptMs: meta.attemptMs,
|
|
117
|
+
});
|
|
118
|
+
console.warn(
|
|
119
|
+
`Retry ${attempt}/${this.maxRetries} for ${task.path}: ${errorMessage}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordFailure(task, attempt, errorMessage, meta = {}) {
|
|
124
|
+
this.completed += 1;
|
|
125
|
+
this.failures.push({
|
|
126
|
+
path: task.path,
|
|
127
|
+
attempt,
|
|
128
|
+
error: errorMessage,
|
|
129
|
+
workerId: meta.workerId,
|
|
130
|
+
queueType: meta.queueType,
|
|
131
|
+
queueWaitMs: meta.queueWaitMs,
|
|
132
|
+
attemptMs: meta.attemptMs,
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
this.attempts.push({
|
|
136
|
+
path: task.path,
|
|
137
|
+
attempt,
|
|
138
|
+
workerId: meta.workerId,
|
|
139
|
+
outcome: "failure",
|
|
140
|
+
queueType: meta.queueType,
|
|
141
|
+
queueWaitMs: meta.queueWaitMs,
|
|
142
|
+
attemptMs: meta.attemptMs,
|
|
143
|
+
});
|
|
144
|
+
console.error(`[${this.completed}/${this.totalTasks}] Failed ${task.path}: ${errorMessage}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
recordRecycle(workerId, reason) {
|
|
148
|
+
this.recycles.push({
|
|
149
|
+
workerId,
|
|
150
|
+
reason,
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
});
|
|
153
|
+
console.log(`Worker ${workerId} recycled (${reason})`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
buildSummary() {
|
|
157
|
+
const durationMs = Date.now() - this.startedAt;
|
|
158
|
+
const workerUtilization = [];
|
|
159
|
+
for (let workerId = 1; workerId <= this.workerCount; workerId += 1) {
|
|
160
|
+
const workerAttempts = this.attempts.filter((item) => item.workerId === workerId);
|
|
161
|
+
const busyMsRaw = workerAttempts.reduce((sum, item) => sum + (item.attemptMs ?? 0), 0);
|
|
162
|
+
const busyMs = Number(busyMsRaw.toFixed(2));
|
|
163
|
+
const utilizationPct = durationMs > 0
|
|
164
|
+
? Number(Math.min(100, (busyMsRaw / durationMs) * 100).toFixed(2))
|
|
165
|
+
: 0;
|
|
166
|
+
workerUtilization.push({
|
|
167
|
+
workerId,
|
|
168
|
+
attempts: workerAttempts.length,
|
|
169
|
+
successful: workerAttempts.filter((item) => item.outcome === "success").length,
|
|
170
|
+
retries: workerAttempts.filter((item) => item.outcome === "retry").length,
|
|
171
|
+
failures: workerAttempts.filter((item) => item.outcome === "failure").length,
|
|
172
|
+
busyMs,
|
|
173
|
+
utilizationPct,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
totalTasks: this.totalTasks,
|
|
179
|
+
completed: this.completed,
|
|
180
|
+
successful: this.successes.length,
|
|
181
|
+
failed: this.failures.length,
|
|
182
|
+
retries: this.retries.length,
|
|
183
|
+
attempts: this.attempts.length,
|
|
184
|
+
durationMs,
|
|
185
|
+
workerCount: this.workerCount,
|
|
186
|
+
isolationMode: this.isolationMode,
|
|
187
|
+
maxRetries: this.maxRetries,
|
|
188
|
+
adaptivePolicy: this.adaptivePolicy,
|
|
189
|
+
schedulingPolicy: this.schedulingPolicy,
|
|
190
|
+
timings: {
|
|
191
|
+
totalMs: summarizeMetric(this.successes, "totalMs"),
|
|
192
|
+
sessionMs: summarizeMetric(this.successes, "sessionMs"),
|
|
193
|
+
resetMs: summarizeMetric(this.successes, "resetMs"),
|
|
194
|
+
navigationMs: summarizeMetric(this.successes, "navigationMs"),
|
|
195
|
+
readyMs: summarizeMetric(this.successes, "readyMs"),
|
|
196
|
+
settleMs: summarizeMetric(this.successes, "settleMs"),
|
|
197
|
+
initialScreenshotMs: summarizeMetric(this.successes, "initialScreenshotMs"),
|
|
198
|
+
stepsMs: summarizeMetric(this.successes, "stepsMs"),
|
|
199
|
+
attemptMs: summarizeValues(this.attempts.map((item) => item.attemptMs)),
|
|
200
|
+
queueWaitMs: summarizeValues(this.attempts.map((item) => item.queueWaitMs)),
|
|
201
|
+
},
|
|
202
|
+
workerUtilization,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
writeMetrics(summary) {
|
|
207
|
+
if (!this.metricsPath) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const payload = {
|
|
212
|
+
generatedAt: new Date().toISOString(),
|
|
213
|
+
summary,
|
|
214
|
+
successes: this.successes,
|
|
215
|
+
failures: this.failures,
|
|
216
|
+
retries: this.retries,
|
|
217
|
+
recycles: this.recycles,
|
|
218
|
+
attempts: this.attempts,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
mkdirSync(dirname(this.metricsPath), { recursive: true });
|
|
222
|
+
writeFileSync(this.metricsPath, JSON.stringify(payload, null, 2));
|
|
223
|
+
console.log(`Capture metrics written to ${this.metricsPath}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
finalize() {
|
|
227
|
+
const summary = this.buildSummary();
|
|
228
|
+
this.writeMetrics(summary);
|
|
229
|
+
return {
|
|
230
|
+
summary,
|
|
231
|
+
failures: this.failures,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function formatScreenshotOrdinal(index) {
|
|
2
|
+
if (!Number.isInteger(index) || index < 1) {
|
|
3
|
+
throw new Error(
|
|
4
|
+
`screenshot index must be an integer >= 1, got ${index}.`,
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
if (index > 99) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
`screenshot index ${index} exceeds supported range (01-99).`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return String(index).padStart(2, "0");
|
|
13
|
+
}
|