Files
rose-ash/tests/playwright/hydration.spec.js
giles e98aedf803 Add comprehensive Playwright hydration tests — 15 tests
Covers all bugs fixed in the DOM-preserving hydration work:

DOM preservation:
- Islands hydrate without errors or warnings
- Both islands report hydrated in boot log
- No replaceChildren called on island elements
- No stray comment markers in island DOM

Counter text nodes (was: "0 / 16" → "0"):
- Counter shows full "0 / 16" text
- Counter has exactly 3 text nodes (value, separator, total)
- Counter updates on forward/back clicks

Event listeners (was: buttons had no click handlers):
- Stepper buttons respond to clicks
- Header navigation links present after hydration

Code view:
- Syntax-highlighted spans present after hydration
- Code highlighting advances with stepper clicks

SSR DOM identity:
- Element count roughly preserved (not doubled)
- Stepper buttons are the SAME DOM nodes (JS property survives)
- Header elements are the SAME DOM nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:29:53 +00:00

271 lines
11 KiB
JavaScript

// @ts-check
const { test, expect } = require('playwright/test');
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
// ─── Helpers ────────────────────────────────────────────────
/** Get SSR HTML before JS hydration by fetching raw page */
async function getSSRHtml(page, selector) {
return page.evaluate(async (sel) => {
const resp = await fetch(window.location.href);
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const el = doc.querySelector(sel);
return el ? el.innerHTML : null;
}, selector);
}
/** Describe childNodes of an element as [{type, text}] */
async function describeChildren(page, selector) {
return page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const nodes = [];
for (let i = 0; i < el.childNodes.length; i++) {
const n = el.childNodes[i];
nodes.push({ type: n.nodeType, text: n.textContent });
}
return nodes;
}, selector);
}
/** Count comment nodes in an element */
async function countComments(page, selector) {
return page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return -1;
let count = 0;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_COMMENT);
while (walker.nextNode()) count++;
return count;
}, selector);
}
// ─── DOM preservation tests ──────────────────────────────────
test.describe('DOM-preserving hydration', () => {
test('islands hydrate without errors or warnings', async ({ page }) => {
const errs = trackErrors(page);
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const bootLog = await page.evaluate(() => {
// Check for hydrate fallback warnings
return (window.__sxBootLog || []).filter(l => l.includes('fallback') || l.includes('FAILED'));
});
expect(bootLog).toHaveLength(0);
expect(errs.errors()).toHaveLength(0);
});
test('both islands report hydrated in boot log', async ({ page }) => {
const logs = [];
page.on('console', msg => { if (msg.text().includes('[sx]')) logs.push(msg.text()); });
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const hydrated = logs.filter(l => l.includes('hydrated island:'));
expect(hydrated.length).toBe(2);
expect(hydrated.some(l => l.includes('layouts/header'))).toBeTruthy();
expect(hydrated.some(l => l.includes('home/stepper'))).toBeTruthy();
});
test('no replaceChildren during hydration — SSR DOM preserved', async ({ page }) => {
// Intercept replaceChildren calls on island elements
await page.addInitScript(() => {
window.__replaceChildrenCalls = 0;
const orig = Element.prototype.replaceChildren;
Element.prototype.replaceChildren = function(...args) {
if (this.hasAttribute && this.hasAttribute('data-sx-island')) {
window.__replaceChildrenCalls++;
}
return orig.apply(this, args);
};
});
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const calls = await page.evaluate(() => window.__replaceChildrenCalls);
expect(calls).toBe(0);
});
test('no stray comment markers in island DOM', async ({ page }) => {
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const stepperComments = await countComments(page, "[data-sx-island='home/stepper']");
expect(stepperComments).toBe(0);
});
});
// ─── Counter text node structure ────────────────────────────
test.describe('stepper counter hydration', () => {
test('counter shows "0 / 16" after hydration', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const text = await page.textContent("[data-sx-island='home/stepper'] span.sx-text-sm");
expect(text).toBe('0 / 16');
});
test('counter has 3 text nodes: value, separator, total', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const children = await describeChildren(page, "[data-sx-island='home/stepper'] span.sx-text-sm");
expect(children).not.toBeNull();
expect(children.length).toBe(3);
expect(children[0].type).toBe(3); // text node
expect(children[0].text).toBe('0');
expect(children[1].type).toBe(3);
expect(children[1].text).toBe(' / ');
expect(children[2].type).toBe(3);
expect(children[2].text).toBe('16');
});
test('counter updates on forward click', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
await page.click("button:has-text('▶')");
await page.waitForTimeout(300);
const text = await page.textContent("[data-sx-island='home/stepper'] span.sx-text-sm");
expect(text).toBe('1 / 16');
});
test('counter updates on back click', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
// Advance twice, then back once
await page.click("button:has-text('▶')");
await page.waitForTimeout(200);
await page.click("button:has-text('▶')");
await page.waitForTimeout(200);
await page.click("button:has-text('◀')");
await page.waitForTimeout(300);
const text = await page.textContent("[data-sx-island='home/stepper'] span.sx-text-sm");
expect(text).toBe('1 / 16');
});
});
// ─── Event listener attachment ──────────────────────────────
test.describe('event listeners on hydrated elements', () => {
test('stepper buttons have click listeners', async ({ page }) => {
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
// Verify both buttons respond to clicks
const before = await page.textContent("[data-sx-island='home/stepper'] span.sx-text-sm");
await page.click("button:has-text('▶')");
await page.waitForTimeout(300);
const after = await page.textContent("[data-sx-island='home/stepper'] span.sx-text-sm");
expect(before).not.toBe(after);
});
test('header navigation links work after hydration', async ({ page }) => {
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
// The header should have clickable links
const links = await page.locator("[data-sx-island='layouts/header'] a[href]").count();
expect(links).toBeGreaterThan(0);
});
});
// ─── Code view integrity ─────────────────────────────────────
test.describe('stepper code view', () => {
test('code view has syntax-highlighted spans after hydration', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const spanCount = await page.locator("[data-sx-island='home/stepper'] [data-code-view] span").count();
// The code view should have many spans (one per token)
expect(spanCount).toBeGreaterThan(20);
});
test('code highlighting advances with stepper', async ({ page }) => {
await page.context().clearCookies();
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
// Get initial highlighted span count (bg-amber-100 = current step)
const initialHighlighted = await page.locator("[data-code-view] span.bg-amber-100").count();
// Click forward
await page.click("button:has-text('▶')");
await page.waitForTimeout(500);
const afterHighlighted = await page.locator("[data-code-view] span.bg-amber-100").count();
// Highlighting should change
expect(afterHighlighted).not.toBe(initialHighlighted);
});
});
// ─── SSR/hydrated DOM comparison ─────────────────────────────
test.describe('SSR DOM preservation', () => {
test('island element count unchanged after hydration', async ({ page }) => {
// Count elements before JS runs
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
const stepper = document.querySelector("[data-sx-island='home/stepper']");
if (stepper) {
window.__ssrElementCount = stepper.querySelectorAll('*').length;
}
});
});
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const ssrCount = await page.evaluate(() => window.__ssrElementCount);
const hydratedCount = await page.evaluate(() => {
const s = document.querySelector("[data-sx-island='home/stepper']");
return s ? s.querySelectorAll('*').length : 0;
});
// Hydrated count should be close to SSR count (may have small additions for new nodes)
// But should NOT be doubled (which would indicate replaceChildren created new DOM)
expect(hydratedCount).toBeGreaterThanOrEqual(ssrCount * 0.8);
expect(hydratedCount).toBeLessThanOrEqual(ssrCount * 1.3);
});
test('stepper buttons are the same DOM nodes after hydration', async ({ page }) => {
// Tag buttons with a marker before hydration, verify they survive
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
const buttons = document.querySelectorAll("[data-sx-island='home/stepper'] button");
buttons.forEach((b, i) => { b.__ssrMarker = 'btn-' + i; });
});
});
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const markers = await page.evaluate(() => {
const buttons = document.querySelectorAll("[data-sx-island='home/stepper'] button");
return Array.from(buttons).map(b => b.__ssrMarker);
});
// If DOM was preserved, markers survive. If replaceChildren ran, they're gone.
expect(markers).toEqual(['btn-0', 'btn-1']);
});
test('header island elements are the same DOM nodes after hydration', async ({ page }) => {
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
const header = document.querySelector("[data-sx-island='layouts/header']");
if (header) header.__ssrMarker = 'header-original';
const firstDiv = header && header.querySelector('div');
if (firstDiv) firstDiv.__ssrMarker = 'div-original';
});
});
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const markers = await page.evaluate(() => {
const header = document.querySelector("[data-sx-island='layouts/header']");
const firstDiv = header && header.querySelector('div');
return {
header: header ? header.__ssrMarker : null,
div: firstDiv ? firstDiv.__ssrMarker : null
};
});
expect(markers.header).toBe('header-original');
expect(markers.div).toBe('div-original');
});
});