/** * Browser print regression tests for Email Library print and PDF export. * * Requirements: * - Node.js (v20+) * - Playwright (`npm ci` or `npm install -D @playwright/test`) * - Chromium browser binary: * - Standard Playwright install: `npx playwright install chromium` * - Or custom executable via env vars: * - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome` * - `CHROME_PATH=/path/to/chrome` * - Or custom browsers cache dir via: * - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache` */ const http = require('http'); const fs = require('fs'); const path = require('path'); const os = require('os'); let chromium; try { ({ chromium } = require('playwright')); } catch (err) { if (err && err.code === 'MODULE_NOT_FOUND') { console.error( 'Error: Playwright module not found.\n' + 'Please install dependencies by running:\n' + ' npm ci\n' + 'or ensure NODE_PATH contains the playwright package.' ); process.exit(1); } throw err; } const ROOT = path.resolve(__dirname, '..'); // Discover available Chromium executable using standard Playwright resolution, // custom environment variables, standard cache paths, or system binaries. function findChromiumPath() { // 1. Explicit environment variable overrides if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) { return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; } if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) { return process.env.CHROME_PATH; } // 2. Playwright's configured/default executable path try { const defaultPath = chromium.executablePath(); if (defaultPath && fs.existsSync(defaultPath)) { return defaultPath; } } catch (e) { // chromium.executablePath() may throw if no default browser is installed } // 3. Inspect standard Playwright browser cache directories const cacheDirs = []; if (process.env.PLAYWRIGHT_BROWSERS_PATH) { cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH); } if (process.platform === 'win32') { if (process.env.LOCALAPPDATA) { cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright')); } cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright')); } else if (process.platform === 'darwin') { cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright')); } else { cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright')); } for (const cacheDir of cacheDirs) { if (!fs.existsSync(cacheDir)) continue; try { const entries = fs.readdirSync(cacheDir).sort().reverse(); for (const entry of entries) { const entryPath = path.join(cacheDir, entry); const candidates = [ path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), path.join(entryPath, 'chrome-linux64', 'chrome'), path.join(entryPath, 'chrome-linux', 'chrome'), path.join(entryPath, 'chrome-win64', 'chrome.exe'), path.join(entryPath, 'chrome-win', 'chrome.exe'), path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } } } catch (e) {} } // 4. System browser fallbacks const systemCandidates = process.platform === 'win32' ? [ path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'), ] : [ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', ]; for (const sc of systemCandidates) { if (fs.existsSync(sc)) return sc; } return undefined; } async function run() { const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://127.0.0.1'); if (url.pathname.startsWith('/api/email/read/')) { const uid = url.pathname.replace('/api/email/read/', ''); if (uid === 'fail_uid' || uid === 'net_fail') { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Mailbox disconnected' })); return; } if (uid === 'crash_network') { req.destroy(); return; } const markSeen = url.searchParams.get('mark_seen'); if (markSeen !== 'false') { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'mark_seen must be false' })); return; } let body_html = ''; if (uid === 'img_test') { body_html = [ '
', '

Email with diverse images

', '', 'Company Brand Logo', '', '', '', '', '', '', '', '', '
' ].join('\n'); } else if (uid === 'long_msg') { body_html = `

${'A'.repeat(5000)}

`; } else { body_html = `

Standard message body for ${uid}

`; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ uid: uid, subject: `Subject ${uid}`, from_name: 'Sender', from_address: 'sender@example.com', to: 'user@example.com', date: '2026-10-20', date_display: 'Oct 20, 2026', body: `Plain text for ${uid}`, body_html: body_html, })); return; } let filePath = path.join(ROOT, url.pathname); if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { const ext = path.extname(filePath); const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain'; res.writeHead(200, { 'Content-Type': mime }); res.end(fs.readFileSync(filePath)); return; } res.writeHead(404); res.end('Not found'); }); await new Promise(r => server.listen(0, '127.0.0.1', r)); const port = server.address().port; const origin = `http://127.0.0.1:${port}`; const launchOpts = { headless: true }; const chromePath = findChromiumPath(); if (chromePath) launchOpts.executablePath = chromePath; let browser; try { browser = await chromium.launch(launchOpts); } catch (launchErr) { throw new Error( `Failed to launch Chromium browser: ${launchErr.message}\n` + 'Missing or unexecutable Chromium binary.\n' + 'To install Playwright Chromium, run:\n' + ' npx playwright install chromium\n' + 'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.' ); } const context = await browser.newContext(); const page = await context.newPage(); const results = {}; try { await page.goto(`${origin}/static/index.html`); await page.evaluate(() => { if (!document.getElementById('email-lib-bulk-actions')) { const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d); } if (!document.getElementById('email-lib-selected-count')) { const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d); } }); // TEST 1: Full Print Image Sanitization and Replacement const printImageResult = await page.evaluate(async (base) => { window.API_BASE = base; const mod = await import(`${base}/static/js/emailLibrary/index.js`); const stateMod = await import(`${base}/static/js/emailLibrary/state.js`); stateMod.state._selectedUids = new Set(['img_test']); stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }]; let iframePrintInvoked = false; let printDocHtml = ''; let afterPrintTriggered = false; let hasPlaceholderElement = false; const observer = new MutationObserver(() => { const frame = document.getElementById('email-browser-print-frame'); if (frame && frame.contentWindow && !frame._patched) { frame._patched = true; frame.contentWindow.print = function() { iframePrintInvoked = true; printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : ''; const body = frame.contentDocument ? frame.contentDocument.body : null; hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder'); frame.contentWindow.dispatchEvent(new Event('afterprint')); afterPrintTriggered = true; }; } }); observer.observe(document.body, { childList: true }); await mod._exportSelectedEmailPdf(); observer.disconnect(); const frameAfter = document.getElementById('email-browser-print-frame'); return { iframePrintInvoked, afterPrintTriggered, frameRemovedAfterPrint: frameAfter === null, hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'), hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'), hasNoAltNotice: printDocHtml.includes('[Image omitted]'), hasPlaceholderElement, hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'), hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'), hasInteractiveActionBtn: printDocHtml.includes('btn-action'), hasXSS: printDocHtml.includes('