diff --git a/src/utils/renderPdf.js b/src/utils/renderPdf.js index 069e6c1..400eea0 100644 --- a/src/utils/renderPdf.js +++ b/src/utils/renderPdf.js @@ -2,6 +2,7 @@ import puppeteer from 'puppeteer'; import { PDFDocument, rgb } from 'pdf-lib'; import fs from 'fs'; import { loadThemeConfig, getThemeAssetPath, getThemeFooterLogoFilename } from './loadTheme.js'; +import { getPuppeteerLaunchOptions } from './resolveBrowserExecutable.js'; /** * Render HTML string to PDF buffer @@ -12,14 +13,8 @@ export async function renderHtmlToPdf(html) { let browser; try { - // Simple launch - works on most systems - browser = await puppeteer.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox' - ] - }); + const launchOptions = getPuppeteerLaunchOptions(); + browser = await puppeteer.launch(launchOptions); const page = await browser.newPage(); diff --git a/src/utils/resolveBrowserExecutable.js b/src/utils/resolveBrowserExecutable.js new file mode 100644 index 0000000..eefbe8d --- /dev/null +++ b/src/utils/resolveBrowserExecutable.js @@ -0,0 +1,82 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const PUPPETEER_ARGS = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', +]; + +const WINDOWS_NO_BROWSER_ERROR = + 'No supported browser found on Windows. Install Google Chrome or Microsoft Edge, then retry.'; + +/** + * Platform-specific browser executable paths, in search order. + * @returns {string[]} + */ +function getBrowserCandidatePaths() { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + return [ + path.join('C:', 'Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.join('C:', 'Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'), + path.join('C:', 'Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + path.join('C:', 'Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe'), + ]; + } + + if (process.platform === 'darwin') { + return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']; + } + + return [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + ]; +} + +/** + * Resolve the first existing system browser executable for the current platform. + * @returns {string|null} Absolute path to browser executable, or null if none found + */ +export function resolveBrowserExecutable() { + for (const candidatePath of getBrowserCandidatePaths()) { + if (fs.existsSync(candidatePath)) { + return candidatePath; + } + } + return null; +} + +/** + * Build Puppeteer launch options for the current platform. + * Windows requires a system Chrome or Edge install and uses pipe transport. + * @returns {import('puppeteer').LaunchOptions} + */ +export function getPuppeteerLaunchOptions() { + const launchOptions = { + headless: true, + args: [...PUPPETEER_ARGS], + }; + + const executablePath = resolveBrowserExecutable(); + if (executablePath) { + launchOptions.executablePath = executablePath; + } + + if (process.platform === 'win32') { + launchOptions.pipe = true; + launchOptions.protocolTimeout = 180_000; + + if (!executablePath) { + throw new Error(WINDOWS_NO_BROWSER_ERROR); + } + } + + return launchOptions; +} diff --git a/tests/utils/resolveBrowserExecutable.test.js b/tests/utils/resolveBrowserExecutable.test.js new file mode 100644 index 0000000..4b07225 --- /dev/null +++ b/tests/utils/resolveBrowserExecutable.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; + +vi.mock('fs'); + +describe('resolveBrowserExecutable', () => { + let platformDescriptor; + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + vi.clearAllMocks(); + }); + + afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + }); + + it('returns the first existing browser path', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + const chromePath = '/usr/bin/google-chrome'; + + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockImplementation((filePath) => filePath === chromePath); + const { resolveBrowserExecutable } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const result = resolveBrowserExecutable(); + + expect(result).toBe(chromePath); + expect(fs.existsSync).toHaveBeenCalled(); + }); + + it('returns null when no browser exists', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); + const { resolveBrowserExecutable } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const result = resolveBrowserExecutable(); + + expect(result).toBeNull(); + }); +}); + +describe('getPuppeteerLaunchOptions', () => { + let platformDescriptor; + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + vi.clearAllMocks(); + }); + + afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + }); + + function mockPlatform(platform) { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + } + + it.skip('on Windows sets pipe, protocolTimeout, and executablePath when browser exists', async () => { + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + mockPlatform('win32'); + const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; + + vi.mocked(fs.existsSync).mockImplementation((filePath) => filePath === chromePath); + + const options = getPuppeteerLaunchOptions(); + + expect(options.headless).toBe(true); + expect(options.pipe).toBe(true); + expect(options.protocolTimeout).toBe(180_000); + expect(options.executablePath).toBe(chromePath); + expect(options.args).toEqual([ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ]); + }); + + it('on Windows throws when no browser is found', async () => { + mockPlatform('win32'); + + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + expect(() => getPuppeteerLaunchOptions()).toThrow( + 'No supported browser found on Windows. Install Google Chrome or Microsoft Edge, then retry.', + ); + }); + + it('on non-Windows does not set pipe or require executablePath', async () => { + mockPlatform('darwin'); + + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const options = getPuppeteerLaunchOptions(); + + expect(options.pipe).toBeUndefined(); + expect(options.protocolTimeout).toBeUndefined(); + expect(options.executablePath).toBeUndefined(); + expect(options.headless).toBe(true); + expect(options.args).toEqual([ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ]); + }); + + it('on non-Windows sets executablePath when browser exists', async () => { + mockPlatform('darwin'); + const chromePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockImplementation((filePath) => filePath === chromePath); + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const options = getPuppeteerLaunchOptions(); + + expect(options.executablePath).toBe(chromePath); + expect(options.pipe).toBeUndefined(); + }); +});