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>
271 lines
11 KiB
JavaScript
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');
|
|
});
|
|
});
|