/ tests / capture / dom-crop-analyzer.test.js
dom-crop-analyzer.test.js
  1  import { test, describe, before, after } from 'node:test';
  2  import assert from 'node:assert';
  3  import { chromium } from 'playwright';
  4  import { fileURLToPath } from 'url';
  5  import { dirname, join } from 'path';
  6  import { analyzeCropBoundaries } from '../../src/utils/dom-crop-analyzer.js';
  7  
  8  const __filename = fileURLToPath(import.meta.url);
  9  const __dirname = dirname(__filename);
 10  
 11  // Check if Playwright/Chromium is available before running browser tests
 12  let playwrightAvailable = false;
 13  try {
 14    const { execSync } = await import('child_process');
 15    const testBrowser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
 16    await testBrowser.close();
 17    playwrightAvailable = true;
 18  } catch {
 19    // Playwright/Chromium not available in this environment — skip browser tests
 20  }
 21  
 22  describe('DOM Crop Analyzer', { skip: playwrightAvailable ? false : 'Playwright/Chromium not available in this environment' }, () => {
 23    let browser;
 24    let page;
 25  
 26    before(async () => {
 27      // Use system chromium on NixOS (same logic as capture.js)
 28      let browserPath = process.env.CHROMIUM_PATH;
 29      if (!browserPath && (process.env.NIX_PATH || process.env.HOME?.includes('/nix/'))) {
 30        const { execSync } = await import('child_process');
 31        try {
 32          browserPath = execSync('which chromium').toString().trim();
 33        } catch (error) {
 34          // Fall back to default
 35        }
 36      }
 37  
 38      const launchOptions = { headless: true, args: ['--no-sandbox'] };
 39      if (browserPath) {
 40        launchOptions.executablePath = browserPath;
 41      }
 42  
 43      browser = await chromium.launch(launchOptions);
 44      page = await (await browser.newContext()).newPage();
 45    });
 46  
 47    after(async () => {
 48      await browser?.close();
 49    });
 50  
 51    test('should preserve nav with CTA button', async () => {
 52      const fixturePath = join(__dirname, 'fixtures', 'sticky-nav-with-cta.html');
 53      await page.goto(`file://${fixturePath}`);
 54      await page.setViewportSize({ width: 1440, height: 900 });
 55  
 56      const result = await analyzeCropBoundaries(page, 'desktop_above');
 57  
 58      // Should NOT crop navigation since it has a CTA
 59      assert.strictEqual(result.topCrop, 0, 'Should not crop navigation with CTA');
 60      assert.ok(
 61        result.metadata.navReasoning.includes('CTAs') ||
 62          result.metadata.navReasoning.includes('trust signals'),
 63        'Reasoning should mention CTAs or trust signals'
 64      );
 65      assert.strictEqual(result.metadata.navCropped, false, 'Nav should not be cropped');
 66    });
 67  
 68    test('should crop empty sticky nav', async () => {
 69      const fixturePath = join(__dirname, 'fixtures', 'sticky-nav-empty.html');
 70      await page.goto(`file://${fixturePath}`);
 71      await page.setViewportSize({ width: 1440, height: 900 });
 72  
 73      const result = await analyzeCropBoundaries(page, 'desktop_above');
 74  
 75      // Should crop navigation since it's sticky and has no important content
 76      assert.ok(result.topCrop > 0, 'Should crop empty sticky navigation');
 77      assert.ok(result.topCrop >= 70 && result.topCrop <= 90, 'Should crop ~80px navbar');
 78      assert.strictEqual(result.metadata.navCropped, true, 'Nav should be marked as cropped');
 79      assert.ok(
 80        result.metadata.navReasoning.includes('sticky') ||
 81          result.metadata.navReasoning.includes('fixed'),
 82        'Reasoning should mention sticky/fixed positioning'
 83      );
 84    });
 85  
 86    test('should handle no nav gracefully', async () => {
 87      const fixturePath = join(__dirname, 'fixtures', 'no-nav-landing-page.html');
 88      await page.goto(`file://${fixturePath}`);
 89      await page.setViewportSize({ width: 1440, height: 900 });
 90  
 91      const result = await analyzeCropBoundaries(page, 'desktop_above');
 92  
 93      // Should not crop anything since there's no navigation
 94      assert.strictEqual(result.topCrop, 0, 'Should not crop without navigation');
 95      assert.strictEqual(result.metadata.hadNav, false, 'Should detect no nav present');
 96      assert.ok(
 97        result.metadata.navReasoning.includes('No navigation'),
 98        'Reasoning should mention no navigation'
 99      );
100    });
101  
102    test('should preserve nav with trust badges', async () => {
103      const fixturePath = join(__dirname, 'fixtures', 'trust-badges-in-header.html');
104      await page.goto(`file://${fixturePath}`);
105      await page.setViewportSize({ width: 1440, height: 900 });
106  
107      const result = await analyzeCropBoundaries(page, 'desktop_above');
108  
109      // Should NOT crop navigation since it has trust badges
110      assert.strictEqual(result.topCrop, 0, 'Should not crop navigation with trust badges');
111      assert.ok(
112        result.metadata.navReasoning.includes('trust'),
113        'Reasoning should mention trust signals'
114      );
115      assert.strictEqual(result.metadata.navCropped, false, 'Nav should not be cropped');
116    });
117  
118    test('should crop side margins for centered content', async () => {
119      const fixturePath = join(__dirname, 'fixtures', 'centered-content-with-margins.html');
120      await page.goto(`file://${fixturePath}`);
121      await page.setViewportSize({ width: 1440, height: 900 });
122  
123      const result = await analyzeCropBoundaries(page, 'desktop_above');
124  
125      // Should detect and crop side margins (900px container on 1440px viewport = ~200-280px each side)
126      assert.ok(result.leftCrop > 0, 'Should crop left margin');
127      assert.ok(result.rightCrop > 0, 'Should crop right margin');
128      assert.ok(result.leftCrop >= 200 && result.leftCrop <= 290, 'Left crop should be in range');
129      assert.ok(result.rightCrop >= 200 && result.rightCrop <= 290, 'Right crop should be in range');
130      assert.ok(
131        result.metadata.marginReasoning.includes('Cropped margins'),
132        'Reasoning should mention cropped margins'
133      );
134    });
135  
136    test('should not crop below-fold screenshots', async () => {
137      const fixturePath = join(__dirname, 'fixtures', 'sticky-nav-empty.html');
138      await page.goto(`file://${fixturePath}`);
139      await page.setViewportSize({ width: 1440, height: 900 });
140  
141      const result = await analyzeCropBoundaries(page, 'desktop_below');
142  
143      // Below-fold should never crop navigation (already scrolled past it)
144      assert.strictEqual(result.topCrop, 0, 'Should not crop top on below-fold screenshot');
145      assert.ok(
146        result.metadata.navReasoning.includes('Below-fold'),
147        'Reasoning should mention below-fold'
148      );
149    });
150  
151    test('should use full-width for mobile', async () => {
152      const fixturePath = join(__dirname, 'fixtures', 'centered-content-with-margins.html');
153      await page.goto(`file://${fixturePath}`);
154      await page.setViewportSize({ width: 390, height: 844 });
155  
156      const result = await analyzeCropBoundaries(page, 'mobile_above');
157  
158      // Mobile should not crop side margins
159      assert.strictEqual(result.leftCrop, 0, 'Should not crop left on mobile');
160      assert.strictEqual(result.rightCrop, 0, 'Should not crop right on mobile');
161      assert.ok(
162        result.metadata.marginReasoning.includes('Mobile'),
163        'Reasoning should mention mobile'
164      );
165    });
166  
167    test('should return fallback on DOM analysis error', async () => {
168      // Close the page to force an error
169      await page.close();
170  
171      const result = await analyzeCropBoundaries(page, 'desktop_above');
172  
173      // Should return conservative fallback (zero-crop)
174      assert.strictEqual(result.topCrop, 0, 'Fallback should have zero top crop');
175      assert.strictEqual(result.leftCrop, 0, 'Fallback should have zero left crop');
176      assert.strictEqual(result.rightCrop, 0, 'Fallback should have zero right crop');
177      assert.strictEqual(result.metadata.fallback, true, 'Should be marked as fallback');
178      assert.ok(result.metadata.error, 'Should include error message');
179  
180      // Recreate page for remaining tests
181      page = await (await browser.newContext()).newPage();
182    });
183  
184    test('should detect viewport dimensions correctly', async () => {
185      const fixturePath = join(__dirname, 'fixtures', 'no-nav-landing-page.html');
186      await page.goto(`file://${fixturePath}`);
187      await page.setViewportSize({ width: 1440, height: 900 });
188  
189      const result = await analyzeCropBoundaries(page, 'desktop_above');
190  
191      assert.strictEqual(result.metadata.viewportWidth, 1440, 'Should capture viewport width');
192      assert.strictEqual(result.metadata.viewportHeight, 900, 'Should capture viewport height');
193    });
194  
195    test('should handle popovers gracefully', async () => {
196      const fixturePath = join(__dirname, 'fixtures', 'page-with-modal.html');
197      await page.goto(`file://${fixturePath}`);
198      await page.setViewportSize({ width: 1440, height: 900 });
199  
200      // Close popovers using the same logic as capture.js
201      const closeSelectors = [
202        "[class*='close']",
203        "[class*='dismiss']",
204        "[class*='modal'] button",
205        "[aria-label='Close']",
206        '.modal-close',
207      ];
208  
209      for (const selector of closeSelectors) {
210        try {
211          const elements = await page.locator(selector).all();
212          for (const element of elements) {
213            if (await element.isVisible()) {
214              await element.click({ timeout: 500 });
215              await page.waitForTimeout(300);
216            }
217          }
218        } catch {
219          // Continue if selector not found
220        }
221      }
222  
223      // Verify modal is hidden
224      const modalVisible = await page.locator('.modal-overlay').isVisible();
225      assert.strictEqual(modalVisible, false, 'Modal should be closed');
226  
227      // DOM analysis should work normally
228      const result = await analyzeCropBoundaries(page, 'desktop_above');
229      assert.strictEqual(result.topCrop, 0, 'Should have zero top crop (no nav)');
230    });
231  });