@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.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. 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 };