capture-evaluate-coverage.test.js
1 /** 2 * Deep Coverage Tests for capture.js 3 * Uses shimmed browser globals in page.evaluate() mock to cover: 4 * Lines 156-204 (overlay hiding), 215-290 (geometric detection), 5 * 467-475, 488-504, 539-560, 598-614 (scroll/viewport), 665-671 (locale) 6 */ 7 8 import { test, describe, mock, beforeEach } from 'node:test'; 9 import assert from 'node:assert/strict'; 10 11 const testState = { responseStatus: 200, responseOk: true }; 12 13 const makeResponse = () => ({ 14 status: () => testState.responseStatus, 15 ok: () => testState.responseOk, 16 statusText: () => (testState.responseOk ? 'OK' : 'Error'), 17 headers: () => ({ 18 server: 'nginx', 19 'x-powered-by': 'PHP', 20 'content-encoding': 'gzip', 21 'cache-control': 'max-age=3600', 22 'content-language': 'en-US', 23 'strict-transport-security': 'max-age=31536000', 24 'content-security-policy': null, 25 'x-frame-options': 'SAMEORIGIN', 26 'x-content-type-options': 'nosniff', 27 }), 28 url: () => 'https://example.com', 29 }); 30 31 // A mock evaluate that tracks which calls are made for verification 32 let evaluateCalls = []; 33 34 function makeTrackingEvaluate(opts) { 35 opts = opts || {}; 36 return async fn => { 37 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 38 evaluateCalls.push(fnStr.substring(0, 80).replace(/\s+/g, ' ')); 39 40 if (opts.throwOnLocale && fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 41 throw new Error('locale data failed'); 42 } 43 if (opts.throwOnGeometric && fnStr.includes('getBoundingClientRect')) { 44 throw new Error('geometric detection failed'); 45 } 46 if ( 47 opts.throwOnOverlay && 48 (fnStr.includes('intercom') || 49 fnStr.includes('overlaySelectors') || 50 fnStr.includes('cookie-banner')) 51 ) { 52 throw new Error('overlay hiding failed'); 53 } 54 55 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 56 return { 57 viewport: { width: 1440, height: 900 }, 58 document: { width: 1440, height: 3000 }, 59 devicePixelRatio: 1, 60 }; 61 } 62 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 63 return 900; 64 if (fnStr.includes('getBoundingClientRect')) { 65 return opts.geometricCount !== undefined ? opts.geometricCount : 0; 66 } 67 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 68 return { 69 htmlLang: opts.htmlLang !== undefined ? opts.htmlLang : 'en-US', 70 hreflangs: opts.hreflangs || [], 71 }; 72 } 73 // currentScrollY in timeout catch 74 if ( 75 fnStr.includes('scrollY') && 76 !fnStr.includes('innerHeight') && 77 !fnStr.includes('target') && 78 !fnStr.includes('htmlLang') && 79 !fnStr.includes('scrollWidth') 80 ) { 81 return opts.currentScrollY || 850; 82 } 83 return undefined; 84 }; 85 } 86 87 const mockPage = { 88 goto: mock.fn(async () => makeResponse()), 89 setViewportSize: mock.fn(async () => {}), 90 evaluate: mock.fn(makeTrackingEvaluate()), 91 screenshot: mock.fn(async () => Buffer.from('fake-screenshot-data')), 92 content: mock.fn(async () => '<html><body>Test content</body></html>'), 93 close: mock.fn(async () => {}), 94 on: mock.fn(), 95 locator: mock.fn(() => ({ 96 all: async () => [], 97 count: async () => 0, 98 first() { 99 return this; 100 }, 101 isVisible: async () => false, 102 click: async () => {}, 103 })), 104 click: mock.fn(async () => {}), 105 waitForFunction: mock.fn(async () => {}), 106 addStyleTag: mock.fn(async () => {}), 107 keyboard: { press: mock.fn(async () => {}) }, 108 waitForTimeout: mock.fn(async () => {}), 109 waitForLoadState: mock.fn(async () => {}), 110 url: () => 'https://example.com', 111 }; 112 113 const mockContext = { newPage: mock.fn(async () => mockPage), close: mock.fn(async () => {}) }; 114 const mockBrowser = { 115 newContext: mock.fn(async () => mockContext), 116 close: mock.fn(async () => {}), 117 }; 118 119 mock.module('../../src/utils/stealth-browser.js', { 120 namedExports: { 121 launchStealthBrowser: mock.fn(async () => mockBrowser), 122 createStealthContext: mock.fn(async () => mockContext), 123 humanScroll: mock.fn(async () => {}), 124 randomDelay: mock.fn(async () => {}), 125 }, 126 }); 127 128 const optimizedResult = { 129 cropped: Buffer.from('optimized-cropped'), 130 uncropped: Buffer.from('optimized-uncropped'), 131 metadata: { uncroppedSkipped: false }, 132 }; 133 134 mock.module('../../src/utils/image-optimizer.js', { 135 namedExports: { 136 optimizeScreenshot: mock.fn(async () => optimizedResult), 137 calculateSavings: mock.fn(() => ({ originalKB: 100, optimizedKB: 50, savingsPercent: 50 })), 138 }, 139 }); 140 141 mock.module('../../src/utils/dom-crop-analyzer.js', { 142 namedExports: { 143 analyzeCropBoundaries: mock.fn(() => ({ 144 left: 0, 145 top: 100, 146 width: 1440, 147 height: 700, 148 metadata: { navReasoning: 'mock nav', navHeight: 60 }, 149 })), 150 }, 151 }); 152 153 const { captureScreenshots } = await import('../../src/capture.js'); 154 const { optimizeScreenshot } = await import('../../src/utils/image-optimizer.js'); 155 156 function resetMocks(opts) { 157 evaluateCalls = []; 158 testState.responseStatus = 200; 159 testState.responseOk = true; 160 mockPage.goto.mock.resetCalls(); 161 mockPage.goto.mock.mockImplementation(async () => makeResponse()); 162 mockPage.setViewportSize.mock.resetCalls(); 163 mockPage.evaluate.mock.resetCalls(); 164 mockPage.evaluate.mock.mockImplementation(makeTrackingEvaluate(opts || {})); 165 mockPage.screenshot.mock.resetCalls(); 166 mockPage.screenshot.mock.mockImplementation(async () => Buffer.from('fake-screenshot-data')); 167 mockPage.content.mock.resetCalls(); 168 mockPage.close.mock.resetCalls(); 169 mockPage.on.mock.resetCalls(); 170 mockPage.waitForFunction.mock.resetCalls(); 171 mockPage.waitForFunction.mock.mockImplementation(async () => {}); 172 mockPage.addStyleTag.mock.resetCalls(); 173 mockPage.waitForTimeout.mock.resetCalls(); 174 mockPage.locator.mock.resetCalls(); 175 mockPage.locator.mock.mockImplementation(() => ({ 176 all: async () => [], 177 count: async () => 0, 178 first() { 179 return this; 180 }, 181 isVisible: async () => false, 182 click: async () => {}, 183 })); 184 mockPage.keyboard.press.mock.resetCalls(); 185 mockContext.newPage.mock.resetCalls(); 186 mockBrowser.close.mock.resetCalls(); 187 optimizeScreenshot.mock.resetCalls(); 188 optimizeScreenshot.mock.mockImplementation(async () => optimizedResult); 189 } 190 191 describe('Capture Module - Deep Evaluate Coverage', () => { 192 beforeEach(() => resetMocks()); 193 194 describe('closePopovers overlay-hiding (lines 156-209)', () => { 195 test('should call overlay-hiding evaluate and handle success', async () => { 196 // The overlay hiding evaluate (lines 152-209) iterates overlaySelectors 197 // It should be called in closePopovers 198 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 199 assert.ok(result.screenshots.desktop_above); 200 // Verify evaluate was invoked (covers that code path is reachable) 201 assert.ok(evaluateCalls.length > 0, 'evaluate should be called'); 202 }); 203 204 test('should catch overlay-hiding evaluate failure (covers lines 207-209)', async () => { 205 resetMocks({ throwOnOverlay: true }); 206 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 207 assert.ok(result.screenshots.desktop_above, 'should succeed when overlay hiding throws'); 208 }); 209 }); 210 211 describe('closePopovers geometric detection (lines 213-298)', () => { 212 test('should call geometric detection evaluate and handle 0 overlays', async () => { 213 resetMocks({ geometricCount: 0 }); 214 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 215 assert.ok(result.screenshots.desktop_above); 216 }); 217 218 test('should log overlay removal when geometric count > 0 (covers line 294)', async () => { 219 resetMocks({ geometricCount: 2 }); 220 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 221 assert.ok(result.screenshots.desktop_above); 222 }); 223 224 test('should catch geometric detection evaluate failure (covers lines 296-298)', async () => { 225 resetMocks({ throwOnGeometric: true }); 226 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 227 assert.ok(result.screenshots.desktop_above, 'should succeed when geometric detection throws'); 228 }); 229 }); 230 231 describe('closePopovers closedCount > 0 (line 300-304)', () => { 232 test('should log closedCount when locators return visible elements', async () => { 233 resetMocks(); 234 mockPage.locator.mock.mockImplementation(() => ({ 235 all: async () => [{ isVisible: async () => true, click: async () => {} }], 236 count: async () => 1, 237 first() { 238 return this; 239 }, 240 isVisible: async () => true, 241 click: async () => {}, 242 })); 243 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 244 assert.ok(result.screenshots.desktop_above); 245 }); 246 }); 247 248 describe('closePopovers outer catch (lines 305-308)', () => { 249 test('should catch unexpected errors in closePopovers outer try', async () => { 250 resetMocks(); 251 // Make locator throw after some calls to trigger the outer catch 252 let count = 0; 253 mockPage.locator.mock.mockImplementation(() => { 254 count++; 255 if (count > 2) throw new Error('unexpected locator failure'); 256 return { 257 all: async () => [], 258 count: async () => 0, 259 first() { 260 return this; 261 }, 262 isVisible: async () => false, 263 click: async () => {}, 264 }; 265 }); 266 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 267 assert.ok(result.screenshots.desktop_above, 'outer catch should swallow error'); 268 }); 269 }); 270 271 describe('SSL status detection (lines 386-393)', () => { 272 test('should set sslStatus to https for https URL', async () => { 273 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 274 assert.strictEqual(result.sslStatus, 'https'); 275 }); 276 277 test('should set sslStatus to http for http URL', async () => { 278 const result = await captureScreenshots(mockContext, 'http://example.com', 'example.com'); 279 assert.strictEqual(result.sslStatus, 'http'); 280 }); 281 }); 282 283 describe('viewport dimensions evaluate (lines 466-476)', () => { 284 test('should call dimensions evaluate and log viewport info', async () => { 285 const dimEvalCalls = []; 286 mockPage.evaluate.mock.mockImplementation(async fn => { 287 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 288 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 289 dimEvalCalls.push(true); 290 return { 291 viewport: { width: 1440, height: 900 }, 292 document: { width: 2880, height: 5000 }, 293 devicePixelRatio: 2, 294 }; 295 } 296 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 297 return 900; 298 if (fnStr.includes('getBoundingClientRect')) return 0; 299 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 300 return { htmlLang: 'en-US', hreflangs: [] }; 301 return undefined; 302 }); 303 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 304 assert.ok(result.screenshots.desktop_above); 305 assert.ok(dimEvalCalls.length > 0, 'dimensions evaluate should be called'); 306 }); 307 }); 308 309 describe('scroll evaluate paths (lines 487-504, 538-560, 597-614)', () => { 310 test('should call scroll-to-top evaluate before desktop above screenshot', async () => { 311 const scrollCalls = []; 312 mockPage.evaluate.mock.mockImplementation(async fn => { 313 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 314 if ( 315 fnStr.includes('scrollBehavior') && 316 fnStr.includes('scrollTo') && 317 !fnStr.includes('scrollY + window') 318 ) { 319 scrollCalls.push('scroll-top'); 320 return undefined; 321 } 322 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 323 return { 324 viewport: { width: 1440, height: 900 }, 325 document: { width: 1440, height: 3000 }, 326 devicePixelRatio: 1, 327 }; 328 } 329 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 330 return 900; 331 if (fnStr.includes('getBoundingClientRect')) return 0; 332 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 333 return { htmlLang: 'en-US', hreflangs: [] }; 334 return undefined; 335 }); 336 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 337 assert.ok(result.screenshots.desktop_above); 338 assert.ok(scrollCalls.length > 0, 'scroll evaluate should be called'); 339 }); 340 341 test('should call scroll-down evaluate for below-fold screenshot', async () => { 342 const scrollDownCalls = []; 343 mockPage.evaluate.mock.mockImplementation(async fn => { 344 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 345 if ( 346 fnStr.includes('scrollY') && 347 fnStr.includes('innerHeight') && 348 fnStr.includes('target') 349 ) { 350 scrollDownCalls.push(true); 351 return 900; 352 } 353 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 354 return { 355 viewport: { width: 1440, height: 900 }, 356 document: { width: 1440, height: 3000 }, 357 devicePixelRatio: 1, 358 }; 359 } 360 if (fnStr.includes('getBoundingClientRect')) return 0; 361 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 362 return { htmlLang: 'en-US', hreflangs: [] }; 363 return undefined; 364 }); 365 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 366 assert.ok(result.screenshots.desktop_below); 367 assert.ok(scrollDownCalls.length > 0, 'scroll-down evaluate should be called'); 368 }); 369 370 test('should execute async catch for scroll-down timeout (lines 572-577)', async () => { 371 // waitForFunction throws causing the async catch to run and call page.evaluate for currentScrollY 372 mockPage.waitForFunction.mock.mockImplementation(async () => { 373 throw new Error('timeout'); 374 }); 375 const currentScrollYCalls = []; 376 mockPage.evaluate.mock.mockImplementation(async fn => { 377 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 378 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 379 return { 380 viewport: { width: 1440, height: 900 }, 381 document: { width: 1440, height: 3000 }, 382 devicePixelRatio: 1, 383 }; 384 } 385 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 386 return 900; 387 if (fnStr.includes('getBoundingClientRect')) return 0; 388 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 389 return { htmlLang: 'en-US', hreflangs: [] }; 390 // currentScrollY call from catch block 391 if ( 392 fnStr.includes('scrollY') && 393 !fnStr.includes('innerHeight') && 394 !fnStr.includes('target') && 395 !fnStr.includes('htmlLang') && 396 !fnStr.includes('scrollWidth') && 397 !fnStr.includes('scrollBehavior') 398 ) { 399 currentScrollYCalls.push(true); 400 return 850; 401 } 402 return undefined; 403 }); 404 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 405 assert.ok(result.screenshots.desktop_above, 'should succeed despite timeout'); 406 }); 407 }); 408 409 describe('locale data evaluate (lines 662-681)', () => { 410 test('should capture locale data with htmlLang and hreflangs', async () => { 411 resetMocks({ 412 hreflangs: [ 413 { hreflang: 'en', href: 'https://example.com/' }, 414 { hreflang: 'fr', href: 'https://example.fr/' }, 415 ], 416 }); 417 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 418 assert.ok(result.localeData); 419 const locale = JSON.parse(result.localeData); 420 assert.strictEqual(locale.htmlLang, 'en-US'); 421 assert.strictEqual(locale.hreflangs.length, 2); 422 }); 423 424 test('should handle null htmlLang (covers the || null branch)', async () => { 425 resetMocks({ htmlLang: null, hreflangs: [] }); 426 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 427 assert.ok(result.localeData); 428 const locale = JSON.parse(result.localeData); 429 assert.strictEqual(locale.htmlLang, null); 430 }); 431 432 test('should set localeData to null when locale evaluate throws', async () => { 433 resetMocks({ throwOnLocale: true }); 434 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 435 assert.strictEqual(result.localeData, null); 436 }); 437 }); 438 439 describe('uncroppedSkipped logging path', () => { 440 test('should log skipped count when uncroppedSkipped is true (covers line 733-735)', async () => { 441 resetMocks(); 442 optimizeScreenshot.mock.mockImplementation(async () => ({ 443 cropped: Buffer.from('cropped'), 444 uncropped: null, 445 metadata: { uncroppedSkipped: true }, 446 })); 447 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 448 assert.ok(result, 'should return result'); 449 // All 3 screenshots have uncroppedSkipped: true -> logs skipped 3/3 450 }); 451 452 test('should not log skipped count when uncroppedSkipped is false (else branch)', async () => { 453 resetMocks(); 454 optimizeScreenshot.mock.mockImplementation(async () => ({ 455 cropped: Buffer.from('cropped'), 456 uncropped: Buffer.from('uncropped'), 457 metadata: { uncroppedSkipped: false }, 458 })); 459 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 460 assert.ok(result.screenshotsUncropped.desktop_above); 461 }); 462 }); 463 });