Files
rose-ash/tests/playwright/site-smoke.spec.js
giles 7492ceac4e Restore hyperscript work on stable site base (908f4f80)
Reset to last known-good state (908f4f80) where links, stepper, and
islands all work, then recovered all hyperscript implementation,
conformance tests, behavioral tests, Playwright specs, site sandbox,
IO-aware server loading, and upstream test suite from f271c88a.

Excludes runtime changes (VM resolve hook, VmSuspended browser handler,
sx_ref.ml guard recovery) that need careful re-integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:29:56 +00:00

196 lines
6.2 KiB
JavaScript

// 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<url, {hasText?: string[], hasIsland?: string[]}>
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([]);
});
}
});
}