Testing
July 27, 2026 · View on GitHub
Two suites, one per side of the contract:
tests/python/— plan generation (plan_of) and the round trip back toschema.build().tests/js/— normalization, validation and form construction in the browser layer.
Neither touches the network, sleeps, writes into the repository or depends on execution order. The test files document themselves; this page lists only how to run them and the cross-cutting guarantees they exist to protect.
Running
pytest tests/python # Python
node --test "tests/js/*.test.mjs" # JavaScript (Node 20+, installs nothing)
pytest tests/python && node --test "tests/js/*.test.mjs" # both
The Python suite needs the test extra (pip install -e ".[test]"). The Python
tests that shell out to Node — the round trip and the producer-validator
invariant — skip themselves when node is not on the path.
Real-browser smoke
The two suites above run against a lightweight fake DOM, which cannot reproduce a
few behaviours only a real browser has — most importantly that a native control
loses focus when it is disabled, that a range input whose stride misses the
maximum really does reach it, and that a File has a real .size a size bound
can be weighed against (a DataTransfer is the only way to build one, and the
only way to hand it to a read-only FileList).
tests/browser/smoke.html covers those against
the actual shipped modules, with no dependency and no build step. Run it headless
and read the verdict from the DOM:
chrome --headless=new --disable-gpu --allow-file-access-from-files \
--virtual-time-budget=8000 --dump-dom "$SMOKE_URL" | grep '<div id="result"'
where $SMOKE_URL is an absolute file:// URL to tests/browser/smoke.html.
Expect SMOKE: PASS (N checks). It runs in CI alongside the Node suite;
drag-and-drop and byte uploads are out of its scope (they need a live host
channel and are exercised by the bundled demo).
Read the verdict from #result, never from a bare text match. Both browser
pages spell their own PASS string in a comment, so grep 'SMOKE: PASS' matches
whatever the page did — including not running at all. Anchor on
id="result">SMOKE: PASS, which is what CI does.
Both pages import their modules statically. A top-level await import(...)
settles after the load event, so --dump-dom raced it and captured PENDING
about half the time; the fix is the import form, not a larger time budget, which
does not help because the race is not about how much time is granted.
Theme cascade
tests/browser/theme.html covers the other thing only a browser has: the CSS
cascade. The Python suite reads widgets.css as text and can prove the theme
blocks exist, agree on their tokens and clear their contrast thresholds, but not
that specificity, inheritance and proximity land where the contract says. The
page is static — no modules, no library JavaScript — and reads computed styles:
automatic mode, an override applied on the root and on an ancestor, the nearest
override winning, two roots disagreeing on one page, and the host document left
untouched. Run it once per system preference, since automatic mode has two
answers:
chrome --headless=new --disable-gpu --allow-file-access-from-files \
--virtual-time-budget=8000 --blink-settings=preferredColorScheme=0 \
--dump-dom "$THEME_URL" | grep 'id="result"'
preferredColorScheme=0 is dark and =1 is light. Expect
THEME: PASS (N checks) from both. CI runs it after the smoke page, failing the
job and dumping the page when either preference does not reach PASS.
Guarantees
The suites are written around the failures that would otherwise be silent:
- every plan
plan_of()emits passescheckPlan()andcompileForm(), and the full round tripplan_of→ JSON →compileForm→read()→schema.build()holds; - a missing property fails with
<path>: is required, never a default; a mistyped or unknown property fails instead of being ignored; - normalization never mutates or aliases its input, and returns fresh objects;
- every documented JSON example validates as written, with no default filling,
and the
plan.mddefault tables matchstatic/defaults.js; - a string from a plan reaches the page as text, never as markup — hostile
payloads are mounted everywhere and none runs, and the fake DOM
(
tests/js/dom.mjs) throws on every HTML-parsing sink so the check cannot go decorative; - a plan
defaultis constraint-valid against its node, not merely shape-compatible, while a live user value may stay representable yet invalid; enabled: falseon a non-optional field is rejected before any widget exists, at the root and inside nested objects;- a slider never represents emptiness —
setValue(null)throws — and, by the general rule that an integer default must be a safe integer, anulldefault on a non-optional slider field is rejected; - a checkbox always represents a value — never empty, never in error;
setValue(null)and a non-boolean throw, and a non-booleandefaultis rejected; - an
inttyped into a float field becomes a builtfloat: the star round trip"3"→read()→ JSON →decode()→build()yields3.0with typefloat; - a non-finite float — a magnitude that overflows to
Infinity— reads asnulland is never transported, the float analogue of the unsafe integer; decode()prepares without validating: it coercesint → floatonly where aFloatshape is the single reading, never mutates its input, and leaves an invalid value untouched for the core to reject;- a
floatslider is refused at plan generation, becausemin + k*stephas no exact float arithmetic; - an ISO string picked in the browser becomes a built
date/time: the star round tripread()→ JSON →decode()→build()yields the exact Python object, and a date exclusive bound is emitted converted by ±1 day while a time bound keeps its flag; decode()never interprets a string by its content: it converts todate/timeonly where the shape (or an explicit$type) says so, so astrfield or branch carrying"2026-07-22"stays a string;- an enum member name picked in the browser becomes the exact built member: the
star round trip
read()→ JSON →decode()→build()yields the member viacls[name]with type the enum class; an alias name resolves to its canonical member, an unknown name is left for the core, and two enums sharing a class name in a union are rejected by the core at compile time (the class name is the wrapper discriminator); - only a local choice makes a new file reference (the file's name slugged to bare
ASCII, a UUID and the file's extension, the UUID carrying the uniqueness on its
own) — one per file,
list[File]minting an array through onemultiplewidget; a reference is filtered only by extension in the widget and the core, never checked for existence; setValue(string)plants an existing reference, shown on screen declared as the current file (not editable) and transported verbatim, so aStructwith an internal path round-trips through an edit form byte-identical when its file is untouched; and astr | fileunion is inconstructible because both branches carry the option id"str";- a file default is that same reference declared in the plan, and it reaches
the widget through
setValue(), so the two cannot drift: compiling with a default is asserted to produce the same observable state as compiling without one and callingsetValue()afterwards — at the widget level and again across the real Python → plan → browser boundary. A default is never a local selection:file()/files()stay empty anduploads()reports nothing, while a user's pick does appear in both.checkPlan()polices the shape —strfor a single node,list[str]for a multiple one, non-empty, extension-filtered, withinminFiles/maxFiles— and the widget owns the semantics. Existence is neither checked nor claimed in the browser: a Python schema default (a prefill included) is certified byIsPathFilebefore a plan exists, so a missing path, a directory, a wrong extension, a violated byte bound or one bad element of alist[File]each fail at the core with no plan produced — every one of those refusals is pinned to the core's own message, so a regression that moved one into the adapter would show; - which file compositions compile is a test, not a sentence:
File,File | None,list[File],list[File | None],list[File | int],list[list[File]], a dataclass holding a file, a dataclass holding alist[File]and a list of such dataclasses all produce a node, and a guard asserts the blanket "does this contain a file" predicate stays deleted. The browser side drives each nested shape through the public API — rows added and removed, branches switched, a real pick two levels down — anddecode()is pinned to call the resolver once per reference, in order, at any depth; - date and time bounds compare lexicographically over the canonical ISO form,
and the core's rejection of the string-group wrapper is pinned, so
decode()is proven to be the only thing that can unwrap it; read()stays callable and honest while the form is incomplete;- string lengths count code points, so
"😀"is one character; a pattern the validator accepts constructs as a JavaScript UnicodeRegExp, and one the two engines would read differently is rejected in Python; - a placeholder is allowed on an ordinary
str/intinput and rejected on a closed choice, which opens on its first option; - the
Color/Emailaliases are pureAnnotated[str, Pattern(...)]: aColorplan is asserted byte-equal to the hand-written str node, so the contract does not distinguish it; the colour picker is presentation only — mounted by string equality with the publishedCOLOR_PATTERN, whose JS mirror is pinned equal to the Python constant — while the text field stays the source of truth; - every control keeps an accessible name, and generated ids stay unique across nested, repeated and separately compiled forms;
- the stylesheet stays inside its own root and its themes stay complete: every
selector starts at
.pth-rootor is one of the twodata-pth-themeroots, the automatic block skips any overridden root and the manual blocks follow it without!important, the four theme blocks assign the same token set, every palette value has a light/dark pair no rule reads directly, and no colour is written outside the palette. The palettes are then measured: both clear 4.5:1 for text and 3:1 for interface components across every relation that appears on screen (tests/python/test_stylesheet_theme.py, with the parser and the WCAG helper intests/python/stylesheet.py). Thresholds are the contract, not the hex values, so the palette can be retuned without editing a test; - every icon is a file, never an embedding: the production sources carry no
data:image/svg, no inline<svg>and no base64 (comments excepted, so the rule can be written down where it applies), everyurl()in the stylesheet is relative and resolves to a shipped file, every shipped icon is referenced, and each one is valid UTF-8 with a single<svg>root, aviewBox, sane dimensions and no script, no<foreignObject>, no remote link and no editor metadata.static/icons/*.svgis asserted to be declared as package data, because nothing undersrc/ships just for being there; - the documentation's anchors follow GitHub's rule, pinned by a table of cases
rather than by the documents that happen to exist
(
tests/python/test_markdown_anchors.py, with the helper intests/python/markdown_anchors.py): punctuation is dropped without leaving a separator,_and non-ASCII letters survive, repeats take-1/-2, and the heading parser skips fenced code blocks so a shell comment never becomes an anchor. Every internal#fragmentin the documentation is then resolved against those anchors; - every browser file the runtime imports ships inside the package.
The bundled demo is a showcase and a debugging tool; it asserts nothing.
Continuous integration and release
.github/workflows/ci.yml runs on every push to main and on pull requests, in
three jobs: Python (a 3.11/3.12/3.13 matrix running mypy and the
pytest suite, packaging/assets test included), JavaScript (the node --test
suite — widgets, size budget and plan-doc examples — plus the headless-Chrome
browser smoke and the theme-cascade page, the latter once per system
preference), and Packaging (uv build then uvx twine check). Installing
the project resolves its one runtime dependency, pytypehint >= 0.0.6, from PyPI.
Releases are published from .github/workflows/publish.yml, which triggers only
when a GitHub Release is published. It re-runs every check, builds the wheel and
sdist, and uploads them to PyPI through Trusted Publishing (OIDC) — no API
token or password. The workflow targets the pypi GitHub environment, which must
be registered as the Trusted Publisher on PyPI.