import { createServer } from 'node:http'; import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { chromium } from 'playwright'; const dist = path.resolve('dist'); const INDEX_FILE = 'index.html'; const MAX_NAV_RETRIES = 4; // Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait / // iPad landscape / common laptop / full HD desktop. const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920]; const CLOSE_TIMEOUT_MS = 3000; const LAUNCH_TIMEOUT_MS = 10000; const CONTEXT_TIMEOUT_MS = 8000; const PAGE_TIMEOUT_MS = 15000; const MEASURE_TIMEOUT_MS = 25000; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', }; function contentType(file) { const ext = path.extname(file).toLowerCase(); return MIME[ext] ?? 'application/octet-stream'; } async function walk(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await walk(fullPath))); } else { files.push(fullPath); } } return files; } async function discoverRoutes() { const files = await walk(dist); const routes = new Set(); for (const file of files) { if (!file.endsWith('.html')) continue; const rel = path.relative(dist, file).replaceAll(path.sep, '/'); if (rel === '404.html') continue; // /writing/* are meta-refresh redirect stubs to /articles/*, not real // pages; measuring them would just remeasure /articles/. if (rel.startsWith('writing/')) continue; if (rel === INDEX_FILE) { routes.add('/'); } else if (rel.endsWith(`/${INDEX_FILE}`)) { routes.add('/' + rel.slice(0, -INDEX_FILE.length)); } else { routes.add('/' + rel.replace(/\.html$/, '/')); } } return [...routes].sort(); } async function resolveFile(url) { const parsed = new URL(url, 'http://localhost'); const safePath = path .normalize(decodeURIComponent(parsed.pathname)) .replace(/^\/+/, '') .replace(/^(\.\.(\/|\\|$))+/, ''); const candidate = path.join(dist, safePath); const candidates = [ candidate, path.join(candidate, 'index.html'), path.join(dist, `${safePath}.html`), ]; for (const file of candidates) { try { const fileStat = await stat(file); if (fileStat.isFile()) return file; } catch { // Try the next candidate. } } return path.join(dist, '404.html'); } try { await stat(dist); } catch { throw new Error('dist/ does not exist. Run npm run build first.'); } const routes = await discoverRoutes(); const server = createServer(async (req, res) => { try { const file = await resolveFile(req.url ?? '/'); const body = await readFile(file); res.writeHead(200, { 'content-type': contentType(file) }); res.end(body); } catch (error) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end(String(error)); } }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); const failures = []; function launchBrowser() { return chromium.launch({ headless: true, args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'], }); } async function withTimeout(promise, timeoutMs, label) { let timeout; try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(label)), timeoutMs); }), ]); } finally { clearTimeout(timeout); } } async function safeClosePage(page) { await withTimeout( page.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Playwright page' ).catch(() => {}); } async function safeCloseContext(context) { await withTimeout( context.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Playwright context' ).catch(() => {}); } async function safeCloseBrowser(browser) { const childProcess = browser.process?.(); try { await withTimeout( browser.close(), CLOSE_TIMEOUT_MS, 'Timed out while closing Chromium' ); } catch { childProcess?.kill('SIGKILL'); } } async function openBrowser() { return withTimeout( launchBrowser(), LAUNCH_TIMEOUT_MS, 'Timed out while launching Chromium' ); } async function newMeasurementContext(browser, width) { const context = await browser.newContext({ viewport: { width, height: 900 }, javaScriptEnabled: false, }); await context.route('**/*', (route) => { const type = route.request().resourceType(); if (['font', 'image', 'media'].includes(type)) { route.abort('blockedbyclient'); } else { route.continue(); } }); return context; } async function openMeasurementContext(browser, width) { return withTimeout( newMeasurementContext(browser, width), CONTEXT_TIMEOUT_MS, `Timed out while creating ${width}px Playwright context` ); } async function measureViewport(page) { await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); return page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); } function shouldRetryNavigation(error) { const message = error instanceof Error ? error.message : String(error); return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test( message ); } async function measureRoute(context, route) { let page; try { page = await withTimeout( context.newPage(), PAGE_TIMEOUT_MS, `Timed out while creating page for ${route}` ); return await withTimeout( (async () => { await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'domcontentloaded', timeout: 15000, }); return measureViewport(page); })(), MEASURE_TIMEOUT_MS, `Timed out while measuring ${route}` ); } finally { if (page) await safeClosePage(page); } } try { for (const width of VIEWPORT_WIDTHS) { let browser; let context; try { browser = await openBrowser(); context = await openMeasurementContext(browser, width); for (const route of routes) { let result; for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { try { result = await measureRoute(context, route); break; } catch (error) { if (!shouldRetryNavigation(error) || attempt === MAX_NAV_RETRIES - 1) { throw error; } await safeCloseContext(context); await safeCloseBrowser(browser); browser = await openBrowser(); context = await openMeasurementContext(browser, width); } } if (result.scrollWidth > result.clientWidth + 1) { failures.push( `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px` ); } } } finally { if (context) await safeCloseContext(context); if (browser) await safeCloseBrowser(browser); } } } finally { server.close(); } if (failures.length > 0) { console.error(failures.join('\n')); process.exit(1); } console.log( `No horizontal overflow detected at ${VIEWPORT_WIDTHS.join(', ')}px across ${routes.length} routes.` );