18 KiB
| title | type | audience | status |
|---|---|---|---|
| Testing, Validation & Codegen | reference | human + agent | current |
Testing, Validation & Codegen
The cross-cutting story of how the framework stays correct: the test suite, the
contract validators, what is generated vs authored, and the SSR posture. The
commands are the scripts in package.json; this groups them by what they are
for.
The verification loop (the short version)
npm run check:gate # types WITH the policy: src/ owes ZERO; web/ measured
# against the shrinking ledger scripts/check-debt.ts
npm run check # raw svelte-check (src/ expect 0; web/ carries the
# frozen ledger errors, which may only shrink)
npm run test # the vitest suite (one run)
npm run gate # everything the pre-push hook runs (see §The gate)
npm run lint # prettier --check · npm run format to fix
# (NOT in `gate` yet — pre-existing repo-wide format debt;
# the one-shot is `npm run format` on a quiet tree)
For a component you also run the contract validators (below). A change is not
done until check is clean and the relevant validators pass.
Tests — two projects
vite.config.ts defines two vitest projects (see CLAUDE.md → "Vitest
Two-Project Structure"):
- client — browser tests via Playwright, for
*.svelte.{test,spec}.{js,ts}. This is where component providers are exercised with real runes + DOM. - server — Node environment for
*.{test,spec}.{js,ts}(excludes the svelte tests). Pure engines, libs, and server logic.
npm run test:unit # watch mode
npm run test # one run
npx vitest run src/uix/morfo/compile.test.ts # a single file
npx vitest run -t "describe name" # by test name
Component providers carry their own {name}-provider.svelte.test.ts under the
client project (a guard fails when an active provider ships without one — the
test tree IS the coverage inventory); the reusable engines they consume live
in $libs/datagrid, $libs/forms, $libs/strings and are tested there, not
inside soma. See
soma-architecture.md §6.
Validation — what each script catches
These catch classes of bug that check (types) and an HTTP 200 (SSR) miss.
| Command | Catches |
|---|---|
npm run morfo:check |
the real DOM vs the morfo contract — navigates each demo and validates emitted data-* against the declaration. |
npm run morfo:vocabulary |
canonical-verb / vocabulary drift in morfo events (e.g. open|closed, declared verbs not in SEMA_VERBS). Hard-fails CI on declared-verb drift. |
npm run component:audit |
the acceptance matrix of completion-checklist.md — per-rule severity/applicability across all four layers + recipe + demo. |
npm run perm:check |
state-transition bugs — cycles a component through its declared states (via data-perm-step annotations) and re-validates morfo after each. Catches reactivity loops and transition-time drift morfo:check can't. |
npm run smoke |
runtime / hydration errors — walks every +page.svelte with Playwright and surfaces pageerror, console.error, missing translation keys, Context "X" not found, and __uix_lang_missing__ markers. Needs npm run dev running. HTTP 200 is SSR only; smoke exercises client hydration. |
npm run layer:check |
a shared visual layer losing a cascade fight — for every element carrying a layer hook (data-viewport-placement), asserts the computed position, that the stacking token resolved, and that no override slot declared inline computes to nothing (the signature of a custom-property CYCLE). Needs npm run dev. Its consumer list is DERIVED from who imports the layer, so a component joins the day it migrates. Deliberate hole, stated in the script: geometry. getComputedStyle reports the USED value, so an inset: auto reads back as pixels (measured: -1976.7px) — there is no property-level way to tell a dead calc() from an intended value. |
npm run translations:check |
missing / malformed translation keys. |
npm run docs:check |
doc-corpus drift — copied vocabulary counts vs the source consts, phantom fields (the legacy morfo text field; rejected API shapes), dependency claims vs package.json, the variant-vocab mirror in component-audit.ts, checklist↔audit rule-ID sync, and relative links (warn severity). Guards the "link the canon, never copy it" law of docs/authoring.md. |
npm run eidos:lint |
classifies every [data-*] selector in eidos CSS as morfo-backed / eidos-only / invalid — drift between the morfo contract and the CSS. A gate member since P0 fase B (audit 2026-08-26); the architectural defense is still the typed semaSelector builder — see the "Eidos drift defense" rule in CLAUDE.md. |
src/uix/eidos/shared-layer-contract.test.ts (vitest) |
the text half a browser cannot cover for a shared layer: every zone has a rule, stretch stays on the axes it was scoped to, no axis is read without its override slot, no geometry rule keys on the component identity, and each consumer imports the layer / stamps the hook / mints no parallel token / re-asserts position when the primitive it composes declares one. It exists for ONE thing the computed check is blind to: getComputedStyle of a safe-area slot returns "0px" on desktop, so a :dir(rtl) remap with one half flipped reads identical to a correct one on every CI machine and only surfaces on a notched phone, sideways, in RTL. |
src/uix/contracts.test.ts (vitest, via npm run test) |
catalogue invariants that keep declarations / recipes coherent as the framework grows. Each fails on drift naming the offender, and excludes the active-dev-track set so it stays green for the maintained catalogue: VG-8 every morfo is as const satisfies Morfo, never : Morfo (a : Morfo annotation widens the literal so the schema can't check it); SYS-1 scope-drift a component shipping an eidos/components/{c}/ recipe declares 'eidos' in scope; A31 no per-item membership predicate (isSelected / isItemPressed / …) doing .current.includes (O(N²) — lift a Set, use .has()); A30 inputId registered with the parent Field in the constructor, not wrapped in a $effect; THEME-SYS-1 overlay z-index references the named --z-index-overlay-* scale, never a raw integer. |
Codegen — what is generated vs authored
Some surfaces are produced from a source of truth, not hand-maintained. Don't edit the output; edit the source and regenerate.
| Command | Generates from |
|---|---|
npm run generate:eidos-css |
the eidos foundation/recipe CSS, from EidosConfig.recipes. (generated/base.css is output — regenerate, don't hand-edit.) |
The data-* contract is not a generated doc — it is the morfo itself, validated
live by npm run morfo:check. (A legacy generate:contracts-docs script and its
output DATA_ATTRS.md, both fossils of the removed terra layer, were deleted.)
The morfo compiler itself is the central codegen primitive: compileMorfo(morfo)
turns a declaration into a CompiledMorfo (resolved attr / keyboard / action
plans + the closed set of CSS selectors eidos may use), cached by morfo identity
(WeakMap). Every consumer reads the compiled morfo, never the raw declaration.
npm run eidos:purge # production: drop unreached foundation CSS (−46 to −55%)
SSR posture
Components are headless-functional without a DOM. The architecture keeps SSR and headless tests working:
dom:falseinjects a shareddisabledDomno-op consumed by Soma / Sema / Eidos — there is no silent fallback to direct DOM writes (seearts/adom/READMEandsrc/uix/contracts.ts).ActiveDomresolves the ownerdocument/window(getDocument(node)/getWindow(node)), so it is correct under iframes, popups and happy-dom — not bound to the globaldocument.- Sema is ornamental —
ActiveUix.events(the perceptual engine) is optional; with no engine,SomaRuntime.trigger()skips the emit and the component stays functional. So sound/haptic-free, audio-disabled and server environments work. - Hydration is what HTTP 200 misses — server render succeeds long before a
hydration-time
Context not foundor effect loop would; that is exactly whatnpm run smokeexists to catch. - The morfo contract server-renders — since the render bag became the
single attr pipeline (P0 fase C, audit 2026-08-26), a part's
role/aria-*/data-state/ literals resolve at render time, server included. The mechanism is SHARED (partPropsForRegistration) and pinned bysrc/uix/soma/ssr-contract.test.ts(server project, node — no window) with two representatives, Toggle and RadioGroup — a CANARY, not a per-provider census; the per-component SSR snapshot census is still owed (informe P1). It was born red against the old client-only effect.
The gate
The drift-defense scripts stopped being advisory in P0 fase B (audit 2026-08-26). Three additions:
npm run check:gate—svelte-checkwith policy: any ERROR undersrc/fails (framework source owes zero); errors in the demo tree are measured against the per-file ledger inscripts/check-debt.ts, which only SHRINKS (the theming-census-debt discipline — a file above its entry, or an erroring file the ledger does not name, fails). The run must end with theCOMPLETEDmarker and the parsed count must match it: a gate that inspected nothing fails, never passes.npm run gate— the chain the pre-push hook runs:check:gate→ the fast validators (morfo:vocabulary,docs:check,arts:check,blocks:check,packs:check,rtl:check,translations:check,agent:check,eidos:lint) → the full vitest suite last.lintis deliberately NOT a member yet: the tree carries pre-existing repo-wide format debt; the pending one-shot isnpm run formaton a quiet tree, then addnpm run lint &&back.- The pre-push hook — source of truth in
scripts/hooks/pre-push; arm it withcp scripts/hooks/pre-push .git/hooks/pre-push. Browser-dependent validators (morfo:check,perm:check,smoke,layer:check) stay manual — they need a dev server.
See also
docs/getting-started.md— the short verification loop in context.component-guide.md— the build checklist that calls these.completion-checklist.md— whatcomponent:auditenforces.