capture-mocked.test.js
1 /** 2 * Unit Tests for Screenshot Capture Module (Mocked) 3 * Uses Node.js 22+ mock.module() to mock Playwright and dependencies 4 */ 5 6 import { test, describe, mock, beforeEach } from 'node:test'; 7 import assert from 'node:assert'; 8 9 // === MOCK SETUP === 10 11 // Create mock response object for page.goto() 12 const mockResponse = { 13 status: () => 200, 14 ok: () => true, 15 headers: () => ({ 'content-type': 'text/html' }), 16 url: () => 'https://example.com', 17 }; 18 19 // Create mock functions for Playwright 20 const mockPage = { 21 goto: mock.fn(async () => mockResponse), 22 setViewportSize: mock.fn(async () => {}), 23 evaluate: mock.fn(async fn => { 24 // page.evaluate receives functions that reference browser globals (window, document). 25 // We can't execute them in Node.js, so we return sensible defaults based on what 26 // capture.js expects from each evaluate call: 27 // 1. Hide cookie banners → returns count (number) 28 // 2. Remove sticky elements → returns count (number) 29 // 3. Get viewport dimensions → returns {viewport, document, devicePixelRatio} 30 // 4. Scroll to top → undefined 31 // 5. Get target scroll position → returns number 32 // 6. Get current scrollY → returns number 33 // 7. Reset scroll behavior → undefined 34 // 8. Get locale data → returns {lang, htmlLang, ...} 35 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 36 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 37 return { 38 viewport: { width: 1440, height: 900 }, 39 document: { width: 1440, height: 3000 }, 40 devicePixelRatio: 1, 41 }; 42 } 43 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) { 44 return 900; // targetScrollY 45 } 46 if (fnStr.includes('scrollY') && !fnStr.includes('innerHeight')) { 47 return 900; // currentScrollY 48 } 49 if (fnStr.includes('navigator.language') || fnStr.includes('htmlLang')) { 50 return { lang: 'en', htmlLang: 'en', metaLang: null, ogLocale: null, hreflangTags: [] }; 51 } 52 if (fnStr.includes('querySelectorAll')) { 53 return 0; // removed count for cookie banners / sticky elements 54 } 55 return undefined; 56 }), 57 screenshot: mock.fn(async () => Buffer.from('fake-screenshot-data')), 58 content: mock.fn(async () => '<html><body>Test content</body></html>'), 59 close: mock.fn(async () => {}), 60 on: mock.fn(), 61 locator: mock.fn(() => ({ count: async () => 0, all: async () => [] })), 62 click: mock.fn(async () => {}), 63 waitForFunction: mock.fn(async () => {}), 64 addStyleTag: mock.fn(async () => {}), 65 keyboard: { 66 press: mock.fn(async () => {}), 67 }, 68 waitForTimeout: mock.fn(async () => {}), 69 waitForLoadState: mock.fn(async () => {}), 70 url: () => 'https://example.com', 71 }; 72 73 const mockContext = { 74 newPage: mock.fn(async () => mockPage), 75 close: mock.fn(async () => {}), 76 }; 77 78 const mockBrowser = { 79 newContext: mock.fn(async () => mockContext), 80 close: mock.fn(async () => {}), 81 }; 82 83 // Mock stealth-browser module 84 mock.module('../../src/utils/stealth-browser.js', { 85 namedExports: { 86 launchStealthBrowser: mock.fn(async () => mockBrowser), 87 createStealthContext: mock.fn(async () => mockContext), 88 humanScroll: mock.fn(async () => {}), 89 randomDelay: mock.fn(async () => {}), 90 }, 91 }); 92 93 // Mock image-optimizer module 94 const optimizedResult = { 95 cropped: Buffer.from('optimized-cropped'), 96 uncropped: Buffer.from('optimized-uncropped'), 97 metadata: { uncroppedSkipped: false }, 98 }; 99 100 mock.module('../../src/utils/image-optimizer.js', { 101 namedExports: { 102 optimizeScreenshot: mock.fn(async () => optimizedResult), 103 calculateSavings: mock.fn(() => ({ 104 originalKB: 100, 105 optimizedKB: 50, 106 savingsPercent: 50, 107 })), 108 }, 109 }); 110 111 // Mock dom-crop-analyzer module 112 mock.module('../../src/utils/dom-crop-analyzer.js', { 113 namedExports: { 114 analyzeCropBoundaries: mock.fn(() => ({ 115 left: 0, 116 top: 100, 117 width: 1440, 118 height: 700, 119 metadata: { navReasoning: 'mock nav detected', navHeight: 60 }, 120 })), 121 }, 122 }); 123 124 // Import after mocking 125 const { VIEWPORTS, captureScreenshots, launchBrowser } = await import('../../src/capture.js'); 126 const { launchStealthBrowser, createStealthContext } = 127 await import('../../src/utils/stealth-browser.js'); 128 const { optimizeScreenshot, calculateSavings } = await import('../../src/utils/image-optimizer.js'); 129 const { analyzeCropBoundaries } = await import('../../src/utils/dom-crop-analyzer.js'); 130 131 describe('Capture Module - Unit Tests', () => { 132 beforeEach(() => { 133 // Reset all mocks before each test 134 // Reset calls and restore default implementations 135 mockPage.goto.mock.resetCalls(); 136 mockPage.goto.mock.mockImplementation(async () => mockResponse); 137 mockPage.setViewportSize.mock.resetCalls(); 138 mockPage.evaluate.mock.resetCalls(); 139 mockPage.screenshot.mock.resetCalls(); 140 mockPage.screenshot.mock.mockImplementation(async () => Buffer.from('fake-screenshot-data')); 141 mockPage.content.mock.resetCalls(); 142 mockPage.close.mock.resetCalls(); 143 mockPage.on.mock.resetCalls(); 144 mockPage.locator.mock.resetCalls(); 145 mockPage.addStyleTag.mock.resetCalls(); 146 mockPage.waitForTimeout.mock.resetCalls(); 147 mockPage.waitForLoadState.mock.resetCalls(); 148 mockPage.waitForFunction.mock.resetCalls(); 149 mockPage.click.mock.resetCalls(); 150 mockPage.keyboard.press.mock.resetCalls(); 151 152 mockContext.newPage.mock.resetCalls(); 153 mockContext.close.mock.resetCalls(); 154 155 mockBrowser.newContext.mock.resetCalls(); 156 mockBrowser.close.mock.resetCalls(); 157 158 launchStealthBrowser.mock.resetCalls(); 159 createStealthContext.mock.resetCalls(); 160 optimizeScreenshot.mock.resetCalls(); 161 calculateSavings.mock.resetCalls(); 162 analyzeCropBoundaries.mock.resetCalls(); 163 }); 164 165 describe('VIEWPORTS configuration', () => { 166 test('desktop viewport has correct dimensions', () => { 167 assert.strictEqual(VIEWPORTS.desktop.width, 1440); 168 assert.strictEqual(VIEWPORTS.desktop.height, 900); 169 }); 170 171 test('mobile viewport has correct dimensions', () => { 172 assert.strictEqual(VIEWPORTS.mobile.width, 390); 173 assert.strictEqual(VIEWPORTS.mobile.height, 844); 174 assert.strictEqual(VIEWPORTS.mobile.isMobile, true); 175 assert.strictEqual(VIEWPORTS.mobile.hasTouch, true); 176 }); 177 }); 178 179 describe('launchBrowser', () => { 180 test('should launch browser with minimal stealth for prospect sites', async () => { 181 const browser = await launchBrowser({ headless: true, slowMo: 0 }); 182 183 assert.strictEqual(launchStealthBrowser.mock.calls.length, 1); 184 185 const callArgs = launchStealthBrowser.mock.calls[0].arguments[0]; 186 assert.strictEqual(callArgs.headless, true); 187 assert.strictEqual(callArgs.slowMo, 0); 188 assert.strictEqual(callArgs.stealthLevel, 'minimal'); 189 190 assert.strictEqual(browser, mockBrowser); 191 }); 192 193 test('should use default options when none provided', async () => { 194 await launchBrowser(); 195 196 assert.strictEqual(launchStealthBrowser.mock.calls.length, 1); 197 198 const callArgs = launchStealthBrowser.mock.calls[0].arguments[0]; 199 assert.strictEqual(callArgs.headless, false); 200 assert.strictEqual(callArgs.slowMo, 100); 201 assert.strictEqual(callArgs.stealthLevel, 'minimal'); 202 }); 203 }); 204 205 describe('captureScreenshots', () => { 206 test('should capture all required screenshots successfully', async () => { 207 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 208 209 // Verify page was created 210 assert.strictEqual(mockContext.newPage.mock.calls.length, 1); 211 212 // Verify goto was called 213 assert.strictEqual(mockPage.goto.mock.calls.length, 1); 214 assert.strictEqual(mockPage.goto.mock.calls[0].arguments[0], 'https://example.com'); 215 216 // Verify viewports were set (desktop + mobile) 217 assert.ok(mockPage.setViewportSize.mock.calls.length >= 2); 218 219 // Verify screenshots were taken (desktop_above, desktop_below, mobile_above) 220 assert.ok(mockPage.screenshot.mock.calls.length >= 3); 221 222 // Verify HTML content was captured 223 assert.strictEqual(mockPage.content.mock.calls.length, 1); 224 225 // Verify page was closed 226 assert.strictEqual(mockPage.close.mock.calls.length, 1); 227 228 // Verify result structure 229 assert.strictEqual(result.url, 'https://example.com'); 230 assert.strictEqual(result.domain, 'example.com'); 231 assert.ok(result.screenshots); 232 assert.ok(result.screenshotsUncropped); 233 assert.ok(result.cropMetadata); 234 assert.strictEqual(result.html, '<html><body>Test content</body></html>'); 235 assert.strictEqual(result.error, null); 236 }); 237 238 test('should throw on navigation errors', async () => { 239 // Mock page.goto to throw error 240 mockPage.goto.mock.mockImplementation(async () => { 241 throw new Error('Navigation timeout'); 242 }); 243 244 await assert.rejects( 245 () => captureScreenshots(mockContext, 'https://example.com', 'example.com'), 246 { message: 'Navigation timeout' } 247 ); 248 }); 249 250 test('should set up console error listeners', async () => { 251 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 252 253 // Verify page.on was called for console and pageerror events 254 assert.ok(mockPage.on.mock.calls.length >= 2); 255 256 const eventTypes = mockPage.on.mock.calls.map(call => call.arguments[0]); 257 assert.ok(eventTypes.includes('console')); 258 assert.ok(eventTypes.includes('pageerror')); 259 }); 260 261 test('should set viewports for desktop and mobile', async () => { 262 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 263 264 // Should set viewport for desktop (1440x900) and mobile (390x844) 265 assert.ok(mockPage.setViewportSize.mock.calls.length >= 2); 266 }); 267 268 test('should call waitForTimeout between operations', async () => { 269 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 270 271 // Should have timeouts between screenshots for rendering 272 assert.ok(mockPage.waitForTimeout.mock.calls.length >= 1); 273 }); 274 275 test('should optimize all screenshots', async () => { 276 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 277 278 // Should optimize desktop_above, desktop_below, mobile_above 279 assert.strictEqual(optimizeScreenshot.mock.calls.length, 3); 280 281 // Verify optimization options 282 const { calls } = optimizeScreenshot.mock; 283 assert.strictEqual(calls[0].arguments[1].type, 'desktop_above'); 284 assert.strictEqual(calls[0].arguments[1].includeUncropped, true); 285 assert.strictEqual(calls[1].arguments[1].type, 'desktop_below'); 286 assert.strictEqual(calls[2].arguments[1].type, 'mobile_above'); 287 }); 288 289 test('should calculate and log savings', async () => { 290 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 291 292 // Verify calculateSavings was called 293 assert.strictEqual(calculateSavings.mock.calls.length, 1); 294 }); 295 296 test('should analyze crop boundaries for each viewport', async () => { 297 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 298 299 // Should analyze desktop_above, desktop_below, mobile_above 300 assert.strictEqual(analyzeCropBoundaries.mock.calls.length, 3); 301 }); 302 303 test('should throw on screenshot errors', async () => { 304 // Mock screenshot to fail 305 mockPage.screenshot.mock.mockImplementation(async () => { 306 throw new Error('Screenshot failed'); 307 }); 308 309 await assert.rejects( 310 () => captureScreenshots(mockContext, 'https://example.com', 'example.com'), 311 { message: 'Screenshot failed' } 312 ); 313 }); 314 }); 315 });