Spec 004D — ui.test selector grammar¶
Status: v1-required. The one page for the
ui.test/find/find-all/queryselector language, referenced by 004-Views.md §The JVM structural subset and theui.testcontract in 008. Nodes are plain maps whose attrs/events live under their own keys — reconciled with the tree contract (004B-UI-Tree-and-Conversion.md): view-id selectors match the tree's view-boundary node. Every form below names its consumer; no speculative combinators.
Scope¶
(ui.test/find tree selector) and (ui.test/find-all tree selector) are structural
queries over Tier-1 trees — the value ui.test/render returns on the JVM (or node),
per 07 §2. tree is any structural map node per the tree contract (element,
fragment, view-boundary, or trusted-HTML; text content is a string, not a node —
results compose; see §What find returns).
Passing a Tier-3 mounted root to find/find-all is a typed error pointing at
ui.test/query (same typed-error style as the JVM-subset errors, 06 §1). The Tier-3
contract is stated by contrast in the final section.
The grammar (closed)¶
selector := tag-kw ; unqualified keyword — element tag
| view-sel ; qualified keyword (registered view id) or the defview Var
| attr-map ; map of attr-key → expected value, matched by rf=
| pred-fn ; (fn [node] boolean) — the escape
No other form is a selector. Deliberately absent: sibling/nth/positional combinators,
wildcard, text-content selectors, attribute-presence-without-value — no guide example or
07 §3 fixture needs them; pred-fn covers the residue. The path/vector form
[selector+] is a demand-bar-deferred item — not shipped at S1 (Stage 1 answered
the demand bar; see [OPEN-2]); a vector selector raises :rf.error/ui-test-bad-selector
naming the composed-find idiom (find (find tree :form) :button).
tag-kw— an unqualified keyword matches a node whose element tag is that keyword, exactly (:buttonmatches a:buttonelement node; no substring, no case-folding). Consumer: guide 09 Tier-1 example ((ui.test/find :button)).view-sel— a qualified keyword matches the view-boundary node whose:view-idis that keyword (the tree contract's explicit node variant for each internal-view expansion); the defview Var is accepted and resolves to its registered id. Because the boundary node exists even when no single root element does, fragment-rooted and nil-rooted views are matchable — the match is the boundary marker, not a root element. Consumer: 07 §2's contract row ("tag, view id, attr predicates"); Story scenes mount by view id (guide 09 §Story). The unqualified/qualified split is what disambiguates tags from view ids; a hypothetical unqualified view id is not selectable by keyword — use the Var.attr-map— matches a node iff for every entryk → vin the map,kis present in the node's projected attribute map —(ui.test/attrs node), the tree contract's merged projection: attrs + events on elements, props on view-boundary nodes — and its value isrf=tov(the value-equality relation of [spec/006 §Host value model]; so{:on-click [:cart/add 42]}matches by event-vector value via the:eventsslot, and map/vector-valued attrs compare structurally). A key absent from the projection never matches; emitter-produced canonical trees carry no present-nil attrs (nil-valued entries are dropped at tree build), so the present-nil arm ofrf=rule 5 governs only hand-built maps.{}matches every node (vacuous truth; harmless, not useful). Consumers: 07 §2's "attr predicates";{:data-testid "x"}for stable test ids; event-vector matching serves guide 09's intent-assertion style ("what does this button do" as an equality check); prop matching on view-boundary nodes rides the same rule at zero extra grammar.pred-fn— any fn; matches a node iff(pred node)is truthy. The node argument is always a map node (text content is never visited), and supports the read surface in §Whatfindreturns. This is the closed grammar's escape hatch (escapes advertise their cost, I-14): reach for it only when the three data forms cannot express the match. Consumer: harness completeness; keeps the data grammar closed instead of growing combinators.
Matching semantics¶
- Traversal order is depth-first pre-order — document order — over the tree's map nodes (elements, fragments, view boundaries, trusted-HTML), descending through fragment and view-boundary children alike; trusted-HTML nodes are leaves (their markup is unparsed) and text content is not visited. A selector is tested against the given node itself and its descendants, in that order.
findreturns the first match in document order, or misses (§Failure).find-allreturns a vector of all matches in document order (possibly empty).- Descendant-scoped assertions compose
find— a found node is itself a validtreeargument, so(find (find tree :form) :button)finds a:buttondescendant of the first:formmatch. This composition is the supported idiom; the sugar[selector+]path form is demand-bar-deferred and not shipped (see [OPEN-2]).
What find returns¶
A structural node of the Tier-1 tree — a plain map per the tree contract
(004B-UI-Tree-and-Conversion.md), whose
field names (:tag / :attrs / :events / :children / :view-id / …) are that
contract's versioned public ABI. Its read surface:
- Queryable. A node is itself a valid
treeargument:(find (find tree :form) :button)composes — the supported idiom for descendant-scoped assertions (the[selector+]path sugar that would fold over it is demand-bar-deferred; see [OPEN-2]). - Keyword lookup reads the node's fields, never its attributes. Attrs and events
live under their own keys (
(:events node),(:attrs node)), so(:on-click node)is a field miss — the ruled node-reading contract. The attribute read is the projection below. (ui.test/attrs node)— the merged projection: attribute map + handler map on elements (collision-free — the compiler routes:on-*to:events), the props map on view-boundary nodes,{}on fragments/trusted-HTML,nilonnil(nil-punning). Handler slots carry event vectors as data — so intent is an equality check:(is (= [:cart/add 42] (:on-click (ui.test/attrs (ui.test/find tree :button))))). (guide 09's example predates this reconciliation and needs the same one-line respell — a fold-in ripple owned by the guide pass.)(ui.test/text node)— the concatenation of the node's text descendants in document order, e.g."Add to cart"; trusted-HTML nodes contribute nothing (their content is unparsed markup). No whitespace normalization beyond what the tree carries.
Children are traversed only via find/find-all (selectors are the query surface —
(:children node) is a public field read, but the selector grammar is the supported
query idiom); the node shape, canonical form, and view-boundary variant are owned by
the tree contract, which this grammar consumes.
Failure behavior¶
find→nilon no match. Idiomatic nil-punning:(-> tree (ui.test/find :button) ui.test/attrs :on-click)yieldsnilthrough the thread —(ui.test/attrs nil)isnilper the tree contract's projection rules — so assertions fail withnil ≠ expected.find-all→[]on no match.ui.test/find!— the recommended strict variant (demand-bar item OPEN-3, not shipped at S1): identical selector semantics; it would throw a typedex-infoon zero matches, carrying the selector, the search root's tag/view-id, and a bounded tree digest (the miss diagnosticsnilcannot give). It would not police uniqueness — first match wins even when several exist; a getBy-style uniqueness-asserting variant is unearned (no consumer). Recommended error id::rf.error/ui-test-find-miss— its Spec 009 catalogue row and 07 §2 contract row land with the feature when it ships (rows-land-with-features), not at S1 promotion. S1 promoted withoutfind!, so:rf.error/ui-test-find-missis correctly un-catalogued today. (See [OPEN-3].)
Tier-3 by contrast — (ui.test/query root css-selector)¶
query is the live-DOM counterpart of find, and shares nothing with the grammar
above:
find / find-all (Tier 1) |
query (Tier 3) |
|
|---|---|---|
| Input | structural tree (data) | mounted root (with-root) |
| Selector | the closed grammar above | native CSS selector string, verbatim |
| Engine | harness matcher, rf= semantics |
the host DOM's querySelector (jsdom or real browser) |
| Returns | structural node / nil |
live DOM element / nil |
| Reads | attrs / text projections + node field reads; event vectors as data |
host interop ((.-value el) — guide 09); real listeners, not vectors |
| Runs on | JVM + node, ms, no flake | browser/jsdom CI |
No rf= matching, no structural projections, no view-id selectors on the Tier-3 side —
CSS is the whole contract (data-testid attributes work via ordinary CSS
[data-testid=x]). There is no query-all: no consumer needs it (demand bar); add it
only when a fixture does. attrs/text on a DOM element, or a CSS string handed to
find, are typed errors pointing at the other tier.
[OPEN] items¶
- ~~[OPEN-1] View-boundary annotation slot.~~ — RESOLVED (2026-07-12) by the
tree contract's view-boundary node variant
(004B-UI-Tree-and-Conversion.md §Node
schema): each internal-view expansion is wrapped in a real
{:view-id …}node (the structural analogue of the DOMdata-rf-viewcontract, spec/006 §View tagging), so a view-id selector matches the boundary marker itself. Fragment-rooted and nil-rooted views are matchable — the boundary node exists (with several children, or none) even when no single root element does. - ~~[OPEN-2] Path-form demand.~~ — RESOLVED (2026-07-12): dropped. No Stage-1
fixture or guide example materialised the need, so the vector/path form was not
shipped —
re-frame.ui.testraises:rf.error/ui-test-bad-selectorfor any vector selector, naming the composed-findidiom(find (find tree :form) :button). The form was pure sugar over node-composedfind, so dropping it cost nothing; re-open only when a fixture demands it. - [OPEN-3]
find!inclusion — deferred at the S1 demand-bar audit. Recommended (miss diagnostics; every Tier-1 test is the 08 §3 consumer forui.test/*), but it is the one name here beyond 07 §2's table and no Stage-1 fixture forced it — S1 shipped withoutfind!(findnil-punning + composed asserts covered the need). It remains a recommended future addition: when it ships, add the 07 §2 row and the:rf.error/ui-test-find-miss009 catalogue row together with the feature. (The "bounded tree digest" it carries can now be defined as a truncated canonical-EDN print per the tree contract's canonical form.)