/** * Permutation runner — cycles soma components through the state space declared * by their demo pages and validates morfo compliance after every transition. * * Closes the gap left by `morfo:check` (single state) and `smoke` (load-time * errors only): reactivity bugs like toolbar's A35 loop and form's A36 * microtask loop manifest during state transitions, not on first paint. * * Opt-in per demo: tag interactive controls with `data-perm-step="N"`. The * runner discovers them, executes them in order, and re-validates morfo after * each settle. See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full * authoring convention. * * Exit codes: * 0 — every demo with `data-perm-step` passes all permutations. * 1 — at least one permutation failed. * 2 — no dev server / no morfos found. */ import { chromium, type Page } from 'playwright'; import { readdirSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import type { Morfo, MorfoPart, MorfoData } from '../src/uix/morfo/types'; import { validateMorfo } from '../src/uix/morfo/schema'; const __dirname = dirname(fileURLToPath(import.meta.url)); const MORFOS_DIR = join(__dirname, '..', 'src', 'uix', 'morfo', 'components'); // ── Shared with morfo-check.ts ────────────────────────────────────────────── type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string }; function flatParts(parts: readonly MorfoPart[]): MorfoPart[] { const out: MorfoPart[] = []; for (const p of parts) { out.push(p); if (p.parts && p.parts.length > 0) out.push(...flatParts(p.parts)); } return out; } function dataAttrFor(kebab: string, part: MorfoPart): string { return part.kebab === 'provider' ? `data-${kebab}` : `data-${kebab}-${part.kebab}`; } async function validateMorfoAgainstDom(page: Page, morfo: Morfo): Promise { const issues: Issue[] = []; const parts = flatParts(morfo.parts).filter((p) => p.kind === 'public'); const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p))); for (const part of parts) { const partAttr = dataAttrFor(morfo.kebab, part); const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) => nodes.map((el) => Array.from(el.attributes) .filter((a) => a.name.startsWith('data-')) .map((a) => ({ name: a.name, value: a.value })) ) ); if (elementsAttrs.length === 0) continue; const declared = new Set(part.data.map((d) => d.attr)); declared.add(partAttr); for (const siblingAttr of allPartAttrs) declared.add(siblingAttr); const declaredByAttr = new Map(part.data.map((d) => [d.attr, d])); for (const elAttrs of elementsAttrs) { for (const data of part.data) { if ((data.severity ?? 'required') !== 'required') continue; const found = elAttrs.find((a) => a.name === data.attr); if (!found) { issues.push({ kind: 'missing', message: `${morfo.kebab}.${part.kebab}: required attr "${data.attr}" not emitted` }); } } for (const { name, value } of elAttrs) { const decl = declaredByAttr.get(name); if (decl?.values && !decl.values.includes(value)) { issues.push({ kind: 'bad-value', message: `${morfo.kebab}.${part.kebab}: "${name}" has value "${value}", morfo declares [${decl.values.join(', ')}]` }); } } for (const { name } of elAttrs) { if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue; if (name.startsWith(`data-_`)) continue; if (declared.has(name)) continue; if (!name.startsWith(`data-${morfo.kebab}`)) continue; issues.push({ kind: 'undeclared', message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" (not in morfo)` }); } } } return issues; } // ── Probe port + load morfos (copied from morfo-check) ────────────────────── async function probePort(start: number, end: number): Promise { for (let port = start; port <= end; port++) { try { const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); if (res.ok || res.status === 404 || res.status === 500) { return `http://localhost:${port}`; } } catch { // try next } } return null; } async function loadMorfos(): Promise { const files = readdirSync(MORFOS_DIR).filter( (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') ); const out: Morfo[] = []; for (const f of files) { const url = pathToFileURL(join(MORFOS_DIR, f)).href; const mod = (await import(url)) as Record; for (const v of Object.values(mod)) { if (typeof v === 'object' && v !== null && 'kebab' in v && 'parts' in v) { out.push(validateMorfo(v)); } } } return out; } // ── Permutation step schema ───────────────────────────────────────────────── interface PermStep { index: number; label: string; mode: { kind: 'click' } | { kind: 'focus' } | { kind: 'type'; value: string } | { kind: 'key'; key: string }; skipValidate: boolean; settle: number; } /** Parse a `data-perm-mode` attribute value. */ function parseMode(raw: string | null): PermStep['mode'] { if (!raw || raw === 'click') return { kind: 'click' }; if (raw === 'focus') return { kind: 'focus' }; const typeMatch = /^type="([^"]*)"$/.exec(raw); if (typeMatch) return { kind: 'type', value: typeMatch[1] }; const keyMatch = /^key="([^"]+)"$/.exec(raw); if (keyMatch) return { kind: 'key', key: keyMatch[1] }; return { kind: 'click' }; } /** * Collect perm steps CURRENTLY present in the DOM, sorted by index. Called * before every step execution because new `data-perm-step` elements may * mount as earlier steps fire (e.g. a Dialog.Close inside a portal that * only exists after `open = true`). */ async function collectSteps(page: Page): Promise { const raw = await page.$$eval('[data-perm-step]', (els) => els.map((el) => { const step = Number(el.getAttribute('data-perm-step') ?? '0'); return { step, label: el.getAttribute('data-perm-label') ?? (el.textContent ?? '').trim().slice(0, 40) ?? `step ${step}`, mode: el.getAttribute('data-perm-mode'), skipValidate: el.hasAttribute('data-perm-skip-validate'), settle: Number(el.getAttribute('data-perm-settle') ?? '300') }; }) ); // Deduplicate by step index — if multiple elements share an index, keep the // first (demos should not do this, but portals sometimes mirror content). const byIndex = new Map(); for (const r of raw) if (!byIndex.has(r.step)) byIndex.set(r.step, r); return [...byIndex.values()] .sort((a, b) => a.step - b.step) .map((r) => ({ index: r.step, label: r.label || `step ${r.step}`, mode: parseMode(r.mode), skipValidate: r.skipValidate, settle: Number.isFinite(r.settle) && r.settle > 0 ? r.settle : 300 })); } /** Execute one step against the live page. Returns the label for logs. */ async function executeStep(page: Page, step: PermStep): Promise { const selector = `[data-perm-step="${step.index}"]`; const locator = page.locator(selector).first(); switch (step.mode.kind) { case 'click': await locator.click({ timeout: 3000 }); break; case 'focus': await locator.focus({ timeout: 3000 }); break; case 'type': await locator.focus({ timeout: 3000 }); await page.keyboard.type(step.mode.value); break; case 'key': await locator.focus({ timeout: 3000 }); await page.keyboard.press(step.mode.key); break; } await page.waitForTimeout(step.settle); } // ── Main loop ─────────────────────────────────────────────────────────────── type StepResult = | { kind: 'pass'; label: string } | { kind: 'fail'; label: string; reason: string } | { kind: 'error'; label: string; err: string } | { kind: 'skipped'; label: string }; type RouteResult = { route: string; morfo: string; stepsTotal: number; steps: StepResult[]; pageErrors: string[]; }; const BASE = process.argv[2] ?? (await probePort(5173, 5180)); if (!BASE) { console.error('Could not find a running dev server on 5173-5180.'); console.error('Start it with `npm run dev` in another terminal.'); process.exit(2); } console.error(`Using dev server at ${BASE}`); const morfos = await loadMorfos(); if (morfos.length === 0) { console.error('No morfos found under src/uix/morfo/components/'); process.exit(2); } console.error(`Loaded ${morfos.length} morfo${morfos.length === 1 ? '' : 's'}`); const browser = await chromium.launch(); const ctx = await browser.newContext(); const results: RouteResult[] = []; const skipped: string[] = []; for (const morfo of morfos) { const route = `/test/soma/${morfo.kebab}`; const page = await ctx.newPage(); const pageErrors: string[] = []; page.on('pageerror', (e) => pageErrors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error') pageErrors.push(m.text()); }); try { await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); await page.waitForTimeout(300); // Initial snapshot — if there are no perm steps at all, skip. const initialSteps = await collectSteps(page); if (initialSteps.length === 0) { skipped.push(morfo.kebab); await page.close(); continue; } // Determine the max index the page declares at any point. Re-check the // DOM after each step so portal-gated elements (Dialog.Close inside // `{#if open}`) get counted once they mount. const knownIndices = new Set(initialSteps.map((s) => s.index)); const executedIndices = new Set(); const routeResult: RouteResult = { route, morfo: morfo.kebab, stepsTotal: 0, // filled after the run steps: [], pageErrors: [] }; // Iterate until no new pending step shows up. Cap at 50 to avoid a // runaway demo that keeps spawning indices. let safety = 50; while (safety-- > 0) { // Re-collect; demos may have mounted new perm elements. const current = await collectSteps(page); for (const s of current) knownIndices.add(s.index); const next = current .filter((s) => !executedIndices.has(s.index)) .sort((a, b) => a.index - b.index)[0]; if (!next) break; const step = next; executedIndices.add(step.index); try { await executeStep(page, step); } catch (e) { routeResult.steps.push({ kind: 'error', label: step.label, err: (e as Error).message.slice(0, 120) }); continue; } // Any new page errors since the last step? if (pageErrors.length > 0) { const captured = pageErrors.splice(0, pageErrors.length).join(' | '); routeResult.steps.push({ kind: 'fail', label: step.label, reason: `pageerror: ${captured.slice(0, 200)}` }); continue; } if (step.skipValidate) { routeResult.steps.push({ kind: 'pass', label: step.label + ' (no-validate)' }); continue; } // Re-validate morfo against current DOM. try { const issues = await validateMorfoAgainstDom(page, morfo); if (issues.length === 0) { routeResult.steps.push({ kind: 'pass', label: step.label }); } else { routeResult.steps.push({ kind: 'fail', label: step.label, reason: issues.map((i) => `[${i.kind}] ${i.message}`).join(' · ') }); } } catch (e) { routeResult.steps.push({ kind: 'error', label: step.label, err: `validation: ${(e as Error).message.slice(0, 120)}` }); } } // Any errors that slipped past the per-step capture routeResult.pageErrors = pageErrors; routeResult.stepsTotal = routeResult.steps.length; results.push(routeResult); } catch (e) { results.push({ route, morfo: morfo.kebab, stepsTotal: 0, steps: [{ kind: 'error', label: 'navigation', err: (e as Error).message.slice(0, 120) }], pageErrors: [] }); } finally { await page.close(); } } await browser.close(); // ── Report ────────────────────────────────────────────────────────────────── console.log(''); let passed = 0; let failed = 0; for (const r of results) { const bad = r.steps.filter((s) => s.kind !== 'pass'); if (bad.length === 0) { console.log(`PASS ${r.morfo.padEnd(20)} ${r.stepsTotal} permutation${r.stepsTotal === 1 ? '' : 's'}`); for (const s of r.steps) { console.log(` · ${s.label}`); } passed++; } else { console.log(`FAIL ${r.morfo.padEnd(20)} ${r.stepsTotal} permutations, ${bad.length} failed`); for (const s of r.steps) { if (s.kind === 'pass') console.log(` · ${s.label}`); else if (s.kind === 'skipped') console.log(` · ${s.label} (skipped)`); else if (s.kind === 'fail') console.log(` ✖ ${s.label} ${s.reason}`); else console.log(` ✖ ${s.label} ERROR ${s.err}`); } failed++; } } console.log(''); if (skipped.length > 0) { console.log( `SKIPPED (${skipped.length}): ${skipped.slice(0, 10).join(', ')}${skipped.length > 10 ? ', …' : ''}` ); console.log(' (demo has no `data-perm-step` annotations yet — see src/uix/morfo/PERMUTATION_RUNNER.md)'); } console.log(''); console.log( failed === 0 ? `All ${passed} instrumented demo${passed === 1 ? '' : 's'} passed.` : `${failed} demo${failed === 1 ? '' : 's'} failed: ${results.filter((r) => r.steps.some((s) => s.kind !== 'pass')).map((r) => r.morfo).join(', ')}` ); process.exit(failed === 0 ? 0 : 1);