test(qa): production-build smoke (B15) — validates split chunks at runtime

- scripts/prod-smoke.js + prod-smoke.sh: build, boot `node server.js` serving
  dist/ against a scratch DB, and drive the real artifact with Playwright
  (login + lazy routes) to confirm the vendor-chunk split loads in production
- npm run smoke:prod; passes green
- docs: B15 harness command + status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-02 21:45:25 -05:00
parent 819cfdfa9f
commit 3c1d000bab
4 changed files with 98 additions and 1 deletions

View File

@ -102,7 +102,7 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ⬜ | 0 / 0 |
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 0 / 3 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | | 0 / 0 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | 🔄 | 0 / 0 |
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
> cycle from B0 against the next build/version.
@ -231,6 +231,7 @@ Manual passes prove a button works **once**; they don't stop it regressing next
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
| `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.js` (dist/), drives the real artifact so the split vendor chunks are validated at runtime |
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.

View File

@ -17,6 +17,7 @@
"test:e2e:ui": "node e2e/setup/prepare-db.js && playwright test --ui",
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
"smoke:prod": "bash scripts/prod-smoke.sh",
"ci": "npm run check:server && npm run test:all && npm run build",
"start": "node server.js"
},

68
scripts/prod-smoke.js Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Production-build smoke (QA_PLAN B15). Drives the REAL built artifact `node
* server.js` serving dist/ — not the Vite dev server, so it validates that the
* split vendor chunks (QA-B0-01) actually load and the app boots in production.
*
* Usage: node scripts/prod-smoke.js (expects `npm run build` already run and a
* server already listening on PROD_SMOKE_URL the shell wrapper handles both).
*/
const { chromium } = require('@playwright/test');
const URL = process.env.PROD_SMOKE_URL || 'http://localhost:3098';
const USER = process.env.E2E_USER || 'e2e_user';
const PASS = process.env.E2E_PASS || 'e2e_pass_1234';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
const errors = [];
const failed = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(String(e)));
page.on('requestfailed', (r) => failed.push(`${r.url()} ${r.failure()?.errorText || ''}`));
let ok = true;
const fail = (msg) => { ok = false; console.error(' ✖', msg); };
try {
// 1. App shell + split chunks load; login page renders.
await page.goto(URL + '/login', { waitUntil: 'networkidle' });
const heading = page.getByRole('heading', { name: /sign in/i });
if (await heading.isVisible().catch(() => false)) console.log(' ✔ login page renders (vendor chunks loaded)');
else fail('login page did not render — a chunk may have failed to load');
// 2. Log in and reach the authenticated app (lazy route chunks load).
await page.locator('#username').fill(USER);
await page.locator('#password').fill(PASS);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 });
const gotIt = page.getByRole('button', { name: 'Got it' });
await gotIt.click({ timeout: 8000 }).catch(() => {});
if (await page.getByRole('button', { name: 'Add Bill' }).isVisible().catch(() => false)) {
console.log(' ✔ authenticated Tracker renders on the production build');
} else fail('authenticated Tracker did not render');
// 3. A couple of lazy-loaded routes render (their chunks resolve).
for (const path of ['/bills', '/analytics', '/spending']) {
await page.goto(URL + path, { waitUntil: 'networkidle' });
const empty = await page.locator('body').innerText().catch(() => '');
if (empty && empty.trim().length > 0) console.log(`${path} rendered`);
else fail(`${path} rendered blank (lazy chunk failure?)`);
}
} catch (e) {
fail('exception: ' + e.message);
}
const chunkErrors = failed.filter((f) => /\.js|\.css|assets/.test(f));
if (chunkErrors.length) fail('failed asset requests:\n ' + chunkErrors.join('\n '));
// The pre-login page probes /api/auth/session and gets 401 by design (then shows
// login) — that's expected, not a defect. Everything else is a real error.
const benign = /session check failed|Not authenticated|auth\/session|status of 401/i;
const realErrors = errors.filter((e) => !benign.test(e));
if (realErrors.length) fail('console/page errors:\n ' + realErrors.slice(0, 5).join('\n '));
await browser.close();
console.log(ok ? '\nPROD SMOKE: PASS' : '\nPROD SMOKE: FAIL');
process.exit(ok ? 0 : 1);
})();

27
scripts/prod-smoke.sh Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Production-build smoke (QA_PLAN B15): builds the app, boots `node server.js`
# serving dist/ against a scratch DB, and drives the real artifact with Playwright
# to confirm the split vendor chunks load and the app works in production.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${PROD_SMOKE_PORT:-3098}"
export PROD_SMOKE_URL="http://localhost:${PORT}"
echo "[prod-smoke] building…"
npm run build >/dev/null
echo "[prod-smoke] preparing scratch DB…"
node e2e/setup/prepare-db.js >/dev/null 2>&1
echo "[prod-smoke] starting production server on :${PORT}"
DB_PATH="db/e2e.db" PORT="${PORT}" BIND_HOST=127.0.0.1 node server.js >/tmp/prod-smoke-server.log 2>&1 &
SRV=$!
trap 'kill "${SRV}" 2>/dev/null || true' EXIT
for _ in $(seq 1 40); do
curl -sf "http://localhost:${PORT}/api/version" >/dev/null 2>&1 && break
sleep 0.5
done
node scripts/prod-smoke.js