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