/ tests / capture / full-page-capture.test.js
full-page-capture.test.js
  1  /**
  2   * Full-Page Capture Unit Tests
  3   * Tests captureFullPage() with mocked Playwright and sharp
  4   */
  5  
  6  import { describe, test, mock, beforeEach } from 'node:test';
  7  import assert from 'node:assert/strict';
  8  
  9  // Create mock page, context, browser
 10  const mockPage = {
 11    goto: mock.fn(),
 12    screenshot: mock.fn(),
 13    evaluate: mock.fn(),
 14    content: mock.fn(),
 15    waitForTimeout: mock.fn(),
 16    setViewportSize: mock.fn(),
 17  };
 18  
 19  const mockContext = {
 20    newPage: mock.fn(() => mockPage),
 21    close: mock.fn(),
 22  };
 23  
 24  const mockBrowser = {
 25    close: mock.fn(),
 26  };
 27  
 28  mock.module('../../src/utils/stealth-browser.js', {
 29    namedExports: {
 30      launchStealthBrowser: mock.fn(async () => mockBrowser),
 31      createStealthContext: mock.fn(async () => mockContext),
 32    },
 33  });
 34  
 35  // Create a minimal valid PNG for mocking
 36  const { default: sharp } = await import('sharp');
 37  const testPng = await sharp({
 38    create: { width: 1440, height: 5000, channels: 3, background: { r: 200, g: 200, b: 200 } },
 39  })
 40    .png()
 41    .toBuffer();
 42  
 43  const smallPng = await sharp({
 44    create: { width: 1440, height: 900, channels: 3, background: { r: 100, g: 100, b: 100 } },
 45  })
 46    .png()
 47    .toBuffer();
 48  
 49  const { captureFullPage } = await import('../../src/reports/full-page-capture.js');
 50  
 51  describe('captureFullPage', () => {
 52    beforeEach(() => {
 53      mockPage.goto.mock.resetCalls();
 54      mockPage.screenshot.mock.resetCalls();
 55      mockPage.evaluate.mock.resetCalls();
 56      mockPage.content.mock.resetCalls();
 57      mockPage.waitForTimeout.mock.resetCalls();
 58      mockPage.setViewportSize.mock.resetCalls();
 59      mockContext.close.mock.resetCalls();
 60      mockBrowser.close.mock.resetCalls();
 61  
 62      // Default mocks
 63      mockPage.goto.mock.mockImplementation(async () => ({
 64        headers: () => ({
 65          'content-type': 'text/html',
 66          server: 'nginx',
 67          'strict-transport-security': 'max-age=31536000',
 68        }),
 69      }));
 70      mockPage.screenshot.mock.mockImplementation(async () => smallPng);
 71      mockPage.evaluate.mock.mockImplementation(async fn => {
 72        if (typeof fn === 'function') return 5000;
 73        return null;
 74      });
 75      mockPage.content.mock.mockImplementation(async () => '<html><body>Test</body></html>');
 76      mockPage.waitForTimeout.mock.mockImplementation(async () => {});
 77      mockPage.setViewportSize.mock.mockImplementation(async () => {});
 78    });
 79  
 80    test('captures full-page and above-fold screenshots', async () => {
 81      const result = await captureFullPage('https://example.com');
 82  
 83      assert.ok(Buffer.isBuffer(result.fullPageBuffer));
 84      assert.ok(Buffer.isBuffer(result.aboveFoldBuffer));
 85      assert.ok(result.htmlContent.includes('Test'));
 86      assert.ok(result.httpHeaders['content-type']);
 87      assert.equal(result.sslStatus, 'https');
 88      assert.ok(result.pageHeight > 0);
 89    });
 90  
 91    test('detects http vs https', async () => {
 92      const result = await captureFullPage('http://insecure-site.com');
 93      assert.equal(result.sslStatus, 'http');
 94    });
 95  
 96    test('filters HTTP headers to security-relevant ones', async () => {
 97      mockPage.goto.mock.mockImplementation(async () => ({
 98        headers: () => ({
 99          'content-type': 'text/html',
100          server: 'nginx',
101          'x-request-id': 'abc123', // Should be filtered out
102          'x-frame-options': 'DENY',
103          'set-cookie': 'session=abc', // Should be filtered out
104        }),
105      }));
106  
107      const result = await captureFullPage('https://example.com');
108  
109      assert.ok(result.httpHeaders['content-type']);
110      assert.ok(result.httpHeaders['x-frame-options']);
111      assert.equal(result.httpHeaders['x-request-id'], undefined);
112      assert.equal(result.httpHeaders['set-cookie'], undefined);
113    });
114  
115    test('closes browser on error', async () => {
116      mockPage.goto.mock.mockImplementation(async () => {
117        throw new Error('Navigation timeout');
118      });
119  
120      await assert.rejects(() => captureFullPage('https://timeout.com'));
121  
122      assert.equal(mockBrowser.close.mock.calls.length, 1);
123    });
124  
125    test('handles null response headers', async () => {
126      mockPage.goto.mock.mockImplementation(async () => null);
127  
128      const result = await captureFullPage('https://no-response.com');
129  
130      assert.deepStrictEqual(result.httpHeaders, {});
131    });
132  
133    test('closes context after capture', async () => {
134      await captureFullPage('https://example.com');
135  
136      assert.equal(mockContext.close.mock.calls.length, 1);
137      assert.equal(mockBrowser.close.mock.calls.length, 1);
138    });
139  
140    test('crops screenshot height when it exceeds maxHeight option', async () => {
141      // Return a tall screenshot (5000px) but set maxHeight to 2000px
142      mockPage.screenshot.mock.mockImplementation(async () => testPng); // 5000px tall
143      mockPage.evaluate.mock.mockImplementation(async fn => {
144        if (typeof fn === 'function') return 5000;
145        return null;
146      });
147  
148      const result = await captureFullPage('https://tall-page.com', { maxHeight: 2000 });
149  
150      // The returned buffer should be cropped to maxHeight
151      const meta = await sharp(result.fullPageBuffer).metadata();
152      assert.ok(meta.height <= 2000, `Height ${meta.height} should be <= 2000`);
153    });
154  
155    test('handles evaluate error gracefully (popover closing is best-effort)', async () => {
156      let callCount = 0;
157      mockPage.evaluate.mock.mockImplementation(async fn => {
158        callCount++;
159        if (callCount === 1) {
160          // First evaluate call (close popovers) throws — should be caught silently
161          throw new Error('evaluate failed');
162        }
163        // Second evaluate call (get page height) returns a number
164        if (typeof fn === 'function') return 3000;
165        return 3000;
166      });
167  
168      // Should not throw despite evaluate error on first call
169      const result = await captureFullPage('https://example.com');
170      assert.ok(Buffer.isBuffer(result.fullPageBuffer), 'Should still return a valid buffer');
171    });
172  });