English · 日本語
Selectors and deterministic resolution (the determinism core)¶
This module defines how you specify which element to act on or verify, and how it narrows that specification to exactly one match. Every execution path (orchestrator / drivers / assertions) depends on this module. Bajutsu's determinism logic is implemented here.
Implementation:
bajutsu/drivers/base.py.
Related: the determinism principles · the DSL in scenarios · drivers
The normalized element (Element)¶
The driver normalizes the backend's output into a common Element (TypedDict). Resolution and
assertions only ever look at this normalized form (the driver absorbs backend differences).
class Element(TypedDict):
identifier: str | None # stable id (iOS accessibilityIdentifier · web data-testid)
label: str | None # accessibilityLabel
traits: list[str] # normalized traits (below)
value: str | None # accessibility value
frame: tuple[float, float, float, float] # x, y, w, h (points)
Normalized traits (Trait)¶
The common tokens that state assertions look at. Drivers normalize at least these:
| Token | Meaning | Used by |
|---|---|---|
button / link |
kind | the traits selector · doctor's actionable check |
notEnabled |
disabled state | enabled / disabled |
selected |
selected / toggled on | selected |
(idb normalizes enabled: false → notEnabled, selected: true → selected. Type strings drop
the AX prefix and lowercase the first letter: AXButton → button. See drivers/idb.py.)
The selector (Selector)¶
Addresses an element. All provided fields are AND-ed.
| Field | Meaning | Stability |
|---|---|---|
id |
exact accessibilityIdentifier; a list is an OR of candidates (matches any), for one scenario carrying several platforms' id forms |
★ first choice |
idMatches |
glob over id (assumes multiple matches, e.g. "list.row.*"); a list matches any glob |
for set operations |
label |
exact accessibilityLabel |
auxiliary / disambiguation only |
labelMatches |
substring / regex over label (re.search) |
auxiliary |
traits |
narrow by trait (subset test, e.g. ["button"]) |
auxiliary |
value |
exact accessibility value | auxiliary |
within |
scope to a container (geometric: the candidate's frame must sit inside one the within selector resolves to; nestable) |
disambiguation |
index |
nth of multiple matches (negative allowed) | last resort · flaky |
id/idMatchesmatch viafnmatch.fnmatchcase(case-sensitive glob),labelMatchesviare.search(regex / substring),traitsis "the given set ⊆ the element's trait set."
id/idMatchesalso accept a list of candidates — an OR: the element matches when its id equals (or glob-matches) any candidate (BE-0221). This lets one shared scenario carry a platform's differing id spelling, e.g.id: [stable.refresh, stable_refresh]for Android Views'android:id(which can't hold./-). Only one form is on screen per app, so resolution stays deterministic — 2+ matching elements still fail fast. See scenarios.
Authoring vs. runtime representation¶
- The scenario-side selector is
scenario/models/selector.py'sSelector(pydantic, with aliases likeidMatches). - What reaches resolution is
drivers/base.py'sSelector(TypedDict). - The conversion is
Selector.as_selector()(dropsNone, turns it into a TypedDict).
Resolution semantics¶
Apply the selector to the elements from query() to narrow candidates. There are three public
functions.
matches(el, sel) -> bool¶
Whether one element satisfies the per-element conditions (AND). within is a cross-element
(spatial) constraint resolved by find_all, not here.
find_all(elements, sel) -> list[Element]¶
All matching elements. Used for idMatches triggers, count assertions, and exists
(multiple matches allowed).
resolve_unique(elements, sel) -> Element¶
Resolves to exactly one element for a single action. The most important function for Bajutsu's determinism.
| Candidate count | Behavior |
|---|---|
| 0 | ElementNotFound (an immediate action fails; via a wait (wait_until), it times out) |
| 1 | resolved |
| 2+ | raises AmbiguousSelector — structurally rules out "tap whatever matched first" |
As an exception, only when index is given does it pick the nth of multiple candidates
(out-of-range = ElementNotFound). index breaks on order changes, so it is a last resort. For
sets, use idMatches + count (scenarios).
# drivers/base.py (excerpt)
def resolve_unique(elements, sel):
candidates = find_all(elements, sel)
if "index" in sel:
... # nth (out-of-range raises ElementNotFound)
if not candidates:
raise ElementNotFound(...)
if len(candidates) > 1:
raise AmbiguousSelector(...) # needs within or index to disambiguate
return candidates[0]
Exception hierarchy: SelectorError (base) ← ElementNotFound / AmbiguousSelector. The
orchestrator and assertions catch these and translate them into "step failure" / "assertion
failure" (they do not propagate the exception upward).
Centralized regardless of backend¶
idb exposes no usable semantic tap, so the abstraction always verifies the candidate count via
query() before acting, then taps the resolved element's frame center. This up-front count check makes the "ambiguous =
fail" behavior identical across idb / playwright / fake (each driver's tap implementation is in
drivers).
The id comes straight from idb's element tree (AXUniqueId), normalized into Element.identifier,
so the id selector resolves directly against the normalized form.
Assertion evaluation¶
Implementation: bajutsu/assertions/ (evaluate.py, split from a single module in BE-0250).
evaluate(elements, assertions) -> list[AssertionResult]
evaluates each assertion, and passed(results) ANDs them. Evaluation is total: a resolution
failure (not-found / ambiguous) is returned as a failed AssertionResult rather than an exception
(it lands straight in the report).
@dataclass(frozen=True)
class AssertionResult:
ok: bool
kind: str # "exists" / "value" / ...
detail: str # what was checked (for the report)
reason: str # failure reason (empty when ok)
Per-kind mechanics:
| Kind | Resolution | Decision |
|---|---|---|
exists |
find_all ≥ 1 |
found != negate (negate checks absence) |
value |
resolve_unique (ambiguous / not-found fails) |
compares value via equals/contains/matches |
label |
same | compares label likewise |
count |
the find_all count |
equals/atLeast/atMost |
enabled |
resolve_unique |
the notEnabled trait is absent |
disabled |
resolve_unique |
the notEnabled trait is present |
selected |
resolve_unique |
the selected trait is present |
request |
matches over the observed network exchanges (not the element tree) | equals/atLeast/… via count, else ≥ 1 (network) |
Only
existsusesfind_all(allows multiple); the other single-element assertions useresolve_unique(ambiguous fails). So "tried to check the value when there were two matches" also fails deterministically. Therequestkind is the one non-UI assertion — it checks the captured HTTP(S) exchanges instead of the elements (eight kinds in total).