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>
196 lines
6.2 KiB
JavaScript
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([]);
|
|
});
|
|
}
|
|
});
|
|
}
|