/** * 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. * * Current route map: `/uix/components/{kebab}`. Morfos without a routed demo * or without `data-perm-*` instrumentation are skipped. * * Exit codes: * 0 — every instrumented routed demo passes all permutations. * 1 — at least one permutation failed. * 2 — no dev server / no morfos found. */ import { chromium, type Page } from 'playwright'; import { existsSync, 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'); const ROUTES_DIR = join(__dirname, '..', 'web', 'routes'); const DEFAULT_ROUTE_PREFIX = '/uix/components'; const routePrefix = normaliseRoutePrefix(process.env.PERM_ROUTE_PREFIX ?? DEFAULT_ROUTE_PREFIX); function normaliseRoutePrefix(value: string): string { const trimmed = value.trim().replace(/\/+$/, ''); if (!trimmed || trimmed === '/') return ''; return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; } function routeToPageFile(route: string): string { const segments = route .split('/') .filter(Boolean) .filter((segment) => !(segment.startsWith('(') && segment.endsWith(')'))); return join(ROUTES_DIR, ...segments, '+page.svelte'); } function routeForMorfo(morfo: Morfo): string | null { const route = `${routePrefix}/${morfo.kebab}`.replace(/\/+/g, '/'); return existsSync(routeToPageFile(route)) ? route : null; } // ── 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); } // ── URL variant matrix (v2 + v3 axis-at-a-time) ──────────────────────────── /** * Universal baseline: every instrumented demo runs at least under `ltr` * (plain URL) and `rtl` (`?perm.dir=rtl`). Additional per-component axes * are discovered at runtime from the demo's `` tag; * each declared axis adds ONE variant that flips that axis from baseline * while keeping everything else at the default — linear, not cartesian, * so the matrix stays bounded. * * Supported axes (v3 initial set): * dir — ltr (baseline) + rtl * orientation — horizontal (baseline) + vertical * disabled — unset + true * readonly — unset + true * invalid — unset + true * loading — unset + true * * Demos opt in by listing the axes their `