/ tests / capture / capture-mocked.test.js
capture-mocked.test.js
  1  /**
  2   * Unit Tests for Screenshot Capture Module (Mocked)
  3   * Uses Node.js 22+ mock.module() to mock Playwright and dependencies
  4   */
  5  
  6  import { test, describe, mock, beforeEach } from 'node:test';
  7  import assert from 'node:assert';
  8  
  9  // === MOCK SETUP ===
 10  
 11  // Create mock response object for page.goto()
 12  const mockResponse = {
 13    status: () => 200,
 14    ok: () => true,
 15    headers: () => ({ 'content-type': 'text/html' }),
 16    url: () => 'https://example.com',
 17  };
 18  
 19  // Create mock functions for Playwright
 20  const mockPage = {
 21    goto: mock.fn(async () => mockResponse),
 22    setViewportSize: mock.fn(async () => {}),
 23    evaluate: mock.fn(async fn => {
 24      // page.evaluate receives functions that reference browser globals (window, document).
 25      // We can't execute them in Node.js, so we return sensible defaults based on what
 26      // capture.js expects from each evaluate call:
 27      // 1. Hide cookie banners → returns count (number)
 28      // 2. Remove sticky elements → returns count (number)
 29      // 3. Get viewport dimensions → returns {viewport, document, devicePixelRatio}
 30      // 4. Scroll to top → undefined
 31      // 5. Get target scroll position → returns number
 32      // 6. Get current scrollY → returns number
 33      // 7. Reset scroll behavior → undefined
 34      // 8. Get locale data → returns {lang, htmlLang, ...}
 35      const fnStr = typeof fn === 'function' ? fn.toString() : '';
 36      if (fnStr.includes('innerWidth') && fnStr.includes('scrollWidth')) {
 37        return {
 38          viewport: { width: 1440, height: 900 },
 39          document: { width: 1440, height: 3000 },
 40          devicePixelRatio: 1,
 41        };
 42      }
 43      if (fnStr.includes('scrollY') && fnStr.includes('innerHeight') && fnStr.includes('target')) {
 44        return 900; // targetScrollY
 45      }
 46      if (fnStr.includes('scrollY') && !fnStr.includes('innerHeight')) {
 47        return 900; // currentScrollY
 48      }
 49      if (fnStr.includes('navigator.language') || fnStr.includes('htmlLang')) {
 50        return { lang: 'en', htmlLang: 'en', metaLang: null, ogLocale: null, hreflangTags: [] };
 51      }
 52      if (fnStr.includes('querySelectorAll')) {
 53        return 0; // removed count for cookie banners / sticky elements
 54      }
 55      return undefined;
 56    }),
 57    screenshot: mock.fn(async () => Buffer.from('fake-screenshot-data')),
 58    content: mock.fn(async () => '<html><body>Test content</body></html>'),
 59    close: mock.fn(async () => {}),
 60    on: mock.fn(),
 61    locator: mock.fn(() => ({ count: async () => 0, all: async () => [] })),
 62    click: mock.fn(async () => {}),
 63    waitForFunction: mock.fn(async () => {}),
 64    addStyleTag: mock.fn(async () => {}),
 65    keyboard: {
 66      press: mock.fn(async () => {}),
 67    },
 68    waitForTimeout: mock.fn(async () => {}),
 69    waitForLoadState: mock.fn(async () => {}),
 70    url: () => 'https://example.com',
 71  };
 72  
 73  const mockContext = {
 74    newPage: mock.fn(async () => mockPage),
 75    close: mock.fn(async () => {}),
 76  };
 77  
 78  const mockBrowser = {
 79    newContext: mock.fn(async () => mockContext),
 80    close: mock.fn(async () => {}),
 81  };
 82  
 83  // Mock stealth-browser module
 84  mock.module('../../src/utils/stealth-browser.js', {
 85    namedExports: {
 86      launchStealthBrowser: mock.fn(async () => mockBrowser),
 87      createStealthContext: mock.fn(async () => mockContext),
 88      humanScroll: mock.fn(async () => {}),
 89      randomDelay: mock.fn(async () => {}),
 90    },
 91  });
 92  
 93  // Mock image-optimizer module
 94  const optimizedResult = {
 95    cropped: Buffer.from('optimized-cropped'),
 96    uncropped: Buffer.from('optimized-uncropped'),
 97    metadata: { uncroppedSkipped: false },
 98  };
 99  
