diff --git a/tests/playwright/demo-interactions.spec.js b/tests/playwright/demo-interactions.spec.js new file mode 100644 index 0000000..2cecf24 --- /dev/null +++ b/tests/playwright/demo-interactions.spec.js @@ -0,0 +1,455 @@ +// @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. + */ +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 assertNoClassLeak(page, scope) { + const loc = scope ? page.locator(scope).first() : page.locator('#sx-root'); + const text = await loc.textContent(); + expect(text).not.toContain('classpx'); + expect(text).not.toContain('classborder'); + expect(text).not.toContain('classbg-'); +} + +// Helper: get island locator +function island(page, pattern) { + return page.locator(`[data-sx-island*="${pattern}"]`); +} + + +// =========================================================================== +// Reactive island demos +// =========================================================================== + +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); + + 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'); + }); + + test('temperature: +/− change celsius and fahrenheit', async ({ page }) => { + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.temperature)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + 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); + + 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); + }); + + 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); + + const el = island(page, 'input-binding'); + await expect(el).toBeVisible({ timeout: 10000 }); + + const input = el.locator('input').first(); + await input.fill('playwright test'); + await page.waitForTimeout(300); + + const text = await el.textContent(); + expect(text).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); + + 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); + }); + + 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); + + 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); + }); + + 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); + + 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); + + 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 + 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); + + const el = island(page, 'portal'); + await expect(el).toBeVisible({ timeout: 10000 }); + + const htmlBefore = await page.locator('#sx-root').innerHTML(); + await el.locator('button').first().click(); + await page.waitForTimeout(500); + const htmlAfter = await page.locator('#sx-root').innerHTML(); + expect(htmlAfter).not.toBe(htmlBefore); + }); + + test('imperative: button triggers DOM manipulation', async ({ page }) => { + await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.imperative)))', { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + + 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); + }); + + 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); + + 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/); + } + }); + + 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); + + 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); + }); + + 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); + + 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'); + }); +}); + + +// =========================================================================== +// Marshes demos — where reactivity meets hypermedia +// =========================================================================== + +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); + + 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); + } + + // 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); + + 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) { + await btn.click(); + await page.waitForTimeout(3000); + const textAfter = await el.textContent(); + expect(textAfter).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); + + 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); + + 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); + } + }); +}); + + +// =========================================================================== +// JIT health check — no DISABLED or SSR failures after warmup +// =========================================================================== + +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); + } + + const real = errors.filter(e => !e.includes('net::ERR') && !e.includes('fetch')); + expect(real).toEqual([]); + }); + + 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); + } + + const real = errors.filter(e => !e.includes('net::ERR') && !e.includes('fetch')); + expect(real).toEqual([]); + }); +});