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>
This commit is contained in:
270
tests/playwright/hydration.spec.js
Normal file
270
tests/playwright/hydration.spec.js
Normal file
@@ -0,0 +1,270 @@
|
||||
// @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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user