// Browser check for the blog SPA (lib/host/blog.sx + lib/host/static.sx). Runs // against an ephemeral host server seeded with a couple of posts by // run-spa-check.sh, which copies this spec into the Playwright env and sets // SX_TEST_URL. Verifies the WASM OCaml kernel boots in the browser, the SX-htmx // engine activates sx-boost on #content's links, and clicking a link does a // fragment swap (no full page reload) with history — i.e. it's a real SPA. const { test, expect } = require('playwright/test'); // boot-init sets data-sx-ready="true" on once the WASM kernel + web stack // have loaded and the page has been processed. WASM compile + ~25 asset fetches, // so allow generous time. async function waitReady(page) { await expect(page.locator('html[data-sx-ready="true"]')).toHaveCount(1, { timeout: 45000 }); } // a post link in the listing (trailing slash); skip /new, /login, /tags. const POSTLINK = '#content a[href$="/"]'; test.describe('blog SPA', () => { test('WASM kernel boots and marks the document ready', async ({ page }) => { const errors = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); page.on('pageerror', (e) => errors.push(String(e))); await page.goto('/'); await waitReady(page); // the shell shipped the WASM loaders expect(await page.locator('script[src*="sx_browser.bc.wasm.js"]').count()).toBe(1); expect(await page.locator('script[src*="sx-platform.js"]').count()).toBe(1); // no boot-time JS errors expect(errors, errors.join('\n')).toEqual([]); }); test('links inside #content get boosted', async ({ page }) => { await page.goto('/'); await waitReady(page); // the engine marks a boosted link with the _sxBoundboost JS property await expect .poll(() => page.locator(POSTLINK).first().evaluate((a) => !!a._sxBoundboost), { timeout: 15000 }) .toBe(true); }); test('clicking a link does a fragment swap — no full reload, URL updates', async ({ page }) => { await page.goto('/'); await waitReady(page); // sentinel survives ONLY if there is no full-page reload await page.evaluate(() => { window.__noReload = true; }); const link = page.locator(POSTLINK).first(); const href = await link.getAttribute('href'); await link.click(); await page.waitForURL((u) => u.pathname === href, { timeout: 15000 }); expect(await page.evaluate(() => window.__noReload)).toBe(true); // no reload // content was swapped into #content (a post page carries the post footer) await expect(page.locator('#content')).toContainText(/all posts/i, { timeout: 15000 }); // the post BODY itself rendered — the
comes from raw! HTML, which // exercises the client SX raw-HTML path (dom-parse-html). If that drops the // content (NodeList-vs-Node bug), the footer still shows but this fails. await expect(page.locator('#content article').first()).toBeVisible({ timeout: 15000 }); }); test('back button restores the listing', async ({ page }) => { await page.goto('/'); await waitReady(page); const link = page.locator(POSTLINK).first(); const href = await link.getAttribute('href'); await link.click(); await page.waitForURL((u) => u.pathname === href, { timeout: 15000 }); await page.goBack(); await page.waitForURL((u) => u.pathname === '/', { timeout: 15000 }); await expect(page.locator('#content h1')).toContainText('Posts'); // and a click AFTER back must still be a SPA nav, not a full reload — the // restored content has to be re-boosted (its [sx-boost] marker is an // ancestor of the swap target, so the re-boost must scan upward). await page.evaluate(() => { window.__noReload2 = true; }); const link2 = page.locator(POSTLINK).first(); const href2 = await link2.getAttribute('href'); await link2.click(); await page.waitForURL((u) => u.pathname === href2, { timeout: 15000 }); expect(await page.evaluate(() => window.__noReload2)).toBe(true); }); });