206 lines
8.2 KiB
JavaScript
Executable file
206 lines
8.2 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',
|
|
'/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);
|