/** * Morfo DOM-validation check. * * For each morfo at `src/uix/morfo/components/*.ts`: * 1. Navigate to the matching routed demo at `/uix/components/{kebab}`. * 2. For every `public` part declared in the morfo, query the DOM for * `[data-{component}-{part}]` (or `[data-{component}]` for root). * 3. For each matching element, verify: * - Every data-attr the morfo declares with `severity: 'required'` * is present. * - Every data-attr with `values: [...]` has a value in that set. * - No `data-{component}-*` attr is present that isn't declared in * the morfo (strict mode — excludes `data-_*` privates). * * Runs after `npm run smoke` passes. Requires `npm run dev` running. * Morfos without a current routed demo are reported as SKIP. * * Exit codes: * 0 — all routed morfos validate against their rendered DOM. * 1 — at least one morfo-vs-DOM discrepancy. * 2 — no dev server / couldn't load morfos / no routed morfos. */ 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.MORFO_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; } 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) { // Use file:// URL on Windows — absolute paths starting with "g:" are // rejected by Node's ESM loader. 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; } type Issue = { kind: 'missing' | 'bad-value' | 'undeclared'; message: string }; /** Flatten morfo parts into a flat array for validation lookup. */ 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'); // Collect every part-identifying attr across the morfo so a part can // legitimately carry a sibling's identifying attr (e.g. tag-group Link // is a variant of Item, the element carries both data-tag-group-link // and data-tag-group-item). const allPartAttrs = new Set(parts.map((p) => dataAttrFor(morfo.kebab, p))); for (const part of parts) { const partAttr = dataAttrFor(morfo.kebab, part); // All elements on the page that carry this part's identifying attr. 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) { // Not on the demo page — skip validation entirely. Morfo "optional" // vs "required" is about composition validity (whether a consumer // MAY omit the part), not about demo-page presence. Dialog.Content // is required in a valid composition but only mounts when // `open=true`, which the static demo page may not exercise. // // A dedicated permutation-matrix script (future) will exercise // each component through its state space and validate part // presence per permutation. For now, demo presence is advisory. continue; } const declared = new Set(part.data.map((d) => d.attr)); declared.add(partAttr); // the part-identifying attr itself // Sibling parts' identifying attrs are legitimate (Link-is-Item pattern). for (const siblingAttr of allPartAttrs) declared.add(siblingAttr); const declaredByAttr = new Map( part.data.map((d) => [d.attr, d]) ); for (const elAttrs of elementsAttrs) { // (a) required attrs present 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` }); } } // (b) enum-valued attrs have valid values 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(', ')}]` }); } } // (c) no undeclared public data-{component}-* attrs for (const { name } of elAttrs) { if (!name.startsWith(`data-${morfo.kebab}`) && !name.startsWith('data-')) continue; if (name.startsWith(`data-_`)) continue; // private escape hatch if (declared.has(name)) continue; // Allow other components' part attrs (nested composition). if (!name.startsWith(`data-${morfo.kebab}`)) continue; issues.push({ kind: 'undeclared', message: `${morfo.kebab}.${part.kebab}: undeclared attr "${name}" emitted on element (not in morfo)` }); } } } return issues; } // ── Main ──────────────────────────────────────────────────────────────────── 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 failures: Array<{ morfo: string; issues: Issue[] }> = []; const skipped: string[] = []; let checked = 0; for (const morfo of morfos) { const route = routeForMorfo(morfo); if (!route) { skipped.push(morfo.kebab); console.log(`SKIP ${morfo.kebab.padEnd(24)} no routed demo under ${routePrefix}`); continue; } checked += 1; const page = await ctx.newPage(); try { await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 }); await page.waitForTimeout(500); const issues = await validateMorfoAgainstDom(page, morfo); if (issues.length === 0) { console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}`); } else { console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`); issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`)); failures.push({ morfo: morfo.kebab, issues }); } } catch (e) { console.log(`ERROR ${morfo.kebab.padEnd(24)} ${(e as Error).message}`); failures.push({ morfo: morfo.kebab, issues: [{ kind: 'missing', message: `navigation error: ${(e as Error).message}` }] }); } finally { await page.close(); } } await browser.close(); console.log(''); if (checked === 0) { console.log(`No routed morfos found under ${routePrefix}.`); process.exit(2); } else if (failures.length === 0) { console.log( `All ${checked} routed morfo${checked === 1 ? '' : 's'} validate against their demo DOM.` ); if (skipped.length > 0) { console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); } process.exit(0); } else { const totalIssues = failures.reduce((sum, f) => sum + f.issues.length, 0); console.log( `${failures.length}/${checked} routed morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}` ); if (skipped.length > 0) { console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`); } process.exit(1); }