@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,1623 @@
1
+ // capture-engine.js - Robust capture engine for SaaS documentation screenshots
2
+ // Designed for stability over flexibility - every action waits and verifies
3
+
4
+ const { chromium } = require("playwright");
5
+ const path = require("path");
6
+ const fs = require("fs-extra");
7
+ const chalk = require("chalk");
8
+ const { buildLaunchOptions } = require("./ci-detect");
9
+ const {
10
+ applyVariantToPage,
11
+ applyStorageAndReload,
12
+ setupHeaderInterception,
13
+ getBrowserOptions,
14
+ logVariantSummary,
15
+ } = require("./variant-injector");
16
+ const {
17
+ cropImageBuffer,
18
+ mergeCropConfigs,
19
+ validateCropConfig,
20
+ scaleRegionByDPR,
21
+ isSharpAvailable,
22
+ } = require("./image-crop");
23
+ const { sanitizeStorageState } = require("./record-cdp");
24
+ const {
25
+ injectPrivacyMasking,
26
+ removePrivacyMasking,
27
+ } = require("./privacy-engine");
28
+ const { applyStyle, isStyleAvailable } = require("./style-engine");
29
+
30
+ // Default path patterns that indicate an auth redirect
31
+ const DEFAULT_AUTH_PATH_PATTERNS = [
32
+ "/auth/signin",
33
+ "/auth/login",
34
+ "/auth/confirm",
35
+ "/login",
36
+ "/signin",
37
+ "/sign-in",
38
+ "/log-in",
39
+ "/sso/",
40
+ "/oauth/",
41
+ "/saml/",
42
+ "/cas/",
43
+ ];
44
+
45
+ // Known OAuth provider domains — if the page lands here, auth is required
46
+ const OAUTH_PROVIDER_DOMAINS = [
47
+ "accounts.google.com",
48
+ "login.microsoftonline.com",
49
+ "auth0.com",
50
+ "okta.com",
51
+ "login.salesforce.com",
52
+ ];
53
+
54
+ /**
55
+ * Check whether a URL indicates an authentication redirect.
56
+ * Matches against default path patterns, known OAuth provider domains,
57
+ * and optional user-supplied custom patterns.
58
+ *
59
+ * @param {string} url - The URL to check
60
+ * @param {string[]} customPatterns - Additional path substrings to match
61
+ * @returns {boolean}
62
+ */
63
+ function isAuthRedirectUrl(url, customPatterns = []) {
64
+ if (!url) return false;
65
+
66
+ let parsed;
67
+ try {
68
+ parsed = new URL(url);
69
+ } catch {
70
+ // If the URL can't be parsed, fall back to substring matching
71
+ const allPatterns = [...DEFAULT_AUTH_PATH_PATTERNS, ...customPatterns];
72
+ return allPatterns.some((p) => url.includes(p));
73
+ }
74
+
75
+ // Check OAuth provider domains
76
+ const hostname = parsed.hostname;
77
+ if (OAUTH_PROVIDER_DOMAINS.some((d) => hostname.includes(d))) {
78
+ return true;
79
+ }
80
+
81
+ // Check path patterns (default + custom)
82
+ const pathname = parsed.pathname;
83
+ const allPatterns = [...DEFAULT_AUTH_PATH_PATTERNS, ...customPatterns];
84
+ return allPatterns.some((p) => pathname.includes(p));
85
+ }
86
+
87
+ /**
88
+ * Browser automation engine with semantic selectors and variant injection.
89
+ */
90
+ class CaptureEngine {
91
+ constructor(options = {}) {
92
+ this.browser = null;
93
+ this.page = null;
94
+ this.context = null;
95
+ this.outputDir = options.outputDir || ".reshot/output";
96
+ this.viewport = options.viewport || { width: 1280, height: 720 };
97
+ this.baseUrl = options.baseUrl || "";
98
+ this.capturedAssets = [];
99
+ this.logger = options.logger || console.log;
100
+ this.headless = options.headless !== false; // Default to headless
101
+
102
+ // Storage state path for authenticated sessions
103
+ // If provided, loads cookies/localStorage from file to preserve auth state
104
+ this.storageStatePath = options.storageStatePath || null;
105
+
106
+ // Hide development UI overlays (Next.js devtools, etc.)
107
+ this.hideDevtools = options.hideDevtools !== false; // Default to true
108
+
109
+ // Universal variant configuration
110
+ // Supports multiple injection methods: localStorage, sessionStorage, cookie,
111
+ // urlParam, browser (locale/timezone), script, header
112
+ this.variantConfig = options.variantConfig || null;
113
+
114
+ // Crop configuration - applied to all captures unless overridden per-step
115
+ // Persisted across variations for consistent output
116
+ this.cropConfig = options.cropConfig || null;
117
+
118
+ // Validate crop config at initialization
119
+ if (this.cropConfig) {
120
+ const validation = validateCropConfig(this.cropConfig);
121
+ if (!validation.valid) {
122
+ this.logger(
123
+ chalk.yellow(` ⚠ Invalid crop config: ${validation.error}`)
124
+ );
125
+ this.cropConfig = null;
126
+ }
127
+ }
128
+
129
+ // Pre-loaded storage state object (avoids redundant file reads in parallel mode)
130
+ this.storageStateData = options.storageStateData || null;
131
+
132
+ // Custom auth redirect patterns (appended to defaults)
133
+ this._customAuthPatterns = options.authPatterns || [];
134
+
135
+ // Custom loading-state hook — lets users specify app-specific readiness signals
136
+ // { selector?: string, expression?: string, timeout?: number }
137
+ this.waitForReady = options.waitForReady || null;
138
+
139
+ // Privacy masking configuration (CSS injection for PII redaction)
140
+ this.privacyConfig = options.privacyConfig || null;
141
+
142
+ // Style configuration (image beautification post-capture)
143
+ this.styleConfig = options.styleConfig || null;
144
+
145
+ // Legacy support for old variant format
146
+ if (!this.variantConfig && options.variant) {
147
+ this.variantConfig = this._convertLegacyVariant(options.variant);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Convert legacy variant format to new universal format
153
+ */
154
+ _convertLegacyVariant(variant) {
155
+ const injections = [];
156
+ const browserOptions = {};
157
+ const summary = [];
158
+
159
+ // Convert locale config
160
+ if (variant.locale) {
161
+ if (variant.locale.browserLocale) {
162
+ browserOptions.locale = variant.locale.browserLocale;
163
+ }
164
+ if (variant.locale.timezone) {
165
+ browserOptions.timezoneId = variant.locale.timezone;
166
+ }
167
+ if (variant.locale.storage) {
168
+ for (const [key, value] of Object.entries(variant.locale.storage)) {
169
+ injections.push({ method: "localStorage", key, value });
170
+ }
171
+ }
172
+ summary.push(
173
+ `Locale: ${variant.locale.name || variant.locale.key || "custom"}`
174
+ );
175
+ }
176
+
177
+ // Convert role config
178
+ if (variant.role) {
179
+ if (variant.role.storage) {
180
+ for (const [key, value] of Object.entries(variant.role.storage)) {
181
+ injections.push({ method: "localStorage", key, value });
182
+ }
183
+ }
184
+ summary.push(
185
+ `Role: ${variant.role.name || variant.role.key || "custom"}`
186
+ );
187
+ }
188
+
189
+ return { injections, browserOptions, summary, metadata: {} };
190
+ }
191
+
192
+ /**
193
+ * Initialize the browser with variant support
194
+ */
195
+ /**
196
+ * Build context options from variant config and storage state.
197
+ * Extracted so it can be reused with browser pool contexts.
198
+ */
199
+ _buildContextOptions() {
200
+ const defaultOptions = {
201
+ viewport: this.viewport,
202
+ deviceScaleFactor: 2, // Retina quality screenshots
203
+ locale: "en-US",
204
+ timezoneId: "America/New_York",
205
+ };
206
+
207
+ const contextOptions = getBrowserOptions(
208
+ this.variantConfig,
209
+ defaultOptions
210
+ );
211
+
212
+ // Suppress permission dialogs (notifications, geolocation, etc.)
213
+ contextOptions.permissions = [];
214
+
215
+ if (contextOptions.colorScheme) {
216
+ this.logger(chalk.gray(` → colorScheme: ${contextOptions.colorScheme}`));
217
+ }
218
+
219
+ // Load storage state: prefer pre-loaded data, fall back to file path
220
+ // Always sanitize to prevent "Invalid cookie fields" from CDP-sourced cookies
221
+ if (this.storageStateData) {
222
+ const { sanitized, stats } = sanitizeStorageState(this.storageStateData);
223
+ contextOptions.storageState = sanitized;
224
+ if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
225
+ this.logger(chalk.gray(` → Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
226
+ }
227
+ this.logger(chalk.gray(` → Using pre-loaded auth session`));
228
+ } else if (this.storageStatePath && fs.existsSync(this.storageStatePath)) {
229
+ // Read and sanitize instead of passing raw file path (Playwright would read it unsanitized)
230
+ try {
231
+ const rawState = JSON.parse(fs.readFileSync(this.storageStatePath, "utf-8"));
232
+ const { sanitized, stats } = sanitizeStorageState(rawState);
233
+ contextOptions.storageState = sanitized;
234
+ if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
235
+ this.logger(chalk.gray(` → Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
236
+ }
237
+ } catch (_e) {
238
+ // Fall back to raw file path if JSON parse fails
239
+ contextOptions.storageState = this.storageStatePath;
240
+ }
241
+ this.logger(
242
+ chalk.gray(
243
+ ` → Loading auth session from: ${path.basename(
244
+ this.storageStatePath
245
+ )}`
246
+ )
247
+ );
248
+ }
249
+
250
+ return contextOptions;
251
+ }
252
+
253
+ async init() {
254
+ this.logger(chalk.cyan("🚀 Initializing capture engine..."));
255
+
256
+ const contextOptions = this._buildContextOptions();
257
+
258
+ this.browser = await chromium.launch(buildLaunchOptions({
259
+ headless: this.headless,
260
+ }));
261
+ this.context = await this.browser.newContext(contextOptions);
262
+ this.page = await this.context.newPage();
263
+
264
+ // Hide development UI overlays (Next.js devtools, React DevTools, etc.)
265
+ if (this.hideDevtools) {
266
+ await this._setupDevtoolsHiding();
267
+ }
268
+
269
+ // Apply all variant injections (localStorage, sessionStorage, cookies, scripts)
270
+ if (this.variantConfig) {
271
+ await applyVariantToPage(this.page, this.variantConfig, this.logger);
272
+
273
+ // Set up header interception if needed
274
+ if (
275
+ this.variantConfig.headers &&
276
+ Object.keys(this.variantConfig.headers).length > 0
277
+ ) {
278
+ await setupHeaderInterception(this.page, this.variantConfig.headers);
279
+ this.logger(
280
+ chalk.gray(
281
+ ` → HTTP headers: ${Object.keys(this.variantConfig.headers).join(
282
+ ", "
283
+ )}`
284
+ )
285
+ );
286
+ }
287
+
288
+ // Log variant summary
289
+ logVariantSummary(this.variantConfig, this.logger);
290
+ }
291
+
292
+ await this._injectWorkspaceStore();
293
+
294
+ // Inject privacy masking CSS (after variant injection, before captures)
295
+ this._privacyInjectionOk = true;
296
+ if (this.privacyConfig && this.privacyConfig.enabled && this.privacyConfig.selectors?.length > 0) {
297
+ const privacyResult = await injectPrivacyMasking(this.page, this.privacyConfig, this.logger);
298
+ this._privacyInjectionOk = privacyResult.success;
299
+ if (!privacyResult.success) {
300
+ // Non-suppressible — always warn even in quiet mode
301
+ console.error(chalk.red(` ✖ PRIVACY: Injection failed — captures will be skipped to prevent PII leak. Error: ${privacyResult.error}`));
302
+ }
303
+ }
304
+
305
+ // Track HTTP 401/403 on document requests — indicates auth is required
306
+ this._authResponseDetected = false;
307
+ this.page.on("response", (response) => {
308
+ const status = response.status();
309
+ if (
310
+ (status === 401 || status === 403) &&
311
+ response.request().resourceType() === "document"
312
+ ) {
313
+ this._authResponseDetected = true;
314
+ }
315
+ });
316
+
317
+ // Set up error handling
318
+ this.page.on("pageerror", (err) => {
319
+ const firstLine = (err.message || '').split('\n')[0].slice(0, 200);
320
+ this.logger(chalk.yellow(` [Page Error] ${firstLine}`));
321
+ });
322
+
323
+ this.logger(chalk.green(" ✔ Browser initialized"));
324
+ return this;
325
+ }
326
+
327
+ /**
328
+ * Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
329
+ * Injects CSS to hide common development UI elements before each navigation
330
+ */
331
+ async _setupDevtoolsHiding() {
332
+ // Add CSS to hide common development overlays
333
+ const hideDevtoolsCSS = `
334
+ /* Next.js Development Overlay */
335
+ [data-nextjs-dialog],
336
+ [data-nextjs-dialog-overlay],
337
+ [data-nextjs-toast],
338
+ #__next-build-watcher,
339
+ nextjs-portal,
340
+
341
+ /* Vercel Toolbar */
342
+ [data-vercel-toolbar],
343
+ #vercel-live-feedback,
344
+
345
+ /* React DevTools */
346
+ #__REACT_DEVTOOLS_GLOBAL_HOOK__,
347
+
348
+ /* Common hot reload indicators */
349
+ [data-hot-reload],
350
+ .webpack-hot-middleware-clientOverlay,
351
+
352
+ /* ChannelIO chat widget */
353
+ #ch-plugin,
354
+ #ch-plugin-core,
355
+ .ch-desk-messenger,
356
+ [class*="ChannelIO"],
357
+ [id*="channel-io"],
358
+ #channel-io-plugin,
359
+
360
+ /* Cookie consent banners */
361
+ .cookie-consent,
362
+ #cookie-banner,
363
+ [data-testid="cookie-banner"],
364
+ .cc-banner,
365
+ #onetrust-banner-sdk,
366
+ .CookieConsent,
367
+ #gdpr-cookie-notice,
368
+ .cookie-notice,
369
+ [class*="cookie-consent"],
370
+ [class*="CookieConsent"],
371
+
372
+ /* Other third-party widgets */
373
+ .intercom-lightweight-app,
374
+ #hubspot-messages-iframe-container {
375
+ display: none !important;
376
+ visibility: hidden !important;
377
+ opacity: 0 !important;
378
+ pointer-events: none !important;
379
+ }
380
+ `;
381
+
382
+ // Inject CSS on every frame and navigation
383
+ await this.page.addStyleTag({ content: hideDevtoolsCSS });
384
+
385
+ // Also inject on route changes for SPAs
386
+ this.page.on("framenavigated", async (frame) => {
387
+ if (frame === this.page.mainFrame()) {
388
+ try {
389
+ await this.page.addStyleTag({ content: hideDevtoolsCSS });
390
+ } catch (e) {
391
+ // Page might have closed, ignore
392
+ }
393
+ }
394
+ });
395
+
396
+ this.logger(chalk.gray(" → Dev overlays will be hidden"));
397
+ }
398
+
399
+ /**
400
+ * Inject workspace store data (activeProjectId + activeWorkspace) into app's localStorage.
401
+ * Without both fields, the target app's Zustand workspace store starts empty
402
+ * and pages show "Failed to load project".
403
+ */
404
+ async _injectWorkspaceStore() {
405
+ // Try to get PROJECT_ID and workspace from multiple sources (fallback chain)
406
+ let projectId = null;
407
+ let workspace = null;
408
+ try {
409
+ const config = require("./config");
410
+ const settings = config.readSettings() || {};
411
+ // 1. Check settings urlVariables
412
+ projectId = settings.urlVariables?.PROJECT_ID;
413
+ // 2. Check settings projectId
414
+ if (!projectId) projectId = settings.projectId;
415
+ // 3. Check docsync.config.json urlVariables
416
+ if (!projectId) {
417
+ try {
418
+ const docsyncConfig = config.readConfig() || {};
419
+ projectId = docsyncConfig.urlVariables?.PROJECT_ID;
420
+ } catch (_e) {
421
+ // Config may not exist
422
+ }
423
+ }
424
+ // 4. Check environment variable
425
+ if (!projectId) projectId = process.env.RESHOT_PROJECT_ID;
426
+ // Get workspace data from settings
427
+ workspace = settings.workspace || null;
428
+ } catch (e) {
429
+ // Settings not available, skip injection
430
+ return;
431
+ }
432
+
433
+ if (!projectId) {
434
+ this.logger(
435
+ chalk.yellow(" ⚠ No PROJECT_ID configured. Platform pages may show 'No project selected'.\n Set urlVariables.PROJECT_ID in .reshot/settings.json or RESHOT_PROJECT_ID env var.")
436
+ );
437
+ return;
438
+ }
439
+
440
+ // Store for post-navigation re-injection
441
+ this._activeProjectId = projectId;
442
+ this._activeWorkspace = workspace;
443
+
444
+ // Inject via addInitScript so it runs before any page JS
445
+ await this.page.addInitScript(({ pid, ws }) => {
446
+ const storeState = {
447
+ activeProjectId: pid,
448
+ sidebarMinimized: true,
449
+ };
450
+ if (ws) {
451
+ storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
452
+ }
453
+
454
+ // Update existing workspace store entries (support both legacy and current key prefixes)
455
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
456
+ let found = false;
457
+ for (let i = 0; i < localStorage.length; i++) {
458
+ const key = localStorage.key(i);
459
+ if (key && storePrefixes.some(p => key.startsWith(p))) {
460
+ try {
461
+ const data = JSON.parse(localStorage.getItem(key) || "{}");
462
+ data.state = { ...data.state, ...storeState };
463
+ data.version = data.version ?? 0;
464
+ localStorage.setItem(key, JSON.stringify(data));
465
+ found = true;
466
+ } catch (e) {
467
+ // Invalid JSON, skip
468
+ }
469
+ }
470
+ }
471
+ // Create default store if none existed (use current Zustand persist key)
472
+ if (!found) {
473
+ localStorage.setItem(
474
+ "reshot-store-workspace",
475
+ JSON.stringify({ state: storeState, version: 0 })
476
+ );
477
+ }
478
+ }, { pid: projectId, ws: workspace });
479
+
480
+ this.logger(
481
+ chalk.gray(` → Injected workspace store: projectId=${projectId.slice(0, 12)}...${workspace ? `, workspace=${workspace.slug}` : ""}`)
482
+ );
483
+ }
484
+
485
+ /**
486
+ * Navigate to a URL and wait for it to be fully loaded
487
+ */
488
+ async goto(url, options = {}) {
489
+ const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
490
+ this.logger(chalk.cyan(`📍 Navigating to ${fullUrl}`));
491
+
492
+ await this.page.goto(fullUrl, {
493
+ waitUntil: "domcontentloaded",
494
+ timeout: 30000,
495
+ });
496
+
497
+ // CRITICAL FIX: For SSR apps with inline <script> tags that read localStorage
498
+ // during HTML parsing, we must use page.evaluate() to set localStorage
499
+ // AFTER navigation, then reload so the inline scripts can read the values.
500
+ // This is because addInitScript runs when JS context initializes, which is
501
+ // AFTER inline <script> tags have already executed.
502
+ if (this.variantConfig && !this._hasAppliedStorageReload) {
503
+ this._hasAppliedStorageReload = true; // Only do this once per session
504
+ const didReload = await applyStorageAndReload(
505
+ this.page,
506
+ this.variantConfig,
507
+ this.logger
508
+ );
509
+ if (didReload) {
510
+ this.logger(chalk.gray(" → Page reloaded with localStorage applied"));
511
+ }
512
+ }
513
+
514
+ // Check for auth redirect after navigation (URL patterns + HTTP 401/403)
515
+ const currentUrl = this.page.url();
516
+ const isAuthRedirect =
517
+ isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
518
+ this._authResponseDetected;
519
+ if (isAuthRedirect) {
520
+ const errorMsg = `Auth redirect detected: navigated to ${currentUrl}. Session may be expired. Re-run \`reshot record\` to refresh session, or export a fresh Playwright storage state to .reshot/auth-state.json.`;
521
+ this.logger(chalk.red(` ✖ ${errorMsg}`));
522
+ throw new Error(errorMsg);
523
+ }
524
+ // Reset flag after check so subsequent navigations get a clean slate
525
+ this._authResponseDetected = false;
526
+
527
+ // Wait for network to settle
528
+ await this._waitForStability();
529
+
530
+ // Additional wait for theme/variants to fully apply
531
+ // This handles CSS transitions and async re-renders
532
+ if (this.variantConfig && this.variantConfig.injections?.length > 0) {
533
+ this.logger(chalk.gray(" → Waiting for variant styles to apply..."));
534
+ await this.page.waitForTimeout(500);
535
+ }
536
+
537
+ // Re-inject workspace store after navigation to handle Zustand hydration resets
538
+ if (this._activeProjectId) {
539
+ await this.page.evaluate(({ pid, ws }) => {
540
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
541
+ let foundKey = null;
542
+ for (let i = 0; i < localStorage.length; i++) {
543
+ const key = localStorage.key(i);
544
+ if (key && storePrefixes.some(p => key.startsWith(p))) {
545
+ try {
546
+ const data = JSON.parse(localStorage.getItem(key) || "{}");
547
+ if (data.state) {
548
+ data.state.activeProjectId = pid;
549
+ if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
550
+ data.version = data.version ?? 0;
551
+ localStorage.setItem(key, JSON.stringify(data));
552
+ foundKey = key;
553
+ }
554
+ } catch (e) {}
555
+ }
556
+ }
557
+ // Trigger Zustand storage listener to rehydrate with the correct key
558
+ window.dispatchEvent(new StorageEvent("storage", { key: foundKey || "reshot-store-workspace" }));
559
+ }, { pid: this._activeProjectId, ws: this._activeWorkspace });
560
+ }
561
+
562
+ this.logger(chalk.green(" ✔ Page loaded"));
563
+ return this;
564
+ }
565
+
566
+ /**
567
+ * Find an element using multiple strategies
568
+ * This is the core of robust element selection
569
+ *
570
+ * Following Playwright/Testing Library best practices:
571
+ * 1. Role-based locators (most semantic, resilient)
572
+ * 2. Label-based locators (for form controls)
573
+ * 3. Placeholder/text locators
574
+ * 4. Test ID locators (explicit contract)
575
+ * 5. CSS selectors (fallback)
576
+ */
577
+ async _findElement(target, options = {}) {
578
+ const { timeout = 10000, mustBeVisible = true } = options;
579
+
580
+ // If target is already a locator, use it
581
+ if (typeof target !== "string") {
582
+ return target;
583
+ }
584
+
585
+ // Handle role-based selectors (from new selector strategies)
586
+ // Format: role=button[name="Submit"]
587
+ if (target.startsWith("role=")) {
588
+ const roleMatch = target.match(/^role=(\w+)(?:\[name="(.+)"\])?$/);
589
+ if (roleMatch) {
590
+ const [, role, name] = roleMatch;
591
+ const locator = name
592
+ ? this.page.getByRole(role, { name })
593
+ : this.page.getByRole(role);
594
+ try {
595
+ await locator.first().waitFor({
596
+ state: mustBeVisible ? "visible" : "attached",
597
+ timeout,
598
+ });
599
+ return locator.first();
600
+ } catch (e) {
601
+ // Continue to other strategies
602
+ this.logger(chalk.gray(` Role selector failed: ${target}`));
603
+ }
604
+ }
605
+ }
606
+
607
+ // Handle label-based selectors
608
+ // Format: label:has-text("Email") >> input
609
+ if (target.includes(" >> ") && target.includes("label:has-text")) {
610
+ try {
611
+ const locator = this.page.locator(target).first();
612
+ await locator.waitFor({
613
+ state: mustBeVisible ? "visible" : "attached",
614
+ timeout: 3000,
615
+ });
616
+ return locator;
617
+ } catch (e) {
618
+ // Continue to other strategies
619
+ }
620
+ }
621
+
622
+ // Strategy 1: If it looks like a CSS selector, try it directly
623
+ if (
624
+ target.startsWith("#") ||
625
+ target.startsWith(".") ||
626
+ target.startsWith("[")
627
+ ) {
628
+ const locator = this.page.locator(target).first();
629
+ try {
630
+ await locator.waitFor({
631
+ state: mustBeVisible ? "visible" : "attached",
632
+ timeout,
633
+ });
634
+ return locator;
635
+ } catch (e) {
636
+ // Continue to other strategies
637
+ }
638
+ }
639
+
640
+ // Strategy 2: Playwright's semantic locators (best practice)
641
+ const semanticStrategies = [
642
+ // Role-based (most reliable for buttons, links, etc.)
643
+ () => this.page.getByRole("button", { name: target }),
644
+ () => this.page.getByRole("link", { name: target }),
645
+ () => this.page.getByRole("menuitem", { name: target }),
646
+ () => this.page.getByRole("tab", { name: target }),
647
+ () => this.page.getByRole("checkbox", { name: target }),
648
+ () => this.page.getByRole("radio", { name: target }),
649
+ () => this.page.getByRole("textbox", { name: target }),
650
+ () => this.page.getByRole("combobox", { name: target }),
651
+ // Label-based (for form controls)
652
+ () => this.page.getByLabel(target),
653
+ // Placeholder-based (for inputs)
654
+ () => this.page.getByPlaceholder(target),
655
+ // Text-based (for general content)
656
+ () => this.page.getByText(target, { exact: true }),
657
+ () => this.page.getByText(target),
658
+ // Test ID (explicit contract)
659
+ () => this.page.getByTestId(target),
660
+ // Alt text (for images)
661
+ () => this.page.getByAltText(target),
662
+ // Title (for elements with title attribute)
663
+ () => this.page.getByTitle(target),
664
+ ];
665
+
666
+ for (const getLocator of semanticStrategies) {
667
+ try {
668
+ const locator = getLocator().first();
669
+ await locator.waitFor({
670
+ state: mustBeVisible ? "visible" : "attached",
671
+ timeout: 2000,
672
+ });
673
+ return locator;
674
+ } catch (e) {
675
+ // Try next strategy
676
+ }
677
+ }
678
+
679
+ // Strategy 3: Text-based CSS selectors (legacy fallback)
680
+ const textStrategies = [
681
+ // Button with text
682
+ `button:has-text("${target}")`,
683
+ // Link with text
684
+ `a:has-text("${target}")`,
685
+ // Any element with text
686
+ `*:has-text("${target}")`,
687
+ ];
688
+
689
+ for (const strategy of textStrategies) {
690
+ try {
691
+ const locator = this.page.locator(strategy).first();
692
+ await locator.waitFor({
693
+ state: mustBeVisible ? "visible" : "attached",
694
+ timeout: 2000,
695
+ });
696
+ return locator;
697
+ } catch (e) {
698
+ // Try next strategy
699
+ }
700
+ }
701
+
702
+ // Strategy 4: Placeholder/label for inputs (legacy)
703
+ const inputStrategies = [
704
+ `input[placeholder*="${target}" i]`,
705
+ `textarea[placeholder*="${target}" i]`,
706
+ `input[name*="${target}" i]`,
707
+ `label:has-text("${target}") + input`,
708
+ `label:has-text("${target}") input`,
709
+ ];
710
+
711
+ for (const strategy of inputStrategies) {
712
+ try {
713
+ const locator = this.page.locator(strategy).first();
714
+ await locator.waitFor({
715
+ state: mustBeVisible ? "visible" : "attached",
716
+ timeout: 1500,
717
+ });
718
+ return locator;
719
+ } catch (e) {
720
+ // Try next strategy
721
+ }
722
+ }
723
+
724
+ // Strategy 5: Aria and test attributes (legacy)
725
+ const ariaStrategies = [
726
+ `[aria-label*="${target}" i]`,
727
+ `[data-testid*="${target}" i]`,
728
+ `[data-test*="${target}" i]`,
729
+ `[data-cy*="${target}" i]`,
730
+ ];
731
+
732
+ for (const strategy of ariaStrategies) {
733
+ try {
734
+ const locator = this.page.locator(strategy).first();
735
+ await locator.waitFor({
736
+ state: mustBeVisible ? "visible" : "attached",
737
+ timeout: 1500,
738
+ });
739
+ return locator;
740
+ } catch (e) {
741
+ // Try next strategy
742
+ }
743
+ }
744
+
745
+ throw new Error(`Could not find element: "${target}"`);
746
+ }
747
+
748
+ /**
749
+ * Click on an element
750
+ */
751
+ async click(target, options = {}) {
752
+ this.logger(chalk.gray(` → Click: ${target}`));
753
+
754
+ await this._waitForStability();
755
+
756
+ const element = await this._findElement(target, options);
757
+
758
+ // Scroll into view if needed
759
+ await element.scrollIntoViewIfNeeded();
760
+
761
+ // Wait for element to be clickable
762
+ await element.waitFor({ state: "visible" });
763
+
764
+ // Perform click - use force:true if element may have CSS animations
765
+ // that Playwright considers "not stable" (e.g., pulsing buttons)
766
+ try {
767
+ await element.click({ timeout: 10000 });
768
+ } catch (clickError) {
769
+ if (clickError.message?.includes('not stable') || clickError.message?.includes('intercept')) {
770
+ this.logger(chalk.gray(" → Retrying click with force:true (animated element)"));
771
+ await element.click({ force: true, timeout: 10000 });
772
+ } else {
773
+ throw clickError;
774
+ }
775
+ }
776
+
777
+ // Wait for any resulting navigation or updates
778
+ await this._waitForStability();
779
+
780
+ this.logger(chalk.green(" ✔ Clicked"));
781
+ return this;
782
+ }
783
+
784
+ /**
785
+ * Hover over an element (for dropdowns, tooltips, etc.)
786
+ */
787
+ async hover(target, options = {}) {
788
+ this.logger(chalk.gray(` → Hover: ${target}`));
789
+
790
+ await this._waitForStability();
791
+
792
+ const element = await this._findElement(target, options);
793
+ await element.scrollIntoViewIfNeeded();
794
+ await element.hover();
795
+
796
+ // Wait for hover effects to appear
797
+ await this.page.waitForTimeout(300);
798
+
799
+ this.logger(chalk.green(" ✔ Hovered"));
800
+ return this;
801
+ }
802
+
803
+ /**
804
+ * Type into an input field
805
+ */
806
+ async type(target, text, options = {}) {
807
+ this.logger(chalk.gray(` → Type into: ${target}`));
808
+
809
+ await this._waitForStability();
810
+
811
+ const element = await this._findElement(target, options);
812
+
813
+ // Clear existing content first
814
+ await element.fill("");
815
+ await element.fill(text);
816
+
817
+ this.logger(chalk.green(" ✔ Typed"));
818
+ return this;
819
+ }
820
+
821
+ /**
822
+ * Wait for an element to appear
823
+ */
824
+ async waitFor(target, options = {}) {
825
+ this.logger(chalk.gray(` → Wait for: ${target}`));
826
+
827
+ await this._findElement(target, {
828
+ ...options,
829
+ timeout: options.timeout || 15000,
830
+ });
831
+
832
+ this.logger(chalk.green(" ✔ Element found"));
833
+ return this;
834
+ }
835
+
836
+ /**
837
+ * Wait for a specific amount of time
838
+ */
839
+ async wait(ms) {
840
+ this.logger(chalk.gray(` → Wait ${ms}ms`));
841
+ await this.page.waitForTimeout(ms);
842
+ return this;
843
+ }
844
+
845
+ /**
846
+ * Capture a screenshot with optional cropping
847
+ * This is the main output of the capture engine
848
+ *
849
+ * Cropping workflow:
850
+ * 1. If step-level cropConfig is provided, it overrides scenario-level config
851
+ * 2. Crop is applied after capture using Sharp for high-quality results
852
+ * 3. Crop coordinates are automatically scaled by device pixel ratio
853
+ * 4. Cropping is lossless and preserves image quality
854
+ */
855
+ async capture(name, options = {}) {
856
+ const {
857
+ selector, // Optional: capture specific element
858
+ fullPage, // Capture full scrollable page
859
+ padding = 16, // Padding around element (if selector specified)
860
+ clip, // Manual clip region {x, y, width, height}
861
+ description, // Human-readable description for documentation
862
+ cropConfig: stepCropConfig, // Step-level crop override
863
+ } = options;
864
+
865
+ this.logger(chalk.cyan(`📸 Capturing: ${name}`));
866
+
867
+ // CRITICAL: If privacy masking was configured but injection failed, skip capture
868
+ if (this.privacyConfig && this.privacyConfig.enabled && !this._privacyInjectionOk) {
869
+ console.error(chalk.red(` ✖ PRIVACY: Skipping capture "${name}" — privacy masking injection failed. Fix the issue or use --no-privacy.`));
870
+ return this;
871
+ }
872
+
873
+ await this._waitForStability();
874
+
875
+ // Guard: final check for loading indicators before capture
876
+ await this._waitForLoadingComplete(3000);
877
+
878
+ // Guard: catch late auth redirects (e.g., token expiring mid-session)
879
+ const currentUrl = this.page.url();
880
+ if (isAuthRedirectUrl(currentUrl, this._customAuthPatterns)) {
881
+ throw new Error(`Auth redirect detected before capture: ${currentUrl}`);
882
+ }
883
+
884
+ // CRITICAL: Final theme enforcement right before capture
885
+ // This ensures theme classes haven't been reset by React/framework re-renders
886
+ if (this.variantConfig && this.variantConfig.injections?.length > 0) {
887
+ await this.page.evaluate(() => {
888
+ if (window.__RESHOT_THEME_OVERRIDE__) {
889
+ const wanted = window.__RESHOT_THEME_OVERRIDE__;
890
+ document.documentElement.classList.remove("dark", "light");
891
+ document.documentElement.classList.add(wanted);
892
+ document.documentElement.style.colorScheme = wanted;
893
+ document.documentElement.setAttribute("data-theme", wanted);
894
+ }
895
+ });
896
+ // Brief wait for CSS to apply
897
+ await this.page.waitForTimeout(100);
898
+ }
899
+
900
+ // Ensure output directory exists
901
+ const outputPath = path.join(this.outputDir, `${name}.png`);
902
+ fs.ensureDirSync(path.dirname(outputPath));
903
+
904
+ let screenshotOptions = {
905
+ type: "png",
906
+ };
907
+
908
+ if (selector) {
909
+ // Capture specific element with padding
910
+ const element = await this._findElement(selector);
911
+ const box = await element.boundingBox();
912
+
913
+ if (box) {
914
+ screenshotOptions.clip = {
915
+ x: Math.max(0, box.x - padding),
916
+ y: Math.max(0, box.y - padding),
917
+ width: box.width + padding * 2,
918
+ height: box.height + padding * 2,
919
+ };
920
+ } else {
921
+ // Element not visible, capture full page
922
+ screenshotOptions.fullPage = true;
923
+ }
924
+ } else if (clip) {
925
+ screenshotOptions.clip = clip;
926
+ } else if (fullPage) {
927
+ screenshotOptions.fullPage = true;
928
+ }
929
+
930
+ // Capture screenshot to buffer first (for optional cropping)
931
+ const screenshotBuffer = await this.page.screenshot(screenshotOptions);
932
+
933
+ // Determine effective crop config (step overrides scenario)
934
+ const effectiveCropConfig = mergeCropConfigs(
935
+ this.cropConfig,
936
+ stepCropConfig
937
+ );
938
+
939
+ // Apply cropping if configured
940
+ let finalBuffer = screenshotBuffer;
941
+ let wasCropped = false;
942
+
943
+ if (
944
+ effectiveCropConfig &&
945
+ effectiveCropConfig.enabled &&
946
+ isSharpAvailable()
947
+ ) {
948
+ try {
949
+ // Get device scale factor for coordinate scaling
950
+ const deviceScaleFactor = await this.page.evaluate(
951
+ () => window.devicePixelRatio || 1
952
+ );
953
+
954
+ finalBuffer = await cropImageBuffer(
955
+ screenshotBuffer,
956
+ effectiveCropConfig,
957
+ {
958
+ deviceScaleFactor,
959
+ }
960
+ );
961
+ wasCropped = true;
962
+
963
+ this.logger(
964
+ chalk.gray(
965
+ ` ✂ Cropped to region: ${JSON.stringify(
966
+ effectiveCropConfig.region
967
+ )}`
968
+ )
969
+ );
970
+ } catch (cropError) {
971
+ this.logger(
972
+ chalk.yellow(
973
+ ` ⚠ Crop failed: ${cropError.message}, using full screenshot`
974
+ )
975
+ );
976
+ finalBuffer = screenshotBuffer;
977
+ }
978
+ } else if (
979
+ effectiveCropConfig &&
980
+ effectiveCropConfig.enabled &&
981
+ !isSharpAvailable()
982
+ ) {
983
+ this.logger(
984
+ chalk.yellow(
985
+ ` ⚠ Sharp not installed, skipping crop. Run: npm install sharp`
986
+ )
987
+ );
988
+ }
989
+
990
+ // Apply style processing (frames, shadow, padding, etc.)
991
+ let wasStyled = false;
992
+ if (this.styleConfig && this.styleConfig.enabled && isStyleAvailable()) {
993
+ // Smart default: skip frame for element screenshots
994
+ const effectiveStyleConfig = selector
995
+ ? { ...this.styleConfig, frame: this.styleConfig.frame === undefined ? "none" : this.styleConfig.frame }
996
+ : { ...this.styleConfig };
997
+
998
+ // Detect dark mode from variant config
999
+ if (this.variantConfig?.browserOptions?.colorScheme === "dark") {
1000
+ effectiveStyleConfig._darkMode = true;
1001
+ }
1002
+
1003
+ // Get DPR for accurate scaling
1004
+ const captureDpr = await this.page.evaluate(() => window.devicePixelRatio || 1);
1005
+ finalBuffer = await applyStyle(finalBuffer, effectiveStyleConfig, this.logger, captureDpr);
1006
+ wasStyled = true;
1007
+ } else if (this.styleConfig && this.styleConfig.enabled && !isStyleAvailable()) {
1008
+ this.logger(chalk.yellow(" ⚠ Sharp not installed, skipping style. Run: npm install sharp"));
1009
+ }
1010
+
1011
+ // Write the final buffer to file
1012
+ await fs.writeFile(outputPath, finalBuffer);
1013
+
1014
+ // Record asset metadata
1015
+ this.capturedAssets.push({
1016
+ name,
1017
+ path: outputPath,
1018
+ description,
1019
+ capturedAt: new Date().toISOString(),
1020
+ viewport: this.viewport,
1021
+ cropped: wasCropped,
1022
+ cropConfig: wasCropped ? effectiveCropConfig : undefined,
1023
+ styled: wasStyled,
1024
+ });
1025
+
1026
+ this.logger(chalk.green(` ✔ Saved: ${outputPath}`));
1027
+ return this;
1028
+ }
1029
+
1030
+ /**
1031
+ * Capture with hover state visible
1032
+ * Useful for dropdowns, menus, tooltips
1033
+ */
1034
+ async captureWithHover(name, hoverTarget, options = {}) {
1035
+ this.logger(chalk.cyan(`📸 Capturing with hover: ${name}`));
1036
+
1037
+ await this.hover(hoverTarget);
1038
+ await this.wait(200); // Let hover animation complete
1039
+ await this.capture(name, options);
1040
+
1041
+ // Move mouse away to clear hover
1042
+ await this.page.mouse.move(0, 0);
1043
+
1044
+ return this;
1045
+ }
1046
+
1047
+ /**
1048
+ * Capture a sequence of steps as a GIF or series of images
1049
+ */
1050
+ async captureSequence(name, steps, options = {}) {
1051
+ const { frameDelay = 500 } = options;
1052
+ const frames = [];
1053
+
1054
+ this.logger(chalk.cyan(`🎬 Capturing sequence: ${name}`));
1055
+
1056
+ for (let i = 0; i < steps.length; i++) {
1057
+ const step = steps[i];
1058
+
1059
+ // Execute the step action
1060
+ if (step.action) {
1061
+ await this[step.action](...(step.args || []));
1062
+ }
1063
+
1064
+ // Capture frame
1065
+ const framePath = path.join(
1066
+ this.outputDir,
1067
+ `${name}-frame-${i.toString().padStart(3, "0")}.png`
1068
+ );
1069
+ fs.ensureDirSync(path.dirname(framePath));
1070
+
1071
+ await this.page.screenshot({ path: framePath });
1072
+ frames.push(framePath);
1073
+
1074
+ await this.wait(frameDelay);
1075
+ }
1076
+
1077
+ this.logger(chalk.green(` ✔ Captured ${frames.length} frames`));
1078
+
1079
+ return frames;
1080
+ }
1081
+
1082
+ /**
1083
+ * Wait for page to be stable (no network activity, no animations, no unrendered i18n keys)
1084
+ */
1085
+ async _waitForStability() {
1086
+ try {
1087
+ // Wait for network to be idle (no requests for 500ms)
1088
+ await this.page.waitForLoadState("networkidle", { timeout: 5000 });
1089
+ } catch (e) {
1090
+ // Network might never be fully idle - check for pending data fetches
1091
+ try {
1092
+ await this.page.waitForFunction(
1093
+ () => {
1094
+ // Check if there are recent fetch/XHR requests still in progress
1095
+ const entries = performance.getEntriesByType("resource");
1096
+ const recentDataFetches = entries.filter((e) => {
1097
+ const isFetch =
1098
+ e.initiatorType === "fetch" || e.initiatorType === "xmlhttprequest";
1099
+ const isRecent = performance.now() - e.startTime < 2000;
1100
+ const isNotComplete = e.duration === 0;
1101
+ return isFetch && isRecent && isNotComplete;
1102
+ });
1103
+ return recentDataFetches.length === 0;
1104
+ },
1105
+ { timeout: 2000 }
1106
+ );
1107
+ } catch (_e) {
1108
+ // Continue anyway - best effort
1109
+ }
1110
+ }
1111
+
1112
+ // Wait for any pending React/Vue hydration and i18n to complete
1113
+ // Check for common i18n key patterns that indicate translations haven't loaded
1114
+ try {
1115
+ await this.page.waitForFunction(
1116
+ () => {
1117
+ // Look for common unrendered i18n key patterns in visible text
1118
+ const body = document.body;
1119
+ if (!body) return true;
1120
+
1121
+ const text = body.innerText || "";
1122
+
1123
+ // Common i18n key patterns that indicate translations haven't loaded yet
1124
+ const i18nKeyPatterns = [
1125
+ /[a-z]+\.[a-z]+\.[a-z]+/i, // nested.key.pattern
1126
+ /[a-z]+:[a-z]+\.[a-z]+/i, // namespace:key.pattern
1127
+ /\{\{[^}]+\}\}/, // {{interpolation}}
1128
+ /\$t\([^)]+\)/, // $t('key')
1129
+ ];
1130
+
1131
+ // Check if visible text contains raw i18n keys
1132
+ // This is a heuristic - we check if there are suspiciously many dotted identifiers
1133
+ const dottedMatches = text.match(/\b[a-z_]+\.[a-z_]+\b/gi) || [];
1134
+
1135
+ // Filter to only those that look like translation keys (all lowercase with dots)
1136
+ const suspiciousKeys = dottedMatches.filter((match) => {
1137
+ // Skip common non-i18n patterns
1138
+ if (
1139
+ match.includes(".com") ||
1140
+ match.includes(".org") ||
1141
+ match.includes(".io")
1142
+ )
1143
+ return false;
1144
+ if (match.match(/\d+\.\d+/)) return false; // version numbers
1145
+ if (match === "e.g" || match === "i.e") return false;
1146
+ // Keys are typically all lowercase with underscores/dots
1147
+ return match === match.toLowerCase() && match.length > 5;
1148
+ });
1149
+
1150
+ // If we find more than 3 suspicious keys visible, translations probably haven't loaded
1151
+ return suspiciousKeys.length < 3;
1152
+ },
1153
+ { timeout: 3000 }
1154
+ );
1155
+ } catch (e) {
1156
+ // Timeout is okay - we'll proceed anyway
1157
+ }
1158
+
1159
+ // Additional wait for any animations/transitions
1160
+ await this.page.waitForTimeout(200);
1161
+
1162
+ // Wait for loading skeletons/spinners to disappear
1163
+ await this._waitForLoadingComplete();
1164
+
1165
+ // Custom ready-state hook (user-configured per scenario)
1166
+ if (this.waitForReady) {
1167
+ const result = await this._waitForCustomReady(this.waitForReady);
1168
+ if (!result.ready) {
1169
+ this.logger(chalk.yellow(` ⚠ Custom ready check failed: ${result.reason}`));
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Wait for loading indicators (skeletons, spinners, etc.) to disappear
1176
+ * Increased timeout for SaaS apps that may have data fetching
1177
+ */
1178
+ async _waitForLoadingComplete(maxWait = 5000) {
1179
+ // Selectors for actual loading states (not decorative animations)
1180
+ const loadingSelectors = [
1181
+ '[class*="skeleton"]',
1182
+ '[class*="Skeleton"]',
1183
+ '[class*="shimmer"]',
1184
+ '[class*="loading"]',
1185
+ '[class*="Loading"]',
1186
+ '[class*="spinner"]',
1187
+ '[class*="Spinner"]',
1188
+ '[class*="loader"]',
1189
+ '[class*="Loader"]',
1190
+ '[role="progressbar"]',
1191
+ '[aria-busy="true"]',
1192
+ '[data-loading="true"]',
1193
+ "[data-skeleton]",
1194
+ // Additional common patterns
1195
+ ".placeholder-glow",
1196
+ ".placeholder-wave", // Bootstrap
1197
+ '[data-testid*="loading"]',
1198
+ '[data-testid*="skeleton"]',
1199
+ ".suspense-fallback",
1200
+ ".lazy-loading",
1201
+ ];
1202
+
1203
+ // Selectors that might be decorative animations (small status indicators)
1204
+ // We check these but require them to be larger than a certain size
1205
+ const decorativeSelectors = [
1206
+ '[class*="pulse"]',
1207
+ '[class*="animate-pulse"]',
1208
+ ];
1209
+
1210
+ const startTime = Date.now();
1211
+ let attempts = 0;
1212
+ let consecutiveNoLoading = 0;
1213
+
1214
+ while (Date.now() - startTime < maxWait) {
1215
+ attempts++;
1216
+ let loadingFound = false;
1217
+
1218
+ // Check strict loading selectors
1219
+ for (const selector of loadingSelectors) {
1220
+ try {
1221
+ const count = await this.page.locator(selector).count();
1222
+ if (count > 0) {
1223
+ const visible = await this.page
1224
+ .locator(selector)
1225
+ .first()
1226
+ .isVisible()
1227
+ .catch(() => false);
1228
+ if (visible) {
1229
+ loadingFound = true;
1230
+ consecutiveNoLoading = 0;
1231
+ break;
1232
+ }
1233
+ }
1234
+ } catch {
1235
+ // Selector didn't match, that's fine
1236
+ }
1237
+ }
1238
+
1239
+ // Check decorative selectors but only if they're large enough to be actual skeletons
1240
+ if (!loadingFound) {
1241
+ for (const selector of decorativeSelectors) {
1242
+ try {
1243
+ const elements = await this.page.locator(selector).all();
1244
+ for (const el of elements) {
1245
+ const visible = await el.isVisible().catch(() => false);
1246
+ if (visible) {
1247
+ // Check size - decorative dots are typically small (< 50px)
1248
+ const box = await el.boundingBox().catch(() => null);
1249
+ if (box && (box.width > 50 || box.height > 50)) {
1250
+ // This is likely a skeleton/loading placeholder, not a decorative dot
1251
+ loadingFound = true;
1252
+ consecutiveNoLoading = 0;
1253
+ break;
1254
+ }
1255
+ }
1256
+ }
1257
+ if (loadingFound) break;
1258
+ } catch {
1259
+ // Selector didn't match, that's fine
1260
+ }
1261
+ }
1262
+ }
1263
+
1264
+ if (!loadingFound) {
1265
+ consecutiveNoLoading++;
1266
+ // Wait for 5 consecutive checks with no loading to ensure stability
1267
+ if (consecutiveNoLoading >= 5) {
1268
+ if (attempts > 5) {
1269
+ this.logger &&
1270
+ this.logger(
1271
+ chalk.dim(
1272
+ ` Loading indicators cleared after ${attempts} checks`
1273
+ )
1274
+ );
1275
+ }
1276
+ return;
1277
+ }
1278
+ }
1279
+
1280
+ // Wait a bit before checking again
1281
+ await this.page.waitForTimeout(200);
1282
+ }
1283
+
1284
+ this.logger &&
1285
+ this.logger(
1286
+ chalk.yellow(
1287
+ ` Warning: Loading indicators still present after ${maxWait}ms, proceeding anyway`
1288
+ )
1289
+ );
1290
+ }
1291
+
1292
+ /**
1293
+ * Wait for a custom ready condition configured per-scenario.
1294
+ * Supports CSS selector presence and/or a JS expression evaluating to true.
1295
+ *
1296
+ * @param {Object} config - { selector?: string, expression?: string, timeout?: number }
1297
+ * @returns {Promise<{ready: boolean, reason?: string}>}
1298
+ */
1299
+ async _waitForCustomReady(config) {
1300
+ const timeout = config.timeout || 10000;
1301
+
1302
+ // Selector check: wait for the element to be attached to the DOM
1303
+ if (config.selector) {
1304
+ try {
1305
+ await this.page
1306
+ .locator(config.selector)
1307
+ .first()
1308
+ .waitFor({ state: "attached", timeout });
1309
+ } catch {
1310
+ return { ready: false, reason: `Selector "${config.selector}" not found within ${timeout}ms` };
1311
+ }
1312
+ }
1313
+
1314
+ // Expression check: wait for a JS expression to return truthy
1315
+ if (config.expression) {
1316
+ try {
1317
+ await this.page.waitForFunction(config.expression, { timeout });
1318
+ } catch {
1319
+ return { ready: false, reason: `Expression "${config.expression}" did not become truthy within ${timeout}ms` };
1320
+ }
1321
+ }
1322
+
1323
+ return { ready: true };
1324
+ }
1325
+
1326
+ /**
1327
+ * Detect error state on the current page
1328
+ * Checks explicit data attributes, custom selectors, and heuristic patterns
1329
+ * @param {Object} options - Detection options
1330
+ * @param {string[]} options.errorSelectors - Custom error selectors to check
1331
+ * @param {boolean} options.errorHeuristics - Whether to use heuristic detection
1332
+ * @returns {Promise<{hasError: boolean, errorType: string|null, errorMessage: string|null}>}
1333
+ */
1334
+ async _detectErrorState(options = {}) {
1335
+ const {
1336
+ errorSelectors = ["[data-testid='page-error']", "[data-error-type]"],
1337
+ errorHeuristics = true,
1338
+ } = options;
1339
+
1340
+ try {
1341
+ return await this.page.evaluate(
1342
+ ({ selectors, useHeuristics }) => {
1343
+ // 1. Check explicit data attributes (fast, deterministic)
1344
+ const errorEl = document.querySelector("[data-testid='page-error']");
1345
+ if (errorEl) {
1346
+ const errorType =
1347
+ errorEl.getAttribute("data-error-type") || "unknown";
1348
+ const errorMessage =
1349
+ errorEl.textContent?.trim().slice(0, 200) || "Page error detected";
1350
+ return { hasError: true, errorType, errorMessage };
1351
+ }
1352
+
1353
+ // Also check data-error-type on any element
1354
+ const errorTypeEl = document.querySelector("[data-error-type]");
1355
+ if (errorTypeEl) {
1356
+ const errorType =
1357
+ errorTypeEl.getAttribute("data-error-type") || "unknown";
1358
+ const errorMessage =
1359
+ errorTypeEl.textContent?.trim().slice(0, 200) ||
1360
+ "Error state detected";
1361
+ return { hasError: true, errorType, errorMessage };
1362
+ }
1363
+
1364
+ // 2. Check custom error selectors from config
1365
+ for (const selector of selectors) {
1366
+ if (
1367
+ selector === "[data-testid='page-error']" ||
1368
+ selector === "[data-error-type]"
1369
+ )
1370
+ continue; // Already checked
1371
+ try {
1372
+ const el = document.querySelector(selector);
1373
+ if (el) {
1374
+ const style = window.getComputedStyle(el);
1375
+ if (
1376
+ style.display !== "none" &&
1377
+ style.visibility !== "hidden"
1378
+ ) {
1379
+ return {
1380
+ hasError: true,
1381
+ errorType: "custom-selector",
1382
+ errorMessage: `Error selector matched: ${selector}`,
1383
+ };
1384
+ }
1385
+ }
1386
+ } catch (e) {
1387
+ // Invalid selector, skip
1388
+ }
1389
+ }
1390
+
1391
+ // 3. Heuristic patterns (fallback)
1392
+ if (useHeuristics) {
1393
+ const bodyText = document.body?.innerText || "";
1394
+ const errorPatterns = [
1395
+ /failed to load/i,
1396
+ /something went wrong/i,
1397
+ /unable to load/i,
1398
+ /error loading/i,
1399
+ /could not load/i,
1400
+ /an error occurred/i,
1401
+ ];
1402
+
1403
+ for (const pattern of errorPatterns) {
1404
+ if (pattern.test(bodyText)) {
1405
+ // Structural check: error text should be in a centered/prominent element
1406
+ const candidates = document.querySelectorAll(
1407
+ ".text-center, .text-destructive, [class*='error'], [class*='Error']"
1408
+ );
1409
+ for (const candidate of candidates) {
1410
+ if (
1411
+ pattern.test(candidate.textContent || "") &&
1412
+ candidate.offsetHeight > 50
1413
+ ) {
1414
+ return {
1415
+ hasError: true,
1416
+ errorType: "heuristic",
1417
+ errorMessage: candidate.textContent
1418
+ ?.trim()
1419
+ .slice(0, 200),
1420
+ };
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ return { hasError: false, errorType: null, errorMessage: null };
1428
+ },
1429
+ { selectors: errorSelectors, useHeuristics: errorHeuristics }
1430
+ );
1431
+ } catch (e) {
1432
+ // Page may be navigating, return no error
1433
+ return { hasError: false, errorType: null, errorMessage: null };
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * Race between page ready state and error state detection
1439
+ * @param {string} readySelector - Selector indicating page is ready
1440
+ * @param {Object} options - Options
1441
+ * @param {number} options.timeout - Max time to wait
1442
+ * @param {string[]} options.errorSelectors - Error selectors
1443
+ * @param {boolean} options.errorHeuristics - Enable heuristic detection
1444
+ * @returns {Promise<{status: 'ready'|'error'|'timeout', errorDetails?: Object}>}
1445
+ */
1446
+ async waitForReadyOrError(readySelector, options = {}) {
1447
+ const {
1448
+ timeout = 15000,
1449
+ errorSelectors = ["[data-testid='page-error']", "[data-error-type]"],
1450
+ errorHeuristics = true,
1451
+ } = options;
1452
+
1453
+ const startTime = Date.now();
1454
+ const pollInterval = 300;
1455
+
1456
+ // Start waitForSelector in background
1457
+ const readyPromise = this.page
1458
+ .locator(readySelector)
1459
+ .first()
1460
+ .waitFor({ state: "visible", timeout })
1461
+ .then(() => ({ status: "ready" }))
1462
+ .catch(() => null); // Will be handled by timeout
1463
+
1464
+ // Poll for error state
1465
+ while (Date.now() - startTime < timeout) {
1466
+ // Check if ready selector resolved
1467
+ const readyResult = await Promise.race([
1468
+ readyPromise,
1469
+ new Promise((resolve) => setTimeout(() => resolve(null), pollInterval)),
1470
+ ]);
1471
+
1472
+ if (readyResult) {
1473
+ return readyResult;
1474
+ }
1475
+
1476
+ // Check for error state
1477
+ const errorState = await this._detectErrorState({
1478
+ errorSelectors,
1479
+ errorHeuristics,
1480
+ });
1481
+
1482
+ if (errorState.hasError) {
1483
+ return { status: "error", errorDetails: errorState };
1484
+ }
1485
+ }
1486
+
1487
+ return { status: "timeout" };
1488
+ }
1489
+
1490
+ /**
1491
+ * Verify page has meaningful content before capture (optional)
1492
+ * @param {Object} options - Verification options
1493
+ * @param {number} options.minContentLength - Minimum body text length
1494
+ * @param {string[]} options.rejectSelectors - Selectors that indicate bad content
1495
+ * @returns {Promise<{valid: boolean, reason?: string}>}
1496
+ */
1497
+ async _verifyContent(options = {}) {
1498
+ const { minContentLength = 100, rejectSelectors = [] } = options;
1499
+
1500
+ try {
1501
+ return await this.page.evaluate(
1502
+ ({ minLen, rejectSels }) => {
1503
+ const bodyText = (document.body?.innerText || "").trim();
1504
+
1505
+ if (bodyText.length < minLen) {
1506
+ return {
1507
+ valid: false,
1508
+ reason: `Page content too short (${bodyText.length} chars, minimum ${minLen})`,
1509
+ };
1510
+ }
1511
+
1512
+ for (const sel of rejectSels) {
1513
+ try {
1514
+ if (document.querySelector(sel)) {
1515
+ return {
1516
+ valid: false,
1517
+ reason: `Reject selector found: ${sel}`,
1518
+ };
1519
+ }
1520
+ } catch (e) {
1521
+ // Invalid selector, skip
1522
+ }
1523
+ }
1524
+
1525
+ return { valid: true };
1526
+ },
1527
+ { minLen: minContentLength, rejectSels: rejectSelectors }
1528
+ );
1529
+ } catch (e) {
1530
+ return { valid: true }; // Don't block on evaluation errors
1531
+ }
1532
+ }
1533
+
1534
+ /**
1535
+ * Get all captured assets
1536
+ */
1537
+ getAssets() {
1538
+ return this.capturedAssets;
1539
+ }
1540
+
1541
+ /**
1542
+ * Close the browser
1543
+ */
1544
+ async close() {
1545
+ if (this.browser) {
1546
+ await this.browser.close();
1547
+ this.browser = null;
1548
+ this.page = null;
1549
+ this.context = null;
1550
+ }
1551
+ this.logger(chalk.green("✔ Browser closed"));
1552
+ }
1553
+
1554
+ /**
1555
+ * Run a capture script
1556
+ * Script is an array of step objects
1557
+ */
1558
+ async runScript(script) {
1559
+ for (const step of script) {
1560
+ const { action, ...params } = step;
1561
+
1562
+ if (!this[action]) {
1563
+ throw new Error(`Unknown action: ${action}`);
1564
+ }
1565
+
1566
+ // Handle different action signatures
1567
+ switch (action) {
1568
+ case "goto":
1569
+ await this.goto(params.url, params);
1570
+ break;
1571
+ case "click":
1572
+ await this.click(params.target, params);
1573
+ break;
1574
+ case "hover":
1575
+ await this.hover(params.target, params);
1576
+ break;
1577
+ case "type":
1578
+ await this.type(params.target, params.text, params);
1579
+ break;
1580
+ case "wait":
1581
+ await this.wait(params.ms || params.duration || 1000);
1582
+ break;
1583
+ case "waitFor":
1584
+ await this.waitFor(params.target, params);
1585
+ break;
1586
+ case "capture":
1587
+ await this.capture(params.name, params);
1588
+ break;
1589
+ case "captureWithHover":
1590
+ await this.captureWithHover(params.name, params.hoverTarget, params);
1591
+ break;
1592
+ default:
1593
+ this.logger(chalk.yellow(` ⚠ Unknown action: ${action}`));
1594
+ }
1595
+ }
1596
+
1597
+ return this.capturedAssets;
1598
+ }
1599
+ }
1600
+
1601
+ /**
1602
+ * Helper to create and run a capture script
1603
+ */
1604
+ async function runCaptureScript(script, options = {}) {
1605
+ const engine = new CaptureEngine(options);
1606
+
1607
+ try {
1608
+ await engine.init();
1609
+ const assets = await engine.runScript(script);
1610
+ return { success: true, assets };
1611
+ } catch (error) {
1612
+ console.error(chalk.red(`Capture failed: ${error.message}`));
1613
+ return { success: false, error: error.message };
1614
+ } finally {
1615
+ await engine.close();
1616
+ }
1617
+ }
1618
+
1619
+ module.exports = {
1620
+ CaptureEngine,
1621
+ runCaptureScript,
1622
+ isAuthRedirectUrl,
1623
+ };