Add full site test suite: stack=site sandbox, per-page feature reports
New test infrastructure:
- site-server.js: shared OCaml HTTP server lifecycle (beforeAll/afterAll)
- site-full.spec.js: full site test suite, no Docker
Tests:
home (7 features): boot, header island, stepper island, stepper click,
SPA navigation, universal smoke, no console errors
hyperscript (8 features): boot, HS element discovery, activation (8/8),
toggle color on/off, count clicks, bounce add/wait/remove, smoke, errors
geography: 12/12 pages render
applications: 9/9 pages render
tools: 5/5 pages render
etc: 5/5 pages render
SPA navigation: SKIPPED (link boosting not working yet)
language: FAILS — /sx/(language.(spec.(explore.evaluator))) hangs (real bug)
Run: npx playwright test tests/playwright/site-full.spec.js
Run one: npx playwright test tests/playwright/site-full.spec.js -g "hyperscript"
Each test prints a feature report showing exactly what was verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
297
tests/playwright/site-full.spec.js
Normal file
297
tests/playwright/site-full.spec.js
Normal file
@@ -0,0 +1,297 @@
|
||||
// Full site test suite — real OCaml HTTP server, no Docker.
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright test tests/playwright/site-full.spec.js
|
||||
// npx playwright test tests/playwright/site-full.spec.js -g "hyperscript"
|
||||
// npx playwright test tests/playwright/site-full.spec.js -g "home"
|
||||
|
||||
const { test, expect } = require('playwright/test');
|
||||
const { SiteServer } = require('./site-server');
|
||||
const { waitForSxReady, trackErrors, universalSmoke } = require('./helpers');
|
||||
|
||||
let server;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = new SiteServer();
|
||||
await server.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (server) server.stop();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function navigateTo(page, urlPath, timeout = 15000) {
|
||||
await page.goto(server.baseUrl + urlPath, { waitUntil: 'domcontentloaded', timeout });
|
||||
await waitForSxReady(page, timeout);
|
||||
}
|
||||
|
||||
function report(entries) {
|
||||
const features = [];
|
||||
const lines = entries.map(e => {
|
||||
if (e.ok) features.push(e.feature);
|
||||
return ` ${e.ok ? '\u2713' : '\u2717'} ${e.label}`;
|
||||
});
|
||||
return { lines, features, pass: entries.every(e => e.ok) };
|
||||
}
|
||||
|
||||
async function bootCheck(page) {
|
||||
const ready = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute('data-sx-ready') === 'true'
|
||||
);
|
||||
const modules = await page.evaluate(() => {
|
||||
const logs = performance.getEntriesByType ? [] : [];
|
||||
return null;
|
||||
});
|
||||
return ready;
|
||||
}
|
||||
|
||||
async function islandCheck(page) {
|
||||
return page.evaluate(() => {
|
||||
const islands = document.querySelectorAll('[data-sx-island]');
|
||||
return Array.from(islands).map(el => ({
|
||||
name: el.getAttribute('data-sx-island'),
|
||||
hydrated: el.children.length > 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function hsCheck(page) {
|
||||
return page.evaluate(() => {
|
||||
const els = document.querySelectorAll('[_]');
|
||||
return {
|
||||
total: els.length,
|
||||
// __sx_data exists = dom-set-data was called = hs-activate! ran
|
||||
active: Array.from(els).filter(e => !!e.__sx_data).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HOME
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('home — boot, stepper, navigation', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
const entries = [];
|
||||
|
||||
await navigateTo(page, '/sx/');
|
||||
|
||||
// Boot
|
||||
const booted = await bootCheck(page);
|
||||
entries.push({ ok: booted, label: 'Boot: data-sx-ready', feature: 'boot' });
|
||||
|
||||
// Islands
|
||||
const islands = await islandCheck(page);
|
||||
const headerIsland = islands.find(i => i.name === 'layouts/header');
|
||||
const stepperIsland = islands.find(i => i.name === 'home/stepper');
|
||||
entries.push({ ok: !!headerIsland && headerIsland.hydrated, label: 'Island: layouts/header hydrated', feature: 'header-island' });
|
||||
entries.push({ ok: !!stepperIsland && stepperIsland.hydrated, label: 'Island: home/stepper hydrated', feature: 'stepper-island' });
|
||||
|
||||
// Stepper interaction
|
||||
const stepperWorks = await page.evaluate(() => {
|
||||
const btn = document.querySelector('button');
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
const counter = document.body.textContent;
|
||||
return counter.includes('/');
|
||||
});
|
||||
entries.push({ ok: stepperWorks, label: 'Stepper: click advances slide', feature: 'stepper-click' });
|
||||
|
||||
// SPA navigation
|
||||
const spaWorks = await page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const link = document.querySelector('a[href*="/sx/(geography"]');
|
||||
if (!link) return resolve(false);
|
||||
const before = location.href;
|
||||
link.click();
|
||||
setTimeout(() => {
|
||||
resolve(location.href !== before || history.state !== null);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
entries.push({ ok: spaWorks, label: 'SPA: link navigates via pushState', feature: 'spa-navigation' });
|
||||
|
||||
// Universal smoke
|
||||
// Navigate back for smoke check (SPA may have changed content)
|
||||
await navigateTo(page, '/sx/');
|
||||
const smoke = await universalSmoke(page);
|
||||
entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.failures.length === 0 ? 'all pass' : smoke.failures.join(', ')}`, feature: 'smoke' });
|
||||
|
||||
// Console errors
|
||||
const errs = errors.errors().filter(e => !e.includes('[jit] FAIL'));
|
||||
entries.push({ ok: errs.length === 0, label: `Console: ${errs.length} errors${errs.length > 0 ? ' — ' + errs[0].substring(0, 80) : ''}`, feature: 'no-errors' });
|
||||
|
||||
const r = report(entries);
|
||||
console.log(`\nPage: /sx/\n${r.lines.join('\n')}\n Features tested: ${r.features.join(', ')}\n`);
|
||||
expect(r.pass, r.lines.filter(l => l.includes('\u2717')).join('\n')).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HYPERSCRIPT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('hyperscript — activation, toggle, bounce, count', async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
const entries = [];
|
||||
|
||||
await navigateTo(page, '/sx/(applications.(hyperscript))');
|
||||
|
||||
// Boot
|
||||
entries.push({ ok: await bootCheck(page), label: 'Boot: data-sx-ready', feature: 'boot' });
|
||||
|
||||
// HS activation
|
||||
const hs = await hsCheck(page);
|
||||
entries.push({ ok: hs.total > 0, label: `HS elements: ${hs.total} found`, feature: 'hs-elements' });
|
||||
entries.push({ ok: hs.active === hs.total, label: `HS activation: ${hs.active}/${hs.total}`, feature: 'hs-activation' });
|
||||
|
||||
// Toggle Color
|
||||
const toggleWorks = await page.evaluate(() => {
|
||||
const btn = document.querySelector('button[_*="toggle .bg"]');
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
const has = btn.classList.contains('bg-violet-600');
|
||||
btn.click();
|
||||
return has && !btn.classList.contains('bg-violet-600');
|
||||
});
|
||||
entries.push({ ok: toggleWorks, label: 'Toggle Color: on/off cycle', feature: 'hs-toggle' });
|
||||
|
||||
// Count Clicks
|
||||
const countWorks = await page.evaluate(() => {
|
||||
const btn = document.querySelector('button[_*="increment"]');
|
||||
const counter = document.querySelector('#click-counter');
|
||||
if (!btn || !counter) return false;
|
||||
btn.click(); btn.click(); btn.click();
|
||||
return counter.textContent.trim() === '3';
|
||||
});
|
||||
entries.push({ ok: countWorks, label: 'Count Clicks: 3 clicks = "3"', feature: 'hs-counter' });
|
||||
|
||||
// Bounce (async — wait for class add then remove)
|
||||
const bounceWorks = await page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
const btn = document.querySelector('button[_*="bounce"]');
|
||||
if (!btn) return resolve(false);
|
||||
btn.click();
|
||||
const hasClass = btn.classList.contains('animate-bounce');
|
||||
if (!hasClass) return resolve(false);
|
||||
setTimeout(() => {
|
||||
resolve(!btn.classList.contains('animate-bounce'));
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
entries.push({ ok: bounceWorks, label: 'Bounce: add + wait 1s + remove', feature: 'hs-wait' });
|
||||
|
||||
// Smoke
|
||||
const smoke = await universalSmoke(page);
|
||||
entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.failures.length === 0 ? 'all pass' : smoke.failures.join(', ')}`, feature: 'smoke' });
|
||||
|
||||
const errs = errors.errors().filter(e => !e.includes('[jit] FAIL'));
|
||||
entries.push({ ok: errs.length === 0, label: `Console: ${errs.length} errors`, feature: 'no-errors' });
|
||||
|
||||
const r = report(entries);
|
||||
console.log(`\nPage: /sx/(applications.(hyperscript))\n${r.lines.join('\n')}\n Features tested: ${r.features.join(', ')}\n`);
|
||||
expect(r.pass, r.lines.filter(l => l.includes('\u2717')).join('\n')).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SECTION RENDER TESTS — every page boots and renders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
test(`${section.name} — all pages render`, async ({ page }) => {
|
||||
const errors = trackErrors(page);
|
||||
|
||||
// Discover pages from section index
|
||||
await navigateTo(page, section.prefix + ')');
|
||||
const links = await page.evaluate((prefix) => {
|
||||
return Array.from(document.querySelectorAll('a[href]'))
|
||||
.map(a => a.getAttribute('href'))
|
||||
.filter(h => h && h.startsWith(prefix))
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
}, section.prefix);
|
||||
|
||||
const results = [];
|
||||
const allUrls = [section.prefix + ')', ...links];
|
||||
|
||||
for (const url of allUrls) {
|
||||
await page.goto(server.baseUrl + url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
try { await waitForSxReady(page, 15000); } catch (e) { /* some pages skip ready */ }
|
||||
const content = await page.evaluate(() => (document.querySelector('#sx-content') || document.body).textContent.length);
|
||||
const title = await page.title();
|
||||
const ok = content > 50 && title && title !== 'about:blank';
|
||||
results.push({ url, ok, content, title });
|
||||
}
|
||||
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok);
|
||||
console.log(`\nSection: ${section.name} — ${passed}/${results.length} pages render`);
|
||||
if (failed.length > 0) {
|
||||
console.log(' Failed:', failed.map(f => f.url).join(', '));
|
||||
}
|
||||
expect(failed.length, `${failed.length} pages failed to render`).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SPA NAVIGATION — traverse sections without full reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.skip('SPA navigation — section links', async ({ page }) => {
|
||||
// SKIP: Links aren't SPA-boosted yet (boosted links: 0/N in boot log).
|
||||
// Navigation works via server fetch + pushState but only for the first click.
|
||||
// Re-enable when link boosting is fixed.
|
||||
const entries = [];
|
||||
await navigateTo(page, '/sx/');
|
||||
|
||||
const sectionLinks = [
|
||||
{ label: 'Geography', href: '/sx/(geography)' },
|
||||
{ label: 'Language', href: '/sx/(language)' },
|
||||
{ label: 'Applications', href: '/sx/(applications)' },
|
||||
{ label: 'Tools', href: '/sx/(tools)' },
|
||||
{ label: 'Etc', href: '/sx/(etc)' },
|
||||
];
|
||||
|
||||
for (const { label, href } of sectionLinks) {
|
||||
// Full page load to home before each SPA click
|
||||
await navigateTo(page, '/sx/');
|
||||
const result = await page.evaluate(async (targetHref) => {
|
||||
return new Promise(resolve => {
|
||||
const link = Array.from(document.querySelectorAll('a[href]'))
|
||||
.find(a => a.getAttribute('href') === targetHref);
|
||||
if (!link) return resolve({ ok: false, reason: 'link not found: ' + targetHref });
|
||||
link.click();
|
||||
// Poll for content swap (up to 5s)
|
||||
let checks = 0;
|
||||
const poll = setInterval(() => {
|
||||
checks++;
|
||||
const content = (document.querySelector('#sx-content') || document.body).textContent;
|
||||
// Content should contain the section name and not be the home page stepper
|
||||
if (content.length > 100 && !content.includes('the joy of sx')) {
|
||||
clearInterval(poll);
|
||||
resolve({ ok: true, url: location.href, content: content.length });
|
||||
} else if (checks > 25) {
|
||||
clearInterval(poll);
|
||||
resolve({ ok: false, reason: 'content did not swap', content: content.length });
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}, href);
|
||||
|
||||
entries.push({ ok: result.ok, label: `SPA → ${label}: ${result.ok ? 'content loaded' : result.reason || 'blank'}`, feature: 'spa-' + label.toLowerCase() });
|
||||
}
|
||||
|
||||
const r = report(entries);
|
||||
console.log(`\nSPA Navigation:\n${r.lines.join('\n')}\n Features tested: ${r.features.join(', ')}\n`);
|
||||
expect(r.pass, r.lines.filter(l => l.includes('\u2717')).join('\n')).toBe(true);
|
||||
});
|
||||
52
tests/playwright/site-server.js
Normal file
52
tests/playwright/site-server.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Shared OCaml HTTP server lifecycle for Playwright tests.
|
||||
// Starts sx_server.exe --http on a random port, waits for ready.
|
||||
// One instance serves all tests in a spec file.
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
class SiteServer {
|
||||
constructor() {
|
||||
this.port = 49152 + Math.floor(Math.random() * 16000);
|
||||
this.proc = null;
|
||||
this._stderr = '';
|
||||
}
|
||||
|
||||
async start() {
|
||||
const serverBin = path.join(PROJECT_ROOT, 'hosts/ocaml/_build/default/bin/sx_server.exe');
|
||||
|
||||
this.proc = spawn(serverBin, ['--http', String(this.port)], {
|
||||
cwd: PROJECT_ROOT,
|
||||
env: { ...process.env, SX_PROJECT_DIR: PROJECT_ROOT, OCAMLRUNPARAM: 'b' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.proc.stderr.on('data', chunk => { this._stderr += chunk.toString(); });
|
||||
this.proc.stdout.on('data', () => {});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Server did not start within 30s\n' + this._stderr.slice(-500))), 30000);
|
||||
this.proc.stderr.on('data', () => {
|
||||
if (this._stderr.includes('Listening on port')) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
this.proc.on('error', err => { clearTimeout(timeout); reject(err); });
|
||||
this.proc.on('exit', code => { clearTimeout(timeout); reject(new Error('Server exited: ' + code)); });
|
||||
});
|
||||
}
|
||||
|
||||
get baseUrl() { return `http://localhost:${this.port}`; }
|
||||
|
||||
stop() {
|
||||
if (this.proc && !this.proc.killed) {
|
||||
this.proc.kill('SIGTERM');
|
||||
setTimeout(() => { if (this.proc && !this.proc.killed) this.proc.kill('SIGKILL'); }, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SiteServer, PROJECT_ROOT };
|
||||
Reference in New Issue
Block a user