Playwright tests for isomorphic SSR
4 tests verifying server-client rendering parity: 1. SSR renders visible content without JavaScript (headings, islands, logo) 2. JS-rendered content matches SSR structure (same article text) 3. CSSX styling works without JS (violet class, rules in <head>) 4. SPA navigation preserves island state (colour + copyright path) Run: cd tests/playwright && npx playwright test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
177
tests/playwright/isomorphic.spec.js
Normal file
177
tests/playwright/isomorphic.spec.js
Normal file
@@ -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('(<sx>)')).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 <head>
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
15
tests/playwright/playwright.config.js
Normal file
15
tests/playwright/playwright.config.js
Normal file
@@ -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' } },
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user