The Edit Profile screen (preview card + checklist) had no route coverage in the boot test. Guards the lazy Settings chunk on that exact URL.
187 lines
7.7 KiB
JavaScript
Executable file
187 lines
7.7 KiB
JavaScript
Executable file
#!/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',
|
|
'/listing/abc/def',
|
|
'/trades',
|
|
'/lab',
|
|
'/become/organizer',
|
|
'/become/barber',
|
|
'/leaderboards',
|
|
'/bounties',
|
|
'/settings',
|
|
'/settings?section=profile',
|
|
'/stash',
|
|
'/collections',
|
|
'/stats',
|
|
'/mints',
|
|
'/app-shop',
|
|
'/review',
|
|
'/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);
|