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 });