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