|
|
/**
|
|
|
* 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;
|
|
|
/**
|
|
|
* The house's instrument-rollout pattern (P0 lesson: measure, adjudicate,
|
|
|
* THEN gate): a new pass sets this flag so its findings print and tally
|
|
|
* WITHOUT failing the run, until its first harvest is adjudicated — then
|
|
|
* the flag is deleted from that pass and it gates. The ARIA pass was born
|
|
|
* advisory 2026-08-26 (258 findings), its crop was adjudicated through the
|
|
|
* unified dossier (fila cero: the composition edge), and it FLIPPED to
|
|
|
* gating 2026-08-27 at zero. Nothing sets this today; the plumbing stays
|
|
|
* for the next pass's rollout.
|
|
|
*/
|
|
|
advisory?: boolean;
|
|
|
};
|
|
|
|
|
|
/**
|
|
|
* ARIA attrs the CONSUMER legally owns (the A-85 naming class: consumer-first
|
|
|
* by vocabulary, no framework default) — their presence on a part without a
|
|
|
* morfo declaration is not drift.
|
|
|
*/
|
|
|
const CONSUMER_NAMING_ARIA = new Set(['aria-label', 'aria-labelledby', 'aria-describedby']);
|
|
|
|
|
|
/**
|
|
|
* SIGNED exceptions of the ARIA pass — `"kebab.part" -> attr -> reason`.
|
|
|
* Growing this map is a decision with an acta, never a convenience (the
|
|
|
* focus/keyboard census discipline). Each row cites its cure's owner.
|
|
|
*/
|
|
|
const ARIA_PASS_EXCEPTIONS: Record<string, Record<string, string>> = {
|
|
|
// The DEMO wires a combobox-shaped harness onto the input
|
|
|
// (+page.svelte:312-315) — consumer ARIA on a framework element, which
|
|
|
// this instrument cannot tell from component ARIA without an edge. Cure
|
|
|
// owned by the demo (dossier L5-side, sources lane).
|
|
|
'search-field.input': {
|
|
|
'aria-controls': 'demo-written combobox harness — cure is the demo’s',
|
|
|
'aria-expanded': 'demo-written combobox harness — cure is the demo’s',
|
|
|
'aria-autocomplete': 'demo-written combobox harness — cure is the demo’s'
|
|
|
},
|
|
|
// The eidos shell hand-writes the status div instead of composing
|
|
|
// Palabras.Status, DELIBERATELY: composing would make the whole bar a
|
|
|
// live region announcing on every keystroke, against palabras’ own
|
|
|
// WCAG 4.1.3 doctrine. Shared-branch territory (palabras axis) — the
|
|
|
// declaration is true of the soma part; the shell’s hand div is the
|
|
|
// open row (dossier, causa 6).
|
|
|
'palabras.status': {
|
|
|
'aria-live': 'eidos shell hand-writes the div (4.1.3 doctrine) — palabras-axis row'
|
|
|
}
|
|
|
};
|
|
|
|
|
|
/** 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}`;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* THE EDGE (fila cero, author-signed 2026-08-27): when a host part SHARES its
|
|
|
* DOM element with a composed component (`chat-message.reaction-add` IS the
|
|
|
* `Popover.Trigger`), the composed component's ARIA belongs to ITS morfo, not
|
|
|
* the host's. The identity already travels through the single attr pipeline:
|
|
|
* every composed part stamps its namespaced identifying attr on the same
|
|
|
* element — so the edge is resolved from CO-LOCATED part-identifying attrs
|
|
|
* (this is the data pass's "Allow other components' part attrs" clause,
|
|
|
* generalized to the ARIA those parts declare). No new contract surface.
|
|
|
*
|
|
|
* Declared limits (named, not covered):
|
|
|
* - Consumer-written ARIA on a framework element (search-field's demo) stays
|
|
|
* flagged — the cure is the demo's, the instrument cannot tell consumers
|
|
|
* from components without an edge.
|
|
|
* - A part that never renders in any routed demo (RateButton class) is
|
|
|
* invisible to ANY render-based resolution — this instrument measures what
|
|
|
* mounts.
|
|
|
* - Two co-located contracts declaring the SAME attr with different values is
|
|
|
* a future check (named in the dossier), not this clause.
|
|
|
*/
|
|
|
type EdgeIndex = Map<string, { kebab: string; part: MorfoPart }>;
|
|
|
|
|
|
function buildEdgeIndex(all: Morfo[]): EdgeIndex {
|
|
|
const index: EdgeIndex = new Map();
|
|
|
for (const m of all) {
|
|
|
for (const p of flatParts(m.parts).filter((p) => p.kind === 'public')) {
|
|
|
const attr = dataAttrFor(m.kebab, p);
|
|
|
// `data-a-b` can name both a.b and a-b.provider. Zero collisions in
|
|
|
// today's 919-attr corpus (measured 2026-08-27); the day one is
|
|
|
// born, the edge would resolve the WRONG contract in silence —
|
|
|
// throw instead and force the adjudication.
|
|
|
const prior = index.get(attr);
|
|
|
if (prior) {
|
|
|
throw new Error(
|
|
|
`edge-index collision: "${attr}" names both ${prior.kebab}.${prior.part.kebab} and ${m.kebab}.${p.kebab}`
|
|
|
);
|
|
|
}
|
|
|
index.set(attr, { kebab: m.kebab, part: p });
|
|
|
}
|
|
|
}
|
|
|
return index;
|
|
|
}
|
|
|
|
|
|
// Tally of the parts a run could not look at (declared here because the
|
|
|
// validator increments them; printed in the summary so the headline can never
|
|
|
// speak for parts that never mounted).
|
|
|
let unmountedParts = 0;
|
|
|
const unmountedMorfos = new Set<string>();
|
|
|
|
|
|
async function validateMorfoAgainstDom(
|
|
|
page: Page,
|
|
|
morfo: Morfo,
|
|
|
edgeIndex: EdgeIndex
|
|
|
): 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.
|
|
|
// data-* feeds the gating pass; role/aria-* feeds the advisory aria pass.
|
|
|
const elementsAttrs = await page.$$eval(`[${partAttr}]`, (nodes) =>
|
|
|
nodes.map((el) =>
|
|
|
Array.from(el.attributes)
|
|
|
.filter(
|
|
|
(a) => a.name.startsWith('data-') || a.name.startsWith('aria-') || a.name === 'role'
|
|
|
)
|
|
|
.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.
|
|
|
//
|
|
|
// BUT THE SKIP IS TALLIED (2026-08-27): this `continue` used to be
|
|
|
// MUTE — it neither counted nor printed, and the run then reported
|
|
|
// "All N routed morfos validate", a sentence that silently spoke
|
|
|
// only for the parts that happened to mount. That is the house's
|
|
|
// own "a guard that inspects NOTHING passes" class, living inside
|
|
|
// the instrument the house uses to catch it. The summary now says
|
|
|
// how many parts went unseen and — the number that turns a figure
|
|
|
// into a diagnosis — across how many DISTINCT components: few
|
|
|
// components means a demo problem, spread out means the headline
|
|
|
// was speaking for a fraction all along.
|
|
|
unmountedParts += 1;
|
|
|
unmountedMorfos.add(morfo.kebab);
|
|
|
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) {
|
|
|
// THE EDGE, resolved per element: the ARIA/role that any CO-LOCATED
|
|
|
// part (another morfo sharing this element — or a sibling part of
|
|
|
// this one, the Link-is-Item pattern) declares is OWNED at that
|
|
|
// edge and never host drift.
|
|
|
const edgeAria = new Set<string>();
|
|
|
const edgeRoles = new Set<string>();
|
|
|
for (const { name } of elAttrs) {
|
|
|
if (name === partAttr) continue;
|
|
|
const edge = edgeIndex.get(name);
|
|
|
if (!edge) continue;
|
|
|
for (const entry of edge.part.aria ?? []) edgeAria.add(entry.attr);
|
|
|
if (edge.part.role) edgeRoles.add(edge.part.role);
|
|
|
}
|
|
|
|
|
|
// (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)`
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// ── ARIA pass (P1·A11Y, ADVISORY until its first harvest is signed) ──
|
|
|
// `part.aria` is now CLOSED to `ARIA_ATTR_VOCABULARY` (P1·CONTRATO):
|
|
|
// the 134 plain-HTML entries this pass used to skip by hand
|
|
|
// (`type` ×130, `tabindex` ×2, `for`, `contenteditable`) live in
|
|
|
// `part.attrs` and the schema throws if one comes back. Nothing to
|
|
|
// filter here any more.
|
|
|
// `Set<string>`, not `Set<MorfoAriaAttr>` — it is probed below with
|
|
|
// rendered attribute names, which are plain strings.
|
|
|
//
|
|
|
// DECLARED WITHOUT A SOURCE IS DECLARED (decision 7, 2026-08-27): an
|
|
|
// entry with no `value` hands the value to the provider's bag, so it
|
|
|
// belongs in this set — checks (d) and (f) below key on `attr` only
|
|
|
// and need no `value` branch. A rendered attr it names is contract,
|
|
|
// not drift.
|
|
|
//
|
|
|
// WHAT (d) ACTUALLY DEMANDS, stated exactly (row 13, author-signed
|
|
|
// 2026-08-27 — an earlier draft of this comment promised more than
|
|
|
// the code does): (d) demands emission only for a `required` entry
|
|
|
// with NO condition. So a sourceless entry is verified when it is
|
|
|
// required and unconditional — the provider owning the value is
|
|
|
// then the promise that it emits one — and is CONTRACT-ONLY when it
|
|
|
// carries `severity: 'optional'` or a `condition`: nothing emits it
|
|
|
// and nothing here demands it. That third state is deliberate (it
|
|
|
// is the only way to declare «the provider owns this AND it is
|
|
|
// conditional»), not an oversight, and `severity` is the dominant
|
|
|
// filter of the two.
|
|
|
const declaredAria = new Set<string>((part.aria ?? []).map((a) => a.attr));
|
|
|
|
|
|
const exceptions = ARIA_PASS_EXCEPTIONS[`${morfo.kebab}.${part.kebab}`];
|
|
|
|
|
|
// (d) required UNCONDITIONAL aria entries present. Conditioned
|
|
|
// entries are skipped — a static demo page may legitimately not
|
|
|
// satisfy `part-present` / `prop-truthy`, same philosophy as the
|
|
|
// data pass trusting `severity` to encode it.
|
|
|
for (const entry of part.aria ?? []) {
|
|
|
if ((entry.severity ?? 'required') !== 'required') continue;
|
|
|
if (entry.condition) continue;
|
|
|
if (exceptions?.[entry.attr]) continue;
|
|
|
if (!elAttrs.some((a) => a.name === entry.attr)) {
|
|
|
issues.push({
|
|
|
kind: 'missing',
|
|
|
message: `${morfo.kebab}.${part.kebab}: required aria "${entry.attr}" not emitted`
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// (e) the part's declared role must be the rendered role; a rendered
|
|
|
// role with no declaration is drift the contract cannot see — unless
|
|
|
// a co-located part owns that role at the edge.
|
|
|
const renderedRole = elAttrs.find((a) => a.name === 'role')?.value;
|
|
|
if (part.role && renderedRole && renderedRole !== part.role) {
|
|
|
if (!edgeRoles.has(renderedRole)) {
|
|
|
issues.push({
|
|
|
kind: 'bad-value',
|
|
|
message: `${morfo.kebab}.${part.kebab}: role "${renderedRole}" rendered, morfo declares "${part.role}"`
|
|
|
});
|
|
|
}
|
|
|
} else if (!part.role && renderedRole && !declaredAria.has('role')) {
|
|
|
if (!edgeRoles.has(renderedRole)) {
|
|
|
issues.push({
|
|
|
kind: 'undeclared',
|
|
|
message: `${morfo.kebab}.${part.kebab}: role "${renderedRole}" rendered but the morfo declares none`
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// (f) rendered aria-* the morfo does not declare — except the A-85
|
|
|
// consumer-naming class, which the consumer legally owns, and the
|
|
|
// attrs a co-located part declares (owned at the edge).
|
|
|
for (const { name } of elAttrs) {
|
|
|
if (!name.startsWith('aria-')) continue;
|
|
|
if (declaredAria.has(name)) continue;
|
|
|
if (CONSUMER_NAMING_ARIA.has(name)) continue;
|
|
|
if (edgeAria.has(name)) continue;
|
|
|
if (exceptions?.[name]) continue;
|
|
|
issues.push({
|
|
|
kind: 'undeclared',
|
|
|
message: `${morfo.kebab}.${part.kebab}: undeclared aria attr "${name}" emitted (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 edgeIndex = buildEdgeIndex(morfos);
|
|
|
|
|
|
const browser = await chromium.launch();
|
|
|
const ctx = await browser.newContext();
|
|
|
const failures: Array<{ morfo: string; issues: Issue[] }> = [];
|
|
|
const skipped: string[] = [];
|
|
|
let checked = 0;
|
|
|
let advisoryCount = 0;
|
|
|
const advisoryMorfos = new Set<string>();
|
|
|
|
|
|
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 allIssues = await validateMorfoAgainstDom(page, morfo, edgeIndex);
|
|
|
// The aria pass is advisory (see the Issue type): printed and tallied
|
|
|
// apart, never failing the run, until its first harvest is signed.
|
|
|
const issues = allIssues.filter((i) => !i.advisory);
|
|
|
const advisories = allIssues.filter((i) => i.advisory);
|
|
|
if (advisories.length > 0) {
|
|
|
advisoryCount += advisories.length;
|
|
|
advisoryMorfos.add(morfo.kebab);
|
|
|
}
|
|
|
if (issues.length === 0) {
|
|
|
const ariaNote = advisories.length > 0 ? ` (aria advisory: ${advisories.length})` : '';
|
|
|
console.log(`PASS ${morfo.kebab.padEnd(24)} ${route}${ariaNote}`);
|
|
|
} else {
|
|
|
console.log(`FAIL ${morfo.kebab.padEnd(24)} ${route}`);
|
|
|
issues.forEach((i) => console.log(` [${i.kind}] ${i.message}`));
|
|
|
failures.push({ morfo: morfo.kebab, issues });
|
|
|
}
|
|
|
advisories.forEach((i) => console.log(` [aria-advisory:${i.kind}] ${i.message}`));
|
|
|
} 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('');
|
|
|
// The headline can never speak for what the run could not look at: parts that
|
|
|
// never mounted in their demo are reported BEFORE the verdict, with how many
|
|
|
// distinct components they come from (few = a demo problem; spread out = the
|
|
|
// headline was speaking for a fraction). Printed on every outcome, including
|
|
|
// the green one — that is the whole point.
|
|
|
if (unmountedParts > 0) {
|
|
|
console.log(
|
|
|
`${unmountedParts} declared part${unmountedParts === 1 ? '' : 's'} never mounted in ${unmountedMorfos.size} of the checked demos — NOT validated (nothing was looked at, so nothing passed).`
|
|
|
);
|
|
|
}
|
|
|
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 (advisoryCount > 0) {
|
|
|
console.log(
|
|
|
`ARIA advisory: ${advisoryCount} finding${advisoryCount === 1 ? '' : 's'} across ${advisoryMorfos.size} morfos — adjudicate, then flip the pass to gating.`
|
|
|
);
|
|
|
}
|
|
|
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 (advisoryCount > 0) {
|
|
|
console.log(
|
|
|
`ARIA advisory: ${advisoryCount} finding${advisoryCount === 1 ? '' : 's'} across ${advisoryMorfos.size} morfos.`
|
|
|
);
|
|
|
}
|
|
|
if (skipped.length > 0) {
|
|
|
console.log(`${skipped.length}/${morfos.length} morfos skipped without routed demo.`);
|
|
|
}
|
|
|
process.exit(1);
|
|
|
}
|