feat(deploy): boot-test the built bundle before deploying; document the vite chunking gotcha
This commit is contained in:
parent
39b4156760
commit
acdde497b4
3 changed files with 177 additions and 1 deletions
19
AGENTS.md
19
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).
|
||||
|
|
|
|||
|
|
@ -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
148
scripts/smoke-test.mjs
Executable 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);
|
||||
Loading…
Reference in a new issue