Reconnect morfo DOM check

active-uix
dev 5 months ago
parent f815dd95ac
commit cf7c83a6d3

@ -210,10 +210,28 @@ Actualizacion smoke 2026-05-16:
- `src/uix/soma/components/range-calendar/README.md`
- `scripts/probe-toast-dismiss.ts` ahora usa por defecto
`/uix/components/toast`.
- Pendiente deliberado: `scripts/morfo-check.ts` y
`scripts/permutation-check.ts` siguen siendo runners historicos del arbol
`/test/soma`; no usarlos como release gate hasta reconectarlos a una route
map real del docs shell actual.
- `scripts/morfo-check.ts` queda reconectado parcialmente: valida los morfos
que tienen ruta concreta bajo `/uix/components/{kebab}` y marca el resto como
`SKIP` en vez de fallar por rutas inexistentes. Se puede cambiar el prefijo
con `MORFO_ROUTE_PREFIX`.
- Al volver a ejecutar `morfo-check`, aparecieron y se corrigieron dos
incoherencias de Morfo:
- `fieldMorfo` declaraba los valores de `data-state` como `states`, pero su
fuente runtime real es `state`; tambien se corrige `aria-busy` para mirar
`state === 'loading'`.
- `schema.ts` permite ahora `emit: 'value'` sin `value` source cuando el attr
es declarativo/manual. Esto cubre `Form.AutoFields`, donde el componente
escribe valores como `data-form-auto-fields-field={path}` sin pasar por
`SomaRuntime`.
- Validacion:
- `node --import tsx/esm scripts/morfo-check.ts http://127.0.0.1:5182` ->
14 morfos con ruta OK, 53 `SKIP`.
- `npx vitest run src/uix/morfo/schema.test.ts src/uix/morfo/registry.test.ts src/uix/morfo/compile.test.ts`
-> 41 tests OK.
- `npm run check` -> 0 errores, 0 warnings.
- Pendiente deliberado: `scripts/permutation-check.ts` sigue siendo runner
historico del arbol `/test/soma`; no usarlo como release gate hasta
reconectarlo a una route map real del docs shell actual.
Actualizacion Eidos visual wrappers 2026-05-16:

@ -2,7 +2,7 @@
* Morfo DOM-validation check.
*
* For each morfo at `src/uix/morfo/components/*.ts`:
* 1. Navigate to the matching demo route at `/test/soma/{kebab}`.
* 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:
@ -13,15 +13,16 @@
* 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 morfos validate against their rendered DOM.
* 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.
* 2 — no dev server / couldn't load morfos / no routed morfos.
*/
import { chromium, type Page } from 'playwright';
import { readdirSync } from 'node:fs';
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';
@ -29,6 +30,29 @@ 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++) {
@ -186,9 +210,18 @@ 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 = `/test/soma/${morfo.kebab}`;
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 });
@ -215,13 +248,24 @@ for (const morfo of morfos) {
await browser.close();
console.log('');
if (failures.length === 0) {
console.log(`All ${morfos.length} morfo${morfos.length === 1 ? '' : 's'} validate against their demo DOM.`);
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}/${morfos.length} morfos failed (${totalIssues} total issues): ${failures.map((f) => f.morfo).join(', ')}`
`${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);
}

