/ tests / capture / dom-crop-analyzer.unit.test.js
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  }