Files
rose-ash/tests/playwright/demo-interactions.spec.js
giles d81a518732 Fix JIT compiler, CSSX browser support, double-fetch, SPA layout
JIT compiler:
- Fix jit_compile_lambda: resolve `compile` via symbol lookup in env
  instead of embedding VmClosure in AST (CEK dispatches differently)
- Register eval-defcomp/eval-defisland/eval-defmacro runtime helpers
  in browser kernel for bytecoded defcomp forms
- Disable broken .sxbc.json path (missing arity in nested code blocks),
  use .sxbc text format only
- Mark JIT-failed closures as sentinel to stop retrying

CSSX in browser:
- Add cssx.sx symlink + cssx.sxbc to browser web stack
- Add flush-cssx! to orchestration.sx post-swap for SPA nav
- Add cssx.sx to compile-modules.js and mcp_tree.ml bytecode lists

SPA navigation:
- Fix double-fetch: check e.defaultPrevented in click delegation
  (bind-event already handled the click)
- Fix layout destruction: change nav links from outerHTML to innerHTML
  swap (outerHTML destroyed #main-panel when response lacked it)
- Guard JS popstate handler when SX engine is booted
- Rename sx-platform.js → sx-platform-2.js to bust immutable cache

Playwright tests:
- Add trackErrors() helper to all test specs
- Add SPA DOM comparison test (SPA nav vs fresh load)
- Add single-fetch + no-duplicate-elements test
- Improve MCP tool output: show failure details and error messages

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

265 lines
11 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, 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([]);
});
});