/ src / utils / browser-helpers.js
browser-helpers.js
  1  /**
  2   * Extracted pure functions from page.evaluate() browser-context code.
  3   * These functions contain the business logic that was previously only executable
  4   * inside Playwright's browser context, now testable with Node.js + JSDOM.
  5   *
  6   * Source modules: capture.js, dom-crop-analyzer.js, stealth-browser.js
  7   */
  8  
  9  // ============================================================
 10  // SELECTOR CONSTANTS
 11  // ============================================================
 12  
 13  /** Selectors for detecting navigation/header elements */
 14  export const NAV_SELECTORS = [
 15    'nav',
 16    'header',
 17    '[role="navigation"]',
 18    '[role="banner"]',
 19    '.navbar',
 20    '.header',
 21    '.nav',
 22    '.navigation',
 23    '#header',
 24    '#navbar',
 25    '#navigation',
 26  ];
 27  
 28  /** Selectors for CTA elements (buttons, contact links, forms) */
 29  export const CTA_SELECTORS = [
 30    'button',
 31    'a[href^="tel:"]',
 32    'a[href^="mailto:"]',
 33    'a[href*="contact"]',
 34    'a[href*="quote"]',
 35    'a[href*="booking"]',
 36    'a[href*="appointment"]',
 37    '[class*="cta"]',
 38    '[class*="btn"]',
 39    '[class*="button"]',
 40    '[class*="call"]',
 41    '[id*="cta"]',
 42    '[id*="call"]',
 43    'input[type="submit"]',
 44  ];
 45  
 46  /** Selectors for trust signal elements (badges, certifications) */
 47  export const TRUST_SIGNAL_SELECTORS = [
 48    'img[alt*="certified"]',
 49    'img[alt*="certification"]',
 50    'img[alt*="badge"]',
 51    'img[alt*="award"]',
 52    'img[alt*="accredited"]',
 53    'img[alt*="licensed"]',
 54    'img[alt*="insured"]',
 55    'img[alt*="rated"]',
 56    'img[alt*="BBB"]',
 57    '[class*="trust"]',
 58    '[class*="badge"]',
 59    '[class*="certification"]',
 60    '[class*="award"]',
 61    '[class*="accredit"]',
 62  ];
 63  
 64  /** Selectors for main content container elements */
 65  export const MAIN_CONTENT_SELECTORS = [
 66    'main',
 67    '[role="main"]',
 68    '.container',
 69    '.content',
 70    '.main-content',
 71    '#main',
 72    '#content',
 73    '.wrapper',
 74    '.page-wrapper',
 75  ];
 76  
 77  /** Selectors for chat widget overlays */
 78  export const CHAT_WIDGET_SELECTORS = [
 79    "[class*='intercom']",
 80    "[class*='drift']",
 81    "[class*='zendesk']",
 82    "[class*='livechat']",
 83    "[class*='tawk']",
 84    "[class*='crisp']",
 85    "[class*='hubspot-messages']",
 86    "[class*='tidio']",
 87    "[class*='olark']",
 88    "[id*='chat-widget']",
 89    "[id*='chat-bubble']",
 90    "[class*='chat-launcher']",
 91    "[class*='chat-button']",
 92    "[class*='messenger-frame']",
 93  ];
 94  
 95  /** Selectors for cookie banner overlays */
 96  export const COOKIE_BANNER_SELECTORS = [
 97    "[class*='cookie-banner']",
 98    "[class*='cookie-notice']",
 99    "[class*='cookie-consent']",
100    "[id*='cookie-banner']",
101    "[id*='cookie-notice']",
102    "[id*='cookie-consent']",
103    '#onetrust-banner-sdk',
104    '#CybotCookiebotDialog',
105    "[class*='gdpr-banner']",
106    "[class*='consent-banner']",
107    '.cc-banner',
108    '#cookieChoiceInfo',
109    '#wow-modal-overlay-1',
110    '[id^="wow-modal-overlay"]',
111    '.wow-modal-overlay',
112  ];
113  
114  /** Cloudflare/Turnstile block detection indicators */
115  export const CLOUDFLARE_INDICATORS = [
116    'checking your browser',
117    'verifying you are human',
118    'just a moment',
119    'enable javascript and cookies',
120    'attention required',
121    'cloudflare',
122  ];
123  
124  // ============================================================
125  // PURE FUNCTIONS (no DOM access needed)
126  // ============================================================
127  
128  /**
129   * Quadratic ease-in-out function for smooth scrolling
130   * @param {number} t - Progress value 0-1
131   * @returns {number} Eased value 0-1
132   */
133  export function easeInOutQuad(t) {
134    return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
135  }
136  
137  /**
138   * Check if an element's position and z-index indicate it's an overlay
139   * @param {string} position - CSS position value
140   * @param {number} zIndex - Parsed z-index value
141   * @returns {boolean}
142   */
143  export function isOverlayPositioned(position, zIndex) {
144    return (position === 'fixed' || position === 'absolute') && zIndex > 1000;
145  }
146  
147  /**
148   * Calculate viewport coverage percentages
149   * @param {{ width: number, height: number }} elementSize - Element dimensions
150   * @param {{ width: number, height: number }} viewport - Viewport dimensions
151   * @returns {{ widthCoverage: number, heightCoverage: number }}
152   */
153  export function calculateViewportCoverage(elementSize, viewport) {
154    return {
155      widthCoverage: elementSize.width / viewport.width,
156      heightCoverage: elementSize.height / viewport.height,
157    };
158  }
159  
160  /**
161   * Determine if an element covers enough viewport to be a full-screen overlay
162   * Must cover at least 80% in both dimensions
163   * @param {number} widthCoverage - Width coverage ratio (0-1)
164   * @param {number} heightCoverage - Height coverage ratio (0-1)
165   * @returns {boolean}
166   */
167  export function isFullScreenOverlay(widthCoverage, heightCoverage) {
168    return widthCoverage >= 0.8 && heightCoverage >= 0.8;
169  }
170  
171  /**
172   * Check if a background color indicates transparency
173   * @param {string} bgColor - CSS backgroundColor value
174   * @param {number} opacity - CSS opacity value
175   * @returns {boolean}
176   */
177  export function isTransparentBackground(bgColor, opacity) {
178    return (
179      bgColor === 'transparent' || bgColor === 'rgba(0, 0, 0, 0)' || opacity === 0 || bgColor === ''
180    );
181  }
182  
183  /**
184   * Check if an element is likely a navigation bar based on its dimensions
185   * @param {number} height - Element height in pixels
186   * @param {number} heightCoverage - Height as a fraction of viewport
187   * @returns {boolean}
188   */
189  export function isLikelyNavElement(height, heightCoverage) {
190    return height < 200 && heightCoverage < 0.3;
191  }
192  
193  /**
194   * Determine if a navigation element should be cropped
195   * Crop only if: (1) sticky/fixed, (2) no important content, (3) reasonable height
196   * @param {Object} params
197   * @param {boolean} params.isSticky - Whether nav is position:fixed or position:sticky
198   * @param {boolean} params.hasImportantContent - Whether nav contains CTAs/trust signals
199   * @param {number} params.navHeight - Nav height in pixels
200   * @param {number} params.viewportHeight - Viewport height in pixels
201   * @returns {{ shouldCrop: boolean, reasoning: string, topCrop: number, navBottom: number }}
202   */
203  export function evaluateNavCrop({
204    isSticky,
205    hasImportantContent,
206    navHeight,
207    viewportHeight,
208    navBottom,
209  }) {
210    const reasonableHeight = navHeight < viewportHeight * 0.3;
211    const shouldCrop = isSticky && !hasImportantContent && reasonableHeight;
212  
213    let reasoning;
214    if (shouldCrop) {
215      const topCrop = Math.ceil(navBottom);
216      reasoning = `Cropped ${topCrop}px sticky nav (no CTAs/trust signals)`;
217      return { shouldCrop, reasoning, topCrop, navBottom };
218    } else if (hasImportantContent) {
219      reasoning = 'Preserved nav (contains CTAs or trust signals)';
220    } else if (!isSticky) {
221      reasoning = 'Preserved nav (not sticky/fixed)';
222    } else if (!reasonableHeight) {
223      reasoning = `Preserved nav (too large: ${Math.round(navHeight)}px)`;
224    }
225  
226    return { shouldCrop: false, reasoning, topCrop: 0, navBottom };
227  }
228  
229  /**
230   * Calculate margin crop values from main content boundaries
231   * Only crops if significant whitespace (>5% on each side)
232   * @param {Object} mainRect - Main content bounding rect
233   * @param {number} mainRect.left - Left edge of main content
234   * @param {number} mainRect.right - Right edge of main content
235   * @param {number} viewportWidth - Viewport width
236   * @returns {{ leftCrop: number, rightCrop: number, reasoning: string }}
237   */
238  export function calculateMarginCrop(mainRect, viewportWidth) {
239    const leftMargin = mainRect.left;
240    const rightMargin = viewportWidth - mainRect.right;
241  
242    let leftCrop = 0;
243    let rightCrop = 0;
244  
245    if (leftMargin > viewportWidth * 0.05) {
246      leftCrop = Math.floor(leftMargin);
247    }
248    if (rightMargin > viewportWidth * 0.05) {
249      rightCrop = Math.floor(rightMargin);
250    }
251  
252    const reasoning =
253      leftCrop > 0 || rightCrop > 0
254        ? `Cropped margins (L:${leftCrop}px, R:${rightCrop}px)`
255        : 'No margin cropping';
256  
257    return { leftCrop, rightCrop, reasoning };
258  }
259  
260  /**
261   * Apply screenshot-type-specific adjustments to crop values
262   * @param {string} screenshotType - 'desktop_above'|'desktop_below'|'mobile_above'
263   * @param {Object} cropResult - Current crop values
264   * @returns {Object} Adjusted crop values with updated reasoning
265   */
266  export function applyTypeAdjustments(screenshotType, cropResult) {
267    const adjusted = { ...cropResult };
268  
269    if (screenshotType === 'desktop_below') {
270      adjusted.topCrop = 0;
271      adjusted.navReasoning = 'Below-fold (no nav cropping needed)';
272    } else if (screenshotType === 'mobile_above') {
273      adjusted.leftCrop = 0;
274      adjusted.rightCrop = 0;
275      adjusted.marginReasoning = 'Mobile (full-width)';
276    }
277  
278    return adjusted;
279  }
280  
281  /**
282   * Check if page content indicates a Cloudflare/Turnstile challenge
283   * @param {string} bodyText - Page body text (lowercased)
284   * @param {string} title - Page title (lowercased)
285   * @returns {boolean}
286   */
287  export function isCloudflareBlocked(bodyText, title) {
288    return CLOUDFLARE_INDICATORS.some(
289      indicator => bodyText.includes(indicator) || title.includes(indicator)
290    );
291  }
292  
293  /**
294   * Check if a CSS element is visible based on computed styles and dimensions
295   * @param {Object} style - Computed style properties
296   * @param {string} style.display - CSS display value
297   * @param {string} style.visibility - CSS visibility value
298   * @param {string} [style.opacity] - CSS opacity value
299   * @param {Object} rect - Bounding client rect
300   * @param {number} rect.width - Element width
301   * @param {number} rect.height - Element height
302   * @param {Object} [options] - Additional options
303   * @param {number} [options.minWidth=0] - Minimum width to consider visible
304   * @param {number} [options.minHeight=0] - Minimum height to consider visible
305   * @returns {boolean}
306   */
307  export function isElementVisible(style, rect, options = {}) {
308    const { minWidth = 0, minHeight = 0 } = options;
309  
310    if (style.display === 'none' || style.visibility === 'hidden') {
311      return false;
312    }
313  
314    if (style.opacity === '0') {
315      return false;
316    }
317  
318    if (rect.width <= minWidth || rect.height <= minHeight) {
319      return false;
320    }
321  
322    return true;
323  }
324  
325  /**
326   * Determine if a positioned element should be treated as a modal overlay
327   * Combines all overlay detection heuristics
328   * @param {Object} params
329   * @param {string} params.position - CSS position value
330   * @param {number} params.zIndex - Parsed z-index value
331   * @param {Object} params.rect - Element bounding rect { width, height }
332   * @param {Object} params.viewport - Viewport dimensions { width, height }
333   * @param {string} params.bgColor - CSS backgroundColor
334   * @param {number} params.opacity - CSS opacity
335   * @param {string} params.display - CSS display value
336   * @param {string} params.visibility - CSS visibility value
337   * @returns {{ isOverlay: boolean, reason: string }}
338   */
339  export function classifyOverlayElement(params) {
340    const { position, zIndex, rect, viewport, bgColor, opacity, display, visibility } = params;
341  
342    // Must be positioned
343    if (position !== 'fixed' && position !== 'absolute') {
344      return { isOverlay: false, reason: 'not positioned' };
345    }
346  
347    // Must not be already hidden
348    if (display === 'none' || visibility === 'hidden') {
349      return { isOverlay: false, reason: 'already hidden' };
350    }
351  
352    // Must have significant z-index
353    if (zIndex < 100) {
354      return { isOverlay: false, reason: 'low z-index' };
355    }
356  
357    // Must cover most of viewport
358    const coverage = calculateViewportCoverage(rect, viewport);
359    if (!isFullScreenOverlay(coverage.widthCoverage, coverage.heightCoverage)) {
360      return { isOverlay: false, reason: 'not full screen' };
361    }
362  
363    // Must not be transparent
364    if (isTransparentBackground(bgColor, opacity)) {
365      return { isOverlay: false, reason: 'transparent' };
366    }
367  
368    // Must not look like a nav bar
369    if (isLikelyNavElement(rect.height, coverage.heightCoverage)) {
370      return { isOverlay: false, reason: 'likely nav element' };
371    }
372  
373    return { isOverlay: true, reason: 'full-screen modal overlay' };
374  }
375  
376  /**
377   * Generate Bezier curve waypoints for human-like mouse movement
378   * @param {number} x1 - Start X
379   * @param {number} y1 - Start Y
380   * @param {number} x2 - End X
381   * @param {number} y2 - End Y
382   * @param {number} numPoints - Number of waypoints (default 4)
383   * @param {Function} [randomFn] - Random function for testing (default Math.random)
384   * @returns {Array<{x: number, y: number}>} Waypoint coordinates
385   */
386  // eslint-disable-next-line max-params -- mirrors Playwright mouse movement API
387  export function generateBezierWaypoints(x1, y1, x2, y2, numPoints = 4, randomFn = Math.random) {
388    const points = [];
389    const cp1x = x1 + (x2 - x1) * (0.2 + randomFn() * 0.3);
390    const cp1y = y1 + (y2 - y1) * (0.2 + randomFn() * 0.3);
391    const cp2x = x1 + (x2 - x1) * (0.5 + randomFn() * 0.3);
392    const cp2y = y1 + (y2 - y1) * (0.5 + randomFn() * 0.3);
393  
394    for (let i = 0; i <= numPoints; i++) {
395      const t = i / numPoints;
396      const mt = 1 - t;
397      const x = mt ** 3 * x1 + 3 * mt ** 2 * t * cp1x + 3 * mt * t ** 2 * cp2x + t ** 3 * x2;
398      const y = mt ** 3 * y1 + 3 * mt ** 2 * t * cp1y + 3 * mt * t ** 2 * cp2y + t ** 3 * y2;
399      points.push({ x: Math.round(x), y: Math.round(y) });
400    }
401    return points;
402  }