Files
rose-ash/tests/playwright/demo-interactions.spec.js
giles e14947cedc Add sx:ready hydration signal, eliminate test sleeps
boot-init now sets data-sx-ready on <html> and dispatches an sx:ready
CustomEvent after all islands are hydrated. Playwright tests use this
instead of networkidle + hard-coded sleeps (50+ seconds eliminated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:47:52 +00:00

259 lines
10 KiB
JavaScript
Raw 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.
*/
const { test, expect } = require('playwright/test');
const { loadPage } = 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', () => {
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', () => {
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([]);
});
});