// Playwright visual/overflow verification: real browser, three widths, // zero pageerror + zero document horizontal overflow asserted per page. // Usage: node frontend/scripts/ui-verify.mjs [routesCsv] import { chromium } from 'playwright'; import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import { createReadStream } from 'node:fs'; const dist = path.resolve(process.argv[2] || 'dist'); const outDir = path.resolve(process.argv[3] || '../artifacts/ui-shadcn'); const routes = (process.argv[4] || 'login,register,reset') .split(',') .map(function (s) { return s.trim(); }) .filter(Boolean); mkdirSync(outDir, { recursive: true }); const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.json': 'application/json' }; // tiny static server for the built dist (hash router friendly, /api 404s) const server = http.createServer(function (req, res) { const url = req.url || '/'; if (url.startsWith('/api')) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { code: 'not_found', message: 'no backend in visual probe' } })); return; } let file = url.split('?')[0].split('#')[0]; if (file === '/' || file === '') file = '/index.html'; var responded = false; const send = function (fp, isFallback) { const stream = createReadStream(fp); stream.on('open', function () { if (responded) return; responded = true; res.writeHead(200, { 'content-type': MIME[path.extname(fp)] || 'application/octet-stream' }); stream.pipe(res); }); stream.on('error', function () { if (isFallback) { if (!responded) { responded = true; res.writeHead(404); res.end(); } return; } send(path.join(dist, 'index.html'), true); }); }; send(path.join(dist, file)); }); await new Promise(function (res) { server.listen(0, '127.0.0.1', res); }); const port = server.address().port; const widths = [375, 768, 1440]; const results = {}; const browser = await chromium.launch(); for (const route of routes) { const url = 'http://127.0.0.1:' + port + '/#' + route; for (const w of widths) { const context = await browser.newContext({ viewport: { width: w, height: Math.round(w * 2) } }); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', function (e) { pageErrors.push(String(e)); }); await page.goto(url, { waitUntil: 'networkidle' }); await page.waitForTimeout(400); const m = await page.evaluate(function () { return { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; }); const overflow = Math.max(0, m.scrollWidth - m.clientWidth); const bg = await page.evaluate(function () { return getComputedStyle(document.body).backgroundColor; }); const fg = await page.evaluate(function () { return getComputedStyle(document.body).color; }); const shot = path.join(outDir, route + '-' + w + '.png'); await page.screenshot({ path: shot, fullPage: true }); results[route + '@' + w] = { overflow: overflow, clientWidth: m.clientWidth, scrollWidth: m.scrollWidth, pageErrors: pageErrors, bg: bg, fg: fg, shot: path.relative(process.cwd(), shot) }; await context.close(); } } await browser.close(); server.close(); writeFileSync(path.join(outDir, 'overflow-probe.json'), JSON.stringify(results, null, 2)); let bad = 0; for (const k of Object.keys(results)) { const r = results[k]; const ok = r.overflow === 0 && r.pageErrors.length === 0 && r.bg !== r.fg; console.log((ok ? 'OK ' : 'FAIL ') + k + ': overflow=' + r.overflow + ' pageErrors=' + r.pageErrors.length + ' bg=' + r.bg + ' fg=' + r.fg); if (!ok) bad++; } process.exit(bad === 0 ? 0 : 1);