// 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 = []; await page.goto(server.baseUrl + '/sx/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitForSxReady(page); 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`, 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); }); }