You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
6.5 KiB
206 lines
6.5 KiB
/**
|
|
* 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);
|