on-settle: increase wait from 2s to 4s — server fetch + settle hook needs more time than the original timeout allowed. server-signals: add actual cross-island signal test — click a price button in writer island and verify reader island updates. view-transform: fetch catalog before toggling view — the view toggle only changes rendering of loaded items, not the empty state. All 19 demo-interaction tests pass (was 14/19). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
// @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(4000);
|
||
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 });
|
||
// Click a price button and verify cross-island signal propagation
|
||
const priceBtn = writer.locator('button').first();
|
||
if (await priceBtn.count() > 0) {
|
||
const readerBefore = await reader.textContent();
|
||
await priceBtn.click();
|
||
await page.waitForTimeout(500);
|
||
const readerAfter = await reader.textContent();
|
||
expect(readerAfter).not.toBe(readerBefore);
|
||
}
|
||
});
|
||
|
||
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 });
|
||
// Fetch catalog first — view toggle only changes rendering of loaded items
|
||
const fetchBtn = el.locator('button:has-text("Fetch Catalog")');
|
||
if (await fetchBtn.count() > 0) {
|
||
await fetchBtn.click();
|
||
await page.waitForTimeout(4000);
|
||
}
|
||
const viewBtns = el.locator('button');
|
||
if (await viewBtns.count() >= 2) {
|
||
const textBefore = await el.textContent();
|
||
await viewBtns.nth(1).click();
|
||
await page.waitForTimeout(500);
|
||
expect(await el.textContent()).not.toBe(textBefore);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
// ===========================================================================
|
||
// 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([]);
|
||
});
|
||
});
|