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>
This commit is contained in:
455
tests/playwright/demo-interactions.spec.js
Normal file
455
tests/playwright/demo-interactions.spec.js
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user