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 = 3; // 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 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 browser = await chromium.launch({ headless: true }); const failures = []; async function measureViewport(page) { for (let attempt = 0; attempt < MAX_NAV_RETRIES; attempt += 1) { try { await page.waitForLoadState('load'); return await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, })); } catch (error) { const message = error instanceof Error ? error.message : String(error); const isLast = attempt === MAX_NAV_RETRIES - 1; if (isLast || !/Execution context was destroyed|navigation/i.test(message)) { throw error; } await page.waitForLoadState('load').catch(() => {}); } } } try { for (const width of VIEWPORT_WIDTHS) { const page = await browser.newPage({ viewport: { width, height: 900 }, javaScriptEnabled: false, }); for (const route of routes) { await page.goto(`http://127.0.0.1:${port}${route}`, { waitUntil: 'load' }); const result = await measureViewport(page); if (result.scrollWidth > result.clientWidth + 1) { failures.push( `${route} overflows at ${width}px: ${result.scrollWidth}px > ${result.clientWidth}px` ); } } await page.close(); } } finally { await browser.close(); 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.` );