Skip to content

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: falsenotEnabled, selected: trueselected. Type strings drop the AX prefix and lowercase the first letter: AXButtonbutton. 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 / idMatches match via fnmatch.fnmatchcase (case-sensitive glob), labelMatches via re.search (regex / substring), traits is "the given set ⊆ the element's trait set."

id / idMatches also 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's Selector (pydantic, with aliases like idMatches).
  • What reaches resolution is drivers/base.py's Selector (TypedDict).
  • The conversion is Selector.as_selector() (drops None, 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 AmbiguousSelectorstructurally 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 exists uses find_all (allows multiple); the other single-element assertions use resolve_unique (ambiguous fails). So "tried to check the value when there were two matches" also fails deterministically. The request kind is the one non-UI assertion — it checks the captured HTTP(S) exchanges instead of the elements (eight kinds in total).