diff --git a/tests/playwright/hydration.spec.js b/tests/playwright/hydration.spec.js new file mode 100644 index 00000000..06d7ef36 --- /dev/null +++ b/tests/playwright/hydration.spec.js @@ -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'); + }); +});