diff --git a/docs/QA_PLAN.md b/docs/QA_PLAN.md index 95a05d7..b2e184d 100644 --- a/docs/QA_PLAN.md +++ b/docs/QA_PLAN.md @@ -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. diff --git a/package.json b/package.json index b80b0bb..060aa9d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/prod-smoke.js b/scripts/prod-smoke.js new file mode 100644 index 0000000..f2f29de --- /dev/null +++ b/scripts/prod-smoke.js @@ -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); +})(); diff --git a/scripts/prod-smoke.sh b/scripts/prod-smoke.sh new file mode 100755 index 0000000..135eadf --- /dev/null +++ b/scripts/prod-smoke.sh @@ -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