capture-coverage-boost.test.js
1 /** 2 * Coverage Boost Tests for Screenshot Capture Module 3 * Covers additional uncovered paths in captureScreenshots and closePopovers 4 * Targets lines: 156-204, 215-290, 306-308, 391-393, 467-475, 488-504, 539-560, 598-614, 665-671 5 */ 6 7 import { test, describe, mock, beforeEach } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 10 // === MOCK SETUP === 11 12 const testState = { 13 responseStatus: 200, 14 responseOk: true, 15 }; 16 17 const makeResponse = () => ({ 18 status: () => testState.responseStatus, 19 ok: () => testState.responseOk, 20 statusText: () => (testState.responseOk ? 'OK' : 'Error'), 21 headers: () => ({ 22 server: 'nginx', 23 'x-powered-by': 'PHP', 24 'content-encoding': 'gzip', 25 'cache-control': 'max-age=3600', 26 'content-language': 'en-US', 27 'strict-transport-security': 'max-age=31536000', 28 'content-security-policy': "default-src 'self'", 29 'x-frame-options': 'SAMEORIGIN', 30 'x-content-type-options': 'nosniff', 31 }), 32 url: () => 'https://example.com', 33 }); 34 35 const makeExecutingEvaluate = 36 (overrides = {}) => 37 async fn => { 38 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 39 if (overrides.throwOnLocale && fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 40 throw new Error('locale data failed'); 41 } 42 if (overrides.throwOnGeometric && fnStr.includes('getBoundingClientRect')) { 43 throw new Error('geometric detection failed'); 44 } 45 if ( 46 (overrides.throwOnOverlay && fnStr.includes('intercom')) || 47 (overrides.throwOnOverlay && fnStr.includes('cookie-banner')) 48 ) { 49 throw new Error('overlay hiding failed'); 50 } 51 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 52 return { 53 viewport: { width: 1440, height: 900 }, 54 document: { width: 1440, height: 3000 }, 55 devicePixelRatio: 1, 56 }; 57 } 58 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 59 return 900; 60 if (fnStr.includes('getBoundingClientRect')) 61 return overrides.geometricCount !== undefined ? overrides.geometricCount : 0; 62 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 63 return { 64 htmlLang: overrides.htmlLang !== undefined ? overrides.htmlLang : 'en-US', 65 hreflangs: overrides.hreflangs || [], 66 }; 67 } 68 return undefined; 69 }; 70 71 const mockPage = { 72 goto: mock.fn(async () => makeResponse()), 73 setViewportSize: mock.fn(async () => {}), 74 evaluate: mock.fn(makeExecutingEvaluate()), 75 screenshot: mock.fn(async () => Buffer.from('fake-screenshot-data')), 76 content: mock.fn(async () => '<html><body>Test content</body></html>'), 77 close: mock.fn(async () => {}), 78 on: mock.fn(), 79 locator: mock.fn(() => ({ 80 all: async () => [], 81 count: async () => 0, 82 first() { 83 return this; 84 }, 85 isVisible: async () => false, 86 click: async () => {}, 87 })), 88 click: mock.fn(async () => {}), 89 waitForFunction: mock.fn(async () => {}), 90 addStyleTag: mock.fn(async () => {}), 91 keyboard: { press: mock.fn(async () => {}) }, 92 waitForTimeout: mock.fn(async () => {}), 93 waitForLoadState: mock.fn(async () => {}), 94 url: () => 'https://example.com', 95 }; 96 97 const mockContext = { 98 newPage: mock.fn(async () => mockPage), 99 close: mock.fn(async () => {}), 100 }; 101 102 const mockBrowser = { 103 newContext: mock.fn(async () => mockContext), 104 close: mock.fn(async () => {}), 105 }; 106 107 mock.module('../../src/utils/stealth-browser.js', { 108 namedExports: { 109 launchStealthBrowser: mock.fn(async () => mockBrowser), 110 createStealthContext: mock.fn(async () => mockContext), 111 humanScroll: mock.fn(async () => {}), 112 randomDelay: mock.fn(async () => {}), 113 }, 114 }); 115 116 const optimizedResult = { 117 cropped: Buffer.from('optimized-cropped'), 118 uncropped: Buffer.from('optimized-uncropped'), 119 metadata: { uncroppedSkipped: false }, 120 }; 121 122 mock.module('../../src/utils/image-optimizer.js', { 123 namedExports: { 124 optimizeScreenshot: mock.fn(async () => optimizedResult), 125 calculateSavings: mock.fn(() => ({ originalKB: 100, optimizedKB: 50, savingsPercent: 50 })), 126 }, 127 }); 128 129 mock.module('../../src/utils/dom-crop-analyzer.js', { 130 namedExports: { 131 analyzeCropBoundaries: mock.fn(() => ({ 132 left: 0, 133 top: 100, 134 width: 1440, 135 height: 700, 136 metadata: { navReasoning: 'mock nav detected', navHeight: 60 }, 137 })), 138 }, 139 }); 140 141 const { captureScreenshots, launchBrowser, captureWebsite, VIEWPORTS } = 142 await import('../../src/capture.js'); 143 const { launchStealthBrowser } = await import('../../src/utils/stealth-browser.js'); 144 const { optimizeScreenshot } = await import('../../src/utils/image-optimizer.js'); 145 146 function resetMocks() { 147 testState.responseStatus = 200; 148 testState.responseOk = true; 149 150 mockPage.goto.mock.resetCalls(); 151 mockPage.goto.mock.mockImplementation(async () => makeResponse()); 152 mockPage.setViewportSize.mock.resetCalls(); 153 mockPage.evaluate.mock.resetCalls(); 154 mockPage.evaluate.mock.mockImplementation(makeExecutingEvaluate()); 155 mockPage.screenshot.mock.resetCalls(); 156 mockPage.screenshot.mock.mockImplementation(async () => Buffer.from('fake-screenshot-data')); 157 mockPage.content.mock.resetCalls(); 158 mockPage.close.mock.resetCalls(); 159 mockPage.on.mock.resetCalls(); 160 mockPage.waitForFunction.mock.resetCalls(); 161 mockPage.addStyleTag.mock.resetCalls(); 162 mockPage.waitForTimeout.mock.resetCalls(); 163 mockPage.locator.mock.resetCalls(); 164 mockPage.locator.mock.mockImplementation(() => ({ 165 all: async () => [], 166 count: async () => 0, 167 first() { 168 return this; 169 }, 170 isVisible: async () => false, 171 click: async () => {}, 172 })); 173 mockContext.newPage.mock.resetCalls(); 174 mockBrowser.close.mock.resetCalls(); 175 launchStealthBrowser.mock.resetCalls(); 176 optimizeScreenshot.mock.resetCalls(); 177 optimizeScreenshot.mock.mockImplementation(async () => optimizedResult); 178 } 179 180 describe('Capture Module - Coverage Boost v4', () => { 181 beforeEach(() => resetMocks()); 182 183 describe('VIEWPORTS export', () => { 184 test('VIEWPORTS has correct desktop dimensions', () => { 185 assert.strictEqual(VIEWPORTS.desktop.width, 1440); 186 assert.strictEqual(VIEWPORTS.desktop.height, 900); 187 }); 188 189 test('VIEWPORTS has correct mobile dimensions', () => { 190 assert.strictEqual(VIEWPORTS.mobile.width, 390); 191 assert.strictEqual(VIEWPORTS.mobile.height, 844); 192 assert.strictEqual(VIEWPORTS.mobile.isMobile, true); 193 assert.strictEqual(VIEWPORTS.mobile.hasTouch, true); 194 }); 195 }); 196 197 describe('captureScreenshots - happy path', () => { 198 test('should return all expected fields on success', async () => { 199 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 200 201 assert.strictEqual(result.url, 'https://example.com'); 202 assert.strictEqual(result.domain, 'example.com'); 203 assert.ok(result.screenshots.desktop_above); 204 assert.ok(result.screenshots.desktop_below); 205 assert.ok(result.screenshots.mobile_above); 206 assert.ok(result.screenshotsUncropped.desktop_above); 207 assert.strictEqual(result.httpStatusCode, 200); 208 assert.strictEqual(result.sslStatus, 'https'); 209 assert.ok(result.html); 210 assert.strictEqual(result.error, null); 211 }); 212 213 test('should set sslStatus to http for non-https URL', async () => { 214 const result = await captureScreenshots(mockContext, 'http://example.com', 'example.com'); 215 assert.strictEqual(result.sslStatus, 'http'); 216 }); 217 218 test('should capture HTTP headers from response', async () => { 219 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 220 assert.ok(result.httpHeaders); 221 const headers = JSON.parse(result.httpHeaders); 222 assert.strictEqual(headers.server, 'nginx'); 223 assert.strictEqual(headers['content-language'], 'en-US'); 224 }); 225 226 test('should set httpHeaders to null when headers() throws', async () => { 227 mockPage.goto.mock.mockImplementation(async () => ({ 228 status: () => 200, 229 ok: () => true, 230 statusText: () => 'OK', 231 headers: () => { 232 throw new Error('no headers'); 233 }, 234 url: () => 'https://example.com', 235 })); 236 237 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 238 assert.strictEqual(result.httpHeaders, null); 239 }); 240 241 test('should set localeData from evaluate', async () => { 242 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 243 assert.ok(result.localeData !== undefined); 244 const locale = JSON.parse(result.localeData); 245 assert.strictEqual(locale.htmlLang, 'en-US'); 246 }); 247 248 test('should set localeData to null when evaluate throws', async () => { 249 mockPage.evaluate.mock.mockImplementation(makeExecutingEvaluate({ throwOnLocale: true })); 250 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 251 assert.strictEqual(result.localeData, null); 252 }); 253 254 test('should call setViewportSize for desktop and mobile', async () => { 255 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 256 const { calls } = mockPage.setViewportSize.mock; 257 assert.ok(calls.length >= 2); 258 const desktopCall = calls.find(c => c.arguments[0].width === 1440); 259 const mobileCall = calls.find(c => c.arguments[0].width === 390); 260 assert.ok(desktopCall); 261 assert.ok(mobileCall); 262 }); 263 264 test('should call optimizeScreenshot three times', async () => { 265 await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 266 assert.strictEqual(optimizeScreenshot.mock.calls.length, 3); 267 }); 268 269 test('should log when skipped uncropped count > 0', async () => { 270 optimizeScreenshot.mock.mockImplementation(async () => ({ 271 cropped: Buffer.from('cropped'), 272 uncropped: null, 273 metadata: { uncroppedSkipped: true }, 274 })); 275 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 276 assert.ok(result); 277 }); 278 279 test('should include cropMetadata for all screenshots', async () => { 280 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 281 assert.ok(result.cropMetadata.desktop_above); 282 assert.ok(result.cropMetadata.desktop_below); 283 assert.ok(result.cropMetadata.mobile_above); 284 }); 285 286 test('should log locale with null htmlLang (covers not-set branch)', async () => { 287 mockPage.evaluate.mock.mockImplementation(makeExecutingEvaluate({ htmlLang: null })); 288 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 289 const locale = JSON.parse(result.localeData); 290 assert.strictEqual(locale.htmlLang, null); 291 }); 292 293 test('should log locale with hreflangs (covers hreflangs.length branch)', async () => { 294 mockPage.evaluate.mock.mockImplementation( 295 makeExecutingEvaluate({ 296 hreflangs: [{ hreflang: 'en', href: 'https://example.com/' }], 297 }) 298 ); 299 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 300 const locale = JSON.parse(result.localeData); 301 assert.strictEqual(locale.hreflangs.length, 1); 302 }); 303 304 test('should log geometric overlay removal when count > 0', async () => { 305 mockPage.evaluate.mock.mockImplementation(makeExecutingEvaluate({ geometricCount: 3 })); 306 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 307 assert.ok(result.screenshots.desktop_above); 308 }); 309 }); 310 311 describe('captureScreenshots - HTTP errors', () => { 312 test('should throw on HTTP 404', async () => { 313 mockPage.goto.mock.mockImplementation(async () => ({ 314 status: () => 404, 315 ok: () => false, 316 statusText: () => 'Not Found', 317 headers: () => ({}), 318 url: () => 'https://example.com', 319 })); 320 await assert.rejects( 321 () => captureScreenshots(mockContext, 'https://example.com', 'example.com'), 322 /HTTP 404/ 323 ); 324 }); 325 326 test('should throw on HTTP 500', async () => { 327 mockPage.goto.mock.mockImplementation(async () => ({ 328 status: () => 500, 329 ok: () => false, 330 statusText: () => 'Internal Server Error', 331 headers: () => ({}), 332 url: () => 'https://example.com', 333 })); 334 await assert.rejects( 335 () => captureScreenshots(mockContext, 'https://example.com', 'example.com'), 336 /HTTP 500/ 337 ); 338 }); 339 340 test('should throw and rethrow when page.goto throws', async () => { 341 mockPage.goto.mock.mockImplementation(async () => { 342 throw new Error('Nav timeout'); 343 }); 344 await assert.rejects( 345 () => captureScreenshots(mockContext, 'https://example.com', 'example.com'), 346 /Nav timeout/ 347 ); 348 }); 349 }); 350 351 describe('captureScreenshots - waitForFunction timeout paths', () => { 352 test('should continue when scroll-to-top waitForFunction times out', async () => { 353 mockPage.waitForFunction.mock.mockImplementation(async () => { 354 throw new Error('Timeout waiting for scroll'); 355 }); 356 // The .catch() handler at line 512-514 logs warning and continues 357 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 358 assert.ok(result.screenshots.desktop_above); 359 }); 360 361 test('should continue when scroll-down waitForFunction times out (covers async catch)', async () => { 362 let callCount = 0; 363 mockPage.waitForFunction.mock.mockImplementation(async () => { 364 callCount++; 365 throw new Error('Timeout'); 366 }); 367 368 // The async catch at 572-577 also calls page.evaluate() for currentScrollY 369 let evalCallCount = 0; 370 mockPage.evaluate.mock.mockImplementation(async fn => { 371 evalCallCount++; 372 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 373 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 374 return { 375 viewport: { width: 1440, height: 900 }, 376 document: { width: 1440, height: 3000 }, 377 devicePixelRatio: 1, 378 }; 379 } 380 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 381 return 900; 382 if (fnStr.includes('getBoundingClientRect')) return 0; 383 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) { 384 return { htmlLang: 'en-US', hreflangs: [] }; 385 } 386 // currentScrollY 387 return 850; 388 }); 389 390 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 391 assert.ok(result.screenshots.desktop_above); 392 }); 393 }); 394 395 describe('closePopovers - overlay-hiding paths', () => { 396 test('should handle overlay-hiding evaluate returning normally', async () => { 397 const evaluateFnNames = []; 398 mockPage.evaluate.mock.mockImplementation(async fn => { 399 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 400 evaluateFnNames.push(fnStr.substring(0, 50)); 401 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 402 return { 403 viewport: { width: 1440, height: 900 }, 404 document: { width: 1440, height: 3000 }, 405 devicePixelRatio: 1, 406 }; 407 } 408 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 409 return 900; 410 if (fnStr.includes('getBoundingClientRect')) return 0; 411 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 412 return { htmlLang: 'en-US', hreflangs: [] }; 413 return undefined; 414 }); 415 416 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 417 assert.ok(result.screenshots.desktop_above); 418 assert.ok(evaluateFnNames.length > 0); 419 }); 420 421 test('should catch overlay-hiding evaluate failure (covers lines 207-209)', async () => { 422 let evalCount = 0; 423 mockPage.evaluate.mock.mockImplementation(async fn => { 424 evalCount++; 425 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 426 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 427 return { 428 viewport: { width: 1440, height: 900 }, 429 document: { width: 1440, height: 3000 }, 430 devicePixelRatio: 1, 431 }; 432 } 433 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 434 return 900; 435 if (fnStr.includes('getBoundingClientRect')) return 0; 436 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 437 return { htmlLang: 'en-US', hreflangs: [] }; 438 // Throw for overlay-hiding evaluate (covers catch at lines 207-209) 439 if ( 440 fnStr.includes('intercom') || 441 fnStr.includes('cookie-banner') || 442 fnStr.includes('overlaySelectors') 443 ) { 444 throw new Error('evaluate failed for overlay hiding'); 445 } 446 return undefined; 447 }); 448 449 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 450 assert.ok(result.screenshots.desktop_above); 451 }); 452 453 test('should catch geometric detection evaluate failure (covers lines 296-298)', async () => { 454 mockPage.evaluate.mock.mockImplementation(async fn => { 455 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 456 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 457 return { 458 viewport: { width: 1440, height: 900 }, 459 document: { width: 1440, height: 3000 }, 460 devicePixelRatio: 1, 461 }; 462 } 463 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 464 return 900; 465 if (fnStr.includes('getBoundingClientRect')) { 466 throw new Error('geometric detection failed'); 467 } 468 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 469 return { htmlLang: 'en-US', hreflangs: [] }; 470 return undefined; 471 }); 472 473 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 474 assert.ok(result.screenshots.desktop_above); 475 }); 476 477 test('should handle locators returning visible elements (closedCount > 0)', async () => { 478 // Mock locators to return visible elements so closedCount > 0 path is taken 479 mockPage.locator.mock.mockImplementation(selector => ({ 480 all: async () => [ 481 { 482 isVisible: async () => true, 483 click: async () => {}, 484 }, 485 ], 486 count: async () => 1, 487 first() { 488 return this; 489 }, 490 isVisible: async () => true, 491 click: async () => {}, 492 })); 493 494 mockPage.evaluate.mock.mockImplementation(async fn => { 495 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 496 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 497 return { 498 viewport: { width: 1440, height: 900 }, 499 document: { width: 1440, height: 3000 }, 500 devicePixelRatio: 1, 501 }; 502 } 503 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 504 return 900; 505 if (fnStr.includes('getBoundingClientRect')) return 0; 506 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 507 return { htmlLang: 'en-US', hreflangs: [] }; 508 return undefined; 509 }); 510 511 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 512 assert.ok(result.screenshots.desktop_above); 513 }); 514 515 test('should catch outer closePopovers error from unexpected throw', async () => { 516 // Make locator throw unexpectedly to trigger outer catch (lines 305-308) 517 let locatorCount = 0; 518 mockPage.locator.mock.mockImplementation(selector => { 519 locatorCount++; 520 if (locatorCount > 3) { 521 throw new Error('unexpected catastrophic locator failure'); 522 } 523 return { 524 all: async () => [], 525 count: async () => 0, 526 first() { 527 return this; 528 }, 529 isVisible: async () => false, 530 click: async () => {}, 531 }; 532 }); 533 534 mockPage.evaluate.mock.mockImplementation(async fn => { 535 const fnStr = typeof fn === 'function' ? fn.toString() : ''; 536 if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) { 537 return { 538 viewport: { width: 1440, height: 900 }, 539 document: { width: 1440, height: 3000 }, 540 devicePixelRatio: 1, 541 }; 542 } 543 if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) 544 return 900; 545 if (fnStr.includes('getBoundingClientRect')) return 0; 546 if (fnStr.includes('htmlLang') && fnStr.includes('hreflang')) 547 return { htmlLang: 'en-US', hreflangs: [] }; 548 return undefined; 549 }); 550 551 const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com'); 552 assert.ok( 553 result.screenshots.desktop_above, 554 'capture should succeed despite closePopovers outer catch' 555 ); 556 }); 557 }); 558 559 describe('launchBrowser', () => { 560 test('should launch with minimal stealth and provided options', async () => { 561 await launchBrowser({ headless: true, slowMo: 0 }); 562 assert.strictEqual(launchStealthBrowser.mock.calls.length, 1); 563 const args = launchStealthBrowser.mock.calls[0].arguments[0]; 564 assert.strictEqual(args.stealthLevel, 'minimal'); 565 assert.strictEqual(args.headless, true); 566 }); 567 568 test('should use defaults when no options provided', async () => { 569 await launchBrowser(); 570 const args = launchStealthBrowser.mock.calls[0].arguments[0]; 571 assert.strictEqual(args.headless, false); 572 assert.strictEqual(args.slowMo, 100); 573 }); 574 }); 575 576 describe('captureWebsite', () => { 577 test('should run full capture with browser lifecycle', async () => { 578 const result = await captureWebsite('https://example.com'); 579 assert.strictEqual(result.url, 'https://example.com'); 580 assert.strictEqual(result.domain, 'example.com'); 581 }); 582 583 test('should close browser in finally even when capture throws', async () => { 584 mockPage.goto.mock.mockImplementation(async () => { 585 throw new Error('Connection refused'); 586 }); 587 588 await assert.rejects(() => captureWebsite('https://example.com'), /Connection refused/); 589 590 // mockContext.close and mockBrowser.close should have been called 591 assert.ok( 592 mockContext.close.mock.calls.length >= 1 || mockBrowser.close.mock.calls.length >= 1, 593 'browser or context should be closed in finally' 594 ); 595 }); 596 }); 597 });