dom-crop-analyzer.unit.test.js
1 /** 2 * Unit tests for DOM-aware screenshot crop boundary analyzer 3 * Uses mocked Playwright page to test logic without browser overhead 4 */ 5 6 import { test, describe, beforeEach, afterEach } from 'node:test'; 7 import assert from 'node:assert'; 8 import { analyzeCropBoundaries } from '../../src/utils/dom-crop-analyzer.js'; 9 10 // Mock logger to avoid console output in tests 11 let originalLogger; 12 beforeEach(() => { 13 originalLogger = global.Logger; 14 global.Logger = class { 15 debug() {} 16 warn() {} 17 info() {} 18 error() {} 19 }; 20 }); 21 22 afterEach(() => { 23 if (originalLogger) { 24 global.Logger = originalLogger; 25 } 26 }); 27 28 describe('analyzeCropBoundaries - Unit Tests', () => { 29 describe('desktop_above with sticky nav', () => { 30 test('crops sticky nav without important content', async () => { 31 const mockPage = createMockPage({ 32 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: true }], 33 hasImportantContent: false, 34 viewportHeight: 1080, 35 viewportWidth: 1920, 36 }); 37 38 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 39 40 assert.strictEqual(result.topCrop, 80); 41 assert.strictEqual(result.leftCrop, 0); 42 assert.strictEqual(result.rightCrop, 0); 43 assert.strictEqual(result.metadata.navCropped, true); 44 assert.match(result.metadata.navReasoning, /Cropped.*sticky nav/); 45 }); 46 47 test('preserves sticky nav with CTAs', async () => { 48 const mockPage = createMockPage({ 49 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: true }], 50 hasImportantContent: true, 51 viewportHeight: 1080, 52 viewportWidth: 1920, 53 }); 54 55 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 56 57 assert.strictEqual(result.topCrop, 0); 58 assert.strictEqual(result.metadata.navCropped, false); 59 assert.match(result.metadata.navReasoning, /contains CTAs/); 60 }); 61 62 test('preserves non-sticky nav', async () => { 63 const mockPage = createMockPage({ 64 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: false }], 65 hasImportantContent: false, 66 viewportHeight: 1080, 67 viewportWidth: 1920, 68 }); 69 70 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 71 72 assert.strictEqual(result.topCrop, 0); 73 assert.strictEqual(result.metadata.navCropped, false); 74 assert.match(result.metadata.navReasoning, /not sticky/); 75 }); 76 77 test('preserves oversized nav (>30% viewport)', async () => { 78 const mockPage = createMockPage({ 79 navElements: [{ top: 0, bottom: 400, height: 400, width: 1920, isSticky: true }], 80 hasImportantContent: false, 81 viewportHeight: 1080, 82 viewportWidth: 1920, 83 }); 84 85 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 86 87 assert.strictEqual(result.topCrop, 0); 88 assert.strictEqual(result.metadata.navCropped, false); 89 assert.match(result.metadata.navReasoning, /too large/); 90 }); 91 }); 92 93 describe('desktop_above with main content margins', () => { 94 test('crops significant left and right margins', async () => { 95 const mockPage = createMockPage({ 96 navElements: [], 97 mainContent: { 98 left: 200, // >5% of 1920 (96px threshold) 99 right: 1720, // 200px right margin 100 width: 1520, 101 height: 1000, 102 }, 103 viewportHeight: 1080, 104 viewportWidth: 1920, 105 }); 106 107 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 108 109 assert.strictEqual(result.leftCrop, 200); 110 assert.strictEqual(result.rightCrop, 200); 111 assert.match(result.metadata.marginReasoning, /Cropped margins.*L:200px.*R:200px/); 112 }); 113 114 test('does not crop small margins (<5%)', async () => { 115 const mockPage = createMockPage({ 116 navElements: [], 117 mainContent: { 118 left: 50, // <5% of 1920 119 right: 1870, 120 width: 1820, 121 height: 1000, 122 }, 123 viewportHeight: 1080, 124 viewportWidth: 1920, 125 }); 126 127 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 128 129 assert.strictEqual(result.leftCrop, 0); 130 assert.strictEqual(result.rightCrop, 0); 131 }); 132 133 test('handles asymmetric margins', async () => { 134 const mockPage = createMockPage({ 135 navElements: [], 136 mainContent: { 137 left: 300, // >5% 138 right: 1900, // Only 20px right margin 139 width: 1600, 140 height: 1000, 141 }, 142 viewportHeight: 1080, 143 viewportWidth: 1920, 144 }); 145 146 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 147 148 assert.strictEqual(result.leftCrop, 300); 149 assert.strictEqual(result.rightCrop, 0); 150 }); 151 }); 152 153 describe('desktop_below type', () => { 154 test('disables nav cropping (already scrolled past nav)', async () => { 155 const mockPage = createMockPage({ 156 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: true }], 157 hasImportantContent: false, 158 viewportHeight: 1080, 159 viewportWidth: 1920, 160 }); 161 162 const result = await analyzeCropBoundaries(mockPage, 'desktop_below'); 163 164 assert.strictEqual(result.topCrop, 0); 165 assert.match(result.metadata.navReasoning, /Below-fold/); 166 }); 167 168 test('still crops margins on desktop_below', async () => { 169 const mockPage = createMockPage({ 170 navElements: [], 171 mainContent: { 172 left: 200, 173 right: 1720, 174 width: 1520, 175 height: 1000, 176 }, 177 viewportHeight: 1080, 178 viewportWidth: 1920, 179 }); 180 181 const result = await analyzeCropBoundaries(mockPage, 'desktop_below'); 182 183 assert.strictEqual(result.leftCrop, 200); 184 assert.strictEqual(result.rightCrop, 200); 185 }); 186 }); 187 188 describe('mobile_above type', () => { 189 test('disables margin cropping (full-width mobile)', async () => { 190 const mockPage = createMockPage({ 191 navElements: [], 192 mainContent: { 193 left: 50, // >5% on desktop would crop, but mobile overrides to 0 194 right: 325, 195 width: 275, 196 height: 600, 197 }, 198 viewportHeight: 640, 199 viewportWidth: 375, 200 }); 201 202 const result = await analyzeCropBoundaries(mockPage, 'mobile_above'); 203 204 assert.strictEqual(result.leftCrop, 0); 205 assert.strictEqual(result.rightCrop, 0); 206 assert.match(result.metadata.marginReasoning, /Mobile.*full-width/); 207 }); 208 209 test('still crops nav on mobile_above', async () => { 210 const mockPage = createMockPage({ 211 navElements: [{ top: 0, bottom: 60, height: 60, width: 375, isSticky: true }], 212 hasImportantContent: false, 213 viewportHeight: 640, 214 viewportWidth: 375, 215 }); 216 217 const result = await analyzeCropBoundaries(mockPage, 'mobile_above'); 218 219 assert.strictEqual(result.topCrop, 60); 220 assert.strictEqual(result.metadata.navCropped, true); 221 }); 222 }); 223 224 describe('no navigation or main content', () => { 225 test('returns zero crop when no elements detected', async () => { 226 const mockPage = createMockPage({ 227 navElements: [], 228 mainContent: null, 229 viewportHeight: 1080, 230 viewportWidth: 1920, 231 }); 232 233 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 234 235 assert.strictEqual(result.topCrop, 0); 236 assert.strictEqual(result.leftCrop, 0); 237 assert.strictEqual(result.rightCrop, 0); 238 assert.strictEqual(result.metadata.hadNav, false); 239 }); 240 }); 241 242 describe('error handling', () => { 243 test('returns fallback on page.evaluate error', async () => { 244 const mockPage = { 245 evaluate: async () => { 246 throw new Error('Execution context was destroyed'); 247 }, 248 }; 249 250 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 251 252 assert.strictEqual(result.topCrop, 0); 253 assert.strictEqual(result.leftCrop, 0); 254 assert.strictEqual(result.rightCrop, 0); 255 assert.strictEqual(result.metadata.fallback, true); 256 assert.strictEqual(result.metadata.error, 'Execution context was destroyed'); 257 assert.match(result.metadata.navReasoning, /conservative crop/); 258 }); 259 260 test('handles page evaluation timeout', async () => { 261 const mockPage = { 262 evaluate: async () => { 263 throw new Error('Timeout: page.evaluate timed out'); 264 }, 265 }; 266 267 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 268 269 assert.strictEqual(result.metadata.fallback, true); 270 assert.match(result.metadata.error, /Timeout/); 271 }); 272 273 test('handles null page gracefully', async () => { 274 const mockPage = { 275 evaluate: async () => { 276 throw new Error('Cannot read properties of null'); 277 }, 278 }; 279 280 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 281 282 assert.strictEqual(result.metadata.fallback, true); 283 assert.strictEqual(result.topCrop, 0); 284 assert.strictEqual(result.leftCrop, 0); 285 assert.strictEqual(result.rightCrop, 0); 286 }); 287 }); 288 289 describe('multiple nav elements', () => { 290 test('selects largest nav in upper half of viewport', async () => { 291 const mockPage = createMockPage({ 292 navElements: [ 293 { top: 0, bottom: 40, height: 40, width: 200, isSticky: false }, // Small nav 294 { top: 0, bottom: 100, height: 100, width: 1920, isSticky: true }, // Large primary nav 295 { top: 1000, bottom: 1050, height: 50, width: 1920, isSticky: false }, // Footer nav 296 ], 297 hasImportantContent: false, 298 viewportHeight: 1080, 299 viewportWidth: 1920, 300 }); 301 302 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 303 304 // Should crop the 100px large nav, not the 40px small nav or footer 305 assert.strictEqual(result.topCrop, 100); 306 }); 307 308 test('ignores nav below viewport midpoint', async () => { 309 const mockPage = createMockPage({ 310 navElements: [ 311 { top: 700, bottom: 780, height: 80, width: 1920, isSticky: true }, // Below midpoint 312 ], 313 hasImportantContent: false, 314 viewportHeight: 1080, 315 viewportWidth: 1920, 316 }); 317 318 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 319 320 assert.strictEqual(result.topCrop, 0); 321 assert.match(result.metadata.navReasoning, /No navigation detected/); 322 }); 323 324 test('handles zero-height nav elements', async () => { 325 const mockPage = createMockPage({ 326 navElements: [ 327 { top: 0, bottom: 0, height: 0, width: 1920, isSticky: true }, // Hidden nav 328 ], 329 hasImportantContent: false, 330 viewportHeight: 1080, 331 viewportWidth: 1920, 332 }); 333 334 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 335 336 assert.strictEqual(result.topCrop, 0); 337 assert.match(result.metadata.navReasoning, /No navigation detected/); 338 }); 339 }); 340 341 describe('viewport metadata', () => { 342 test('includes viewport dimensions in metadata', async () => { 343 const mockPage = createMockPage({ 344 navElements: [], 345 viewportHeight: 1080, 346 viewportWidth: 1920, 347 }); 348 349 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 350 351 assert.strictEqual(result.metadata.viewportHeight, 1080); 352 assert.strictEqual(result.metadata.viewportWidth, 1920); 353 }); 354 355 test('handles non-standard viewport sizes', async () => { 356 const mockPage = createMockPage({ 357 navElements: [], 358 viewportHeight: 768, 359 viewportWidth: 1366, 360 }); 361 362 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 363 364 assert.strictEqual(result.metadata.viewportHeight, 768); 365 assert.strictEqual(result.metadata.viewportWidth, 1366); 366 }); 367 }); 368 369 describe('combined nav and margin cropping', () => { 370 test('crops both nav and margins', async () => { 371 const mockPage = createMockPage({ 372 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: true }], 373 hasImportantContent: false, 374 mainContent: { 375 left: 200, 376 right: 1720, 377 width: 1520, 378 height: 1000, 379 }, 380 viewportHeight: 1080, 381 viewportWidth: 1920, 382 }); 383 384 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 385 386 assert.strictEqual(result.topCrop, 80); 387 assert.strictEqual(result.leftCrop, 200); 388 assert.strictEqual(result.rightCrop, 200); 389 assert.strictEqual(result.metadata.navCropped, true); 390 }); 391 392 test('crops margins but preserves nav with important content', async () => { 393 const mockPage = createMockPage({ 394 navElements: [{ top: 0, bottom: 80, height: 80, width: 1920, isSticky: true }], 395 hasImportantContent: true, 396 mainContent: { 397 left: 200, 398 right: 1720, 399 width: 1520, 400 height: 1000, 401 }, 402 viewportHeight: 1080, 403 viewportWidth: 1920, 404 }); 405 406 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 407 408 assert.strictEqual(result.topCrop, 0); 409 assert.strictEqual(result.leftCrop, 200); 410 assert.strictEqual(result.rightCrop, 200); 411 assert.strictEqual(result.metadata.navCropped, false); 412 }); 413 }); 414 415 describe('edge cases', () => { 416 test('handles exact 5% margin threshold (does not crop)', async () => { 417 const mockPage = createMockPage({ 418 navElements: [], 419 mainContent: { 420 left: 96, // Exactly 5% of 1920 421 right: 1824, 422 width: 1728, 423 height: 1000, 424 }, 425 viewportHeight: 1080, 426 viewportWidth: 1920, 427 }); 428 429 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 430 431 // Should NOT crop at exactly 5% (uses > operator, not >=) 432 assert.strictEqual(result.leftCrop, 0); 433 }); 434 435 test('handles just above 5% margin threshold (does crop)', async () => { 436 const mockPage = createMockPage({ 437 navElements: [], 438 mainContent: { 439 left: 97, // Just over 5% of 1920 (96px = 5%) 440 right: 1823, 441 width: 1726, 442 height: 1000, 443 }, 444 viewportHeight: 1080, 445 viewportWidth: 1920, 446 }); 447 448 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 449 450 // Should crop just over 5% 451 assert.strictEqual(result.leftCrop, 97); 452 }); 453 454 test('handles nav at exactly 30% viewport height (does crop)', async () => { 455 const mockPage = createMockPage({ 456 navElements: [{ top: 0, bottom: 324, height: 324, width: 1920, isSticky: true }], 457 hasImportantContent: false, 458 viewportHeight: 1080, 459 viewportWidth: 1920, 460 }); 461 462 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 463 464 // Should crop at exactly 30% (uses < operator, so 324 < 324 is false, but Math.ceil rounds up) 465 // Actually checking: 324 < 1080 * 0.3 → 324 < 324 → false, so won't crop 466 assert.strictEqual(result.topCrop, 0); 467 }); 468 469 test('handles nav just under 30% viewport height (does crop)', async () => { 470 const mockPage = createMockPage({ 471 navElements: [{ top: 0, bottom: 323, height: 323, width: 1920, isSticky: true }], 472 hasImportantContent: false, 473 viewportHeight: 1080, 474 viewportWidth: 1920, 475 }); 476 477 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 478 479 // Should crop just under 30% 480 assert.strictEqual(result.topCrop, 323); 481 }); 482 483 test('handles main content with zero dimensions', async () => { 484 const mockPage = createMockPage({ 485 navElements: [], 486 mainContent: { 487 left: 0, 488 right: 0, 489 width: 0, 490 height: 0, 491 }, 492 viewportHeight: 1080, 493 viewportWidth: 1920, 494 }); 495 496 const result = await analyzeCropBoundaries(mockPage, 'desktop_above'); 497 498 assert.strictEqual(result.leftCrop, 0); 499 assert.strictEqual(result.rightCrop, 0); 500 }); 501 }); 502 }); 503 504 /** 505 * Create mock Playwright page for testing 506 * @param {Object} config - Page configuration 507 * @returns {Object} Mock page object 508 */ 509 function createMockPage(config) { 510 const { 511 navElements = [], 512 mainContent = null, 513 hasImportantContent = false, 514 viewportHeight = 1080, 515 viewportWidth = 1920, 516 } = config; 517 518 return { 519 evaluate: async (pageFunction, arg) => { 520 // The pageFunction is the browser-side code, arg is the screenshot type 521 // We need to execute it in a simulated browser context 522 523 // Create a simulated browser context with mocked DOM 524 const mockDom = { 525 navElements: navElements.map(nav => ({ 526 top: nav.top, 527 bottom: nav.bottom, 528 isSticky: nav.isSticky, // Preserve for lookup 529 getBoundingClientRect: () => ({ 530 top: nav.top, 531 bottom: nav.bottom, 532 height: nav.height, 533 width: nav.width, 534 }), 535 querySelector: () => (hasImportantContent ? {} : null), 536 })), 537 mainContent, 538 viewportHeight, 539 viewportWidth, 540 hasImportantContent, 541 }; 542 543 // Call the browser function with the screenshot type and return simulated result 544 return simulateBrowserEvaluation(mockDom, arg); 545 }, 546 }; 547 } 548 549 /** 550 * Simulate browser-side evaluation with mocked DOM 551 */ 552 function simulateBrowserEvaluation(mockDom, screenshotType) { 553 const { 554 navElements: navElems, 555 mainContent, 556 viewportHeight, 557 viewportWidth, 558 hasImportantContent, 559 } = mockDom; 560 561 let primaryNav = null; 562 let maxArea = 0; 563 564 for (const el of navElems) { 565 const rect = el.getBoundingClientRect(); 566 const area = rect.width * rect.height; 567 568 if (rect.top < viewportHeight / 2 && area > maxArea && rect.height > 0) { 569 maxArea = area; 570 primaryNav = { el, rect }; 571 } 572 } 573 574 let topCrop = 0; 575 let shouldCropNav = false; 576 let navReasoning = 'No navigation detected'; 577 578 if (primaryNav) { 579 const nav = navElems.find( 580 n => n.top === primaryNav.rect.top && n.bottom === primaryNav.rect.bottom 581 ); 582 const isSticky = nav ? nav.isSticky : false; 583 const hasImportant = hasImportantContent; 584 const reasonableHeight = primaryNav.rect.height < viewportHeight * 0.3; 585 586 shouldCropNav = isSticky && !hasImportant && reasonableHeight; 587 588 if (shouldCropNav) { 589 topCrop = Math.ceil(primaryNav.rect.bottom); 590 navReasoning = `Cropped ${topCrop}px sticky nav (no CTAs/trust signals)`; 591 } else if (hasImportant) { 592 navReasoning = 'Preserved nav (contains CTAs or trust signals)'; 593 } else if (!isSticky) { 594 navReasoning = 'Preserved nav (not sticky/fixed)'; 595 } else if (!reasonableHeight) { 596 navReasoning = `Preserved nav (too large: ${Math.round(primaryNav.rect.height)}px)`; 597 } 598 } 599 600 let leftCrop = 0; 601 let rightCrop = 0; 602 let marginReasoning = 'No margin cropping'; 603 604 if (mainContent && mainContent.width > 0 && mainContent.height > 0) { 605 const leftMargin = mainContent.left; 606 const rightMargin = viewportWidth - mainContent.right; 607 608 if (leftMargin > viewportWidth * 0.05) { 609 leftCrop = Math.floor(leftMargin); 610 } 611 if (rightMargin > viewportWidth * 0.05) { 612 rightCrop = Math.floor(rightMargin); 613 } 614 615 if (leftCrop > 0 || rightCrop > 0) { 616 marginReasoning = `Cropped margins (L:${leftCrop}px, R:${rightCrop}px)`; 617 } 618 } 619 620 // Type-specific adjustments (must happen AFTER all calculations) 621 if (screenshotType === 'desktop_below') { 622 topCrop = 0; 623 navReasoning = 'Below-fold (no nav cropping needed)'; 624 shouldCropNav = false; 625 } else if (screenshotType === 'mobile_above') { 626 leftCrop = 0; 627 rightCrop = 0; 628 marginReasoning = 'Mobile (full-width)'; 629 } 630 631 return { 632 topCrop, 633 leftCrop, 634 rightCrop, 635 metadata: { 636 hadNav: navElems.length > 0, 637 navCropped: shouldCropNav, 638 navReasoning, 639 marginReasoning, 640 viewportWidth, 641 viewportHeight, 642 }, 643 }; 644 }