#!/usr/bin/env node /** * Production-bundle boot smoke test. * * WHY THIS EXISTS * ─────────────── * `vite build` only bundles — it never EXECUTES the output. Dev mode doesn't * either. So a build can compile cleanly yet crash at runtime (e.g. the * cross-chunk circular-import TDZ in the old custom manualChunks: * "ReferenceError: Cannot access 'X' before initialization"), shipping a white * screen to production. * * This serves dist/, loads it in headless Chromium, and fails if the app * doesn't mount. Used by scripts/auto-deploy.sh to abort a bad deploy. * * Usage: node scripts/smoke-test.mjs * Env: CHROME_PATH (default /snap/bin/chromium), SMOKE_TIMEOUT_MS (default 25000) * * Exits 0 = booted OK, 1 = failed to boot / no Chromium. */ import { createServer } from 'node:http'; import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { existsSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, extname, normalize } from 'node:path'; import { tmpdir } from 'node:os'; import net from 'node:net'; import WebSocket from 'ws'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const distDir = join(root, 'dist'); const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium'; const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS || 25000); const log = (m) => console.log(`[smoke-test] ${m}`); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); if (!existsSync(join(distDir, 'index.html'))) { console.error('[smoke-test] dist/index.html missing — build first.'); process.exit(1); } if (!existsSync(chromePath)) { console.error(`[smoke-test] Chromium not found at ${chromePath}. Set CHROME_PATH. Refusing to skip the boot test.`); process.exit(1); } const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.txt': 'text/plain', }; const freePort = () => new Promise((res) => { const s = net.createServer(); s.on('error', () => res(0)); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); // ── static server ──────────────────────────────────────────────────────────── const servePort = await freePort(); const server = createServer(async (req, res) => { try { const urlPath = decodeURIComponent((req.url || '/').split('?')[0]); let filePath = join(distDir, normalize(urlPath).replace(/^(\.\.[/\\])+/, '')); if (urlPath === '/' || !extname(filePath)) filePath = join(distDir, 'index.html'); let body; try { body = await readFile(filePath); } catch { filePath = join(distDir, 'index.html'); body = await readFile(filePath); } res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' }); res.end(body); } catch { res.writeHead(500); res.end('error'); } }); await new Promise((r) => server.listen(servePort, '127.0.0.1', r)); const appUrl = `http://127.0.0.1:${servePort}/`; // ── headless Chromium ──────────────────────────────────────────────────────── const cdpPort = await freePort(); const profileDir = join(tmpdir(), `skatespots-smoke-${process.pid}-${cdpPort}`); const chrome = spawn(chromePath, [ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profileDir}`, `--remote-debugging-port=${cdpPort}`, appUrl, ], { stdio: ['ignore', 'ignore', 'pipe'] }); chrome.stderr.on('data', () => {}); // drain const cleanup = () => { try { chrome.kill('SIGKILL'); } catch {} try { server.close(); } catch {} try { rmSync(profileDir, { recursive: true, force: true }); } catch {} }; process.on('exit', cleanup); async function findPageTarget() { for (let i = 0; i < 60; i++) { try { const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); const p = list.find((t) => t.type === 'page' && t.url.startsWith('http')); if (p) return p; } catch {} await sleep(300); } return null; } const target = await findPageTarget(); if (!target) { console.error('[smoke-test] FAIL: no Chromium page target'); cleanup(); process.exit(1); } const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 }); let id = 0; const pend = new Map(); const exceptions = []; ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); return; } if (m.method === 'Runtime.exceptionThrown') { const e = m.params.exceptionDetails; exceptions.push(e.exception?.description || e.text || 'unknown exception'); } }); const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); await new Promise((r) => ws.on('open', r)); await send('Runtime.enable'); await send('Page.enable'); const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value; // ── boot each route (lazy route chunks can fail independently) ────────────── // NOTE: the app uses HashRouter, so the route MUST be in the hash — a bare // `/settings` path loads index.html with no hash and silently renders the // homepage, which made this test pass without exercising those chunks. const ROUTES = [ '/', '/marketplace', '/marketplace-guidelines', '/listing/abc/def', '/trades', '/lab', '/become/organizer', '/become/barber', '/become/clothing', '/leaderboards', '/bounties', '/community', '/settings', '/settings?section=profile', '/settings?section=coordinator', '/settings?section=pro', '/settings?section=trust', '/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', '/stash', '/collections', '/stats', '/trust', '/alerts', '/mints', '/app-shop', '/diy', '/diy/deck-cost', '/diy/deck-designer', '/review', '/card', '/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx', '/verify/notarealnaddr', '/shop/abc/def', '/spot/abc/def', '/event/abc/def', '/crew/abc/def', '/messages/group/abc123', ]; const problems = []; let lastText = ''; for (const route of ROUTES) { const url = appUrl.replace(/\/$/, '') + '/#' + route; await send('Page.navigate', { url }); let mounted = false; let stable = 0; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { lastText = (await evalJS('document.body ? document.body.innerText : ""')) || ''; const kids = await evalJS('document.getElementById("root")?.children.length ?? 0'); if (kids > 0) mounted = true; // Stop early on a hard crash; otherwise require the page to stay rendered // for a few polls so a lazy route chunk has time to load (and, if broken, // to fall through to the error boundary). if (/failed to start|this page slammed/i.test(lastText)) break; if (mounted) { stable++; if (stable >= 3) break; } await sleep(500); } const crashed = /failed to start|this page slammed/i.test(lastText); if (!mounted) problems.push(`${route}: #root never mounted`); if (crashed) problems.push(`${route}: crash screen`); } ws.close(); const ok = problems.length === 0 && exceptions.length === 0; if (ok) { log(`PASS — production bundle booted and mounted (${ROUTES.join(', ')}).`); } else { console.error('[smoke-test] FAIL — the built bundle did not boot:'); for (const p of problems) console.error(' • ' + p); for (const e of exceptions.slice(0, 4)) console.error(' • ' + e.split('\n')[0]); console.error(' body: ' + JSON.stringify(lastText.slice(0, 160))); } cleanup(); process.exit(ok ? 0 : 1);