capture-execute-coverage.test.js
1 /** 2 * Execute-Based Coverage Tests for capture.js 3 * Actually executes page.evaluate() callbacks with shimmed browser globals 4 * to cover lines: 156-204, 215-290, 467-476, 488-505, 538-561, 597-615, 664-681 5 */ 6 7 import { test, describe, mock, beforeEach } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 10 // === BROWSER GLOBALS SHIM === 11 // Provide window/document globals so evaluate callbacks can execute 12 13 function createBrowserShim(opts) { 14 opts = opts || {}; 15 16 const elements = opts.elements || []; 17 const mockEl = overrides => { 18 overrides = overrides || {}; 19 return { 20 style: { display: overrides.display || '', visibility: '', overflow: '' }, 21 getBoundingClientRect: () => ({ 22 width: overrides.width !== undefined ? overrides.width : 100, 23 height: overrides.height !== undefined ? overrides.height : 100, 24 }), 25 getAttribute: attr => { 26 const attrs = overrides.attrs || {}; 27 return attrs[attr] !== undefined ? attrs[attr] : null; 28 }, 29 textContent: 'button', 30 dataset: { field: 'name', value: 'Test' }, 31 addEventListener: () => {}, 32 remove: () => {}, 33 }; 34 }; 35 36 // Global document shim 37 const shimDocument = { 38 documentElement: { 39 lang: opts.lang !== undefined ? opts.lang : 'en-US', 40 style: { scrollBehavior: 'smooth', overflow: '' }, 41 scrollTop: 0, 42 scrollLeft: 0, 43 scrollWidth: 1440, 44 scrollHeight: 3000, 45 }, 46 body: { 47 style: { overflow: 'hidden' }, 48 scrollTop: 0, 49 scrollLeft: 0, 50 insertAdjacentHTML: () => {}, 51 }, 52 querySelectorAll(selector) { 53 if (selector === '*') { 54 return opts.allElements || []; 55 } 56 if (selector.includes('hreflang')) { 57 const hreflangs = opts.hreflangs || []; 58 return hreflangs.map(hl => ({ 59 getAttribute: a => (a === 'hreflang' ? hl.hreflang : hl.href), 60 })); 61 } 62 return elements; 63 }, 64 getElementById: () => null, 65 querySelector: () => null, 66 }; 67 68 // Global window shim 69 const shimWindow = { 70 innerWidth: opts.innerWidth !== undefined ? opts.innerWidth : 1440, 71 innerHeight: opts.innerHeight !== undefined ? opts.innerHeight : 900, 72 scrollY: opts.scrollY !== undefined ? opts.scrollY : 0, 73 devicePixelRatio: opts.devicePixelRatio !== undefined ? opts.devicePixelRatio : 1, 74 scrollTo(scrollOpts) { 75 if (scrollOpts && scrollOpts.top !== undefined) { 76 shimWindow.scrollY = scrollOpts.top; 77 } 78 }, 79 getComputedStyle(el) { 80 return { 81 position: opts.elPosition !== undefined ? opts.elPosition : 'static', 82 zIndex: opts.elZIndex !== undefined ? String(opts.elZIndex) : '0', 83 display: el && el.style ? el.style.display : '', 84 visibility: el && el.style ? el.style.visibility : '', 85 backgroundColor: opts.elBgColor !== undefined ? opts.elBgColor : 'transparent', 86 opacity: opts.elOpacity !== undefined ? String(opts.elOpacity) : '1', 87 }; 88 }, 89 localStorage: { 90 _data: {}, 91 getItem(k) { 92 return this._data[k] || null; 93 }, 94 setItem(k, v) { 95 this._data[k] = v; 96 }, 97 }, 98 }; 99 100 return { shimWindow, shimDocument }; 101 } 102 103 // Creates a mock that executes the evaluate callback with shimmed globals 104 function makeExecutingEvaluate(shimOpts) { 105 shimOpts = shimOpts || {}; 106 return async function (fn) { 107 if (typeof fn !== 'function') return undefined; 108 109 const fnStr = fn.toString(); 110 const { shimWindow, shimDocument } = createBrowserShim(shimOpts); 111 112 // Install browser globals temporarily in a sandboxed Function call 113 // We pass them as parameters to avoid modifying actual global scope 114 try { 115 // Create a wrapper that makes window/document/navigator available as parameters 116 // eslint-disable-next-line no-new-func -- test shim needs Function constructor to execute capture.js browser code 117 const wrapper = new Function( // NOSONAR 118 'window', 119 'document', 120 'navigator', 121 `return (${fnStr}).call(null)` 122 ); 123 return wrapper(shimWindow, shimDocument, { 124 language: 'en-US', 125 clipboard: { writeText: async () => {} }, 126 }); 127 } catch (execErr) { 128 // Fallback: pattern-match return values 129 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 130 return { 131 viewport: { width: 1440, height: 900 }, 132 document: { width: 1440, height: 3000 }, 133 devicePixelRatio: 1, 134 }; 135 } 136 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 137 return 900; 138 if (fnStr.includes('getBoundingClientRect')) return 0; 139 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 140 return { htmlLang: 'en-US', hreflangs: [] }; 141 } 142 return undefined; 143 } 144 }; 145 } 146 147 // === MOCK SETUP === 148 149 const makeResponse = () => ({ 150 status: () => 200, 151 ok: () => true, 152 statusText: () => 'OK', 153 headers: () => ({ 154 server: 'nginx', 155 'x-powered-by': null, 156 'content-encoding': null, 157 'cache-control': null, 158 'content-language': 'en-US', 159 'strict-transport-security': null, 160 'content-security-policy': null, 161 'x-frame-options': null, 162 'x-content-type-options': null, 163 }), 164 url: () => 'https://example.com', 165 }); 166 167 const mockPage = { 168 goto: mock.fn(async () => makeResponse()), 169 setViewportSize: mock.fn(async () => {}), 170 evaluate: mock.fn(makeExecutingEvaluate()), 171 screenshot: mock.fn(async () => Buffer.from('fake-screenshot')), 172 content: mock.fn(async () => '<html><body>Test</body></html>'), 173 close: mock.fn(async () => {}), 174 on: mock.fn(), 175 locator: mock.fn(() => ({ 176 all: async () => [], 177 count: async () => 0, 178 first() { 179 return this; 180 }, 181 isVisible: async () => false, 182 click: async () => {}, 183 })), 184 click: mock.fn(async () => {}), 185 waitForFunction: mock.fn(async () => {}), 186 addStyleTag: mock.fn(async () => {}), 187 keyboard: { press: mock.fn(async () => {}) }, 188 waitForTimeout: mock.fn(async () => {}), 189 waitForLoadState: mock.fn(async () => {}), 190 url: () => 'https://example.com', 191 }; 192 193 const mockContext = { newPage: mock.fn(async () => mockPage), close: mock.fn(async () => {}) }; 194 const mockBrowser = { 195 newContext: mock.fn(async () => mockContext), 196 close: mock.fn(async () => {}), 197 }; 198 199 mock.module('../../src/utils/stealth-browser.js', { 200 namedExports: { 201 launchStealthBrowser: mock.fn(async () => mockBrowser), 202 createStealthContext: mock.fn(async () => mockContext), 203 humanScroll: mock.fn(async () => {}), 204 randomDelay: mock.fn(async () => {}), 205 }, 206 }); 207 208 const optimizedResult = { 209 cropped: Buffer.from('opt-cropped'), 210 uncropped: Buffer.from('opt-uncropped'), 211 metadata: { uncroppedSkipped: false }, 212 }; 213 214 mock.module('../../src/utils/image-optimizer.js', { 215 namedExports: { 216 optimizeScreenshot: mock.fn(async () => optimizedResult), 217 calculateSavings: mock.fn(() => ({ originalKB: 100, optimizedKB: 50, savingsPercent: 50 })), 218 }, 219 }); 220 221 mock.module('../../src/utils/dom-crop-analyzer.js', { 222 namedExports: { 223 analyzeCropBoundaries: mock.fn(() => ({ 224 left: 0, 225 top: 100, 226 width: 1440, 227 height: 700, 228 metadata: { navReasoning: 'test', navHeight: 60 }, 229 })), 230 }, 231 }); 232 233 const { captureScreenshots } = await import('../../src/capture.js'); 234 const { optimizeScreenshot } = await import('../../src/utils/image-optimizer.js'); 235 236 function resetMocks(shimOpts) { 237 mockPage.goto.mock.resetCalls(); 238 mockPage.goto.mock.mockImplementation(async () => makeResponse()); 239 mockPage.setViewportSize.mock.resetCalls(); 240 mockPage.evaluate.mock.resetCalls(); 241 mockPage.evaluate.mock.mockImplementation(makeExecutingEvaluate(shimOpts)); 242 mockPage.screenshot.mock.resetCalls(); 243 mockPage.screenshot.mock.mockImplementation(async () => Buffer.from('fake-screenshot')); 244 mockPage.content.mock.resetCalls(); 245 mockPage.close.mock.resetCalls(); 246 mockPage.on.mock.resetCalls(); 247 mockPage.waitForFunction.mock.resetCalls(); 248 mockPage.waitForFunction.mock.mockImplementation(async () => {}); 249 mockPage.addStyleTag.mock.resetCalls(); 250 mockPage.waitForTimeout.mock.resetCalls(); 251 mockPage.locator.mock.resetCalls(); 252 mockPage.locator.mock.mockImplementation(() => ({ 253 all: async () => [], 254 count: async () => 0, 255 first() { 256 return this; 257 }, 258 isVisible: async () => false, 259 click: async () => {}, 260 })); 261 mockPage.keyboard.press.mock.resetCalls(); 262 mockContext.newPage.mock.resetCalls(); 263 mockBrowser.close.mock.resetCalls(); 264 optimizeScreenshot.mock.resetCalls(); 265 optimizeScreenshot.mock.mockImplementation(async () => optimizedResult); 266 } 267 268 describe('Capture Module - Execute-Based Coverage', () => { 269 beforeEach(() => resetMocks()); 270 271 describe('closePopovers overlay-hiding execute (lines 155-205)', () => { 272 test('should execute overlay hiding callback with empty DOM (no overlays)', async () => { 273 // The overlay-hiding callback runs with empty elements - all selectors return [] 274 resetMocks({ elements: [] }); 275 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 276 assert.ok(result.screenshots.desktop_above); 277 }); 278 279 test('should execute overlay hiding with fixed/high-zindex elements (covers lines 196-201)', async () => { 280 // Elements that have position fixed + high zindex -> style.display = none 281 const el = { 282 style: { display: '' }, 283 getBoundingClientRect: () => ({ width: 200, height: 200 }), 284 }; 285 resetMocks({ 286 elements: [el], 287 elPosition: 'fixed', 288 elZIndex: 2000, 289 }); 290 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 291 assert.ok(result.screenshots.desktop_above); 292 }); 293 294 test('should execute overlay hiding with non-overlay element (position not fixed/absolute)', async () => { 295 const el = { 296 style: { display: '' }, 297 getBoundingClientRect: () => ({ width: 200, height: 200 }), 298 }; 299 resetMocks({ 300 elements: [el], 301 elPosition: 'static', 302 elZIndex: 0, 303 }); 304 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 305 assert.ok(result.screenshots.desktop_above); 306 }); 307 }); 308 309 describe('closePopovers geometric detection execute (lines 213-298)', () => { 310 test('should execute geometric detection with no full-screen overlays', async () => { 311 // allElements returns small elements -> no full-screen overlays detected 312 const smallEl = { 313 style: { display: '', visibility: '' }, 314 getBoundingClientRect: () => ({ width: 100, height: 100 }), 315 }; 316 resetMocks({ allElements: [smallEl], elPosition: 'fixed', elZIndex: 200 }); 317 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 318 assert.ok(result.screenshots.desktop_above); 319 }); 320 321 test('should execute geometric detection with non-fixed element (skip branch)', async () => { 322 // Element not positioned fixed/absolute - skipped 323 const el = { 324 style: { display: '', visibility: '' }, 325 getBoundingClientRect: () => ({ width: 1300, height: 800 }), 326 }; 327 resetMocks({ allElements: [el], elPosition: 'static', elZIndex: 0 }); 328 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 329 assert.ok(result.screenshots.desktop_above); 330 }); 331 332 test('should execute geometric detection with already-hidden element (skip branch)', async () => { 333 // Element has display:none - skipped 334 const el = { 335 style: { display: 'none', visibility: 'hidden' }, 336 getBoundingClientRect: () => ({ width: 1300, height: 800 }), 337 }; 338 resetMocks({ allElements: [el], elPosition: 'fixed', elZIndex: 200 }); 339 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 340 assert.ok(result.screenshots.desktop_above); 341 }); 342 343 test('should execute geometric detection with low-zindex element (skip branch)', async () => { 344 // Element has low z-index - skipped 345 const el = { 346 style: { display: '', visibility: '' }, 347 getBoundingClientRect: () => ({ width: 1300, height: 800 }), 348 }; 349 resetMocks({ allElements: [el], elPosition: 'fixed', elZIndex: 50 }); 350 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 351 assert.ok(result.screenshots.desktop_above); 352 }); 353 354 test('should execute geometric detection with transparent element (skip branch)', async () => { 355 // Full-screen element but transparent background - skipped 356 const el = { 357 style: { display: '', visibility: '' }, 358 getBoundingClientRect: () => ({ width: 1300, height: 800 }), 359 }; 360 resetMocks({ 361 allElements: [el], 362 elPosition: 'fixed', 363 elZIndex: 200, 364 elBgColor: 'transparent', 365 }); 366 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 367 assert.ok(result.screenshots.desktop_above); 368 }); 369 370 test('should execute geometric detection with likely-nav element (skip branch)', async () => { 371 // Full-screen width but short height - likely nav, skip 372 const el = { 373 style: { display: '', visibility: '' }, 374 getBoundingClientRect: () => ({ width: 1300, height: 60 }), 375 }; 376 resetMocks({ 377 allElements: [el], 378 elPosition: 'fixed', 379 elZIndex: 200, 380 elBgColor: 'white', 381 elOpacity: 1, 382 }); 383 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 384 assert.ok(result.screenshots.desktop_above); 385 }); 386 387 test('should execute geometric detection and hide full-screen overlay (line 281)', async () => { 388 // Full-screen, opaque, not nav -> hides element (count++) 389 const el = { 390 style: { display: '', visibility: '' }, 391 getBoundingClientRect: () => ({ width: 1300, height: 800 }), 392 }; 393 resetMocks({ 394 allElements: [el], 395 elPosition: 'fixed', 396 elZIndex: 200, 397 elBgColor: 'rgba(0, 0, 0, 0.5)', 398 elOpacity: 0.8, 399 }); 400 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 401 assert.ok(result.screenshots.desktop_above); 402 }); 403 }); 404 405 describe('viewport dimensions execute (lines 466-476)', () => { 406 test('should execute viewport dimensions callback and return dimensions', async () => { 407 // The dimensions evaluate runs and returns viewport/document dimensions 408 resetMocks(); 409 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 410 assert.ok(result.screenshots.desktop_above); 411 }); 412 }); 413 414 describe('scroll-to-top execute (lines 487-505)', () => { 415 test('should execute scroll-to-top callback with shimmed window.scrollTo', async () => { 416 // The scroll-to-top evaluate uses window.scrollTo and document.documentElement 417 resetMocks({ scrollY: 500 }); 418 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 419 assert.ok(result.screenshots.desktop_above); 420 }); 421 }); 422 423 describe('scroll-down execute (lines 538-561)', () => { 424 test('should execute scroll-down callback and return target scroll position', async () => { 425 resetMocks({ scrollY: 0, innerHeight: 900 }); 426 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 427 assert.ok(result.screenshots.desktop_below); 428 }); 429 }); 430 431 describe('scroll-back-to-top execute (lines 597-615)', () => { 432 test('should execute second scroll-to-top callback', async () => { 433 resetMocks(); 434 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 435 assert.ok(result.screenshots.mobile_above); 436 }); 437 }); 438 439 describe('locale data execute (lines 664-671)', () => { 440 test('should execute locale data callback with hreflangs', async () => { 441 resetMocks({ 442 lang: 'fr-FR', 443 hreflangs: [{ hreflang: 'fr', href: 'https://example.fr/' }], 444 }); 445 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 446 assert.ok(result.localeData); 447 const locale = JSON.parse(result.localeData); 448 assert.strictEqual(locale.htmlLang, 'fr-FR'); 449 assert.strictEqual(locale.hreflangs.length, 1); 450 }); 451 452 test('should execute locale data callback with no lang (covers null branch)', async () => { 453 resetMocks({ lang: null, hreflangs: [] }); 454 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 455 assert.ok(result.localeData !== undefined); 456 }); 457 }); 458 });