Root cause: the lake had (when (not (client?)) ...) guard — SSR rendered "the joy of sx" preview but client skipped it. replaceChildren swapped in an empty lake. The rebuild-preview effect was skipped (first-run optimization), so the preview stayed blank for ~500ms. Fix: remove the client? guard so the lake renders on both server and client. The template's steps-to-preview produces the initial preview. The effect only fires on subsequent step changes (not first run). Test: replaced MutationObserver approach with screenshot comparison. Loads page with JS blocked (pure SSR), takes screenshot. Loads with JS (hydration), takes screenshot. Compares pixels. Any visual difference fails the test. Result: "No visual flash: screenshots identical" — passes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
394 lines
15 KiB
JavaScript
394 lines
15 KiB
JavaScript
// Full site test suite — data-driven, discovers features from the DOM.
|
|
// Starts a real OCaml HTTP server subprocess (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();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feature report
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function featureReport(pageName, entries) {
|
|
const features = [];
|
|
const lines = entries.map(e => {
|
|
if (e.ok) features.push(e.feature);
|
|
return ` ${e.ok ? '\u2713' : '\u2717'} ${e.label}`;
|
|
});
|
|
const pass = entries.every(e => e.ok);
|
|
console.log(`\nPage: ${pageName}\n${lines.join('\n')}\n Features tested: ${features.join(', ')}\n`);
|
|
return { pass, features, failures: entries.filter(e => !e.ok).map(e => e.label) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-discovery: probe a page for testable features
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function discoverPage(page) {
|
|
return page.evaluate(() => {
|
|
const islands = Array.from(document.querySelectorAll('[data-sx-island]')).map(el => ({
|
|
name: el.getAttribute('data-sx-island'),
|
|
hydrated: el.children.length > 0,
|
|
}));
|
|
const hsElements = Array.from(document.querySelectorAll('[_]')).map(el => ({
|
|
tag: el.tagName,
|
|
text: el.textContent.trim().substring(0, 40),
|
|
src: el.getAttribute('_'),
|
|
activated: !!el.__sx_data,
|
|
}));
|
|
const sxGetLinks = Array.from(document.querySelectorAll('a[sx-get]')).map(a => ({
|
|
href: a.getAttribute('href'),
|
|
text: a.textContent.trim().substring(0, 30),
|
|
}));
|
|
const buttons = document.querySelectorAll('button').length;
|
|
const contentLen = (document.querySelector('#sx-content') || document.body).textContent.length;
|
|
return { islands, hsElements, sxGetLinks, buttons, contentLen };
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HS element testing — clicks each element and checks for effect
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function testHsElement(page, index) {
|
|
return page.evaluate((idx) => {
|
|
const el = document.querySelectorAll('[_]')[idx];
|
|
if (!el) return { ok: false, reason: 'not found' };
|
|
const src = el.getAttribute('_');
|
|
|
|
// Snapshot state before click
|
|
const classBefore = el.className;
|
|
const htmlBefore = el.innerHTML;
|
|
const textBefore = el.textContent;
|
|
|
|
// Check for target elements referenced in the HS source
|
|
let targetBefore = null;
|
|
const targetMatch = src.match(/#([\w-]+)/);
|
|
if (targetMatch) {
|
|
const tgt = document.getElementById(targetMatch[1]);
|
|
if (tgt) targetBefore = tgt.innerHTML;
|
|
}
|
|
const attrMatch = src.match(/@([\w-]+)/);
|
|
let attrBefore = null;
|
|
if (attrMatch) attrBefore = el.getAttribute(attrMatch[1]);
|
|
|
|
el.click();
|
|
|
|
// Check what changed (synchronous effects only — async waits tested separately)
|
|
const classAfter = el.className;
|
|
const htmlAfter = el.innerHTML;
|
|
const textAfter = el.textContent;
|
|
let targetAfter = null;
|
|
if (targetMatch) {
|
|
const tgt = document.getElementById(targetMatch[1]);
|
|
if (tgt) targetAfter = tgt.innerHTML;
|
|
}
|
|
let attrAfter = null;
|
|
if (attrMatch) attrAfter = el.getAttribute(attrMatch[1]);
|
|
|
|
const classChanged = classBefore !== classAfter;
|
|
const htmlChanged = htmlBefore !== htmlAfter;
|
|
const textChanged = textBefore !== textAfter;
|
|
const targetChanged = targetBefore !== null && targetBefore !== targetAfter;
|
|
const attrChanged = attrBefore !== null && attrBefore !== attrAfter;
|
|
const anyChange = classChanged || htmlChanged || textChanged || targetChanged || attrChanged;
|
|
|
|
return {
|
|
ok: anyChange,
|
|
src: src.substring(0, 60),
|
|
changes: {
|
|
class: classChanged ? classAfter.substring(0, 60) : null,
|
|
html: htmlChanged,
|
|
text: textChanged,
|
|
target: targetChanged,
|
|
attr: attrChanged ? attrAfter : null,
|
|
},
|
|
};
|
|
}, index);
|
|
}
|
|
|
|
// HS elements with async effects (wait) — need Promise-based check
|
|
async function testHsWaitElement(page, index) {
|
|
return page.evaluate((idx) => {
|
|
return new Promise(resolve => {
|
|
const el = document.querySelectorAll('[_]')[idx];
|
|
if (!el) return resolve({ ok: false, reason: 'not found' });
|
|
const classBefore = el.className;
|
|
el.click();
|
|
const classAfterClick = el.className;
|
|
const addedClass = classAfterClick !== classBefore;
|
|
|
|
// Wait for async effect to complete (up to 5s)
|
|
let checks = 0;
|
|
const poll = setInterval(() => {
|
|
checks++;
|
|
if (el.className !== classAfterClick || checks > 25) {
|
|
clearInterval(poll);
|
|
resolve({
|
|
ok: addedClass && el.className !== classAfterClick,
|
|
src: el.getAttribute('_').substring(0, 60),
|
|
classFlow: [classBefore.substring(0, 40), classAfterClick.substring(0, 40), el.className.substring(0, 40)],
|
|
});
|
|
}
|
|
}, 200);
|
|
});
|
|
}, index);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HOME
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('home', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
const entries = [];
|
|
|
|
// Set cookie to step 7, then load page — SSR should render at 7
|
|
await page.context().addCookies([{
|
|
name: 'sx-home-stepper', value: '7',
|
|
url: server.baseUrl,
|
|
}]);
|
|
|
|
// Screenshot-based flash detection.
|
|
// 1. Load with JS disabled → pure SSR render → screenshot
|
|
// 2. Load with JS enabled → hydration runs → screenshot
|
|
// 3. Compare pixels. Any difference = visible flash.
|
|
|
|
// Step 1: SSR screenshot (no JS)
|
|
await page.route('**/*.js', route => {
|
|
if (route.request().url().includes('sx_browser') || route.request().url().includes('sx-platform')) {
|
|
route.abort();
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
await page.goto(server.baseUrl + '/sx/', { waitUntil: 'networkidle', timeout: 30000 });
|
|
const ssrScreenshot = await page.screenshot({ clip: { x: 0, y: 0, width: 800, height: 600 } });
|
|
const ssrIndex = await page.evaluate(() => {
|
|
const m = document.body.textContent.match(/(\d+)\s*\/\s*16/);
|
|
return m ? m[1] : null;
|
|
});
|
|
|
|
// Step 2: Hydrated screenshot (with JS)
|
|
await page.unrouteAll();
|
|
await page.goto(server.baseUrl + '/sx/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await waitForSxReady(page);
|
|
// Wait a tick for any deferred effects
|
|
await page.waitForTimeout(500);
|
|
const hydratedScreenshot = await page.screenshot({ clip: { x: 0, y: 0, width: 800, height: 600 } });
|
|
const hydratedIndex = await page.evaluate(() => {
|
|
const m = document.body.textContent.match(/(\d+)\s*\/\s*16/);
|
|
return m ? m[1] : null;
|
|
});
|
|
|
|
// Step 3: Compare
|
|
entries.push({ ok: ssrIndex === hydratedIndex, label: `No flash: SSR=${ssrIndex} hydrated=${hydratedIndex} (cookie=7)`, feature: 'no-flash' });
|
|
const pixelMatch = Buffer.from(ssrScreenshot).equals(Buffer.from(hydratedScreenshot));
|
|
entries.push({ ok: pixelMatch, label: `No visual flash: ${pixelMatch ? 'screenshots identical' : 'screenshots differ'}`, feature: 'no-visual-flash' });
|
|
|
|
const info = await discoverPage(page);
|
|
entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' });
|
|
|
|
// Islands
|
|
for (const island of info.islands) {
|
|
entries.push({ ok: island.hydrated, label: `Island: ${island.name} ${island.hydrated ? 'hydrated' : 'NOT hydrated'}`, feature: island.name });
|
|
}
|
|
|
|
// Stepper
|
|
const stepperWorks = await page.evaluate(() => {
|
|
const btns = document.querySelectorAll('button');
|
|
if (btns.length < 2) return false;
|
|
const before = document.body.textContent;
|
|
btns[btns.length - 1].click(); // ▶ button
|
|
const after = document.body.textContent;
|
|
return before !== after;
|
|
});
|
|
entries.push({ ok: stepperWorks, label: 'Stepper: click changes content', feature: 'stepper' });
|
|
|
|
// Smoke + errors
|
|
const smoke = await universalSmoke(page);
|
|
entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.pass ? '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${errs.length > 0 ? ' — ' + errs[0].substring(0, 100) : ''}`, feature: 'no-errors' });
|
|
|
|
const r = featureReport('/sx/', entries);
|
|
expect(r.pass, r.failures.join('\n')).toBe(true);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HYPERSCRIPT — test every _="..." element
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('hyperscript', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
const entries = [];
|
|
|
|
await page.goto(server.baseUrl + '/sx/(applications.(hyperscript))', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await waitForSxReady(page);
|
|
entries.push({ ok: true, label: 'Boot: data-sx-ready', feature: 'boot' });
|
|
|
|
const info = await discoverPage(page);
|
|
entries.push({ ok: info.hsElements.length > 0, label: `HS elements: ${info.hsElements.length} found`, feature: 'hs-discovery' });
|
|
|
|
const activated = info.hsElements.filter(e => e.activated).length;
|
|
entries.push({ ok: activated === info.hsElements.length, label: `HS activation: ${activated}/${info.hsElements.length}`, feature: 'hs-activation' });
|
|
|
|
// Test each HS element
|
|
for (let i = 0; i < info.hsElements.length; i++) {
|
|
const hs = info.hsElements[i];
|
|
const hasWait = hs.src.includes('wait');
|
|
|
|
if (hasWait) {
|
|
const result = await testHsWaitElement(page, i);
|
|
entries.push({
|
|
ok: result.ok,
|
|
label: `HS[${i}] ${hs.src.substring(0, 50)}: ${result.ok ? 'async cycle OK' : 'no async effect'}`,
|
|
feature: `hs-${i}-wait`,
|
|
});
|
|
} else {
|
|
const result = await testHsElement(page, i);
|
|
entries.push({
|
|
ok: result.ok,
|
|
label: `HS[${i}] ${hs.src.substring(0, 50)}: ${result.ok ? 'changed' : 'no effect'}`,
|
|
feature: `hs-${i}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Smoke + errors
|
|
const smoke = await universalSmoke(page);
|
|
entries.push({ ok: smoke.pass, label: `Smoke: ${smoke.pass ? '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 = featureReport('/sx/(applications.(hyperscript))', entries);
|
|
expect(r.pass, r.failures.join('\n')).toBe(true);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SPA NAVIGATION — click sx-get links, verify content swaps
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('spa-navigation', async ({ page }) => {
|
|
const entries = [];
|
|
|
|
await page.goto(server.baseUrl + '/sx/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await waitForSxReady(page);
|
|
|
|
const sections = [
|
|
{ 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 sections) {
|
|
// Click the sx-get link using Playwright's click (fires real DOM event)
|
|
const link = page.locator(`a[href="${href}"]`).first();
|
|
const linkVisible = await link.isVisible().catch(() => false);
|
|
|
|
if (!linkVisible) {
|
|
entries.push({ ok: false, label: `SPA → ${label}: link not visible`, feature: `spa-${label.toLowerCase()}` });
|
|
continue;
|
|
}
|
|
|
|
await link.click();
|
|
|
|
// Wait for content swap — URL should change or content should update
|
|
try {
|
|
await page.waitForFunction(
|
|
(h) => {
|
|
const content = (document.querySelector('#sx-content') || document.body).textContent;
|
|
return content.length > 100;
|
|
},
|
|
href,
|
|
{ timeout: 5000 }
|
|
);
|
|
const url = page.url();
|
|
const hasContent = await page.evaluate(() =>
|
|
(document.querySelector('#sx-content') || document.body).textContent.length > 100
|
|
);
|
|
entries.push({ ok: hasContent, label: `SPA → ${label}: content loaded (${url.split('/').pop()})`, feature: `spa-${label.toLowerCase()}` });
|
|
} catch (e) {
|
|
entries.push({ ok: false, label: `SPA → ${label}: timeout`, feature: `spa-${label.toLowerCase()}` });
|
|
}
|
|
|
|
// Navigate back to home for next test
|
|
await page.goto(server.baseUrl + '/sx/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
await waitForSxReady(page);
|
|
}
|
|
|
|
const r = featureReport('SPA navigation', entries);
|
|
expect(r.pass, r.failures.join('\n')).toBe(true);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SECTION PAGES — every page in each section boots and renders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SECTIONS = [
|
|
{ name: 'geography', entry: '/sx/(geography)', linkPrefix: '/sx/(geography' },
|
|
{ name: 'language', entry: '/sx/(language)', linkPrefix: '/sx/(language' },
|
|
{ name: 'applications', entry: '/sx/(applications)', linkPrefix: '/sx/(applications' },
|
|
{ name: 'tools', entry: '/sx/(tools)', linkPrefix: '/sx/(tools' },
|
|
{ name: 'etc', entry: '/sx/(etc)', linkPrefix: '/sx/(etc' },
|
|
];
|
|
|
|
for (const section of SECTIONS) {
|
|
test(`pages: ${section.name}`, async ({ page }) => {
|
|
const entries = [];
|
|
|
|
// Discover all pages in this section
|
|
await page.goto(server.baseUrl + section.entry, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
try { await waitForSxReady(page); } catch (e) {}
|
|
|
|
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.linkPrefix);
|
|
|
|
const allUrls = [section.entry, ...links.filter(l => l !== section.entry)];
|
|
|
|
for (const url of allUrls) {
|
|
await page.goto(server.baseUrl + url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
try { await waitForSxReady(page, 15000); } catch (e) {}
|
|
|
|
const info = await discoverPage(page);
|
|
const smoke = await universalSmoke(page);
|
|
const pageName = url.replace('/sx/', '');
|
|
|
|
entries.push({
|
|
ok: smoke.pass && info.contentLen > 50,
|
|
label: `${pageName}: ${info.contentLen} chars, ${info.islands.length} islands, ${info.hsElements.length} hs${smoke.pass ? '' : ' [' + smoke.failures.join(', ') + ']'}`,
|
|
feature: pageName,
|
|
});
|
|
}
|
|
|
|
const passed = entries.filter(e => e.ok).length;
|
|
console.log(`\nSection: ${section.name} — ${passed}/${entries.length} pages`);
|
|
entries.forEach(e => console.log(` ${e.ok ? '\u2713' : '\u2717'} ${e.label}`));
|
|
|
|
const failures = entries.filter(e => !e.ok);
|
|
expect(failures.length, failures.map(f => f.label).join('\n')).toBe(0);
|
|
});
|
|
}
|