@reshotdev/screenshot 0.0.1-beta.0
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/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// privacy-engine.js - DOM-level PII masking via CSS injection
|
|
2
|
+
// Injects CSS rules into Playwright pages to hide/redact/blur sensitive elements
|
|
3
|
+
// before screenshots are taken. Zero-trust: data is obfuscated in the rendering engine.
|
|
4
|
+
|
|
5
|
+
const chalk = require("chalk");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Data attribute used to identify privacy style tags.
|
|
9
|
+
* Extracted as a constant to avoid hardcoded strings scattered across the codebase.
|
|
10
|
+
*/
|
|
11
|
+
const PRIVACY_STYLE_ATTR = "data-reshot-privacy";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Valid masking methods and their CSS rules
|
|
15
|
+
*/
|
|
16
|
+
const MASKING_METHODS = {
|
|
17
|
+
redact: (blurRadius) =>
|
|
18
|
+
`color: transparent !important; background-color: currentColor !important;`,
|
|
19
|
+
blur: (blurRadius) => `filter: blur(${blurRadius || 8}px) !important;`,
|
|
20
|
+
hide: (blurRadius) => `visibility: hidden !important;`,
|
|
21
|
+
remove: (blurRadius) => `display: none !important;`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default privacy configuration
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_PRIVACY_CONFIG = {
|
|
28
|
+
enabled: true,
|
|
29
|
+
method: "redact",
|
|
30
|
+
blurRadius: 8,
|
|
31
|
+
selectors: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize a selector entry to { selector, method, blurRadius } form.
|
|
36
|
+
* Accepts either a plain string or an object with those fields.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|Object} entry
|
|
39
|
+
* @param {string} defaultMethod
|
|
40
|
+
* @param {number} defaultBlurRadius
|
|
41
|
+
* @returns {{ selector: string, method: string, blurRadius: number }|null}
|
|
42
|
+
*/
|
|
43
|
+
function normalizeSelector(entry, defaultMethod, defaultBlurRadius) {
|
|
44
|
+
if (typeof entry === "string") {
|
|
45
|
+
if (!entry.trim()) return null;
|
|
46
|
+
return {
|
|
47
|
+
selector: entry.trim(),
|
|
48
|
+
method: defaultMethod,
|
|
49
|
+
blurRadius: defaultBlurRadius,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (entry && typeof entry === "object" && entry.selector) {
|
|
54
|
+
return {
|
|
55
|
+
selector: entry.selector.trim(),
|
|
56
|
+
method: entry.method || defaultMethod,
|
|
57
|
+
blurRadius: entry.blurRadius || defaultBlurRadius,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a privacy configuration object.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} config
|
|
68
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
69
|
+
*/
|
|
70
|
+
function validatePrivacyConfig(config) {
|
|
71
|
+
const errors = [];
|
|
72
|
+
|
|
73
|
+
if (!config || typeof config !== "object") {
|
|
74
|
+
return { valid: false, errors: ["Privacy config must be an object"] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.method && !MASKING_METHODS[config.method]) {
|
|
78
|
+
errors.push(
|
|
79
|
+
`Invalid masking method "${config.method}". Valid: ${Object.keys(MASKING_METHODS).join(", ")}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (config.blurRadius !== undefined) {
|
|
84
|
+
if (
|
|
85
|
+
typeof config.blurRadius !== "number" ||
|
|
86
|
+
config.blurRadius < 1 ||
|
|
87
|
+
config.blurRadius > 100
|
|
88
|
+
) {
|
|
89
|
+
errors.push("blurRadius must be a number between 1 and 100");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.selectors !== undefined) {
|
|
94
|
+
if (!Array.isArray(config.selectors)) {
|
|
95
|
+
errors.push("selectors must be an array");
|
|
96
|
+
} else {
|
|
97
|
+
for (let i = 0; i < config.selectors.length; i++) {
|
|
98
|
+
const entry = config.selectors[i];
|
|
99
|
+
if (typeof entry === "string") {
|
|
100
|
+
if (!entry.trim()) {
|
|
101
|
+
errors.push(`selectors[${i}] is empty`);
|
|
102
|
+
}
|
|
103
|
+
} else if (entry && typeof entry === "object") {
|
|
104
|
+
if (!entry.selector || !entry.selector.trim()) {
|
|
105
|
+
errors.push(`selectors[${i}].selector is required`);
|
|
106
|
+
}
|
|
107
|
+
if (entry.method && !MASKING_METHODS[entry.method]) {
|
|
108
|
+
errors.push(
|
|
109
|
+
`selectors[${i}].method "${entry.method}" is invalid`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
errors.push(
|
|
114
|
+
`selectors[${i}] must be a string or { selector, method?, blurRadius? }`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { valid: errors.length === 0, errors };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge global privacy config with scenario/step overrides.
|
|
126
|
+
* Selectors are ADDITIVE (union). method/blurRadius are overridden.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} globalConfig - Global privacy config
|
|
129
|
+
* @param {Object} [overrides] - Scenario or step-level overrides
|
|
130
|
+
* @returns {Object} Merged privacy config
|
|
131
|
+
*/
|
|
132
|
+
function mergePrivacyConfig(globalConfig, overrides) {
|
|
133
|
+
if (!overrides) return { ...globalConfig };
|
|
134
|
+
if (!globalConfig) return { ...DEFAULT_PRIVACY_CONFIG, ...overrides };
|
|
135
|
+
|
|
136
|
+
// Selectors are additive (union), then deduplicated by normalized selector string
|
|
137
|
+
const allSelectors = [
|
|
138
|
+
...(globalConfig.selectors || []),
|
|
139
|
+
...(overrides.selectors || []),
|
|
140
|
+
];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const dedupedSelectors = [];
|
|
143
|
+
for (const entry of allSelectors) {
|
|
144
|
+
const key = typeof entry === "string" ? entry.trim() : (entry?.selector || "").trim();
|
|
145
|
+
if (key && !seen.has(key)) {
|
|
146
|
+
seen.add(key);
|
|
147
|
+
dedupedSelectors.push(entry);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const merged = {
|
|
152
|
+
enabled:
|
|
153
|
+
overrides.enabled !== undefined ? overrides.enabled : globalConfig.enabled,
|
|
154
|
+
method: overrides.method || globalConfig.method,
|
|
155
|
+
blurRadius: overrides.blurRadius || globalConfig.blurRadius,
|
|
156
|
+
selectors: dedupedSelectors,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return merged;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generate CSS string from a privacy config.
|
|
164
|
+
* Each selector gets its own rule so one invalid selector doesn't break others.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object} config - Privacy config with method, blurRadius, selectors
|
|
167
|
+
* @returns {string} CSS text
|
|
168
|
+
*/
|
|
169
|
+
function generatePrivacyCSS(config) {
|
|
170
|
+
if (!config || !config.selectors || config.selectors.length === 0) {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const defaultMethod = config.method || "redact";
|
|
175
|
+
const defaultBlurRadius = config.blurRadius || 8;
|
|
176
|
+
const rules = [];
|
|
177
|
+
|
|
178
|
+
for (const entry of config.selectors) {
|
|
179
|
+
const normalized = normalizeSelector(entry, defaultMethod, defaultBlurRadius);
|
|
180
|
+
if (!normalized) continue;
|
|
181
|
+
|
|
182
|
+
// Validate the selector before generating CSS
|
|
183
|
+
const validation = validateCSSSelector(normalized.selector);
|
|
184
|
+
if (!validation.valid) {
|
|
185
|
+
console.warn(
|
|
186
|
+
chalk.yellow(` ⚠ Skipping invalid privacy selector "${normalized.selector}": ${validation.reason}`)
|
|
187
|
+
);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cssRule = MASKING_METHODS[normalized.method];
|
|
192
|
+
if (!cssRule) continue;
|
|
193
|
+
|
|
194
|
+
rules.push(
|
|
195
|
+
`${normalized.selector} { ${cssRule(normalized.blurRadius)} }`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return rules.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Inject privacy masking CSS into a Playwright page.
|
|
204
|
+
* Also sets up re-injection on SPA route changes via framenavigated event.
|
|
205
|
+
*
|
|
206
|
+
* Returns a result object so callers can detect injection failures and
|
|
207
|
+
* abort captures rather than proceeding unmasked.
|
|
208
|
+
*
|
|
209
|
+
* @param {import('playwright').Page} page
|
|
210
|
+
* @param {Object} privacyConfig - Privacy config
|
|
211
|
+
* @param {Function} [logger] - Logging function
|
|
212
|
+
* @returns {Promise<{ success: boolean, injectedCount?: number, failedSelectors?: string[], error?: string }>}
|
|
213
|
+
*/
|
|
214
|
+
async function injectPrivacyMasking(page, privacyConfig, logger) {
|
|
215
|
+
if (!privacyConfig || !privacyConfig.enabled) {
|
|
216
|
+
return { success: true, injectedCount: 0, failedSelectors: [] };
|
|
217
|
+
}
|
|
218
|
+
if (!privacyConfig.selectors || privacyConfig.selectors.length === 0) {
|
|
219
|
+
return { success: true, injectedCount: 0, failedSelectors: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const css = generatePrivacyCSS(privacyConfig);
|
|
223
|
+
if (!css) {
|
|
224
|
+
return { success: true, injectedCount: 0, failedSelectors: [] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
// Inject our identified style tag (remove stale ones first)
|
|
229
|
+
await page.evaluate(({ cssContent, attr }) => {
|
|
230
|
+
document
|
|
231
|
+
.querySelectorAll(`style[${attr}]`)
|
|
232
|
+
.forEach((el) => el.remove());
|
|
233
|
+
|
|
234
|
+
const style = document.createElement("style");
|
|
235
|
+
style.setAttribute(attr, "true");
|
|
236
|
+
style.textContent = cssContent;
|
|
237
|
+
(document.head || document.documentElement).appendChild(style);
|
|
238
|
+
}, { cssContent: css, attr: PRIVACY_STYLE_ATTR });
|
|
239
|
+
|
|
240
|
+
// Verify the style tag actually exists in the DOM
|
|
241
|
+
const verified = await page.evaluate((attr) => {
|
|
242
|
+
return document.querySelectorAll(`style[${attr}]`).length > 0;
|
|
243
|
+
}, PRIVACY_STYLE_ATTR);
|
|
244
|
+
|
|
245
|
+
if (!verified) {
|
|
246
|
+
const errMsg = "Privacy style tag not found in DOM after injection";
|
|
247
|
+
// Non-suppressible warning — always print even in quiet mode
|
|
248
|
+
console.error(chalk.red(` ✖ PRIVACY: ${errMsg}`));
|
|
249
|
+
return { success: false, error: errMsg, injectedCount: 0, failedSelectors: [] };
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
const errMsg = `Failed to inject privacy CSS: ${e.message}`;
|
|
253
|
+
// Non-suppressible warning — always print even in quiet mode
|
|
254
|
+
console.error(chalk.red(` ✖ PRIVACY: ${errMsg}`));
|
|
255
|
+
return { success: false, error: errMsg, injectedCount: 0, failedSelectors: [] };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Re-inject on SPA route changes (respects pause flag for race-free removal)
|
|
259
|
+
const reinjectionHandler = async (frame) => {
|
|
260
|
+
if (frame === page.mainFrame() && !page._reshotPrivacyPaused) {
|
|
261
|
+
try {
|
|
262
|
+
await page.evaluate(({ cssContent, attr }) => {
|
|
263
|
+
document
|
|
264
|
+
.querySelectorAll(`style[${attr}]`)
|
|
265
|
+
.forEach((el) => el.remove());
|
|
266
|
+
const style = document.createElement("style");
|
|
267
|
+
style.setAttribute(attr, "true");
|
|
268
|
+
style.textContent = cssContent;
|
|
269
|
+
(document.head || document.documentElement).appendChild(style);
|
|
270
|
+
}, { cssContent: css, attr: PRIVACY_STYLE_ATTR });
|
|
271
|
+
} catch (_e) {
|
|
272
|
+
// Page might have closed, ignore
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
page.on("framenavigated", reinjectionHandler);
|
|
278
|
+
|
|
279
|
+
// Store handler reference for cleanup
|
|
280
|
+
if (!page._reshotPrivacyHandlers) {
|
|
281
|
+
page._reshotPrivacyHandlers = [];
|
|
282
|
+
}
|
|
283
|
+
page._reshotPrivacyHandlers.push(reinjectionHandler);
|
|
284
|
+
|
|
285
|
+
if (logger) {
|
|
286
|
+
const selectorCount = privacyConfig.selectors.length;
|
|
287
|
+
const method = privacyConfig.method || "redact";
|
|
288
|
+
logger(
|
|
289
|
+
chalk.gray(
|
|
290
|
+
` → Privacy masking: ${selectorCount} selector(s), method=${method}`
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { success: true, injectedCount: privacyConfig.selectors.length, failedSelectors: [] };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Remove all privacy masking CSS from a Playwright page.
|
|
300
|
+
* Also removes framenavigated event handlers.
|
|
301
|
+
*
|
|
302
|
+
* @param {import('playwright').Page} page
|
|
303
|
+
* @returns {Promise<void>}
|
|
304
|
+
*/
|
|
305
|
+
async function removePrivacyMasking(page) {
|
|
306
|
+
try {
|
|
307
|
+
await page.evaluate((attr) => {
|
|
308
|
+
document
|
|
309
|
+
.querySelectorAll(`style[${attr}]`)
|
|
310
|
+
.forEach((el) => el.remove());
|
|
311
|
+
}, PRIVACY_STYLE_ATTR);
|
|
312
|
+
} catch (_e) {
|
|
313
|
+
// Page might have closed
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Remove framenavigated handlers
|
|
317
|
+
if (page._reshotPrivacyHandlers) {
|
|
318
|
+
for (const handler of page._reshotPrivacyHandlers) {
|
|
319
|
+
page.removeListener("framenavigated", handler);
|
|
320
|
+
}
|
|
321
|
+
page._reshotPrivacyHandlers = [];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Generate privacy CSS as a string for injection via addInitScript (video captures).
|
|
327
|
+
* Returns a self-executing script that injects the CSS before page load.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} privacyConfig
|
|
330
|
+
* @returns {string} JavaScript code to inject as init script
|
|
331
|
+
*/
|
|
332
|
+
function generatePrivacyInitScript(privacyConfig) {
|
|
333
|
+
if (!privacyConfig || !privacyConfig.enabled) return null;
|
|
334
|
+
if (!privacyConfig.selectors || privacyConfig.selectors.length === 0)
|
|
335
|
+
return null;
|
|
336
|
+
|
|
337
|
+
const css = generatePrivacyCSS(privacyConfig);
|
|
338
|
+
if (!css) return null;
|
|
339
|
+
|
|
340
|
+
return css;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Pause privacy CSS re-injection on framenavigated events.
|
|
345
|
+
* Call before removePrivacyMasking in step-level override blocks
|
|
346
|
+
* to prevent the handler from re-adding CSS that was just removed.
|
|
347
|
+
*
|
|
348
|
+
* @param {import('playwright').Page} page
|
|
349
|
+
*/
|
|
350
|
+
function pausePrivacyReinjection(page) {
|
|
351
|
+
page._reshotPrivacyPaused = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Resume privacy CSS re-injection on framenavigated events.
|
|
356
|
+
*
|
|
357
|
+
* @param {import('playwright').Page} page
|
|
358
|
+
*/
|
|
359
|
+
function resumePrivacyReinjection(page) {
|
|
360
|
+
page._reshotPrivacyPaused = false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Validate a CSS selector string for safety.
|
|
365
|
+
* Rejects HTML injection attempts, excessively long selectors,
|
|
366
|
+
* and characters that are clearly not valid CSS.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} selector
|
|
369
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
370
|
+
*/
|
|
371
|
+
function validateCSSSelector(selector) {
|
|
372
|
+
if (typeof selector !== "string") {
|
|
373
|
+
return { valid: false, reason: "Selector must be a string" };
|
|
374
|
+
}
|
|
375
|
+
const trimmed = selector.trim();
|
|
376
|
+
if (!trimmed) {
|
|
377
|
+
return { valid: false, reason: "Selector is empty" };
|
|
378
|
+
}
|
|
379
|
+
if (trimmed.length > 500) {
|
|
380
|
+
return { valid: false, reason: "Selector exceeds 500 characters" };
|
|
381
|
+
}
|
|
382
|
+
// Block HTML/script injection via </style> or <script> tags
|
|
383
|
+
if (/<\/?[a-z]/i.test(trimmed)) {
|
|
384
|
+
return { valid: false, reason: "Selector contains HTML tags" };
|
|
385
|
+
}
|
|
386
|
+
// Block characters that should never appear in a CSS selector
|
|
387
|
+
// Allow typical CSS chars: letters, digits, -, _, ., #, [, ], =, ", ', :, (, ), *, +, ~, >, ^, |, $, comma, space, @
|
|
388
|
+
if (/[{}<;]/.test(trimmed)) {
|
|
389
|
+
return { valid: false, reason: "Selector contains invalid characters" };
|
|
390
|
+
}
|
|
391
|
+
return { valid: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
DEFAULT_PRIVACY_CONFIG,
|
|
396
|
+
PRIVACY_STYLE_ATTR,
|
|
397
|
+
MASKING_METHODS,
|
|
398
|
+
normalizeSelector,
|
|
399
|
+
validatePrivacyConfig,
|
|
400
|
+
mergePrivacyConfig,
|
|
401
|
+
generatePrivacyCSS,
|
|
402
|
+
injectPrivacyMasking,
|
|
403
|
+
removePrivacyMasking,
|
|
404
|
+
generatePrivacyInitScript,
|
|
405
|
+
pausePrivacyReinjection,
|
|
406
|
+
resumePrivacyReinjection,
|
|
407
|
+
validateCSSSelector,
|
|
408
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// progress-tracker.js - Track capture progress with ETA and throughput
|
|
2
|
+
// Provides structured progress output for CLI and Studio UI parsing.
|
|
3
|
+
|
|
4
|
+
class ProgressTracker {
|
|
5
|
+
/**
|
|
6
|
+
* @param {number} total - Total number of tasks
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {number} options.concurrency - Number of parallel workers
|
|
9
|
+
*/
|
|
10
|
+
constructor(total, options = {}) {
|
|
11
|
+
this.total = total;
|
|
12
|
+
this.concurrency = options.concurrency || 1;
|
|
13
|
+
this.completed = 0;
|
|
14
|
+
this.durations = [];
|
|
15
|
+
this.startTime = Date.now();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record completion of a task.
|
|
20
|
+
* @param {number} durationMs - How long the task took
|
|
21
|
+
*/
|
|
22
|
+
recordCompletion(durationMs) {
|
|
23
|
+
this.completed++;
|
|
24
|
+
this.durations.push(durationMs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get average task duration in ms.
|
|
29
|
+
*/
|
|
30
|
+
getAverageDuration() {
|
|
31
|
+
if (this.durations.length === 0) return 0;
|
|
32
|
+
const sum = this.durations.reduce((a, b) => a + b, 0);
|
|
33
|
+
return sum / this.durations.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get estimated time remaining as a formatted string (e.g., "1m45s").
|
|
38
|
+
*/
|
|
39
|
+
getETA() {
|
|
40
|
+
if (this.completed === 0) return "calculating...";
|
|
41
|
+
const remaining = this.total - this.completed;
|
|
42
|
+
const avgMs = this.getAverageDuration();
|
|
43
|
+
// With parallelism, effective time per batch = avg / concurrency
|
|
44
|
+
const etaMs = (remaining * avgMs) / this.concurrency;
|
|
45
|
+
return formatDuration(etaMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get throughput as tasks per minute.
|
|
50
|
+
*/
|
|
51
|
+
getThroughput() {
|
|
52
|
+
const elapsedMinutes = (Date.now() - this.startTime) / 60000;
|
|
53
|
+
if (elapsedMinutes < 0.01) return "0.0";
|
|
54
|
+
return (this.completed / elapsedMinutes).toFixed(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get elapsed time since start.
|
|
59
|
+
*/
|
|
60
|
+
getElapsed() {
|
|
61
|
+
return formatDuration(Date.now() - this.startTime);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get a full summary object.
|
|
66
|
+
*/
|
|
67
|
+
getSummary() {
|
|
68
|
+
return {
|
|
69
|
+
completed: this.completed,
|
|
70
|
+
total: this.total,
|
|
71
|
+
elapsed: this.getElapsed(),
|
|
72
|
+
eta: this.getETA(),
|
|
73
|
+
throughput: this.getThroughput(),
|
|
74
|
+
avgDuration: formatDuration(this.getAverageDuration()),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Format a structured progress log line for CLI output.
|
|
80
|
+
* Parseable by Studio UI's FloatingJobMonitor.
|
|
81
|
+
*
|
|
82
|
+
* @param {number} activeWorkers - Currently active workers
|
|
83
|
+
* @param {number} lastDurationMs - Duration of the last completed task
|
|
84
|
+
*/
|
|
85
|
+
formatProgressLine(activeWorkers, lastDurationMs) {
|
|
86
|
+
const last = formatDuration(lastDurationMs);
|
|
87
|
+
const eta = this.getETA();
|
|
88
|
+
const rate = this.getThroughput();
|
|
89
|
+
return `[PROGRESS] ${this.completed}/${this.total} | active:${activeWorkers} | last:${last} | eta:${eta} | rate:${rate}/min`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format a per-scenario completion line.
|
|
94
|
+
* @param {string} name - Scenario name
|
|
95
|
+
* @param {number} durationMs - Duration
|
|
96
|
+
* @param {boolean} success - Whether it succeeded
|
|
97
|
+
* @param {string} [error] - Error message if failed
|
|
98
|
+
*/
|
|
99
|
+
formatCompletionLine(name, durationMs, success, error) {
|
|
100
|
+
const duration = formatDuration(durationMs);
|
|
101
|
+
const counter = `[${this.completed}/${this.total}]`;
|
|
102
|
+
if (success) {
|
|
103
|
+
return `\u2714 ${name} in ${duration} ${counter}`;
|
|
104
|
+
}
|
|
105
|
+
const reason = error ? ` - ${error}` : "";
|
|
106
|
+
return `\u2718 ${name} in ${duration}${reason} ${counter}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format a summary footer for the entire run.
|
|
111
|
+
*/
|
|
112
|
+
formatSummary() {
|
|
113
|
+
const elapsed = this.getElapsed();
|
|
114
|
+
const avgDuration = formatDuration(this.getAverageDuration());
|
|
115
|
+
const successful = this.durations.length;
|
|
116
|
+
const failed = this.completed - successful;
|
|
117
|
+
const lines = [
|
|
118
|
+
`Completed ${this.completed}/${this.total} in ${elapsed}`,
|
|
119
|
+
`Average: ${avgDuration}/scenario | Throughput: ${this.getThroughput()}/min`,
|
|
120
|
+
];
|
|
121
|
+
if (this.concurrency > 1) {
|
|
122
|
+
lines[0] += ` (${this.concurrency} workers)`;
|
|
123
|
+
}
|
|
124
|
+
if (failed > 0) {
|
|
125
|
+
lines.push(`Failed: ${failed}`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Format milliseconds into human-readable duration (e.g., "1m45s", "3.2s").
|
|
133
|
+
*/
|
|
134
|
+
function formatDuration(ms) {
|
|
135
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
136
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
137
|
+
const minutes = Math.floor(ms / 60000);
|
|
138
|
+
const seconds = Math.round((ms % 60000) / 1000);
|
|
139
|
+
return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { ProgressTracker, formatDuration };
|