/ tests / capture / capture-coverage-boost.test.js
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  });