@ -425,6 +425,9 @@ data: [
// Free value: writes the raw value, not an empty presence attr.
{ attr: 'data-value', value: v.propRef('value'), emit: 'value' },
// Free value written manually by the component, not by SomaRuntime.
{ attr: 'data-form-auto-fields-field', emit: 'value' },
// Conditionally-emitted attr.
{
attr: 'data-starting-style',
@ -436,7 +439,10 @@ data: [
Non-enum `data-*` entries default to `emit: 'presence'`: truthy writes
`data-x=""`, falsy removes the attr. Use `emit: 'value'` only when the attr
must carry a real value, such as `data-value`, `data-min`, or `data-max`.
must carry a real value, such as `data-value`, `data-min`, `data-max` or a
manual component attr like `data-form-auto-fields-field`. When `value` is
omitted, the attr remains declarative-only: Morfo documents the contract, but
the component/provider is still responsible for writing the value.
**Severity rules**:
@ -672,10 +678,11 @@ Two npm scripts exercise the UI shell and, historically, morfos against the real
`__uix_lang_missing__` fallbacks. Not morfo-specific but catches common
regressions. Set `SMOKE_SCOPE=/uix` to restrict the run to the UIX shell.
- `npm run morfo:check` — Legacy DOM validator from the old per-morfo demo
tree. The routes moved from `/test/soma/{kebab}` to the current
`web/routes` docs shell, so this runner must be reconnected to a concrete
route map before being used as a release gate again. Its contract remains:
- `npm run morfo:check` — DOM validator for morfos that have a routed demo
under `/uix/components/{kebab}`. Morfos without a current routed demo are
reported as `SKIP`; they are not treated as failures. Override the prefix
with `MORFO_ROUTE_PREFIX=/some/path` if a local docs shell maps morfos
elsewhere. Its contract:
- Every declared data-attr with `severity: 'required'` is emitted.
- Every emitted data-attr value matches `values[]` if declared.
- No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private
@ -822,7 +829,7 @@ runtime that interprets a compiled morfo lives in soma — see
| `npm run check` | TypeScript type-check across the repo (catches shape errors in morfos). |
| `npx vitest run src/uix/morfo` | Run morfo unit tests (schema invariants). |
| `npm run smoke` | Playwright smoke over concrete `web/routes` pages (requires dev server). |
| `npm run morfo:check` | Legacy DOM validator; reconnect to current route map before relying on it. |
| `npm run morfo:check` | Validate routed `/uix/components/{kebab}` demos vs morfo; unrouted morfos skip. |
| `npm run morfo:vocabulary` | Flag data-state enums that diverge from canonical vocabularies. |
---

@ -13,7 +13,7 @@ export const fieldMorfo = {
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['idle', 'error', 'disabled', 'readonly', 'loading'],
states: ['state'],
data: [
{
attr: 'data-state',
@ -57,7 +57,7 @@ export const fieldMorfo = {
kind: 'public',
defaultElement: 'div',
optional: true,
states: ['idle', 'error', 'disabled', 'readonly', 'loading'],
states: ['state'],
data: [
{
attr: 'data-state',
@ -76,7 +76,7 @@ export const fieldMorfo = {
kind: 'public',
defaultElement: 'input',
optional: true,
states: ['idle', 'error', 'disabled', 'readonly', 'loading'],
states: ['state'],
data: [
{
attr: 'data-state',
@ -103,7 +103,7 @@ export const fieldMorfo = {
attr: 'aria-busy',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'state-equals', state: 'loading', value: 'true' }
condition: { when: 'state-equals', state: 'state', value: 'loading' }
},
{
attr: 'aria-required',

@ -55,4 +55,32 @@ describe('validateMorfo — data attrs', () => {
/lowercase kebab-case/
);
});
it('allows declarative-only free-value attrs', () => {
expect(() =>
validateMorfo({
...baseMorfo,
parts: [
{
...baseMorfo.parts[0],
data: [{ attr: 'data-sample-value', emit: 'value' }]
}
]
})
).not.toThrow();
});
it('rejects emit mode on enum-valued attrs', () => {
expect(() =>
validateMorfo({
...baseMorfo,
parts: [
{
...baseMorfo.parts[0],
data: [{ attr: 'data-state', values: ['open', 'closed'], emit: 'value' }]
}
]
})
).toThrow(/emit is only valid for non-enum data attrs/);
});
});

@ -479,9 +479,6 @@ function validateInvariants(morfo: Morfo): void {
for (const dataEntry of part.data) {
validateDataAttrName(dataEntry.attr, path, 'data attr');
if (dataEntry.emit && !dataEntry.value) {
throw new MorfoInvariantError(`data[${dataEntry.attr}].emit requires a value source`, path);
}
if (dataEntry.emit && dataEntry.values && dataEntry.values.length > 0) {
throw new MorfoInvariantError(
`data[${dataEntry.attr}].emit is only valid for non-enum data attrs`,

Loading…
Cancel
Save

Powered by TurnKey Linux.