/** * Playwright smoke check for the SvelteKit demo/docs pages under web/routes. * * What it verifies per route (beyond HTTP 200): * - No uncaught `pageerror` (thrown during component init / hydration). * - No `console.error` (runtime exceptions that didn't throw synchronously). * - No translation key missing warnings (`Translation key not found`). * - No soma context-not-found warnings (`Context "X" not found`). * - No rendered `__uix_lang_missing__` fallback marker. * * Usage: * node scripts/smoke-check.mjs [base-url] * SMOKE_SCOPE=/uix node scripts/smoke-check.mjs [base-url] * npm run smoke * * Requires `npm run dev` running in another terminal (or passes an explicit * base URL). The script tries 5173-5180 if no URL is given. */ import { chromium } from 'playwright'; import { existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROUTES_DIR = join(__dirname, '..', 'web', 'routes'); const scope = normaliseScope(process.env.SMOKE_SCOPE); function normaliseScope(value) { if (!value) return null; const trimmed = value.trim(); if (!trimmed || trimmed === '/') return null; return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; } function routeFromSegments(segments) { const routeSegments = []; for (const segment of segments) { if (segment.startsWith('(') && segment.endsWith(')')) continue; if (segment.startsWith('@')) continue; if (segment.includes('[')) return null; routeSegments.push(segment); } return `/${routeSegments.join('/')}`.replace(/\/$/, '') || '/'; } /** Find every concrete `{route}/+page.svelte` under web/routes recursively. */ function discoverRoutes(dir, segments = []) { const routes = []; const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const name = entry.name; const full = join(dir, name); if (entry.isDirectory()) { routes.push(...discoverRoutes(full, [...segments, name])); } else if (name === '+page.svelte') { const route = routeFromSegments(segments); if (route && (!scope || route === scope || route.startsWith(`${scope}/`))) { routes.push(route); } } } return routes; } async function probePort(startPort, endPort) { for (let port = startPort; port <= endPort; port++) { try { const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); if (res.ok || res.status === 404 || res.status === 500) { return `http://localhost:${port}`; } } catch { // connection refused — try next } } return null; } const BASE = process.argv[2] ?? (await probePort(5173, 5180)); if (!BASE) { console.error('Could not find a running dev server on 5173-5180.'); console.error('Start it with `npm run dev` in another terminal.'); process.exit(2); } console.error(`Using dev server at ${BASE}`); const baseOrigin = new URL(BASE).origin; if (!existsSync(ROUTES_DIR)) { console.error(`Routes directory not found: ${ROUTES_DIR}`); process.exit(2); } const routes = discoverRoutes(ROUTES_DIR).sort(); if (routes.length === 0) { console.error( scope ? `No concrete +page.svelte files found under web/routes for scope ${scope}.` : 'No concrete +page.svelte files found under web/routes.' ); process.exit(2); } console.error(`Checking ${routes.length} routes${scope ? ` under ${scope}` : ''}...`); const browser = await chromium.launch(); const ctx = await browser.newContext(); const failures = []; const MAX_PARALLEL = 4; async function checkRoute(route) { const page = await ctx.newPage(); const errors = []; const warnings = []; page.on('pageerror', (e) => errors.push(` pageerror: ${e.message}`)); page.on('requestfailed', (request) => { const url = request.url(); if (url.startsWith('data:') || url.startsWith('blob:') || url.includes('/@vite/')) return; const origin = new URL(url).origin; if (origin !== baseOrigin) return; errors.push(` requestfailed: ${url} (${request.failure()?.errorText ?? 'unknown error'})`); }); page.on('console', (m) => { const t = m.text(); if (m.type() === 'error') { // Filter out some noisy devtools messages unrelated to correctness. if (t.includes('Download the React DevTools')) return; // Resource request failures are handled by the `requestfailed` hook above, // where we can distinguish local app regressions from optional external assets. if (t.startsWith('Failed to load resource:')) return; errors.push(` console.error: ${t}`); } if (m.type() === 'warning' || m.type() === 'warn') { if ( t.includes('Translation key not found') || t.includes('__uix_lang_missing__') || t.includes('Context') || t.includes('[soma]') || t.includes('soma context') ) { warnings.push(` console.warning: ${t}`); } } }); try { const res = await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 20000 }); const status = res?.status() ?? 0; // Give reactive effects + lazy demos a moment to settle. await page.waitForTimeout(500); const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => ''); if (bodyText.includes('__uix_lang_missing__')) { errors.push(' body: rendered __uix_lang_missing__ fallback marker'); } if ( bodyText.includes('Internal Error') || bodyText.includes('undefined is not a function') || bodyText.includes('Cannot read properties') ) { errors.push(' body: rendered runtime error text'); } if (status !== 200 || errors.length > 0 || warnings.length > 0) { const lines = [`FAIL ${route} (HTTP ${status})`]; errors.forEach((e) => lines.push(e)); warnings.forEach((w) => lines.push(w)); return { ok: false, lines }; } return { ok: true, lines: [`PASS ${route}`] }; } catch (e) { return { ok: false, lines: [`FAIL ${route} (navigation error: ${e.message})`] }; } finally { await page.close(); } } // Run checks with bounded concurrency so the dev server doesn't get hammered. let cursor = 0; async function worker() { while (cursor < routes.length) { const idx = cursor++; const route = routes[idx]; const result = await checkRoute(route); result.lines.forEach((l) => console.log(l)); if (!result.ok) failures.push(route); } } await Promise.all(Array.from({ length: MAX_PARALLEL }, () => worker())); await browser.close(); console.log(''); console.log( failures.length === 0 ? `All ${routes.length} routes passed.` : `${failures.length}/${routes.length} routes failed: ${failures.join(', ')}` ); process.exit(failures.length === 0 ? 0 : 1);