100  mock.module('../../src/utils/image-optimizer.js', {
101    namedExports: {
102      optimizeScreenshot: mock.fn(async () => optimizedResult),
103      calculateSavings: mock.fn(() => ({
104        originalKB: 100,
105        optimizedKB: 50,
106        savingsPercent: 50,
107      })),
108    },
109  });
110  
111  // Mock dom-crop-analyzer module
112  mock.module('../../src/utils/dom-crop-analyzer.js', {
113    namedExports: {
114      analyzeCropBoundaries: mock.fn(() => ({
115        left: 0,
116        top: 100,
117        width: 1440,
118        height: 700,
119        metadata: { navReasoning: 'mock nav detected', navHeight: 60 },
120      })),
121    },
122  });
123  
124  // Import after mocking
125  const { VIEWPORTS, captureScreenshots, launchBrowser } = await import('../../src/capture.js');
126  const { launchStealthBrowser, createStealthContext } =
127    await import('../../src/utils/stealth-browser.js');
128  const { optimizeScreenshot, calculateSavings } = await import('../../src/utils/image-optimizer.js');
129  const { analyzeCropBoundaries } = await import('../../src/utils/dom-crop-analyzer.js');
130  
131  describe('Capture Module - Unit Tests', () => {
132    beforeEach(() => {
133      // Reset all mocks before each test
134      // Reset calls and restore default implementations
135      mockPage.goto.mock.resetCalls();
136      mockPage.goto.mock.mockImplementation(async () => mockResponse);
137      mockPage.setViewportSize.mock.resetCalls();
138      mockPage.evaluate.mock.resetCalls();
139      mockPage.screenshot.mock.resetCalls();
140      mockPage.screenshot.mock.mockImplementation(async () => Buffer.from('fake-screenshot-data'));
141      mockPage.content.mock.resetCalls();
142      mockPage.close.mock.resetCalls();
143      mockPage.on.mock.resetCalls();
144      mockPage.locator.mock.resetCalls();
145      mockPage.addStyleTag.mock.resetCalls();
146      mockPage.waitForTimeout.mock.resetCalls();
147      mockPage.waitForLoadState.mock.resetCalls();
148      mockPage.waitForFunction.mock.resetCalls();
149      mockPage.click.mock.resetCalls();
150      mockPage.keyboard.press.mock.resetCalls();
151  
152      mockContext.newPage.mock.resetCalls();
153      mockContext.close.mock.resetCalls();
154  
155      mockBrowser.newContext.mock.resetCalls();
156      mockBrowser.close.mock.resetCalls();
157  
158      launchStealthBrowser.mock.resetCalls();
159      createStealthContext.mock.resetCalls();
160      optimizeScreenshot.mock.resetCalls();
161      calculateSavings.mock.resetCalls();
162      analyzeCropBoundaries.mock.resetCalls();
163    });
164  
165    describe('VIEWPORTS configuration', () => {
166      test('desktop viewport has correct dimensions', () => {
167        assert.strictEqual(VIEWPORTS.desktop.width, 1440);
168        assert.strictEqual(VIEWPORTS.desktop.height, 900);
169      });
170  
171      test('mobile viewport has correct dimensions', () => {
172        assert.strictEqual(VIEWPORTS.mobile.width, 390);
173        assert.strictEqual(VIEWPORTS.mobile.height, 844);
174        assert.strictEqual(VIEWPORTS.mobile.isMobile, true);
175        assert.strictEqual(VIEWPORTS.mobile.hasTouch, true);
176      });
177    });
178  
179    describe('launchBrowser', () => {
180      test('should launch browser with minimal stealth for prospect sites', async () => {
181        const browser = await launchBrowser({ headless: true, slowMo: 0 });
182  
183        assert.strictEqual(launchStealthBrowser.mock.calls.length, 1);
184  
185        const callArgs = launchStealthBrowser.mock.calls[0].arguments[0];
186        assert.strictEqual(callArgs.headless, true);
187        assert.strictEqual(callArgs.slowMo, 0);
188        assert.strictEqual(callArgs.stealthLevel, 'minimal');
189  
190        assert.strictEqual(browser, mockBrowser);
191      });
192  
193      test('should use default options when none provided', async () => {
194        await launchBrowser();
195  
196        assert.strictEqual(launchStealthBrowser.mock.calls.length, 1);
197  
198        const callArgs = launchStealthBrowser.mock.calls[0].arguments[0];
199        assert.strictEqual(callArgs.headless, false);
200        assert.strictEqual(callArgs.slowMo, 100);
201        assert.strictEqual(callArgs.stealthLevel, 'minimal');
202      });
203    });
204  
205    describe('captureScreenshots', () => {
206      test('should capture all required screenshots successfully', async () => {
207        const result = await captureScreenshots(mockContext, 'https://example.com', 'example.com');
208  
209        // Verify page was created
210        assert.strictEqual(mockContext.newPage.mock.calls.length, 1);
211  
212        // Verify goto was called
213        assert.strictEqual(mockPage.goto.mock.calls.length, 1);
214        assert.strictEqual(mockPage.goto.mock.calls[0].arguments[0], 'https://example.com');
215  
216        // Verify viewports were set (desktop + mobile)
217        assert.ok(mockPage.setViewportSize.mock.calls.length >= 2);
218  
219        // Verify screenshots were taken (desktop_above, desktop_below, mobile_above)
220        assert.ok(mockPage.screenshot.mock.calls.length >= 3);
221  
222        // Verify HTML content was captured
223        assert.strictEqual(mockPage.content.mock.calls.length, 1);
224  
225        // Verify page was closed
226        assert.strictEqual(mockPage.close.mock.calls.length, 1);
227  
228        // Verify result structure
229        assert.strictEqual(result.url, 'https://example.com');
230        assert.strictEqual(result.domain, 'example.com');
231        assert.ok(result.screenshots);
232        assert.ok(result.screenshotsUncropped);
233        assert.ok(result.cropMetadata);
234        assert.strictEqual(result.html, '<html><body>Test content</body></html>');
235        assert.strictEqual(result.error, null);
236      });
237  
238      test('should throw on navigation errors', async () => {
239        // Mock page.goto to throw error
240        mockPage.goto.mock.mockImplementation(async () => {
241          throw new Error('Navigation timeout');
242        });
243  
244        await assert.rejects(
245          () => captureScreenshots(mockContext, 'https://example.com', 'example.com'),
246          { message: 'Navigation timeout' }
247        );
248      });
249  
250      test('should set up console error listeners', async () => {
251        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
252  
253        // Verify page.on was called for console and pageerror events
254        assert.ok(mockPage.on.mock.calls.length >= 2);
255  
256        const eventTypes = mockPage.on.mock.calls.map(call => call.arguments[0]);
257        assert.ok(eventTypes.includes('console'));
258        assert.ok(eventTypes.includes('pageerror'));
259      });
260  
261      test('should set viewports for desktop and mobile', async () => {
262        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
263  
264        // Should set viewport for desktop (1440x900) and mobile (390x844)
265        assert.ok(mockPage.setViewportSize.mock.calls.length >= 2);
266      });
267  
268      test('should call waitForTimeout between operations', async () => {
269        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
270  
271        // Should have timeouts between screenshots for rendering
272        assert.ok(mockPage.waitForTimeout.mock.calls.length >= 1);
273      });
274  
275      test('should optimize all screenshots', async () => {
276        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
277  
278        // Should optimize desktop_above, desktop_below, mobile_above
279        assert.strictEqual(optimizeScreenshot.mock.calls.length, 3);
280  
281        // Verify optimization options
282        const { calls } = optimizeScreenshot.mock;
283        assert.strictEqual(calls[0].arguments[1].type, 'desktop_above');
284        assert.strictEqual(calls[0].arguments[1].includeUncropped, true);
285        assert.strictEqual(calls[1].arguments[1].type, 'desktop_below');
286        assert.strictEqual(calls[2].arguments[1].type, 'mobile_above');
287      });
288  
289      test('should calculate and log savings', async () => {
290        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
291  
292        // Verify calculateSavings was called
293        assert.strictEqual(calculateSavings.mock.calls.length, 1);
294      });
295  
296      test('should analyze crop boundaries for each viewport', async () => {
297        await captureScreenshots(mockContext, 'https://example.com', 'example.com');
298  
299        // Should analyze desktop_above, desktop_below, mobile_above
300        assert.strictEqual(analyzeCropBoundaries.mock.calls.length, 3);
301      });
302  
303      test('should throw on screenshot errors', async () => {
304        // Mock screenshot to fail
305        mockPage.screenshot.mock.mockImplementation(async () => {
306          throw new Error('Screenshot failed');
307        });
308  
309        await assert.rejects(
310          () => captureScreenshots(mockContext, 'https://example.com', 'example.com'),
311          { message: 'Screenshot failed' }
312        );
313      });
314    });
315  });