/ tests / reports / html-to-pdf-supplement.test.js
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  });