html-to-pdf-supplement.test.js
1 /** 2 * Supplement tests for src/reports/html-to-pdf.js 3 * 4 * Covers: 5 * - renderReportPDF: creates output directory, launches browser, sets content, generates PDF 6 * - renderReportPDF: always closes browser in finally block 7 * - capturePageScreenshots: processes sections, skips missing sections, captures hero preview 8 * 9 * Playwright is mocked so no real browser is launched. 10 * 11 * Run: NODE_ENV=test LOGS_DIR=/tmp/test-logs node --experimental-test-module-mocks --test tests/reports/html-to-pdf-supplement.test.js 12 */ 13 14 import { test, describe, mock, before, after } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 import { join } from 'path'; 17 import { tmpdir } from 'os'; 18 import { mkdirSync, rmSync, existsSync } from 'fs'; 19 20 // ── Mock Playwright browser ─────────────────────────────────────────────────── 21 22 // Track calls 23 const browserCalls = { 24 closed: 0, 25 contextClosed: 0, 26 pdfGenerated: 0, 27 contentSet: 0, 28 viewportSet: 0, 29 screenshots: [], 30 }; 31 32 // Mock page element for screenshot/boundingBox 33 const makeElement = (box = { x: 0, y: 50, width: 800, height: 400 }) => ({ 34 boundingBox: async () => box, 35 }); 36 37 // Mock page 38 const mockPage = { 39 setContent: async (_html, _opts) => { 40 browserCalls.contentSet++; 41 }, 42 pdf: async _opts => { 43 browserCalls.pdfGenerated++; 44 }, 45 setViewportSize: async _opts => { 46 browserCalls.viewportSet++; 47 }, 48 $: async selector => { 49 // Return null for #missing-section, element for others 50 if (selector.includes('missing')) return null; 51 return makeElement(); 52 }, 53 screenshot: async opts => { 54 browserCalls.screenshots.push(opts); 55 }, 56 }; 57 58 // Mock context 59 const mockContext = { 60 newPage: async () => mockPage, 61 close: async () => { 62 browserCalls.contextClosed++; 63 }, 64 }; 65 66 // Mock browser 67 const mockBrowser = { 68 close: async () => { 69 browserCalls.closed++; 70 }, 71 }; 72 73 const mockLaunchStealthBrowser = mock.fn(async () => mockBrowser); 74 const mockCreateStealthContext = mock.fn(async () => mockContext); 75 const mockLogger = class { 76 info() {} 77 success() {} 78 warn() {} 79 error() {} 80 }; 81 82 mock.module('../../src/utils/stealth-browser.js', { 83 namedExports: { 84 launchStealthBrowser: mockLaunchStealthBrowser, 85 createStealthContext: mockCreateStealthContext, 86 }, 87 }); 88 89 mock.module('../../src/utils/logger.js', { defaultExport: mockLogger }); 90 91 // Import module AFTER mocks 92 const { renderReportPDF, capturePageScreenshots } = 93 await import('../../src/reports/html-to-pdf.js'); 94 95 // ── Setup ───────────────────────────────────────────────────────────────────── 96 97 let tmpDir; 98 99 before(() => { 100 tmpDir = join(tmpdir(), `html-to-pdf-test-${Date.now()}`); 101 mkdirSync(tmpDir, { recursive: true }); 102 }); 103 104 after(() => { 105 rmSync(tmpDir, { recursive: true, force: true }); 106 }); 107 108 function resetCalls() { 109 browserCalls.closed = 0; 110 browserCalls.contextClosed = 0; 111 browserCalls.pdfGenerated = 0; 112 browserCalls.contentSet = 0; 113 browserCalls.viewportSet = 0; 114 browserCalls.screenshots = []; 115 mockLaunchStealthBrowser.mock.resetCalls(); 116 mockCreateStealthContext.mock.resetCalls(); 117 } 118 119 // ── renderReportPDF tests ───────────────────────────────────────────────────── 120 121 describe('renderReportPDF', () => { 122 test('creates output directory if it does not exist', async () => { 123 resetCalls(); 124 const outputPath = join(tmpDir, 'nested', 'dir', 'report.pdf'); 125 await renderReportPDF({ html: '<html><body>Test</body></html>', outputPath }); 126 assert.ok(existsSync(join(tmpDir, 'nested', 'dir')), 'Directory should be created'); 127 }); 128 129 test('calls launchStealthBrowser with stealthLevel minimal', async () => { 130 resetCalls(); 131 const outputPath = join(tmpDir, 'test1.pdf'); 132 await renderReportPDF({ html: '<html/>', outputPath }); 133 134 assert.equal(mockLaunchStealthBrowser.mock.callCount(), 1); 135 const launchArgs = mockLaunchStealthBrowser.mock.calls[0].arguments[0]; 136 assert.equal(launchArgs.stealthLevel, 'minimal'); 137 }); 138 139 test('sets HTML content on page', async () => { 140 resetCalls(); 141 const html = '<html><head></head><body><h1>Report</h1></body></html>'; 142 const outputPath = join(tmpDir, 'test2.pdf'); 143 await renderReportPDF({ html, outputPath }); 144 assert.equal(browserCalls.contentSet, 1); 145 }); 146 147 test('generates PDF on the page', async () => { 148 resetCalls(); 149 const outputPath = join(tmpDir, 'test3.pdf'); 150 await renderReportPDF({ html: '<html/>', outputPath }); 151 assert.equal(browserCalls.pdfGenerated, 1); 152 }); 153 154 test('closes browser in finally block (even on success)', async () => { 155 resetCalls(); 156 const outputPath = join(tmpDir, 'test4.pdf'); 157 await renderReportPDF({ html: '<html/>', outputPath }); 158 assert.equal(browserCalls.closed, 1, 'Browser must be closed after render'); 159 }); 160 161 test('closes browser even if page.pdf throws', async () => { 162 resetCalls(); 163 const origPdf = mockPage.pdf; 164 mockPage.pdf = async () => { 165 throw new Error('PDF rendering failed'); 166 }; 167 168 const outputPath = join(tmpDir, 'test-error.pdf'); 169 await assert.rejects(() => renderReportPDF({ html: '<html/>', outputPath })); 170 171 assert.equal(browserCalls.closed, 1, 'Browser must be closed even on error'); 172 mockPage.pdf = origPdf; 173 }); 174 175 test('returns the outputPath', async () => { 176 resetCalls(); 177 const outputPath = join(tmpDir, 'test5.pdf'); 178 const result = await renderReportPDF({ html: '<html/>', outputPath }); 179 assert.equal(result, outputPath); 180 }); 181 }); 182 183 // ── capturePageScreenshots tests ────────────────────────────────────────────── 184 185 describe('capturePageScreenshots', () => { 186 test('creates output directory if missing', async () => { 187 resetCalls(); 188 const outputDir = join(tmpDir, 'screenshots', 'new-dir'); 189 await capturePageScreenshots({ 190 html: '<html/>', 191 outputDir, 192 sections: [{ id: 'page-cover', name: 'report-cover' }], 193 }); 194 assert.ok(existsSync(outputDir), 'Output directory should be created'); 195 }); 196 197 test('sets viewport before capturing sections', async () => { 198 resetCalls(); 199 const outputDir = join(tmpDir, 'screenshots-viewport'); 200 await capturePageScreenshots({ 201 html: '<html/>', 202 outputDir, 203 sections: [{ id: 'page-cover', name: 'report-cover' }], 204 }); 205 assert.equal(browserCalls.viewportSet, 1); 206 }); 207 208 test('returns results array with captured sections', async () => { 209 resetCalls(); 210 const outputDir = join(tmpDir, 'screenshots-results'); 211 const results = await capturePageScreenshots({ 212 html: '<html/>', 213 outputDir, 214 sections: [ 215 { id: 'page-cover', name: 'report-cover' }, 216 { id: 'page-factors', name: 'report-factors' }, 217 ], 218 }); 219 // 2 sections + 1 hero preview = 3 results 220 assert.ok(results.length >= 2, 'Should return at least 2 results'); 221 }); 222 223 test('skips sections where element is not found', async () => { 224 resetCalls(); 225 const outputDir = join(tmpDir, 'screenshots-missing'); 226 const results = await capturePageScreenshots({ 227 html: '<html/>', 228 outputDir, 229 sections: [ 230 { id: 'missing-section', name: 'missing' }, // page.$('#missing-section') returns null 231 { id: 'page-cover', name: 'report-cover' }, 232 ], 233 }); 234 // Only the non-missing section + hero preview 235 assert.ok(!results.some(r => r.name === 'missing'), 'Missing section should be skipped'); 236 assert.ok( 237 results.some(r => r.name === 'report-cover'), 238 'Valid section should be captured' 239 ); 240 }); 241 242 test('closes browser in finally block', async () => { 243 resetCalls(); 244 const outputDir = join(tmpDir, 'screenshots-close'); 245 await capturePageScreenshots({ 246 html: '<html/>', 247 outputDir, 248 sections: [{ id: 'page-cover', name: 'report-cover' }], 249 }); 250 assert.equal(browserCalls.closed, 1, 'Browser must be closed'); 251 }); 252 253 test('uses default sections when none provided', async () => { 254 resetCalls(); 255 const outputDir = join(tmpDir, 'screenshots-default'); 256 const results = await capturePageScreenshots({ html: '<html/>', outputDir }); 257 // Default sections: page-cover, page-factors, page-screenshots, page-recommendations 258 // plus hero preview = 5 items if all found 259 assert.ok(results.length > 0, 'Should capture default sections'); 260 }); 261 });