## Why
`morfo:check` validates single-state DOM at page load; `smoke` catches
hydration-time errors. Neither ejercices state TRANSITIONS — and that is
where every reactivity bug we caught this week actually lived:
- toolbar A35 loop (mount-time, not caught by either)
- form A36 microtask loop (mount-time with onChange)
- slider RTL transform (static value, off-by-thumb-width)
All passed the existing CI. All would have failed a "click, re-validate"
pass.
## What
- **`scripts/permutation-check.ts`** — Playwright runner. For each demo,
collects `data-perm-step="N"` elements, executes them in ascending
order (re-discovering after each step so portal-gated controls get
counted), and re-validates the component's morfo + captures any
`pageerror` / `console.error` between steps.
- **`data-perm-*` authoring convention** — step index, human label, mode
(click / focus / type / key), settle delay, skip-validate flag. Demos
opt in incrementally; the runner SKIPs annotation-less pages without
failing.
- **`src/uix/morfo/PERMUTATION_RUNNER.md`** — full design doc covering
what it validates, the annotation convention, example, failure format,
and v2–v4 roadmap (URL-driven states → morfo-inferred cycles →
MutationObserver ordering for Sema).
- **COMPONENT_GUIDE A37 + checklist item 40** — instrumentation is now a
ship-gate rule; doc lists coverage targets (overlays / toggleables /
composite roving / forms / RTL) and explicit skips (alerts, file
pickers).
- **`npm run perm:check`** — pipeline entry, exit codes 0/1/2
parallelling `morfo:check`.
## Instrumented demos (v1 seed)
- dialog — 2 steps: open via trigger → close via Close button. Step 1 is
inside `{#if open}` + Portal; runner's dynamic re-discovery handles it.
- tabs — 3 steps: initial tab 1 → switch to tab 2 → switch to tab 3.
- toolbar — 3 steps: toggle bold / italic / underline GroupItems
(exercises the A35 pattern that previously looped).
Result: 8/8 permutations pass; 63 demos SKIPPED pending instrumentation.
## Verification
- `npm run check`: 0 errors in soma/morfo/scripts scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 instrumented demos, 8/8 permutations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
parent
6efbfeb795
commit
61e4ba6d26
@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* 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<Issue[]> {
|
||||||
|
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<string, MorfoData>(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<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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<PermStep[]> {
|
||||||
|
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<number, (typeof raw)[number]>();
|
||||||
|
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<void> {
|
||||||
|
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<number>(initialSteps.map((s) => s.index));
|
||||||
|
const executedIndices = new Set<number>();
|
||||||
|
|
||||||
|
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);
|
||||||
Loading…
Reference in new issue