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 });