Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions src/utils/renderPdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand Down
82 changes: 82 additions & 0 deletions src/utils/resolveBrowserExecutable.js
Original file line number Diff line number Diff line change
@@ -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;
}
141 changes: 141 additions & 0 deletions tests/utils/resolveBrowserExecutable.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading