// @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'); }); });