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.
svelte-kit-vice/docs/testing-and-tooling.md

27 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/ and scripts/ owe 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 (first in `gate`)

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 Output From
npm run generate:eidos-css (+ npm run eidos:purge) src/uix/eidos/generated/base.css (and the purged variant) EidosConfig.recipes
npm run generate:boot src/uix/active-uix/generated/boot.js — the framework's pre-hydration boot + its CSP hash src/uix/active-uix/boot/*, compiled with esbuild
npm run boot -w apps/<name> apps/<name>/src/generated/boot.js — that site's own boot, its own hash the app's prefs-schema.ts (--schema / --out)
npm run docs:vocabularies canon/vocabularies.md — the closed sets the code consts; docs:check (I7) compares it byte for byte
npm run generate:emoji-data src/libs/emoji/data.ts static/emoji/*.json (emojibase), pruned
node --import tsx/esm scripts/theming-census.ts --report docs/audit/theming/ the theming census

Two invariants for all of them:

  • The generator owns the file. Edit the source and regenerate; a hand edit is lost on the next run, and docs:check fails outright for the generated vocabularies.
  • They are all in .prettierignore. A formatter that reformats generated output turns every regeneration into a diff — and, for a byte-compared artifact, into a red gate. (Measured: the repo-wide format pass reformatted canon/vocabularies.md and broke docs:check until both were excluded.)

An app's copy of the framework's fonts and sounds (apps/*/static/{fonts,sounds}) is generated the same way: assets:sync refreshes it before dev and build, git ignores it, and --check fails when it drifted.

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:false injects a shared disabledDom no-op consumed by Soma / Sema / Eidos — there is no silent fallback to direct DOM writes (see arts/adom/README and src/uix/contracts.ts).
  • ActiveDom resolves the owner document / window (getDocument(node) / getWindow(node)), so it is correct under iframes, popups and happy-dom — not bound to the global document.
  • 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 found or effect loop would; that is exactly what npm run smoke exists 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 by src/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-check with policy: any ERROR under src/ or scripts/ fails (framework source and its tooling owe zero; agent probes scripts/__* are excluded in tsconfig.json); errors in the demo tree are measured against the per-file ledger in scripts/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 the COMPLETED marker and the parsed count must match it: a gate that inspected nothing fails, never passes. The mechanism for scripts/: Kit's generated tsconfig lists routes, lib and src, so svelte.config.js pushes ../scripts/**/*.ts (and ../uix.aliases.js) into the include via kit.typescript.config, and the root tsconfig.json excludes the probes — its own exclude overrides the generated one, which is why the entry lives there and not in the config callback.
  • npm run gate — the chain the pre-push hook runs: lint first (prettier --check; the repo-wide one-shot landed in the closure plan's F5, and its commit is listed in .git-blame-ignore-revs) → check:gate → the fast validators (morfo:vocabulary, docs:check, arts:check, blocks:check, packs:check, rtl:check, translations:check, agent:check, eidos:lint) → apps:check (check + build + smoke of apps/base, see consuming.md §8) → the full vitest suite last. Generated artifacts and the frozen web/ tree are in .prettierignore.
  • The pre-push hook — source of truth in scripts/hooks/pre-push; arm it with cp 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.

Format policy

Prettier is the only formatter, and npm run lint (prettier --check .) is the FIRST member of the gate: the tree is either formatted or the push is rejected. Style values (tabs, single quotes, no trailing commas, 100 columns) live in .prettierrc and are summarized in CLAUDE.md → Code Style.

.prettierignore holds five kinds of exception, and nothing else:

Excluded Why
lockfiles the package manager owns them
/static/ assets, not source
the generated artifacts (§ Codegen) — including canon/vocabularies.md the generator owns the bytes; see the invariants above
web/ the frozen demo tree (repository.md)
.claude/ tool configuration, including the author's local settings

The historical repo-wide debt was paid in a single formatting-only commit, and that commit is listed in .git-blame-ignore-revs. git blame ignores it only once per clone, on request:

git config blame.ignoreRevsFile .git-blame-ignore-revs

(GitHub honours the file by name; the Gitea remote this repo pushes to does not — the mitigation is the config above, plus keeping formatting commits pure.)

A formatting pass is a contract change for every guard that reads source text — the next section says why, and what to measure before committing one.

Guards that read source text

A large family of guards here does not run the code: it READS it. A test or a script opens .ts / .svelte / .css / .md files and asserts on the text — contracts.test.ts, the censuses (focus-census, source-census, pack-census, opts-census), recipe-css-contract, value-channels, docs-check, component-audit, eidos:lint, rtl-check. They catch what types cannot: a declaration nobody wired, a token spelled by hand, a doc that copied a canonical list instead of linking it.

The price is a coupling nothing declares: a text guard is coupled to the formatter. Reformat the tree and some of them change what they inspect. One direction is loud, the other is silent:

  • a positive guard (this must be PRESENT) that stops matching goes RED against correct code — noisy, but you find out;
  • a negative guard (this must be ABSENT; the offender list must stay empty) that stops matching goes GREEN while inspecting nothing. The gate says OK and the contract is gone. It is the same failure as a guard whose corpus is empty: check-gate.ts refuses to judge without the COMPLETED marker for exactly this reason.

Measured on the repo-wide pass, one of each:

Symptom Cause
A capture ran past its own declaration into the next one, so a field living in the WRONG type satisfied the assert the delimiter was \}>;, and a long declaration prints as } then >; on the next line
/policy:.*runtime\.focus/ went red against correct code . does not cross a newline, and the value wrapped
Disposition markers in a ## Gaps section dropped to zero *diferir* normalizes to _diferir_, and _ is a word character, so \b stops matching
Six false offenders in the CSS typography rule a per-line matcher read calc( as the whole value, and the /* literal: … */ valve fell outside the match
A whole chronicle became ONE line for anything splitting on \n endOfLine: "auto" amplified a single stray CR into a CR-only file

Writing one that does not depend on the formatter

  • Anchor on tokens, not on line shape. Let whitespace be whitespace: \}\s*>; instead of \}>;, [^;{}]+ instead of [^\n]+.
  • Bound the scan by the construct. A lazy [\s\S]*? needs a delimiter that can only appear where the thing you are matching ends.
  • In markdown, \b is not a word boundary — emphasis markers are word characters. Use letter boundaries: (?<!\p{L})…(?!\p{L}).
  • Ask the compiler when you can. A hand-written morfo-targeting selector is an architecture violation because a rename must break at type level (CLAUDE.md → Eidos drift defense); a guard that could read the AST or import the module instead of grepping is the same argument.
  • Fail on an empty corpus. Assert the count of files/candidates you inspected, so a guard that stops finding its subject goes red instead of green.
  • See it fail. Plant the violation, watch it fail, restore the file byte for byte. A text guard whose red nobody has seen is a decoration.

Before committing a formatting pass

  1. Neutrality of the code — compile and minify every changed file on both sides (esbuild for .ts / .css, the Svelte compiler for .svelte, client AND server) and compare. Differences must be explained, not assumed. (On the repo-wide pass: 1 031 files byte-identical, 9 CSS differing only in whitespace immediately inside a parenthesis, which CSS does not tokenize.)
  2. The guards' own view — extract the patterns of every text guard and count their matches on both sides. A count that moves is a guard to inspect, the negative ones first.
  3. The whole gate, plus the validators that are NOT members (component:audit, theming:sentinel, the browser-driven ones): a red outside the gate is still a red.
  4. One commit, formatting only, listed in .git-blame-ignore-revs.

See also

Powered by TurnKey Linux.