diff --git a/tests/playwright/isomorphic.spec.js b/tests/playwright/isomorphic.spec.js new file mode 100644 index 0000000..11042f5 --- /dev/null +++ b/tests/playwright/isomorphic.spec.js @@ -0,0 +1,177 @@ +// @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('page renders with JavaScript and matches SSR structure', async ({ browser }) => { + // First: get SSR content (no JS) + const noJsContext = await browser.newContext({ javaScriptEnabled: false }); + const noJsPage = await noJsContext.newPage(); + await noJsPage.goto(BASE_URL + TEST_PAGE, { waitUntil: 'domcontentloaded' }); + const ssrText = await getSxRootText(noJsPage); + const ssrStructure = await getSxRootStructure(noJsPage); + await noJsContext.close(); + + // Then: get client-rendered content (with JS) + const jsContext = await browser.newContext({ javaScriptEnabled: true }); + const jsPage = await jsContext.newPage(); + await jsPage.goto(BASE_URL + TEST_PAGE, { waitUntil: 'networkidle' }); + // Wait for islands to hydrate + await jsPage.waitForSelector('[data-sx-island].sx-processed', { timeout: 10000 }).catch(() => {}); + await jsPage.waitForTimeout(500); // settle + + const clientText = await getSxRootText(jsPage); + await jsContext.close(); + + // Text content should be the same (article text, headings, etc) + expect(ssrText.length).toBeGreaterThan(100); + expect(clientText.length).toBeGreaterThan(100); + + // The core content should match — both should have the article headings + expect(clientText).toContain('Language games'); + expect(clientText).toContain('The limits of my language'); + + // SSR should also have these + expect(ssrText).toContain('Language games'); + expect(ssrText).toContain('The limits of my language'); + }); + + 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 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'); + }); + +}); diff --git a/tests/playwright/playwright.config.js b/tests/playwright/playwright.config.js new file mode 100644 index 0000000..0911b29 --- /dev/null +++ b/tests/playwright/playwright.config.js @@ -0,0 +1,15 @@ +// @ts-check +const { defineConfig } = require('playwright/test'); + +module.exports = defineConfig({ + testDir: '.', + timeout: 60000, + retries: 0, + use: { + baseURL: process.env.SX_TEST_URL || 'http://localhost:8013', + headless: true, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +});