Files
rose-ash/tests/playwright/demo-interactions.spec.js
giles 32df71abd4 Add 21 demo interaction + health check Playwright tests
Reactive island tests (14): counter, temperature, stopwatch, input-binding,
dynamic-class, reactive-list, stores, refs, portal, imperative,
error-boundary, event-bridge, transition, resource

Marshes tests (5): hypermedia-feeds, on-settle, server-signals,
signal-triggers, view-transform

Health checks (2): no JS errors on reactive or marshes pages

Known failures: island signal reactivity broken on first page load
(buttons render but on-click handlers don't attach). Regression from
commits 2d87417/3ae49b6/13ba5ee — needs investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:08:55 +00:00

456 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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([]);
});
});