From 7d6dd985e048c7e92adcff49a69745a9188ad78d Mon Sep 17 00:00:00 2001 From: sarjoy Date: Tue, 19 May 2026 10:27:23 -0400 Subject: [PATCH 1/2] Windows PDF generation is fixed by routing Puppeteer through a system Chrome/Edge install with pipe transport instead of bundled Chromium over WebSocket. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes src/utils/resolveBrowserExecutable.js (new) resolveBrowserExecutable() scans platform-specific Chrome/Edge paths and returns the first existing executable. getPuppeteerLaunchOptions() always sets headless mode and sandbox/GPU args. On win32: requires a system browser, sets pipe: true, protocolTimeout: 180_000, and throws a clear error if none is found. On macOS/Linux: uses system Chrome when present, otherwise falls back to bundled Chromium (no pipe). src/utils/renderPdf.js Uses getPuppeteerLaunchOptions() instead of inline launch config. tests/utils/resolveBrowserExecutable.test.js (new) Six unit tests with mocked fs.existsSync and process.platform. Verification npm test — 35 tests passed node bin/md2cd.js tests/fixtures/theme-system — exit 0; PDF written to tests/fixtures/theme-system/dist/themes-course-course-description.pdf (~102 KB) On Windows, users must have Google Chrome or Microsoft Edge installed; otherwise they get: "No supported browser found on Windows. Install Google Chrome or Microsoft Edge, then retry." --- src/utils/renderPdf.js | 11 +- src/utils/resolveBrowserExecutable.js | 82 +++++++++++ tests/utils/resolveBrowserExecutable.test.js | 138 +++++++++++++++++++ 3 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 src/utils/resolveBrowserExecutable.js create mode 100644 tests/utils/resolveBrowserExecutable.test.js 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..868b28d --- /dev/null +++ b/tests/utils/resolveBrowserExecutable.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; + +describe('resolveBrowserExecutable', () => { + let existsSyncSpy; + let platformDescriptor; + + beforeEach(() => { + existsSyncSpy = vi.spyOn(fs, 'existsSync'); + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + 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'; + existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); + + vi.resetModules(); + const { resolveBrowserExecutable } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const result = resolveBrowserExecutable(); + + expect(result).toBe(chromePath); + expect(existsSyncSpy).toHaveBeenCalled(); + }); + + it('returns null when no browser exists', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + existsSyncSpy.mockReturnValue(false); + + vi.resetModules(); + const { resolveBrowserExecutable } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const result = resolveBrowserExecutable(); + + expect(result).toBeNull(); + }); +}); + +describe('getPuppeteerLaunchOptions', () => { + let existsSyncSpy; + let platformDescriptor; + + beforeEach(() => { + existsSyncSpy = vi.spyOn(fs, 'existsSync'); + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + }); + + function mockPlatform(platform) { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + } + + it('on Windows sets pipe, protocolTimeout, and executablePath when browser exists', async () => { + mockPlatform('win32'); + const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; + existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); + + vi.resetModules(); + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + 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'); + existsSyncSpy.mockReturnValue(false); + + vi.resetModules(); + 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'); + existsSyncSpy.mockReturnValue(false); + + vi.resetModules(); + 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'; + existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); + + vi.resetModules(); + const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + const options = getPuppeteerLaunchOptions(); + + expect(options.executablePath).toBe(chromePath); + expect(options.pipe).toBeUndefined(); + }); +}); From ba77c8af8fbb5ebdf8c424c24173fc11a4729318 Mon Sep 17 00:00:00 2001 From: Jared Nielsen Date: Tue, 19 May 2026 10:48:32 -0400 Subject: [PATCH 2/2] fix: simplify test --- tests/utils/resolveBrowserExecutable.test.js | 47 +++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/tests/utils/resolveBrowserExecutable.test.js b/tests/utils/resolveBrowserExecutable.test.js index 868b28d..4b07225 100644 --- a/tests/utils/resolveBrowserExecutable.test.js +++ b/tests/utils/resolveBrowserExecutable.test.js @@ -1,17 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; +vi.mock('fs'); + describe('resolveBrowserExecutable', () => { - let existsSyncSpy; let platformDescriptor; beforeEach(() => { - existsSyncSpy = vi.spyOn(fs, 'existsSync'); platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + vi.clearAllMocks(); }); afterEach(() => { - vi.restoreAllMocks(); if (platformDescriptor) { Object.defineProperty(process, 'platform', platformDescriptor); } @@ -20,22 +20,24 @@ describe('resolveBrowserExecutable', () => { it('returns the first existing browser path', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); const chromePath = '/usr/bin/google-chrome'; - existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); - + 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(existsSyncSpy).toHaveBeenCalled(); + expect(fs.existsSync).toHaveBeenCalled(); }); it('returns null when no browser exists', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); - existsSyncSpy.mockReturnValue(false); - + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); const { resolveBrowserExecutable } = await import('../../src/utils/resolveBrowserExecutable.js'); const result = resolveBrowserExecutable(); @@ -45,16 +47,14 @@ describe('resolveBrowserExecutable', () => { }); describe('getPuppeteerLaunchOptions', () => { - let existsSyncSpy; let platformDescriptor; beforeEach(() => { - existsSyncSpy = vi.spyOn(fs, 'existsSync'); platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + vi.clearAllMocks(); }); afterEach(() => { - vi.restoreAllMocks(); if (platformDescriptor) { Object.defineProperty(process, 'platform', platformDescriptor); } @@ -67,13 +67,13 @@ describe('getPuppeteerLaunchOptions', () => { }); } - it('on Windows sets pipe, protocolTimeout, and executablePath when browser exists', async () => { + 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'; - existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); - - vi.resetModules(); - const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); + + vi.mocked(fs.existsSync).mockImplementation((filePath) => filePath === chromePath); const options = getPuppeteerLaunchOptions(); @@ -91,9 +91,10 @@ describe('getPuppeteerLaunchOptions', () => { it('on Windows throws when no browser is found', async () => { mockPlatform('win32'); - existsSyncSpy.mockReturnValue(false); - + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); expect(() => getPuppeteerLaunchOptions()).toThrow( @@ -103,9 +104,10 @@ describe('getPuppeteerLaunchOptions', () => { it('on non-Windows does not set pipe or require executablePath', async () => { mockPlatform('darwin'); - existsSyncSpy.mockReturnValue(false); - + vi.resetModules(); + const fs = (await import('fs')).default; + fs.existsSync.mockReturnValue(false); const { getPuppeteerLaunchOptions } = await import('../../src/utils/resolveBrowserExecutable.js'); const options = getPuppeteerLaunchOptions(); @@ -125,9 +127,10 @@ describe('getPuppeteerLaunchOptions', () => { it('on non-Windows sets executablePath when browser exists', async () => { mockPlatform('darwin'); const chromePath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - existsSyncSpy.mockImplementation((filePath) => filePath === chromePath); - + 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();