/ tests / utils / browser-helpers.test.js
browser-helpers.test.js
  1  /**
  2   * Tests for src/utils/browser-helpers.js
  3   *
  4   * Covers all pure functions and selector constant arrays.
  5   * No external dependencies — pure math/logic.
  6   */
  7  
  8  import { test, describe } from 'node:test';
  9  import assert from 'node:assert/strict';
 10  
 11  import {
 12    NAV_SELECTORS,
 13    CTA_SELECTORS,
 14    TRUST_SIGNAL_SELECTORS,
 15    MAIN_CONTENT_SELECTORS,
 16    CHAT_WIDGET_SELECTORS,
 17    COOKIE_BANNER_SELECTORS,
 18    CLOUDFLARE_INDICATORS,
 19    easeInOutQuad,
 20    isOverlayPositioned,
 21    calculateViewportCoverage,
 22    isFullScreenOverlay,
 23    isTransparentBackground,
 24    isLikelyNavElement,
 25    evaluateNavCrop,
 26    calculateMarginCrop,
 27    applyTypeAdjustments,
 28    isCloudflareBlocked,
 29    isElementVisible,
 30    classifyOverlayElement,
 31    generateBezierWaypoints,
 32  } from '../../src/utils/browser-helpers.js';
 33  
 34  // ─── Selector constants ────────────────────────────────────────────────────────
 35  
 36  describe('selector constants', () => {
 37    test('NAV_SELECTORS is a non-empty array', () => {
 38      assert.ok(Array.isArray(NAV_SELECTORS));
 39      assert.ok(NAV_SELECTORS.length > 0);
 40      assert.ok(NAV_SELECTORS.includes('nav'));
 41      assert.ok(NAV_SELECTORS.includes('header'));
 42    });
 43  
 44    test('CTA_SELECTORS is a non-empty array', () => {
 45      assert.ok(Array.isArray(CTA_SELECTORS));
 46      assert.ok(CTA_SELECTORS.includes('button'));
 47      assert.ok(CTA_SELECTORS.includes('a[href^="tel:"]'));
 48    });
 49  
 50    test('TRUST_SIGNAL_SELECTORS is a non-empty array', () => {
 51      assert.ok(Array.isArray(TRUST_SIGNAL_SELECTORS));
 52      assert.ok(TRUST_SIGNAL_SELECTORS.some(s => s.includes('certified')));
 53    });
 54  
 55    test('MAIN_CONTENT_SELECTORS is a non-empty array', () => {
 56      assert.ok(Array.isArray(MAIN_CONTENT_SELECTORS));
 57      assert.ok(MAIN_CONTENT_SELECTORS.includes('main'));
 58    });
 59  
 60    test('CHAT_WIDGET_SELECTORS is a non-empty array', () => {
 61      assert.ok(Array.isArray(CHAT_WIDGET_SELECTORS));
 62      assert.ok(CHAT_WIDGET_SELECTORS.some(s => s.includes('intercom')));
 63    });
 64  
 65    test('COOKIE_BANNER_SELECTORS is a non-empty array', () => {
 66      assert.ok(Array.isArray(COOKIE_BANNER_SELECTORS));
 67      assert.ok(COOKIE_BANNER_SELECTORS.some(s => s.includes('cookie')));
 68    });
 69  
 70    test('CLOUDFLARE_INDICATORS is a non-empty array of strings', () => {
 71      assert.ok(Array.isArray(CLOUDFLARE_INDICATORS));
 72      assert.ok(CLOUDFLARE_INDICATORS.includes('cloudflare'));
 73      assert.ok(CLOUDFLARE_INDICATORS.includes('just a moment'));
 74    });
 75  });
 76  
 77  // ─── easeInOutQuad ────────────────────────────────────────────────────────────
 78  
 79  describe('easeInOutQuad', () => {
 80    test('returns 0 at t=0', () => {
 81      assert.equal(easeInOutQuad(0), 0);
 82    });
 83  
 84    test('returns 1 at t=1', () => {
 85      assert.equal(easeInOutQuad(1), 1);
 86    });
 87  
 88    test('returns 0.5 at t=0.5 (midpoint symmetry)', () => {
 89      assert.equal(easeInOutQuad(0.5), 0.5);
 90    });
 91  
 92    test('returns value < 0.5 for t < 0.5 (slow start)', () => {
 93      const v = easeInOutQuad(0.25);
 94      assert.ok(v < 0.25, `expected easing < linear at 0.25, got ${v}`);
 95    });
 96  
 97    test('returns value > 0.5 for t just above 0.5 (fast end)', () => {
 98      const v = easeInOutQuad(0.75);
 99      assert.ok(v > 0.75, `expected easing > linear at 0.75, got ${v}`);
100    });
101  });
102  
103  // ─── isOverlayPositioned ──────────────────────────────────────────────────────
104  
105  describe('isOverlayPositioned', () => {
106    test('returns true for fixed + high z-index', () => {
107      assert.equal(isOverlayPositioned('fixed', 1001), true);
108    });
109  
110    test('returns true for absolute + high z-index', () => {
111      assert.equal(isOverlayPositioned('absolute', 9999), true);
112    });
113  
114    test('returns false for static position', () => {
115      assert.equal(isOverlayPositioned('static', 9999), false);
116    });
117  
118    test('returns false for relative position', () => {
119      assert.equal(isOverlayPositioned('relative', 9999), false);
120    });
121  
122    test('returns false for z-index exactly 1000 (boundary)', () => {
123      assert.equal(isOverlayPositioned('fixed', 1000), false);
124    });
125  
126    test('returns false for z-index 0', () => {
127      assert.equal(isOverlayPositioned('fixed', 0), false);
128    });
129  });
130  
131  // ─── calculateViewportCoverage ────────────────────────────────────────────────
132  
133  describe('calculateViewportCoverage', () => {
134    test('returns full coverage for same-size element and viewport', () => {
135      const { widthCoverage, heightCoverage } = calculateViewportCoverage(
136        { width: 1280, height: 800 },
137        { width: 1280, height: 800 }
138      );
139      assert.equal(widthCoverage, 1);
140      assert.equal(heightCoverage, 1);
141    });
142  
143    test('returns half coverage for half-size element', () => {
144      const { widthCoverage, heightCoverage } = calculateViewportCoverage(
145        { width: 640, height: 400 },
146        { width: 1280, height: 800 }
147      );
148      assert.equal(widthCoverage, 0.5);
149      assert.equal(heightCoverage, 0.5);
150    });
151  
152    test('returns fractional coverage', () => {
153      const { widthCoverage } = calculateViewportCoverage(
154        { width: 100, height: 100 },
155        { width: 1280, height: 800 }
156      );
157      assert.ok(widthCoverage > 0 && widthCoverage < 1);
158    });
159  });
160  
161  // ─── isFullScreenOverlay ──────────────────────────────────────────────────────
162  
163  describe('isFullScreenOverlay', () => {
164    test('returns true when both >= 0.8', () => {
165      assert.equal(isFullScreenOverlay(0.8, 0.8), true);
166      assert.equal(isFullScreenOverlay(1.0, 1.0), true);
167      assert.equal(isFullScreenOverlay(0.9, 0.85), true);
168    });
169  
170    test('returns false when width < 0.8', () => {
171      assert.equal(isFullScreenOverlay(0.79, 0.9), false);
172    });
173  
174    test('returns false when height < 0.8', () => {
175      assert.equal(isFullScreenOverlay(0.9, 0.79), false);
176    });
177  
178    test('returns false when both < 0.8', () => {
179      assert.equal(isFullScreenOverlay(0.5, 0.5), false);
180    });
181  });
182  
183  // ─── isTransparentBackground ──────────────────────────────────────────────────
184  
185  describe('isTransparentBackground', () => {
186    test('returns true for "transparent"', () => {
187      assert.equal(isTransparentBackground('transparent', 1), true);
188    });
189  
190    test('returns true for rgba(0, 0, 0, 0)', () => {
191      assert.equal(isTransparentBackground('rgba(0, 0, 0, 0)', 1), true);
192    });
193  
194    test('returns true for opacity=0', () => {
195      assert.equal(isTransparentBackground('white', 0), true);
196    });
197  
198    test('returns true for empty bgColor', () => {
199      assert.equal(isTransparentBackground('', 1), true);
200    });
201  
202    test('returns false for solid white', () => {
203      assert.equal(isTransparentBackground('white', 1), false);
204    });
205  
206    test('returns false for rgba with alpha=1', () => {
207      assert.equal(isTransparentBackground('rgba(0, 0, 0, 1)', 1), false);
208    });
209  });
210  
211  // ─── isLikelyNavElement ───────────────────────────────────────────────────────
212  
213  describe('isLikelyNavElement', () => {
214    test('returns true for short element with small height coverage', () => {
215      assert.equal(isLikelyNavElement(80, 0.1), true);
216    });
217  
218    test('returns false for tall element', () => {
219      assert.equal(isLikelyNavElement(250, 0.1), false);
220    });
221  
222    test('returns false when height coverage >= 0.3', () => {
223      assert.equal(isLikelyNavElement(80, 0.3), false);
224    });
225  
226    test('returns false for both conditions failed', () => {
227      assert.equal(isLikelyNavElement(300, 0.5), false);
228    });
229  });
230  
231  // ─── evaluateNavCrop ──────────────────────────────────────────────────────────
232  
233  describe('evaluateNavCrop', () => {
234    test('crops sticky nav with no important content and reasonable height', () => {
235      const result = evaluateNavCrop({
236        isSticky: true,
237        hasImportantContent: false,
238        navHeight: 60,
239        viewportHeight: 800,
240        navBottom: 60,
241      });
242      assert.equal(result.shouldCrop, true);
243      assert.equal(result.topCrop, 60);
244      assert.ok(result.reasoning.includes('sticky nav'));
245    });
246  
247    test('preserves nav with important content (CTAs)', () => {
248      const result = evaluateNavCrop({
249        isSticky: true,
250        hasImportantContent: true,
251        navHeight: 60,
252        viewportHeight: 800,
253        navBottom: 60,
254      });
255      assert.equal(result.shouldCrop, false);
256      assert.ok(result.reasoning.includes('CTAs'));
257    });
258  
259    test('preserves non-sticky nav', () => {
260      const result = evaluateNavCrop({
261        isSticky: false,
262        hasImportantContent: false,
263        navHeight: 60,
264        viewportHeight: 800,
265        navBottom: 60,
266      });
267      assert.equal(result.shouldCrop, false);
268      assert.ok(result.reasoning.includes('not sticky'));
269    });
270  
271    test('preserves oversized nav (>30% viewport height)', () => {
272      const result = evaluateNavCrop({
273        isSticky: true,
274        hasImportantContent: false,
275        navHeight: 300,
276        viewportHeight: 800,
277        navBottom: 300,
278      });
279      assert.equal(result.shouldCrop, false);
280      assert.ok(result.reasoning.includes('too large'));
281    });
282  });
283  
284  // ─── calculateMarginCrop ──────────────────────────────────────────────────────
285  
286  describe('calculateMarginCrop', () => {
287    test('crops significant left and right margins', () => {
288      const result = calculateMarginCrop({ left: 100, right: 1180 }, 1280);
289      assert.equal(result.leftCrop, 100);
290      assert.equal(result.rightCrop, 100);
291      assert.ok(result.reasoning.includes('Cropped'));
292    });
293  
294    test('no crop for small margins (< 5% viewport)', () => {
295      // 1% of 1280 = 12.8 — below threshold
296      const result = calculateMarginCrop({ left: 10, right: 1270 }, 1280);
297      assert.equal(result.leftCrop, 0);
298      assert.equal(result.rightCrop, 0);
299      assert.ok(result.reasoning.includes('No margin'));
300    });
301  
302    test('only crops left when only left margin is significant', () => {
303      const result = calculateMarginCrop({ left: 200, right: 1275 }, 1280);
304      assert.ok(result.leftCrop > 0);
305      assert.equal(result.rightCrop, 0);
306    });
307  
308    test('only crops right when only right margin is significant', () => {
309      const result = calculateMarginCrop({ left: 10, right: 1080 }, 1280);
310      assert.equal(result.leftCrop, 0);
311      assert.ok(result.rightCrop > 0);
312    });
313  });
314  
315  // ─── applyTypeAdjustments ─────────────────────────────────────────────────────
316  
317  describe('applyTypeAdjustments', () => {
318    test('desktop_below clears topCrop', () => {
319      const crop = { topCrop: 60, leftCrop: 0, rightCrop: 0 };
320      const result = applyTypeAdjustments('desktop_below', crop);
321      assert.equal(result.topCrop, 0);
322      assert.ok(result.navReasoning.includes('Below-fold'));
323    });
324  
325    test('mobile_above clears left and right crop', () => {
326      const crop = { topCrop: 60, leftCrop: 50, rightCrop: 50 };
327      const result = applyTypeAdjustments('mobile_above', crop);
328      assert.equal(result.leftCrop, 0);
329      assert.equal(result.rightCrop, 0);
330      assert.ok(result.marginReasoning.includes('Mobile'));
331    });
332  
333    test('desktop_above makes no changes', () => {
334      const crop = { topCrop: 60, leftCrop: 50, rightCrop: 50 };
335      const result = applyTypeAdjustments('desktop_above', crop);
336      assert.equal(result.topCrop, 60);
337      assert.equal(result.leftCrop, 50);
338      assert.equal(result.rightCrop, 50);
339    });
340  
341    test('does not mutate original crop object', () => {
342      const crop = { topCrop: 60, leftCrop: 50, rightCrop: 50 };
343      applyTypeAdjustments('desktop_below', crop);
344      assert.equal(crop.topCrop, 60); // original unchanged
345    });
346  });
347  
348  // ─── isCloudflareBlocked ──────────────────────────────────────────────────────
349  
350  describe('isCloudflareBlocked', () => {
351    test('detects "just a moment" in body text', () => {
352      assert.equal(isCloudflareBlocked('please wait, just a moment', ''), true);
353    });
354  
355    test('detects "cloudflare" in body text', () => {
356      assert.equal(isCloudflareBlocked('protected by cloudflare', ''), true);
357    });
358  
359    test('detects indicator in page title', () => {
360      assert.equal(isCloudflareBlocked('', 'attention required | cloudflare'), true);
361    });
362  
363    test('detects "checking your browser" in body', () => {
364      assert.equal(isCloudflareBlocked('checking your browser before accessing', ''), true);
365    });
366  
367    test('returns false for normal page content', () => {
368      assert.equal(
369        isCloudflareBlocked('welcome to our plumbing service', 'Home | Acme Plumbing'),
370        false
371      );
372    });
373  
374    test('returns false for empty strings', () => {
375      assert.equal(isCloudflareBlocked('', ''), false);
376    });
377  });
378  
379  // ─── isElementVisible ─────────────────────────────────────────────────────────
380  
381  describe('isElementVisible', () => {
382    test('returns false when display is none', () => {
383      assert.equal(
384        isElementVisible({ display: 'none', visibility: 'visible' }, { width: 100, height: 100 }),
385        false
386      );
387    });
388  
389    test('returns false when visibility is hidden', () => {
390      assert.equal(
391        isElementVisible({ display: 'block', visibility: 'hidden' }, { width: 100, height: 100 }),
392        false
393      );
394    });
395  
396    test('returns false when opacity is "0"', () => {
397      assert.equal(
398        isElementVisible(
399          { display: 'block', visibility: 'visible', opacity: '0' },
400          { width: 100, height: 100 }
401        ),
402        false
403      );
404    });
405  
406    test('returns false when width is 0', () => {
407      assert.equal(
408        isElementVisible({ display: 'block', visibility: 'visible' }, { width: 0, height: 100 }),
409        false
410      );
411    });
412  
413    test('returns false when height is 0', () => {
414      assert.equal(
415        isElementVisible({ display: 'block', visibility: 'visible' }, { width: 100, height: 0 }),
416        false
417      );
418    });
419  
420    test('returns true for normal visible element', () => {
421      assert.equal(
422        isElementVisible({ display: 'block', visibility: 'visible' }, { width: 100, height: 100 }),
423        true
424      );
425    });
426  
427    test('respects minWidth option', () => {
428      assert.equal(
429        isElementVisible(
430          { display: 'block', visibility: 'visible' },
431          { width: 5, height: 100 },
432          { minWidth: 10 }
433        ),
434        false
435      );
436    });
437  
438    test('respects minHeight option', () => {
439      assert.equal(
440        isElementVisible(
441          { display: 'block', visibility: 'visible' },
442          { width: 100, height: 5 },
443          { minHeight: 10 }
444        ),
445        false
446      );
447    });
448  });
449  
450  // ─── classifyOverlayElement ───────────────────────────────────────────────────
451  
452  describe('classifyOverlayElement', () => {
453    const fullOverlay = {
454      position: 'fixed',
455      zIndex: 9999,
456      rect: { width: 1280, height: 800 },
457      viewport: { width: 1280, height: 800 },
458      bgColor: 'rgba(0,0,0,0.8)',
459      opacity: 1,
460      display: 'block',
461      visibility: 'visible',
462    };
463  
464    test('returns isOverlay=true for full-screen fixed modal', () => {
465      const result = classifyOverlayElement(fullOverlay);
466      assert.equal(result.isOverlay, true);
467      assert.ok(result.reason.includes('modal'));
468    });
469  
470    test('returns isOverlay=false for static position', () => {
471      const result = classifyOverlayElement({ ...fullOverlay, position: 'static' });
472      assert.equal(result.isOverlay, false);
473      assert.equal(result.reason, 'not positioned');
474    });
475  
476    test('returns isOverlay=false for display:none', () => {
477      const result = classifyOverlayElement({ ...fullOverlay, display: 'none' });
478      assert.equal(result.isOverlay, false);
479      assert.equal(result.reason, 'already hidden');
480    });
481  
482    test('returns isOverlay=false for visibility:hidden', () => {
483      const result = classifyOverlayElement({ ...fullOverlay, visibility: 'hidden' });
484      assert.equal(result.isOverlay, false);
485      assert.equal(result.reason, 'already hidden');
486    });
487  
488    test('returns isOverlay=false for low z-index', () => {
489      const result = classifyOverlayElement({ ...fullOverlay, zIndex: 50 });
490      assert.equal(result.isOverlay, false);
491      assert.equal(result.reason, 'low z-index');
492    });
493  
494    test('returns isOverlay=false for small element (not full-screen)', () => {
495      const result = classifyOverlayElement({
496        ...fullOverlay,
497        rect: { width: 300, height: 200 },
498      });
499      assert.equal(result.isOverlay, false);
500      assert.equal(result.reason, 'not full screen');
501    });
502  
503    test('returns isOverlay=false for transparent background', () => {
504      const result = classifyOverlayElement({ ...fullOverlay, bgColor: 'transparent' });
505      assert.equal(result.isOverlay, false);
506      assert.equal(result.reason, 'transparent');
507    });
508  
509    test('returns isOverlay=false for nav-like element', () => {
510      // Height of 60 with full width but small height coverage
511      const result = classifyOverlayElement({
512        ...fullOverlay,
513        rect: { width: 1280, height: 60 }, // would pass full-screen width but fail height
514      });
515      // height coverage = 60/800 = 0.075 < 0.8, so "not full screen"
516      assert.equal(result.isOverlay, false);
517    });
518  });
519  
520  // ─── generateBezierWaypoints ──────────────────────────────────────────────────
521  
522  describe('generateBezierWaypoints', () => {
523    test('returns array of waypoints', () => {
524      const points = generateBezierWaypoints(0, 0, 100, 100);
525      assert.ok(Array.isArray(points));
526      assert.ok(points.length > 0);
527    });
528  
529    test('first point is near start coordinates', () => {
530      const points = generateBezierWaypoints(0, 0, 100, 100, 4);
531      const first = points[0];
532      assert.equal(first.x, 0);
533      assert.equal(first.y, 0);
534    });
535  
536    test('last point is at end coordinates', () => {
537      const points = generateBezierWaypoints(0, 0, 100, 100, 4);
538      const last = points[points.length - 1];
539      assert.equal(last.x, 100);
540      assert.equal(last.y, 100);
541    });
542  
543    test('returns numPoints+1 waypoints', () => {
544      const points = generateBezierWaypoints(0, 0, 100, 100, 4);
545      assert.equal(points.length, 5); // numPoints + 1
546    });
547  
548    test('all points have x and y properties', () => {
549      const points = generateBezierWaypoints(0, 0, 100, 100, 3);
550      for (const p of points) {
551        assert.ok('x' in p);
552        assert.ok('y' in p);
553        assert.equal(typeof p.x, 'number');
554        assert.equal(typeof p.y, 'number');
555      }
556    });
557  
558    test('accepts custom random function for determinism', () => {
559      const deterministicRandom = () => 0.5;
560      const p1 = generateBezierWaypoints(0, 0, 100, 100, 4, deterministicRandom);
561      const p2 = generateBezierWaypoints(0, 0, 100, 100, 4, deterministicRandom);
562      assert.deepEqual(p1, p2);
563    });
564  
565    test('works with non-zero start coordinates', () => {
566      const points = generateBezierWaypoints(50, 30, 200, 400, 4);
567      assert.equal(points[0].x, 50);
568      assert.equal(points[0].y, 30);
569      assert.equal(points[points.length - 1].x, 200);
570      assert.equal(points[points.length - 1].y, 400);
571    });
572  });