Files
rose-ash/tests/playwright/site-full.spec.js
giles 2805e0077b Fix clobber test: detect text content change, not just empty state
The previous test only checked if childNodes.length hit zero. With
replaceChildren that never happens — but the flash is still visible
because the SSR DOM is replaced with different reactive DOM.

New test captures SSR textContent before JS boots, watches for any
change via MutationObserver. Now correctly fails:
  "text changed — ssr:(div (~tw :tokens... → hydrated:..."

This proves the flash: island hydration replaces SSR DOM wholesale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:08:02 +00:00

406 lines
16 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,
}]);
// Inject observer before page JS boots to detect hydration flash.
// A flash = the island's visible text content changes during hydration.
// True hydration should preserve SSR DOM — no visible change at all.
await page.addInitScript(() => {
window.__flashDetected = false;
window.__flashDetail = null;
const check = () => {
const stepper = document.querySelector('[data-sx-island="home/stepper"]');
if (!stepper || !stepper.parentNode) return;
const ssrText = stepper.textContent;
new MutationObserver(() => {
const newText = stepper.textContent;
if (newText !== ssrText && !window.__flashDetected) {
window.__flashDetected = true;
window.__flashDetail = { ssr: ssrText.substring(0, 80), hydrated: newText.substring(0, 80) };
}
}).observe(stepper, { childList: true, subtree: true, characterData: true });
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', check);
} else {
check();
}
});
// Capture SSR state before JS runs
const ssrResponse = await page.goto(server.baseUrl + '/sx/', { waitUntil: 'commit', timeout: 30000 });
const ssrHtml = await ssrResponse.text();
const ssrMatch = ssrHtml.match(/tabular-nums[^>]*>(\d+) \/ (\d+)<\/span>/);
const ssrIndex = ssrMatch ? ssrMatch[1] : null;
// Wait for hydration
await waitForSxReady(page);
// Check post-hydration index matches SSR
const hydratedIndex = await page.evaluate(() => {
const m = document.body.textContent.match(/(\d+)\s*\/\s*16/);
return m ? m[1] : null;
});
const noFlash = ssrIndex === hydratedIndex;
entries.push({ ok: noFlash, label: `No flash: SSR=${ssrIndex} hydrated=${hydratedIndex} (cookie=7)`, feature: 'no-flash' });
// Check for hydration flash — island text content changed during hydration
const flash = await page.evaluate(() => ({
detected: window.__flashDetected || false,
detail: window.__flashDetail,
}));
entries.push({ ok: !flash.detected, label: `No clobber: ${flash.detected ? 'text changed — ssr:"' + (flash.detail?.ssr || '').substring(0, 40) + '" → hydrated:"' + (flash.detail?.hydrated || '').substring(0, 40) + '"' : 'clean'}`, feature: 'no-clobber' });
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);
});
}