/** * cli:run-smoke — browser.ts * * Minimal real-browser pass for the runtime smoke gate. HTTP probing sees 200 * for every SPA route (Vite serves index.html for any path), so it is * STRUCTURALLY BLIND to the two classes that shipped broken: * - a client React crash (e.g. the cross-app "Absolute route path … nested … * is not valid") — the HTML is 200, the failure is a console/page error; * - a CSS/PostCSS 500 — only visible as a failed CSS module request + a Vite * error overlay, never on GET /. * * This loads `/` + each derived page route in headless Chromium and fails on a * Vite error overlay, any non-whitelisted console.error / uncaught pageerror, * or any 4xx/5xx sub-resource (excluding auth 401/403). Playwright is imported * DYNAMICALLY: a missing package or Chromium download returns * `{ available: false }` so the caller records a `smoke.browser-unavailable` * blocker rather than a silent pass. Mirrors the /uat playwright-driver contract. */ import type { BrowserPageResult, BrowserProbeOutcome } from './types.js'; /** Benign dev-mode console noise that must not fail an otherwise-healthy page. */ const CONSOLE_WHITELIST: RegExp[] = [ /vite|hmr/i, /react ?devtools/i, /mismatching versions of react/i, /favicon/i, /net::ERR_ABORTED/i, /\/api\/auth\/me/i, // anonymous session probe — an expected 401 ]; export async function runBrowserProbes( frontendHost: string, routes: string[], timeoutMs: number, ): Promise { let pw: typeof import('playwright'); try { pw = await import('playwright'); } catch (e) { return { available: false, reason: `Playwright is not installed (${(e as Error).message}). From the deployed web app run ` + `\`npm install\` then \`npx playwright install chromium\`, and re-run the smoke gate.`, pages: [], }; } let browser: import('playwright').Browser; try { browser = await pw.chromium.launch({ headless: true }); } catch (e) { return { available: false, reason: `Chromium failed to launch (${(e as Error).message}). Run \`npx playwright install chromium\`.`, pages: [], }; } const pages: BrowserPageResult[] = []; try { const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await context.newPage(); // `/` first (catches boot-time / CSS failures), then the unique page routes. const seen = new Set(); const targets = ['/', ...routes].filter((r) => { const k = r || '/'; if (seen.has(k)) return false; seen.add(k); return true; }); for (const route of targets) { const consoleErrors: string[] = []; const failedRequests: { url: string; status: number }[] = []; const onConsole = (msg: import('playwright').ConsoleMessage): void => { if (msg.type() !== 'error') return; const text = msg.text(); if (CONSOLE_WHITELIST.some((re) => re.test(text))) return; consoleErrors.push(text.slice(0, 300)); }; const onPageError = (err: Error): void => { consoleErrors.push(`pageerror: ${String(err.message ?? err).slice(0, 300)}`); }; const onResponse = (res: import('playwright').Response): void => { const status = res.status(); if (status >= 400 && status !== 401 && status !== 403) { failedRequests.push({ url: res.url().slice(0, 200), status }); } }; page.on('console', onConsole); page.on('pageerror', onPageError); page.on('response', onResponse); let navOk = true; let navErr = ''; try { await page.goto(`${frontendHost}${route}`, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); await page.waitForLoadState('networkidle', { timeout: 4000 }).catch(() => undefined); } catch (e) { navOk = false; navErr = (e as Error).message; } const overlay = await page .evaluate(() => document.querySelector('vite-error-overlay') !== null) .catch(() => true); const path = await page.evaluate(() => location.pathname).catch(() => route); page.off('console', onConsole); page.off('pageerror', onPageError); page.off('response', onResponse); const accessState: BrowserPageResult['accessState'] = !navOk ? 'nav-failed' : overlay ? 'error' : path.startsWith('/login') ? 'redirect_login' : 'allowed'; // Fail on: nav error, Vite overlay, any non-whitelisted console/page error, // or a 4xx/5xx sub-resource. A `/login` bounce is expected anonymously and // is NOT a failure on its own. const ok = navOk && !overlay && consoleErrors.length === 0 && failedRequests.length === 0; pages.push({ url: route, ok, accessState, consoleErrors, failedRequests, viteOverlay: overlay, ...(navOk ? {} : { reason: `navigation failed: ${navErr}` }), }); } await context.close().catch(() => undefined); } finally { await browser.close().catch(() => undefined); } return { available: true, pages }; }