Phase 1 Step 1 of the architecture roadmap. The old cssx.sx (cssx-resolve, cssx-process-token, cssx-template, old tw function) is superseded by the ~tw component system in tw.sx. - Delete shared/sx/templates/cssx.sx - Remove cssx.sx from all load lists (sx_server.ml, run_tests.ml, mcp_tree.ml, compile-modules.js, bundle.sh, sx-build-all.sh) - Replace (tw "tokens") inline style calls with (~tw :tokens "tokens") in layouts.sx and not-found.sx - Remove _css-hash / init-css-tracking / SX-Css header plumbing (dead code — ~tw/flush + flush-collected-styles handle CSS now) - Remove sx-css-classes param and meta tag from shell template - Update stale data-cssx references to data-sx-css in tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
211 lines
8.0 KiB
JavaScript
211 lines
8.0 KiB
JavaScript
// @ts-check
|
|
const { test, expect } = require('playwright/test');
|
|
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
|
|
|
|
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('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: 'domcontentloaded' });
|
|
await waitForSxReady(jsPage);
|
|
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 <head>
|
|
const cssxInHead = await page.evaluate(() => {
|
|
const style = document.querySelector('head style[data-sx-css]');
|
|
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-sx-css]');
|
|
return style ? style.textContent.includes('sx-text-violet-699') : false;
|
|
});
|
|
expect(hasVioletRule).toBe(true);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
test('islands hydrate and reactive signals work', async ({ page }) => {
|
|
const t = trackErrors(page);
|
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
|
await waitForSxReady(page);
|
|
|
|
await expect(page.locator('[data-sx-island="layouts/header"]')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.locator('[data-sx-island="home/stepper"]')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Stepper buttons change the count
|
|
const stepper = page.locator('[data-sx-island="home/stepper"]');
|
|
const textBefore = await stepper.textContent();
|
|
await stepper.locator('button').last().click();
|
|
await page.waitForTimeout(300);
|
|
const textAfter = await stepper.textContent();
|
|
expect(textAfter).not.toBe(textBefore);
|
|
|
|
// Reactive colour cycling on "reactive" word
|
|
const reactive = page.locator('[data-sx-island="layouts/header"]').getByText('reactive');
|
|
const colourBefore = await reactive.evaluate(el => el.style.color);
|
|
await reactive.click();
|
|
await page.waitForTimeout(300);
|
|
const colourAfter = await reactive.evaluate(el => el.style.color);
|
|
expect(colourAfter).not.toBe(colourBefore);
|
|
expect(t.errors()).toEqual([]);
|
|
});
|
|
|
|
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
|
|
const t = trackErrors(page);
|
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
|
await waitForSxReady(page);
|
|
|
|
// 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([]);
|
|
expect(t.errors()).toEqual([]);
|
|
});
|
|
|
|
test('navigation preserves header island state', async ({ page }) => {
|
|
const t = trackErrors(page);
|
|
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
|
|
await waitForSxReady(page);
|
|
|
|
// 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 expect(page).toHaveURL(/geography/, { timeout: 5000 });
|
|
|
|
// Colour should be preserved (def-store keeps signals across re-hydration)
|
|
const colourAfter = await reactive.evaluate(el => el.style.color);
|
|
expect(colourAfter).toBe(colourBefore);
|
|
expect(t.errors()).toEqual([]);
|
|
});
|
|
|
|
});
|