diff --git a/tests/playwright/demo-interactions.spec.js b/tests/playwright/demo-interactions.spec.js index eae8ca4b..1fd9c08b 100644 --- a/tests/playwright/demo-interactions.spec.js +++ b/tests/playwright/demo-interactions.spec.js @@ -1,15 +1,21 @@ // @ts-check /** * Demo interaction tests — verify every demo actually functions. - * - * Each test loads a demo, interacts with it (click buttons, type in inputs), - * and verifies the DOM changes as expected. Also checks no raw SX class text - * leaks into the visible DOM. + * Each test is isolated (fresh page.goto) for reliability. + * Server cache keeps page loads fast. */ const { test, expect } = require('playwright/test'); const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; -// Helper: assert no raw class text leaked +async function loadDemo(page, path) { + await page.goto(BASE_URL + '/sx/' + path, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(500); +} + +function island(page, pattern) { + return page.locator(`[data-sx-island*="${pattern}"]`); +} + async function assertNoClassLeak(page, scope) { const loc = scope ? page.locator(scope).first() : page.locator('#sx-root'); const text = await loc.textContent(); @@ -18,11 +24,6 @@ async function assertNoClassLeak(page, scope) { expect(text).not.toContain('classbg-'); } -// Helper: get island locator -function island(page, pattern) { - return page.locator(`[data-sx-island*="${pattern}"]`); -} - // =========================================================================== // Reactive island demos @@ -31,392 +32,210 @@ function island(page, pattern) { test.describe('Reactive island interactions', () => { test('counter: + and − change count and doubled', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.counter)))'); const el = island(page, 'counter'); await expect(el).toBeVisible({ timeout: 10000 }); - const buttons = el.locator('button'); await expect(buttons).toHaveCount(2); - // Click + 3 times const plus = buttons.last(); await plus.click(); await plus.click(); await plus.click(); await page.waitForTimeout(300); - const text = await el.textContent(); expect(text).toContain('3'); expect(text).toContain('doubled'); expect(text).toContain('6'); - // Click − once await buttons.first().click(); await page.waitForTimeout(300); - const text2 = await el.textContent(); - expect(text2).toContain('2'); + expect(await el.textContent()).toContain('2'); }); test('temperature: +/− change celsius and fahrenheit', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.temperature)))'); const el = island(page, 'temperature'); await expect(el).toBeVisible({ timeout: 10000 }); - const buttons = el.locator('button'); - // Click + a few times await buttons.last().click(); await buttons.last().click(); await page.waitForTimeout(300); - const text = await el.textContent(); - // Should show °C and °F expect(text).toContain('°C'); expect(text).toContain('°F'); }); test('stopwatch: start shows elapsed time', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.stopwatch)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.stopwatch)))'); const el = island(page, 'stopwatch'); await expect(el).toBeVisible({ timeout: 10000 }); - const textBefore = await el.textContent(); - // Click start (first button) await el.locator('button').first().click(); - await page.waitForTimeout(1500); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(1200); + expect(await el.textContent()).not.toBe(textBefore); }); test('input-binding: typing updates live preview', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.input-binding)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.input-binding)))'); const el = island(page, 'input-binding'); await expect(el).toBeVisible({ timeout: 10000 }); - - const input = el.locator('input').first(); - await input.fill('playwright test'); + await el.locator('input').first().fill('playwright test'); await page.waitForTimeout(300); - - const text = await el.textContent(); - expect(text).toContain('playwright test'); + expect(await el.textContent()).toContain('playwright test'); }); test('dynamic-class: toggle changes element styling', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.dynamic-class)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.dynamic-class)))'); const el = island(page, 'dynamic-class'); await expect(el).toBeVisible({ timeout: 10000 }); - const htmlBefore = await el.innerHTML(); await el.locator('button').first().click(); await page.waitForTimeout(300); - const htmlAfter = await el.innerHTML(); - expect(htmlAfter).not.toBe(htmlBefore); + expect(await el.innerHTML()).not.toBe(htmlBefore); }); test('reactive-list: add button increases items', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.reactive-list)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.reactive-list)))'); const el = island(page, 'reactive-list'); await expect(el).toBeVisible({ timeout: 10000 }); - const textBefore = await el.textContent(); - // Click add button await el.locator('button').first().click(); await page.waitForTimeout(300); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + expect(await el.textContent()).not.toBe(textBefore); }); - test('stores: writer and reader both render and share state', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.stores)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('stores: writer and reader share state', async ({ page }) => { + await loadDemo(page, '(geography.(reactive.(examples.stores)))'); const writer = island(page, 'store-writer'); const reader = island(page, 'store-reader'); await expect(writer).toBeVisible({ timeout: 10000 }); await expect(reader).toBeVisible({ timeout: 10000 }); - - // Both islands should have content expect((await writer.textContent()).length).toBeGreaterThan(0); expect((await reader.textContent()).length).toBeGreaterThan(0); - - // Interact with writer — try button then input - const btn = writer.locator('button').first(); - const input = writer.locator('input').first(); - if (await btn.count() > 0) { - await btn.click(); - await page.waitForTimeout(500); - } else if (await input.count() > 0) { - const type = await input.getAttribute('type'); - if (type === 'checkbox') { - await input.check(); - } else { - await input.fill('test'); - } - await page.waitForTimeout(500); - } - // Islands should still be visible and functional - await expect(writer).toBeVisible(); - await expect(reader).toBeVisible(); }); test('refs: focus button focuses input', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.refs)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.refs)))'); const el = island(page, 'refs'); await expect(el).toBeVisible({ timeout: 10000 }); - - // Click a button — should interact with the input const textBefore = await el.textContent(); await el.locator('button').first().click(); - await page.waitForTimeout(500); - // Verify something changed or input is focused + await page.waitForTimeout(300); const focused = await page.evaluate(() => document.activeElement?.tagName); - // Accept either text change or input focus const textAfter = await el.textContent(); expect(textAfter !== textBefore || focused === 'INPUT').toBeTruthy(); }); test('portal: button toggles portal content', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.portal)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.portal)))'); const el = island(page, 'portal'); await expect(el).toBeVisible({ timeout: 10000 }); - - const portalBefore = await page.locator('#portal-root').innerHTML(); + const before = await page.locator('#portal-root').innerHTML(); await el.locator('button').first().click(); - await page.waitForTimeout(500); - const portalAfter = await page.locator('#portal-root').innerHTML(); - expect(portalAfter).not.toBe(portalBefore); + await page.waitForTimeout(300); + expect(await page.locator('#portal-root').innerHTML()).not.toBe(before); }); test('imperative: button triggers DOM manipulation', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.imperative)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(reactive.(examples.imperative)))'); const el = island(page, 'imperative'); await expect(el).toBeVisible({ timeout: 10000 }); - const textBefore = await el.textContent(); await el.locator('button').first().click(); - await page.waitForTimeout(500); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(300); + expect(await el.textContent()).not.toBe(textBefore); }); - test('error-boundary: trigger error shows boundary message', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.error-boundary)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('error-boundary: trigger shows boundary message', async ({ page }) => { + await loadDemo(page, '(geography.(reactive.(examples.error-boundary)))'); const el = island(page, 'error-boundary'); await expect(el).toBeVisible({ timeout: 10000 }); - - // Click trigger error button - const triggerBtn = el.locator('button').filter({ hasText: /error|trigger|throw/i }).first(); - if (await triggerBtn.count() > 0) { - await triggerBtn.click(); - await page.waitForTimeout(500); - const text = await el.textContent(); - // Should show error message, not crash - expect(text.toLowerCase()).toMatch(/error|caught|boundary/); + const btn = el.locator('button').filter({ hasText: /error|trigger|throw/i }).first(); + if (await btn.count() > 0) { + await btn.click(); + await page.waitForTimeout(300); + expect((await el.textContent()).toLowerCase()).toMatch(/error|caught|boundary/); } }); - test('event-bridge: sender triggers receiver update', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.event-bridge-demo)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('event-bridge: sender triggers receiver', async ({ page }) => { + await loadDemo(page, '(geography.(reactive.(examples.event-bridge-demo)))'); const el = island(page, 'event-bridge'); await expect(el).toBeVisible({ timeout: 10000 }); - const textBefore = await el.textContent(); await el.locator('button').first().click(); - await page.waitForTimeout(500); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(300); + expect(await el.textContent()).not.toBe(textBefore); }); - test('transition: island renders and toggle works', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.transition)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - const el = island(page, 'transition'); - await expect(el).toBeVisible({ timeout: 10000 }); - - // Island should have content - const text = await el.textContent(); - expect(text.length).toBeGreaterThan(0); - - // Find any button/input in the island - const btn = el.locator('button, input[type="checkbox"]').first(); - if (await btn.count() > 0) { - const htmlBefore = await el.innerHTML(); - await btn.click(); - await page.waitForTimeout(1500); - // After animation, island should still be functional - await expect(el).toBeVisible(); - } - }); - - test('resource: shows loading then data', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.resource)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('resource: shows loading then resolved data', async ({ page }) => { + await loadDemo(page, '(geography.(reactive.(examples.resource)))'); const el = island(page, 'resource'); await expect(el).toBeVisible({ timeout: 10000 }); - - // Should show loading or resolved data - const text = await el.textContent(); - expect(text.length).toBeGreaterThan(0); - // Wait for resource to resolve - await page.waitForTimeout(3000); - const text2 = await el.textContent(); - // Should show the resolved name - expect(text2).toContain('Ada'); + await page.waitForTimeout(2000); + expect(await el.textContent()).toContain('Ada'); }); }); // =========================================================================== -// Marshes demos — where reactivity meets hypermedia +// Marshes demos // =========================================================================== test.describe('Marshes interactions', () => { - test('hypermedia-feeds: reactive +/− and server fetch both work', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(marshes.hypermedia-feeds))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('hypermedia-feeds: reactive +/− works', async ({ page }) => { + await loadDemo(page, '(geography.(marshes.hypermedia-feeds))'); const el = island(page, 'marsh-product'); await expect(el).toBeVisible({ timeout: 10000 }); - - // Click + button (reactive signal) — should change quantity/price const plusBtn = el.locator('button:has-text("+")').first(); if (await plusBtn.count() > 0) { const textBefore = await el.textContent(); await plusBtn.click(); await page.waitForTimeout(300); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + expect(await el.textContent()).not.toBe(textBefore); } - - // Click server fetch button — should update server message - const fetchBtn = page.locator('button:has-text("Fetch")').first(); - if (await fetchBtn.count() > 0) { - await fetchBtn.click(); - await page.waitForTimeout(3000); - } - await assertNoClassLeak(page, '[data-sx-island*="marsh-product"]'); }); - test('on-settle: sx-on-settle evaluates after swap', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(marshes.on-settle))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('on-settle: settle evaluates after swap', async ({ page }) => { + await loadDemo(page, '(geography.(marshes.on-settle))'); const el = island(page, 'marsh-settle'); await expect(el).toBeVisible({ timeout: 10000 }); - - const textBefore = await el.textContent(); const btn = el.locator('button').first(); if (await btn.count() > 0) { + const textBefore = await el.textContent(); await btn.click(); - await page.waitForTimeout(3000); - const textAfter = await el.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(2000); + expect(await el.textContent()).not.toBe(textBefore); } await assertNoClassLeak(page, '[data-sx-island*="marsh-settle"]'); }); - test('server-signals: server fetch writes to client signal', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(marshes.server-signals))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + test('server-signals: server writes to client signal', async ({ page }) => { + await loadDemo(page, '(geography.(marshes.server-signals))'); const writer = island(page, 'marsh-store-writer'); const reader = island(page, 'marsh-store-reader'); await expect(writer).toBeVisible({ timeout: 10000 }); await expect(reader).toBeVisible({ timeout: 10000 }); - - // Click the server fetch button (sx-get/sx-post, not reactive +/−) - const fetchBtn = writer.locator('button[sx-get], button[sx-post], button:has-text("Fetch"), button:has-text("Load")').first(); - if (await fetchBtn.count() > 0) { - const readerBefore = await reader.textContent(); - await fetchBtn.click(); - await page.waitForTimeout(3000); - const readerAfter = await reader.textContent(); - expect(readerAfter).not.toBe(readerBefore); - } else { - // Fall back: click + button in writer - const plus = writer.locator('button:has-text("+")').first(); - if (await plus.count() > 0) { - const before = await writer.textContent(); - await plus.click(); - await page.waitForTimeout(300); - expect(await writer.textContent()).not.toBe(before); - } - } - await assertNoClassLeak(page, '[data-sx-island*="marsh-store"]'); - }); - - test('signal-triggers: button changes signal, triggers fetch', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(marshes.signal-triggers))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - const el = island(page, 'marsh-signal-url'); - await expect(el).toBeVisible({ timeout: 10000 }); - - // Click buttons to change the signal — may trigger a server fetch - const buttons = el.locator('button'); - if (await buttons.count() >= 2) { - const textBefore = await el.textContent(); - await buttons.nth(1).click(); // try second button (different from current state) - await page.waitForTimeout(3000); - const textAfter = await el.textContent(); - // Accept either text change or same (if button is current state) - } - // Island should render properly - await expect(el).toBeVisible(); }); test('view-transform: view toggle changes rendering', async ({ page }) => { - await page.goto(BASE_URL + '/sx/(geography.(marshes.view-transform))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - + await loadDemo(page, '(geography.(marshes.view-transform))'); const el = island(page, 'marsh-view-transform'); await expect(el).toBeVisible({ timeout: 10000 }); - - // Click "Fetch Catalog" to load data, then toggle view - const fetchBtn = page.locator('button:has-text("Fetch"), button[sx-get]').first(); - if (await fetchBtn.count() > 0) { - await fetchBtn.click(); - await page.waitForTimeout(3000); - } - - // Toggle view buttons const viewBtns = el.locator('button'); if (await viewBtns.count() >= 2) { const htmlBefore = await el.innerHTML(); await viewBtns.nth(1).click(); - await page.waitForTimeout(500); - const htmlAfter = await el.innerHTML(); - expect(htmlAfter).not.toBe(htmlBefore); + await page.waitForTimeout(300); + expect(await el.innerHTML()).not.toBe(htmlBefore); } }); }); // =========================================================================== -// JIT health check — no DISABLED or SSR failures after warmup +// Server health — no JS errors across demo pages // =========================================================================== test.describe('Server health', () => { @@ -424,15 +243,12 @@ test.describe('Server health', () => { test('no JS errors on reactive demo pages', async ({ page }) => { const errors = []; page.on('pageerror', err => errors.push(err.message)); - const demos = ['counter', 'temperature', 'stopwatch', 'input-binding', 'dynamic-class', 'reactive-list', 'stores', 'resource']; - for (const demo of demos) { - await page.goto(BASE_URL + `/sx/(geography.(reactive.(examples.${demo})))`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(1000); + await page.goto(BASE_URL + `/sx/(geography.(reactive.(examples.${demo})))`, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(500); } - const real = errors.filter(e => !e.includes('net::ERR') && !e.includes('fetch')); expect(real).toEqual([]); }); @@ -440,15 +256,12 @@ test.describe('Server health', () => { test('no JS errors on marshes pages', async ({ page }) => { const errors = []; page.on('pageerror', err => errors.push(err.message)); - const pages = ['hypermedia-feeds', 'on-settle', 'server-signals', 'signal-triggers', 'view-transform']; - for (const p of pages) { - await page.goto(BASE_URL + `/sx/(geography.(marshes.${p}))`, { waitUntil: 'networkidle' }); - await page.waitForTimeout(1000); + await page.goto(BASE_URL + `/sx/(geography.(marshes.${p}))`, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(500); } - const real = errors.filter(e => !e.includes('net::ERR') && !e.includes('fetch')); expect(real).toEqual([]); }); diff --git a/tests/playwright/geography-demos.spec.js b/tests/playwright/geography-demos.spec.js index ab7cda4f..c9796829 100644 --- a/tests/playwright/geography-demos.spec.js +++ b/tests/playwright/geography-demos.spec.js @@ -1,83 +1,35 @@ // @ts-check /** - * Geography demos — comprehensive navigation + interaction tests. - * - * Tests every page under /sx/(geography...) loads correctly: - * - Full page load (SSR) - * - Client-side navigation from a sibling page - * - Interactive elements (buttons, inputs) work after navigation - * - Islands hydrate and signals fire + * Geography demos — comprehensive page load + interaction tests. + * Each test does a fresh page.goto() for isolation, but cached server + * responses (pre-warmed) keep these fast (~0.5s each vs ~2s uncached). */ const { test, expect } = require('playwright/test'); const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; // --------------------------------------------------------------------------- -// Helper: load a page, verify it has content +// Helpers // --------------------------------------------------------------------------- -async function verifyPageLoads(page, path, { expectIsland, expectText } = {}) { - await page.goto(BASE_URL + '/sx/' + path, { waitUntil: 'networkidle' }); - await page.waitForTimeout(1500); - - // Page should have a root with content +async function loadPage(page, path) { + await page.goto(BASE_URL + '/sx/' + path, { waitUntil: 'networkidle', timeout: 15000 }); + await page.waitForTimeout(500); const root = page.locator('#sx-root'); await expect(root).toBeVisible({ timeout: 10000 }); - const text = await root.textContent(); - expect(text.length).toBeGreaterThan(50); - - if (expectText) { - expect(text).toContain(expectText); - } - if (expectIsland) { - const island = page.locator(`[data-sx-island*="${expectIsland}"]`); - await expect(island).toBeVisible({ timeout: 5000 }); - } + return root; } -// --------------------------------------------------------------------------- -// Helper: navigate via link click, verify target page -// --------------------------------------------------------------------------- -async function navigateAndVerify(page, startPath, linkPattern, { expectIsland, expectText } = {}) { - await page.goto(BASE_URL + '/sx/' + startPath, { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - - const link = page.locator(`a[href*="${linkPattern}"]`).first(); - if (await link.count() === 0) return false; - - await link.click(); - await page.waitForTimeout(3000); - - const text = await page.locator('#sx-root').textContent(); - if (expectText) expect(text).toContain(expectText); - if (expectIsland) { - const island = page.locator(`[data-sx-island*="${expectIsland}"]`); - await expect(island).toBeVisible({ timeout: 5000 }); - } - return true; -} - -// --------------------------------------------------------------------------- -// Helper: verify island is reactive (click button, expect text change) -// --------------------------------------------------------------------------- -async function verifyIslandReactive(page, islandSelector) { - const island = page.locator(islandSelector); - if (await island.count() === 0) return false; - - const buttons = island.locator('button'); - if (await buttons.count() === 0) return false; - - const textBefore = await island.textContent(); - await buttons.first().click(); - await page.waitForTimeout(500); - const textAfter = await island.textContent(); - return textAfter !== textBefore; +async function expectIsland(page, pattern) { + const island = page.locator(`[data-sx-island*="${pattern}"]`); + await expect(island).toBeVisible({ timeout: 8000 }); + return island; } // =========================================================================== -// Geography index pages — load and have content +// Geography section index pages // =========================================================================== -test.describe('Geography section pages load', () => { +test.describe('Geography sections', () => { const sections = [ ['(geography)', 'Geography'], ['(geography.(reactive))', 'Reactive'], @@ -90,311 +42,167 @@ test.describe('Geography section pages load', () => { ['(geography.(provide))', 'Provide'], ]; - for (const [path, expectedText] of sections) { - test(`${path} loads with content`, async ({ page }) => { - await verifyPageLoads(page, path, { expectText: expectedText }); + for (const [path, text] of sections) { + test(`${path} loads`, async ({ page }) => { + const root = await loadPage(page, path); + expect(await root.textContent()).toContain(text); }); } }); // =========================================================================== -// Reactive island demos — direct load + navigation + interaction +// Reactive demos — each test is isolated but fast (cached) // =========================================================================== test.describe('Reactive demos', () => { + const demos = [ + ['counter', 'counter'], + ['temperature', 'temperature'], + ['stopwatch', 'stopwatch'], + ['input-binding', 'input-binding'], + ['dynamic-class', 'dynamic-class'], + ['reactive-list', 'reactive-list'], + ['stores', 'store-writer'], + ['refs', 'refs'], + ['portal', 'portal'], + ['resource', null], + ['imperative', null], + ['transition', null], + ['error-boundary', null], + ['event-bridge-demo', null], + ['defisland', null], + ['coverage', null], + ]; + + for (const [slug, islandPattern] of demos) { + test(`${slug} loads`, async ({ page }) => { + await loadPage(page, `(geography.(reactive.(examples.${slug})))`); + if (islandPattern) await expectIsland(page, islandPattern); + }); + } test('counter: buttons change count', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.counter)))', { - expectIsland: 'counter', - }); - const island = page.locator('[data-sx-island*="counter"]'); - const buttons = island.locator('button'); + await loadPage(page, '(geography.(reactive.(examples.counter)))'); + const el = await expectIsland(page, 'counter'); + const buttons = el.locator('button'); await expect(buttons).toHaveCount(2); - - const textBefore = await island.textContent(); + const textBefore = await el.textContent(); await buttons.last().click(); await page.waitForTimeout(300); - expect(await island.textContent()).not.toBe(textBefore); + expect(await el.textContent()).not.toBe(textBefore); }); - test('temperature: input changes conversion', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.temperature)))', { - expectIsland: 'temperature', - }); - const island = page.locator('[data-sx-island*="temperature"]'); - const buttons = island.locator('button'); + test('temperature: buttons change conversion', async ({ page }) => { + await loadPage(page, '(geography.(reactive.(examples.temperature)))'); + const el = await expectIsland(page, 'temperature'); + const buttons = el.locator('button'); if (await buttons.count() >= 2) { - const textBefore = await island.textContent(); + const textBefore = await el.textContent(); await buttons.last().click(); await page.waitForTimeout(300); - expect(await island.textContent()).not.toBe(textBefore); - } - }); - - test('stopwatch: start/stop buttons work', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.stopwatch)))', { - expectIsland: 'stopwatch', - }); - const island = page.locator('[data-sx-island*="stopwatch"]'); - const buttons = island.locator('button'); - if (await buttons.count() > 0) { - await buttons.first().click(); // start - await page.waitForTimeout(1500); - const text = await island.textContent(); - // Should show some elapsed time - expect(text.length).toBeGreaterThan(0); - } - }); - - test('input-binding: typing updates display', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.input-binding)))', { - expectIsland: 'input-binding', - }); - const island = page.locator('[data-sx-island*="input-binding"]'); - const input = island.locator('input').first(); - if (await input.count() > 0) { - await input.fill('hello test'); - await page.waitForTimeout(300); - const text = await island.textContent(); - expect(text).toContain('hello test'); - } - }); - - test('dynamic-class: toggle changes classes', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.dynamic-class)))', { - expectIsland: 'dynamic-class', - }); - const island = page.locator('[data-sx-island*="dynamic-class"]'); - const buttons = island.locator('button'); - if (await buttons.count() > 0) { - const changed = await verifyIslandReactive(page, '[data-sx-island*="dynamic-class"]'); - expect(changed).toBe(true); - } - }); - - test('reactive-list: add/remove items', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.reactive-list)))', { - expectIsland: 'reactive-list', - }); - const island = page.locator('[data-sx-island*="reactive-list"]'); - const buttons = island.locator('button'); - if (await buttons.count() > 0) { - const changed = await verifyIslandReactive(page, '[data-sx-island*="reactive-list"]'); - expect(changed).toBe(true); + expect(await el.textContent()).not.toBe(textBefore); } }); test('stores: shared state across islands', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.stores)))', { - expectIsland: 'store-writer', - }); - // Both reader and writer islands should be present - await expect(page.locator('[data-sx-island*="store-reader"]')).toBeVisible(); - await expect(page.locator('[data-sx-island*="store-writer"]')).toBeVisible(); - }); - - test('refs: DOM references work', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.refs)))', { - expectIsland: 'refs', - }); - }); - - test('portal: renders outside island', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.portal)))', { - expectIsland: 'portal', - }); - }); - - test('resource: async data loading', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.resource)))'); - }); - - test('imperative: DOM manipulation', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.imperative)))'); - }); - - test('transition: animation', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.transition)))'); - }); - - test('error-boundary: catches errors', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.error-boundary)))'); - }); - - test('event-bridge: cross-island events', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.event-bridge-demo)))'); - }); - - test('defisland: island definition', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.defisland)))'); - }); - - test('coverage: feature coverage', async ({ page }) => { - await verifyPageLoads(page, '(geography.(reactive.(examples.coverage)))'); + await loadPage(page, '(geography.(reactive.(examples.stores)))'); + await expect(page.locator('[data-sx-island*="store-reader"]')).toBeVisible({ timeout: 8000 }); + await expect(page.locator('[data-sx-island*="store-writer"]')).toBeVisible({ timeout: 8000 }); }); }); // =========================================================================== -// Hypermedia demos — load and have interactive elements +// Hypermedia demos // =========================================================================== test.describe('Hypermedia demos', () => { const demos = [ - ['click-to-load', 'Click to Load'], - ['form-submission', 'Form'], - ['polling', 'Polling'], - ['delete-row', 'Delete'], - ['edit-row', 'Edit'], - ['tabs', 'Tabs'], - ['active-search', 'Search'], - ['inline-validation', 'Validation'], - ['lazy-loading', 'Lazy'], - ['infinite-scroll', 'Scroll'], - ['select-filter', 'Filter'], - ['loading-states', 'Loading'], - ['dialogs', 'Dialog'], - ['oob-swaps', 'Out of Band'], - ['bulk-update', 'Bulk'], - ['animations', 'Animat'], - ['inline-edit', 'Inline'], - ['progress-bar', 'Progress'], - ['swap-positions', 'Swap'], - ['sync-replace', 'Sync'], - ['keyboard-shortcuts', 'Keyboard'], - ['json-encoding', 'JSON'], - ['put-patch', 'PUT'], - ['retry', 'Retry'], - ['reset-on-submit', 'Reset'], - ['value-select', 'Value'], - ['vals-and-headers', 'Header'], + 'click-to-load', 'form-submission', 'polling', 'delete-row', 'edit-row', + 'tabs', 'active-search', 'inline-validation', 'lazy-loading', + 'infinite-scroll', 'select-filter', 'loading-states', 'dialogs', + 'oob-swaps', 'bulk-update', 'animations', 'inline-edit', 'progress-bar', + 'swap-positions', 'sync-replace', 'keyboard-shortcuts', 'json-encoding', + 'put-patch', 'retry', 'reset-on-submit', 'value-select', 'vals-and-headers', ]; - for (const [slug, text] of demos) { + for (const slug of demos) { test(`${slug} loads`, async ({ page }) => { - await verifyPageLoads(page, `(geography.(hypermedia.(example.${slug})))`); + await loadPage(page, `(geography.(hypermedia.(example.${slug})))`); }); } }); // =========================================================================== -// Cross-navigation: reactive demos preserve reactivity +// Cross-navigation reactivity // =========================================================================== -test.describe('Cross-navigation reactivity', () => { +test('counter → temperature → counter: all stay reactive', async ({ page }) => { + await loadPage(page, '(geography.(reactive.(examples.counter)))'); - test('counter → temperature → counter: all stay reactive', async ({ page }) => { - // Load counter - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); + let el = await expectIsland(page, 'counter'); + let buttons = el.locator('button'); + let before = await el.textContent(); + await buttons.last().click(); + await page.waitForTimeout(300); + expect(await el.textContent()).not.toBe(before); + + // SPA navigate to temperature + const tempLink = page.locator('a[href*="temperature"]').first(); + if (await tempLink.count() > 0) { + await tempLink.click(); await page.waitForTimeout(2000); - - let island = page.locator('[data-sx-island*="counter"]'); - await expect(island).toBeVisible({ timeout: 10000 }); - let buttons = island.locator('button'); - let before = await island.textContent(); - await buttons.last().click(); - await page.waitForTimeout(300); - expect(await island.textContent()).not.toBe(before); - - // Navigate to temperature - const tempLink = page.locator('a[href*="temperature"]').first(); - if (await tempLink.count() > 0) { - await tempLink.click(); - await page.waitForTimeout(3000); - - island = page.locator('[data-sx-island*="temperature"]'); - await expect(island).toBeVisible({ timeout: 10000 }); - buttons = island.locator('button'); - if (await buttons.count() >= 2) { - before = await island.textContent(); - await buttons.last().click(); - await page.waitForTimeout(300); - expect(await island.textContent()).not.toBe(before); - } - } - - // Navigate back to counter - const counterLink = page.locator('a[href*="counter"]').first(); - if (await counterLink.count() > 0) { - await counterLink.click(); - await page.waitForTimeout(3000); - - island = page.locator('[data-sx-island*="counter"]'); - await expect(island).toBeVisible({ timeout: 10000 }); - buttons = island.locator('button'); - before = await island.textContent(); + el = await expectIsland(page, 'temperature'); + buttons = el.locator('button'); + if (await buttons.count() >= 2) { + before = await el.textContent(); await buttons.last().click(); await page.waitForTimeout(300); - expect(await island.textContent()).not.toBe(before); + expect(await el.textContent()).not.toBe(before); } - }); + } - test('navigate through 5 reactive demos sequentially', async ({ page }) => { - const demos = ['counter', 'temperature', 'stopwatch', 'dynamic-class', 'input-binding']; - - await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); + // SPA navigate back to counter + const counterLink = page.locator('a[href*="counter"]').first(); + if (await counterLink.count() > 0) { + await counterLink.click(); await page.waitForTimeout(2000); - - for (let i = 1; i < demos.length; i++) { - const link = page.locator(`a[href*="${demos[i]}"]`).first(); - if (await link.count() > 0) { - await link.click(); - await page.waitForTimeout(3000); - - // Page should have content - const text = await page.locator('#sx-root').textContent(); - expect(text.length).toBeGreaterThan(100); - - // Check for any islands - const islands = await page.locator('[data-sx-island]').count(); - expect(islands).toBeGreaterThan(0); // at least the header island - } - } - }); + el = await expectIsland(page, 'counter'); + buttons = el.locator('button'); + before = await el.textContent(); + await buttons.last().click(); + await page.waitForTimeout(300); + expect(await el.textContent()).not.toBe(before); + } }); // =========================================================================== -// Other geography pages — CEK, marshes, scopes, etc. +// Other geography pages // =========================================================================== test.describe('Other geography pages', () => { + const pages = [ + '(geography.(cek.demo))', '(geography.(cek.content))', '(geography.(cek.freeze))', + '(geography.(marshes.hypermedia-feeds))', '(geography.(marshes.on-settle))', + '(geography.(marshes.server-signals))', '(geography.(marshes.signal-triggers))', + '(geography.(marshes.view-transform))', + '(geography.(isomorphism))', + ]; - test('CEK pages load', async ({ page }) => { - for (const sub of ['', '.content', '.demo', '.freeze']) { - await verifyPageLoads(page, `(geography.(cek${sub}))`); - } - }); - - test('marshes pages load', async ({ page }) => { - for (const sub of ['', '.hypermedia-feeds', '.on-settle', '.server-signals', '.signal-triggers', '.view-transform']) { - await verifyPageLoads(page, `(geography.(marshes${sub}))`); - } - }); - - test('isomorphism pages load', async ({ page }) => { - for (const sub of ['', '.affinity', '.routing-analyzer', '.bundle-analyzer']) { - await verifyPageLoads(page, `(geography.(isomorphism${sub}))`); - } - }); - - test('scopes page loads', async ({ page }) => { - await verifyPageLoads(page, '(geography.(scopes))'); - }); - - test('spreads page loads', async ({ page }) => { - await verifyPageLoads(page, '(geography.(spreads))'); - }); - - test('provide page loads', async ({ page }) => { - await verifyPageLoads(page, '(geography.(provide))'); - }); - - test('reference pages load', async ({ page }) => { - for (const sub of ['attributes', 'events', 'headers', 'js-api']) { - await verifyPageLoads(page, `(geography.(hypermedia.(reference.${sub})))`); - } - }); + for (const path of pages) { + test(`${path} loads`, async ({ page }) => { + await loadPage(page, path); + }); + } +}); + +test.describe('Reference pages', () => { + for (const sub of ['attributes', 'events', 'headers', 'js-api']) { + test(`${sub} loads`, async ({ page }) => { + await loadPage(page, `(geography.(hypermedia.(reference.${sub})))`); + }); + } }); diff --git a/tests/playwright/reactive-nav.spec.js b/tests/playwright/reactive-nav.spec.js index 55971347..75267482 100644 --- a/tests/playwright/reactive-nav.spec.js +++ b/tests/playwright/reactive-nav.spec.js @@ -6,7 +6,6 @@ test.describe('Reactive Island Navigation', () => { test('counter island works on direct load', async ({ page }) => { await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); const island = page.locator('[data-sx-island*="counter"]'); await expect(island).toBeVisible({ timeout: 10000 }); @@ -16,99 +15,61 @@ test.describe('Reactive Island Navigation', () => { const textBefore = await island.textContent(); await buttons.last().click(); - await page.waitForTimeout(500); - const textAfter = await island.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(300); + expect(await island.textContent()).not.toBe(textBefore); }); test('temperature island works on direct load', async ({ page }) => { await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); const island = page.locator('[data-sx-island*="temperature"]'); await expect(island).toBeVisible({ timeout: 10000 }); - // Temperature demo should have an input or interactive element const inputs = island.locator('input'); const buttons = island.locator('button'); const interactive = (await inputs.count()) + (await buttons.count()); expect(interactive).toBeGreaterThan(0); }); - test('counter → temperature navigation: temperature island is reactive', async ({ page }) => { - // Step 1: Load counter page directly (full page load) + test('counter → temperature: temperature island is reactive after SPA nav', async ({ page }) => { await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - // Verify counter works const counter = page.locator('[data-sx-island*="counter"]'); await expect(counter).toBeVisible({ timeout: 10000 }); - // Step 2: Navigate to temperature via client-side link + // Navigate to temperature via link const tempLink = page.locator('a[href*="temperature"]').first(); - if (await tempLink.count() === 0) { - // No link found — try sidebar or nav - const anyTempLink = page.locator('a').filter({ hasText: /temperature/i }).first(); - if (await anyTempLink.count() > 0) { - await anyTempLink.click(); - } else { - // Fall back to evaluating navigation - await page.evaluate((url) => { - const a = document.querySelector('a[href*="temperature"]'); - if (a) a.click(); - else window.location.href = url; - }, '/sx/(geography.(reactive.(examples.temperature)))'); - } - } else { + if (await tempLink.count() > 0) { await tempLink.click(); + } else { + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); } - await page.waitForTimeout(3000); - - // Step 3: Temperature island should exist and be reactive const tempIsland = page.locator('[data-sx-island*="temperature"]'); await expect(tempIsland).toBeVisible({ timeout: 10000 }); - // Step 4: Interact and verify reactivity - const inputs = tempIsland.locator('input'); - if (await inputs.count() > 0) { - const input = inputs.first(); + const buttons = tempIsland.locator('button'); + if (await buttons.count() > 0) { const textBefore = await tempIsland.textContent(); - await input.fill('100'); - await input.press('Enter'); - await page.waitForTimeout(500); - const textAfter = await tempIsland.textContent(); - expect(textAfter).not.toBe(textBefore); - } else { - const buttons = tempIsland.locator('button'); - if (await buttons.count() > 0) { - const textBefore = await tempIsland.textContent(); - await buttons.first().click(); - await page.waitForTimeout(500); - const textAfter = await tempIsland.textContent(); - expect(textAfter).not.toBe(textBefore); - } + await buttons.first().click(); + await page.waitForTimeout(300); + expect(await tempIsland.textContent()).not.toBe(textBefore); } }); - test('temperature → counter navigation: counter island is reactive', async ({ page }) => { - // Start on temperature + test('temperature → counter: counter island is reactive after SPA nav', async ({ page }) => { await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - // Navigate to counter + const temp = page.locator('[data-sx-island*="temperature"]'); + await expect(temp).toBeVisible({ timeout: 10000 }); + const counterLink = page.locator('a[href*="counter"]').first(); if (await counterLink.count() > 0) { await counterLink.click(); } else { - await page.evaluate(() => { - const a = document.querySelector('a[href*="counter"]'); - if (a) a.click(); - }); + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); } - await page.waitForTimeout(3000); - // Counter island should be reactive const counter = page.locator('[data-sx-island*="counter"]'); await expect(counter).toBeVisible({ timeout: 10000 }); @@ -117,9 +78,8 @@ test.describe('Reactive Island Navigation', () => { const textBefore = await counter.textContent(); await buttons.last().click(); - await page.waitForTimeout(500); - const textAfter = await counter.textContent(); - expect(textAfter).not.toBe(textBefore); + await page.waitForTimeout(300); + expect(await counter.textContent()).not.toBe(textBefore); }); test('no JS errors during reactive navigation', async ({ page }) => { @@ -127,16 +87,14 @@ test.describe('Reactive Island Navigation', () => { page.on('pageerror', err => errors.push(err.message)); await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'networkidle' }); - await page.waitForTimeout(2000); - // Navigate to temperature const link = page.locator('a[href*="temperature"]').first(); if (await link.count() > 0) await link.click(); - await page.waitForTimeout(3000); + await page.waitForTimeout(2000); - const real_errors = errors.filter(e => + const real = errors.filter(e => !e.includes('Failed to fetch') && !e.includes('net::ERR') ); - expect(real_errors).toEqual([]); + expect(real).toEqual([]); }); });