You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
274 lines
9.9 KiB
274 lines
9.9 KiB
/**
|
|
* 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 { EIDOS_ONLY_ATTRS } from './eidos-only-attrs';
|
|
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<string | null> {
|
|
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<Morfo[]> {
|
|
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<string, unknown>;
|
|
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<Issue[]> {
|
|
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 <a> 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<string, MorfoData>(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;
|
|
// Eidos-owned cross-cutting hooks (wrapper visual attrs, shared
|
|
// styling surfaces) are sanctioned OUTSIDE the morfo — the signed
|
|
// wrapper-attrs doctrine. One list, shared with eidos-lint-all,
|
|
// so the two instruments agree (P0 fase A, audit 2026-08-26).
|
|
if (EIDOS_ONLY_ATTRS.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);
|
|
}
|