// Site Smoke Tests — visit every page in the nav tree, run universal checks. // No HTTP server. OCaml subprocess renders pages via epoch protocol. // Playwright intercepts all navigation and serves rendered HTML + static assets. const { test, expect } = require('playwright/test'); const path = require('path'); const fs = require('fs'); const { SxRenderer } = require('./sx-renderer'); const { universalSmoke, trackErrors } = require('./helpers'); const PROJECT_ROOT = path.resolve(__dirname, '../..'); const STATIC_DIR = path.join(PROJECT_ROOT, 'shared/static'); const WASM_DIR = path.join(STATIC_DIR, 'wasm'); const FAKE_ORIGIN = 'http://sx-sandbox'; // Mime types for static file serving const MIME = { '.js': 'application/javascript', '.css': 'text/css', '.wasm': 'application/wasm', '.sx': 'text/plain', '.sxbc': 'application/octet-stream', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', }; /** Resolve a static file request to a local path. */ function resolveStatic(urlPath) { // /static/wasm/... → shared/static/wasm/... // /static/scripts/... → shared/static/scripts/... // /static/... → shared/static/... if (urlPath.startsWith('/static/')) { const rel = urlPath.replace(/\?.*$/, ''); // strip query params (cache busters) return path.join(PROJECT_ROOT, 'shared', rel); } return null; } /** Resolve .sx/.sxbc file requests (platform lazy-loads these). */ function resolveSxFile(urlPath) { const clean = urlPath.replace(/\?.*$/, ''); // /sx/sx/... → PROJECT_ROOT/sx/sx/... // /wasm/sx/... → shared/static/wasm/sx/... if (clean.startsWith('/wasm/sx/') || clean.startsWith('/static/wasm/sx/')) { const rel = clean.replace(/^\/(?:static\/)?wasm\/sx\//, ''); return path.join(WASM_DIR, 'sx', rel); } return null; } // ---- Shared renderer + page specs ---- let renderer; let pageSpecs; // Map test.beforeAll(async () => { renderer = new SxRenderer(PROJECT_ROOT); await renderer.ready(); pageSpecs = await renderer.pageTestSpecs(); }); test.afterAll(async () => { if (renderer) renderer.close(); }); // ---- Test sections ---- // Group pages by top-level section for readable output. // Each section is one test with steps for each page. const SECTIONS = [ { name: 'Geography', prefix: '/sx/(geography' }, { name: 'Language', prefix: '/sx/(language' }, { name: 'Applications', prefix: '/sx/(applications' }, { name: 'Tools', prefix: '/sx/(tools' }, { name: 'Etc', prefix: '/sx/(etc' }, { name: 'Home', prefix: '/sx/', exact: true }, ]; function categorize(href) { for (const s of SECTIONS) { if (s.exact ? href === s.prefix : href.startsWith(s.prefix)) return s.name; } return 'Other'; } for (const section of SECTIONS) { test(`smoke: ${section.name}`, async ({ page }) => { // Get all URLs for this section const allUrls = await renderer.navUrls(); const urls = allUrls.filter(([href]) => categorize(href) === section.name); // Set up route interception — all requests go through us await page.route('**/*', async (route) => { const url = new URL(route.request().url()); // Static assets from filesystem const staticPath = resolveStatic(url.pathname); if (staticPath && fs.existsSync(staticPath)) { const ext = path.extname(staticPath); await route.fulfill({ path: staticPath, contentType: MIME[ext] || 'application/octet-stream', }); return; } // .sx/.sxbc files const sxPath = resolveSxFile(url.pathname); if (sxPath && fs.existsSync(sxPath)) { const ext = path.extname(sxPath); await route.fulfill({ path: sxPath, contentType: MIME[ext] || 'text/plain', }); return; } // Page render via OCaml subprocess try { const html = await renderer.render(url.pathname); await route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: html, }); } catch (e) { await route.fulfill({ status: 500, contentType: 'text/plain', body: `render error: ${e.message}`, }); } }); // Visit each page in this section for (const [href, label] of urls) { await test.step(`${label} — ${href}`, async () => { const errors = trackErrors(page); await page.goto(`${FAKE_ORIGIN}${href}`, { waitUntil: 'load', timeout: 30000, }); // Wait for full hydration — WASM boot + island mounting try { await page.waitForSelector('html[data-sx-ready]', { timeout: 20000 }); } catch (_) { // Hydration timeout is a hard failure } const result = await universalSmoke(page); // Check hydration completed const sxReady = await page.evaluate(() => document.documentElement.getAttribute('data-sx-ready')); if (!sxReady) { result.failures.push('hydration failed: data-sx-ready not set'); result.pass = false; } const consoleErrors = errors.errors(); if (consoleErrors.length > 0) { result.failures.push(`console errors: ${consoleErrors.join('; ')}`); result.pass = false; } // Per-page assertions from page-tests.sx const spec = pageSpecs.get(href); if (spec) { if (spec.hasText) { const bodyText = await page.evaluate(() => document.body.textContent); for (const text of spec.hasText) { if (!bodyText.includes(text)) { result.failures.push(`missing text: "${text}"`); result.pass = false; } } } if (spec.hasIsland) { for (const island of spec.hasIsland) { const count = await page.locator(`[data-sx-island="${island}"]`).count(); if (count === 0) { result.failures.push(`missing island: ${island}`); result.pass = false; } } } } expect.soft(result.failures, `${label}: ${result.failures.join(', ')}`).toEqual([]); }); } }); }