// @ts-check /** * Demo interaction tests — verify every demo actually functions. */ const { test, expect } = require('playwright/test'); const { loadPage, trackErrors } = require('./helpers'); 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(); expect(text).not.toContain('classpx'); expect(text).not.toContain('classborder'); expect(text).not.toContain('classbg-'); } // =========================================================================== // Reactive island demos // =========================================================================== test.describe('Reactive island interactions', () => { let t; test.beforeEach(({ page }) => { t = trackErrors(page); }); test.afterEach(() => { expect(t.errors()).toEqual([]); }); test('counter: + and − change count and doubled', async ({ page }) => { await loadPage(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); 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'); await buttons.first().click(); await page.waitForTimeout(300); expect(await el.textContent()).toContain('2'); }); test('temperature: +/− change celsius and fahrenheit', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.temperature)))'); const el = island(page, 'temperature'); await expect(el).toBeVisible({ timeout: 10000 }); const buttons = el.locator('button'); await buttons.last().click(); await buttons.last().click(); await page.waitForTimeout(300); const text = await el.textContent(); expect(text).toContain('°C'); expect(text).toContain('°F'); }); test('stopwatch: start shows elapsed time', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.stopwatch)))'); const el = island(page, 'stopwatch'); await expect(el).toBeVisible({ timeout: 10000 }); const textBefore = await el.textContent(); await el.locator('button').first().click(); await page.waitForTimeout(1200); expect(await el.textContent()).not.toBe(textBefore); }); test('input-binding: typing updates live preview', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.input-binding)))'); const el = island(page, 'input-binding'); await expect(el).toBeVisible({ timeout: 10000 }); await el.locator('input').first().fill('playwright test'); await page.waitForTimeout(300); expect(await el.textContent()).toContain('playwright test'); }); test('dynamic-class: toggle changes element styling', async ({ page }) => { await loadPage(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); expect(await el.innerHTML()).not.toBe(htmlBefore); }); test('reactive-list: add button increases items', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.reactive-list)))'); const el = island(page, 'reactive-list'); await expect(el).toBeVisible({ timeout: 10000 }); const textBefore = await el.textContent(); await el.locator('button').first().click(); await page.waitForTimeout(300); expect(await el.textContent()).not.toBe(textBefore); }); test('stores: writer and reader share state', async ({ page }) => { await loadPage(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 }); expect((await writer.textContent()).length).toBeGreaterThan(0); expect((await reader.textContent()).length).toBeGreaterThan(0); }); test('refs: focus button focuses input', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.refs)))'); const el = island(page, 'refs'); await expect(el).toBeVisible({ timeout: 10000 }); const textBefore = await el.textContent(); await el.locator('button').first().click(); await page.waitForTimeout(300); const focused = await page.evaluate(() => document.activeElement?.tagName); const textAfter = await el.textContent(); expect(textAfter !== textBefore || focused === 'INPUT').toBeTruthy(); }); test('portal: button toggles portal content', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.portal)))'); const el = island(page, 'portal'); await expect(el).toBeVisible({ timeout: 10000 }); const before = await page.locator('#portal-root').innerHTML(); await el.locator('button').first().click(); await page.waitForTimeout(300); expect(await page.locator('#portal-root').innerHTML()).not.toBe(before); }); test('imperative: button triggers DOM manipulation', async ({ page }) => { await loadPage(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(300); expect(await el.textContent()).not.toBe(textBefore); }); test('error-boundary: trigger shows boundary message', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.error-boundary)))'); const el = island(page, 'error-boundary'); await expect(el).toBeVisible({ timeout: 10000 }); 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', async ({ page }) => { await loadPage(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(300); expect(await el.textContent()).not.toBe(textBefore); }); test('resource: shows loading then resolved data', async ({ page }) => { await loadPage(page, '(geography.(reactive.(examples.resource)))'); const el = island(page, 'resource'); await expect(el).toBeVisible({ timeout: 10000 }); await expect(el).toContainText('Ada', { timeout: 5000 }); }); }); // =========================================================================== // Marshes demos // =========================================================================== test.describe('Marshes interactions', () => { let t; test.beforeEach(({ page }) => { t = trackErrors(page); }); test.afterEach(() => { expect(t.errors()).toEqual([]); }); test('hypermedia-feeds: reactive +/− works', async ({ page }) => { await loadPage(page, '(geography.(marshes.hypermedia-feeds))'); const el = island(page, 'marsh-product'); await expect(el).toBeVisible({ timeout: 10000 }); 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); expect(await el.textContent()).not.toBe(textBefore); } await assertNoClassLeak(page, '[data-sx-island*="marsh-product"]'); }); test('on-settle: settle evaluates after swap', async ({ page }) => { await loadPage(page, '(geography.(marshes.on-settle))'); const el = island(page, 'marsh-settle'); await expect(el).toBeVisible({ timeout: 10000 }); const btn = el.locator('button').first(); if (await btn.count() > 0) { const textBefore = await el.textContent(); await btn.click(); await page.waitForTimeout(2000); expect(await el.textContent()).not.toBe(textBefore); } await assertNoClassLeak(page, '[data-sx-island*="marsh-settle"]'); }); test('server-signals: server writes to client signal', async ({ page }) => { await loadPage(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 }); }); test('view-transform: view toggle changes rendering', async ({ page }) => { await loadPage(page, '(geography.(marshes.view-transform))'); const el = island(page, 'marsh-view-transform'); await expect(el).toBeVisible({ timeout: 10000 }); const viewBtns = el.locator('button'); if (await viewBtns.count() >= 2) { const htmlBefore = await el.innerHTML(); await viewBtns.nth(1).click(); await page.waitForTimeout(300); expect(await el.innerHTML()).not.toBe(htmlBefore); } }); }); // =========================================================================== // Server health — no JS errors across demo pages // =========================================================================== 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 loadPage(page, `(geography.(reactive.(examples.${demo})))`); } 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 loadPage(page, `(geography.(marshes.${p}))`); } const real = errors.filter(e => !e.includes('net::ERR') && !e.includes('fetch')); expect(real).toEqual([]); }); });