feat(deploy): boot-test the built bundle before deploying; document the vite chunking gotcha

This commit is contained in:
SkateSpots 2026-09-26 16:57:17 +07:00
parent 39b4156760
commit acdde497b4
3 changed files with 177 additions and 1 deletions

View file

@ -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).

View file

@ -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

148
scripts/smoke-test.mjs Executable file
View file

@ -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);