// @ts-check const { test, expect } = require('playwright/test'); const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; const TEST_PAGE = '/sx/(etc.(philosophy.wittgenstein))'; /** * Helper: get the text content of #sx-root, normalised. * Strips whitespace differences so SSR and client render can be compared. */ async function getSxRootText(page) { return page.evaluate(() => { const root = document.getElementById('sx-root'); if (!root) return ''; return root.innerText.replace(/\s+/g, ' ').trim(); }); } /** * Helper: get structural snapshot of #sx-root — tag names, ids, classes, text. * Ignores attributes that differ between SSR and client (event handlers, etc). */ async function getSxRootStructure(page) { return page.evaluate(() => { function snapshot(el) { if (el.nodeType === 3) { const text = el.textContent.trim(); return text ? { t: text } : null; } if (el.nodeType !== 1) return null; const node = { tag: el.tagName.toLowerCase() }; if (el.id) node.id = el.id; // Collect class names, sorted for stability const cls = Array.from(el.classList).sort().join(' '); if (cls) node.cls = cls; // Data attributes for islands and lakes const island = el.getAttribute('data-sx-island'); if (island) node.island = island; const lake = el.getAttribute('data-sx-lake'); if (lake) node.lake = lake; // Recurse children const children = []; for (const child of el.childNodes) { const s = snapshot(child); if (s) children.push(s); } if (children.length) node.children = children; return node; } const root = document.getElementById('sx-root'); return root ? snapshot(root) : null; }); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test.describe('Isomorphic SSR', () => { test('page renders visible content without JavaScript', async ({ browser }) => { const context = await browser.newContext({ javaScriptEnabled: false }); const page = await context.newPage(); await page.goto(BASE_URL + TEST_PAGE, { waitUntil: 'domcontentloaded' }); // sx-root should exist and have content const root = page.locator('#sx-root'); await expect(root).toBeVisible(); // Should have real HTML content (headings from the article) const headings = await page.locator('#sx-root h2').allTextContents(); expect(headings.length).toBeGreaterThan(0); expect(headings[0]).toContain('Language games'); // Header island should be rendered with hydration marker const headerIsland = page.locator('[data-sx-island="layouts/header"]'); await expect(headerIsland).toBeVisible(); // Logo should be visible await expect(page.locator('#sx-root').getByText('()')).toBeVisible(); // Copyright should show the path await expect(page.locator('#sx-root').getByText('© Giles Bradshaw 2026')).toBeVisible(); await context.close(); }); test('JS and no-JS render the same DOM structure and styles', async ({ browser }) => { // Get SSR DOM (no JS) const noJsContext = await browser.newContext({ javaScriptEnabled: false }); const noJsPage = await noJsContext.newPage(); await noJsPage.goto(BASE_URL + TEST_PAGE, { waitUntil: 'domcontentloaded' }); const ssrStructure = await getSxRootStructure(noJsPage); const ssrText = await getSxRootText(noJsPage); await noJsContext.close(); // Get client DOM (with JS) const jsContext = await browser.newContext({ javaScriptEnabled: true }); const jsPage = await jsContext.newPage(); await jsPage.goto(BASE_URL + TEST_PAGE, { waitUntil: 'networkidle' }); await jsPage.waitForTimeout(2000); // wait for hydration const clientStructure = await getSxRootStructure(jsPage); const clientText = await getSxRootText(jsPage); await jsContext.close(); // Text content must match expect(ssrText).toBe(clientText); // Structure must match — same tags, ids, classes expect(JSON.stringify(ssrStructure)).toBe(JSON.stringify(clientStructure)); }); test('header island has CSSX styling without JavaScript', async ({ browser }) => { const context = await browser.newContext({ javaScriptEnabled: false }); const page = await context.newPage(); await page.goto(BASE_URL + TEST_PAGE, { waitUntil: 'domcontentloaded' }); // The logo should have the violet color class const logo = page.locator('[data-sx-island="layouts/header"] span.sx-text-violet-699'); await expect(logo).toBeVisible(); // Check that the CSSX style tag is in const cssxInHead = await page.evaluate(() => { const style = document.querySelector('head style[data-cssx]'); return style ? style.textContent.length : 0; }); expect(cssxInHead).toBeGreaterThan(0); // The violet rule should exist const hasVioletRule = await page.evaluate(() => { const style = document.querySelector('head style[data-cssx]'); return style ? style.textContent.includes('sx-text-violet-699') : false; }); expect(hasVioletRule).toBe(true); await context.close(); }); test('navigation links have valid URLs (no [object Object])', async ({ page }) => { await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' }); await page.waitForTimeout(1000); // Check all nav links for [object Object] — regression for FFI primitive overrides const brokenLinks = await page.evaluate(() => { const links = document.querySelectorAll('a[href]'); const broken = []; for (const a of links) { if (a.href.includes('[object') || a.href.includes('object%20Object')) { broken.push({ href: a.href, text: a.textContent.trim().slice(0, 40) }); } } return broken; }); expect(brokenLinks).toEqual([]); }); test('navigation preserves header island state', async ({ page }) => { await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' }); // Wait for header island to hydrate await page.waitForSelector('[data-sx-island="layouts/header"]', { timeout: 15000 }); await page.waitForTimeout(1000); // Click "reactive" to change colour const reactive = page.locator('[data-sx-island="layouts/header"]').getByText('reactive'); await reactive.click(); await page.waitForTimeout(300); // Get the colour after click const colourBefore = await reactive.evaluate(el => el.style.color); expect(colourBefore).toBeTruthy(); // Navigate via SPA link const geoLink = page.locator('a[sx-get*="geography"]').first(); await geoLink.click(); await page.waitForTimeout(2000); // Colour should be preserved (island not disposed) const colourAfter = await reactive.evaluate(el => el.style.color); expect(colourAfter).toBe(colourBefore); // Copyright path should update const copyright = page.locator('[data-sx-lake="copyright"]'); await expect(copyright).toContainText('geography'); }); });