From acdde497b4941d53c0a97ee5b6a91fe89ccccd6b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 16:57:17 +0700 Subject: [PATCH] feat(deploy): boot-test the built bundle before deploying; document the vite chunking gotcha --- AGENTS.md | 19 ++++++ scripts/auto-deploy.sh | 11 ++- scripts/smoke-test.mjs | 148 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100755 scripts/smoke-test.mjs diff --git a/AGENTS.md b/AGENTS.md index e604adf..4fdd9d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,25 @@ The committed `package-lock.json` (from commit `f25fc2b`) had entries stripped o 1. Bump `package.json` `version` (Zapstore rejects an already-published version). 2. Update the **top** section of `release-notes.txt` (format: `vX.Y.Z`, blank line, notes, then `---` before older versions). +### ⚠️ Production-bundle runtime ≠ dev (read before touching vite.config) + +`vite build` only bundles; it never *executes* the output, and dev mode doesn't +either. A build can compile cleanly and still crash at runtime — that's how the +old custom `manualChunks()` shipped a white screen to production: +`ReferenceError: Cannot access 'X' before initialization` (cross-chunk circular +import TDZ, first in `vendor-charts`, then `vendor-bitcoin`). It only affected +the **web** build; the Capacitor build uses `inlineDynamicImports` (single +bundle) and was unaffected. + +Rules: +- **Do not re-introduce custom `manualChunks`** without a boot test. Rollup's + default splitting is cycle-safe. +- `scripts/smoke-test.mjs` boots the built `dist/` in headless Chromium and + fails if `#root` doesn't mount. `scripts/auto-deploy.sh` runs it before every + deploy and **aborts the deploy** if it fails. +- Any time the deploy output isn't verified by the smoke test, run + `node scripts/smoke-test.mjs` manually before shipping. + ### Deploy - **Web (auto)**: a systemd user timer (`skatespots-deploy.timer`) runs `scripts/auto-deploy.sh` every ~3 min. When `origin/main` changes it builds and runs `netlify deploy --prod --dir=dist`. It deploys **only committed code** (skips if tracked files are modified) and never mutates a dirty tree. One-time setup: `netlify login` + `netlify link`. Logs: `journalctl --user -u skatespots-deploy`. Manual run: `scripts/auto-deploy.sh`. - ⚠️ Shakespeare's "Deploy" rebuilds **its own workspace**, which is NOT updated by git pushes — so pushing to the repo does not change what Shakespeare deploys. Prefer the auto-deploy timer (or drag `dist/` to Netlify). diff --git a/scripts/auto-deploy.sh b/scripts/auto-deploy.sh index 0854a11..6a6052e 100755 --- a/scripts/auto-deploy.sh +++ b/scripts/auto-deploy.sh @@ -79,7 +79,16 @@ log "building…" ./node_modules/.bin/vite build -l error cp dist/index.html dist/404.html -# 6. Deploy to Netlify production. +# 6. Smoke-test the built bundle — abort if it doesn't boot. +# `vite build` never executes its output, so this is the only guard against +# a production-only crash (e.g. the manualChunks TDZ) reaching the site. +log "smoke-testing the built bundle…" +if ! node scripts/smoke-test.mjs; then + log "SMOKE TEST FAILED — aborting deploy (the built bundle did not boot)" + exit 1 +fi + +# 7. Deploy to Netlify production. log "deploying to Netlify…" netlify deploy --prod --dir=dist diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100755 index 0000000..4568c65 --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,148 @@ +#!/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'); +const evalJS = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }))?.result?.result?.value; + +// ── wait for the app to mount ──────────────────────────────────────────────── +let mounted = false; let lastText = ''; +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; break; } + if (/failed to start/i.test(lastText)) break; + await sleep(400); +} + +ws.close(); +const crashed = /failed to start/i.test(lastText); +const ok = mounted && !crashed && exceptions.length === 0; + +if (ok) { + log('PASS — production bundle booted and mounted.'); +} else { + console.error('[smoke-test] FAIL — the built bundle did not boot:'); + if (!mounted) console.error(' • #root never mounted'); + if (crashed) console.error(' • app rendered its "failed to start" crash screen'); + 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);