Driver & selectors¶
The common interface every backend satisfies, and the shared types a selector resolves against.
bajutsu.drivers.base
¶
Driver abstraction — the linchpin shared by every backend, real or fake.
Frozen first because everything else depends on it: - common types Point / Element / Selector - the Driver Protocol (only the actuator performs actions) - selector resolution (the determinism core): a single action requires a unique match, and an ambiguous match (2+) raises AmbiguousSelector to rule out nondeterminism structurally.
Capability
¶
Capability names returned by Driver.capabilities().
Used to pick the actuator and resolve fallbacks. A backend with SEMANTIC_TAP actuates more stably (no coordinates involved).
Element
¶
Bases: TypedDict
A single on-screen element, normalized from a device backend's output.
Trait
¶
Normalized accessibility traits.
Drivers normalize at least the following to these common tokens. NOT_ENABLED and
SELECTED back state assertions and BUTTON / LINK the traits selector and doctor
check; OTHER instead backs resolve_unique's ambiguity filtering (below).
Selector
¶
Bases: TypedDict
How to address an element. Provided fields are combined with AND.
The stable selector is id (non-localized, data-derived). label /
labelMatches are auxiliary; index is a last resort (flaky).
InterruptionPolicyTarget
¶
Bases: Protocol
A backend that answers an alert interrupting one of its own interactions, by our policy.
A narrow opt-in, like ViewportProvider / ActuationReporter: a backend that does not implement
it is simply never asked, and the run is otherwise unchanged. Only XCUITest needs it. XCUITest
resolves an out-of-process alert that interrupts an interaction before it synthesizes that
interaction, and with nothing installed it answers using the alert's own default button — the
opposite of the least-destructive policy the guard applies, and invisible to the run's report.
set_interruption_policy hands over the labels AlertGuardConfig has already resolved (a rule's
identifying label set with the label it taps, then the ordered fallback candidates), so the
decision stays in the orchestrator and the backend only applies it. drain_interruptions takes
back what it answered, so such a dismissal reaches the report as an AlertEvent rather than
happening silently.
Driver
¶
Bases: Protocol
Common interface for every backend.
Actions (tap/type/swipe/wait/query) are performed by the actuator only. On a backend without semantic tap (a coordinate-only backend), the abstraction resolves the frame center via query() / resolve_unique() and taps by coordinates.
EvidenceProvider
¶
Bases: Protocol
A read-only evidence source from a non-actuator backend (BE-0020).
A multi-backend run keeps actuation on the one actuator and may consult another same-platform
backend read-only to fill an evidence gap the actuator lacks (e.g. a backend with no native
network capture, so a same-platform backend supplies it). The narrow surface — capabilities plus observation
methods only, never tap / type / swipe / wait / query — makes "the fallback never
actuates" a type-level fact rather than a convention.
ViewportProvider
¶
Bases: Protocol
A backend that can report its true viewport size, for the scroll stop condition (BE-0326).
scroll stops when the target's frame center lands inside the viewport, so it needs the real
viewport bounds — and the queried tree cannot supply them, because a lazy list keeps buffered
off-screen rows in the tree (and the web tree keeps off-screen DOM nodes), so
screen_size_from_elements overshoots the screen and would judge an off-screen center as
on-screen. Each backend reports the real viewport its own way: Playwright via window.innerWidth
/ window.innerHeight, adb via wm size, XCUITest via the runner's app-window frame, and
FakeDriver from its in-memory scrollable model. The handler falls back to
screen_size_from_elements for any backend that does not implement this, so it stays a narrow
opt-in rather than a Driver requirement.
ReadLagProvider
¶
Bases: Protocol
A backend whose query() can describe the screen as it was before the last actuation.
Most backends read synchronously enough that a fresh query() already reflects the action just
performed, so scroll can treat one unchanged region as proof the content stopped. Android breaks
that assumption: the gesture moves the list, and the accessibility update naming the new frames is
published afterwards, so a read taken in between returns the pre-scroll tree even though the
content has already moved. Left unhandled, that late tree becomes a spurious "end of content"
failure.
A backend that can lag reports the budget scroll should give a step's result before concluding
the region really stopped. Not implementing this means "my reads do not lag", which keeps the
end-of-content failure immediate — so the fast, synchronous backends (FakeDriver, Playwright,
XCUITest) pay nothing and stay exactly as fail-fast as before. A narrow opt-in, like
ViewportProvider above, rather than a Driver requirement.
ReadOrderProvider
¶
Bases: Protocol
A backend that can tell whether its last query() postdates the last actuation (BE-0332 Unit 3).
ReadLagProvider above bounds the wait for a lagging read with a wall-clock budget; this answers
the ordering question directly. Android's resident reader stamps each read with the device-clock
time of the most recent accessibility event it has seen, and the driver takes a device-clock mark
before each gesture, so it knows the moment a read reflects device state after the action — no
host-to-device clock skew, because both marks are the device's.
No production caller reads this through the protocol today. The extract poll used to release
early on a confirmed order, until that release was found to accept a stale value — the mark says
an accessibility event postdates the gesture, not that the property being copied out has been
republished — so extract now keeps its wall-clock budget unconditionally. The driver's own
catch-up barrier is the remaining ordering consumer, and it reads the backend's device mark
directly rather than through here. The protocol stays declared because the driver conformance
suite (BE-0114) checks the marked-read contract against the real backend, and because narrowing
the barrier to the reads that still need it is an open unit of the device-side actuation item — a
live contract without a live caller, not a leftover. A backend that cannot answer simply does not
implement it, and every poll keeps its wall-clock budget unchanged — the same narrow opt-in as
ReadLagProvider and ViewportProvider.
SettledReadProvider
¶
Bases: Protocol
A backend whose reads need settling before a coordinate is resolved from one for actuation.
Every selector-addressed actuator the adb driver owns — tap, double_tap, long_press,
pinch, rotate — resolves its target through the driver's own settle, which waits out the
catch-up barrier so the frame a touch aims at comes from a tree the device published after the
previous gesture. A directional swipe and a drag cannot: their endpoints are computed above
the driver (_directional_endpoints), which only has query() to work with, and the driver
receives two coordinates that no longer name an element. One unbarriered read there is enough to
anchor a pan on the previous screen's frames — the failure mode ReadLagProvider above describes,
reached by the one door the barrier does not cover.
A backend that needs the settle exposes it here, so the handler can ask for an actuation-grade
read rather than a bare one. Not implementing this means "a single query() is already good
enough to actuate from", which keeps the synchronous backends (FakeDriver, Playwright,
XCUITest) on exactly the read they take today. A narrow opt-in, like the three protocols above,
rather than a Driver requirement.
RawSource
dataclass
¶
The device's own reply behind a backend's last _describe(), untouched by bajutsu's processing.
text is the reply exactly as the device/runner answered it — before any structural transform a
backend applies and before Element normalization — so a diagnosis can tell "the device's own dump
already looked wrong" apart from "bajutsu's own processing changed it". parsed_input is the same
read after a backend's own structural transform of it, when that transform actually changed
something: adb's resident channel strips SystemUI decor windows (narrow_to_active_window) before
handing the result to parse_hierarchy, so parsed_input is what the parser actually consumed.
None when the backend applies no such transform (the dump-subprocess path, XCUITest) or the
transform left text unchanged — text alone already describes what was parsed. suffix names the
format text is actually written in (adb: .xml; XCUITest's GET /elements body is undecoded JSON,
so it sets .json) — carried here rather than hardcoded by the writer, so a future
RawSourceProvider with a different dump format needs no edit outside the backend that produces it.
Required, not defaulted: a default of .xml would let a future backend construct
RawSource(text=body) and silently mislabel a non-XML dump, the exact bug this field exists to
prevent — every producer must state its own format, or mypy catches the omission at the call site.
RawSourceProvider
¶
Bases: Protocol
A backend that retains the raw dump behind its last parsed tree, for the rawTree capture kind.
Every coordinate-tree backend's frame computation is normally a black box once parsed into
Elements: diagnosing whether a mismatch between the screen and a resolved coordinate comes from
the device's own dump or from bajutsu's parsing of it needs the dump itself, which _describe()
otherwise discards as a local variable the moment it is parsed. A backend that keeps it exposes
this seam so bajutsu/evidence/core.py's write_raw_tree can persist it alongside elements.json
— opt-in (a scenario's capture: [rawTree, ...]), never in the default capture list, since it adds
a same-sized text artifact per captured step. AdbDriver and XcuitestDriver implement it (the raw
UI Automator dump, the raw GET /elements body). Not implementing this means "no raw dump to
persist", which keeps every other backend (FakeDriver, Playwright) exactly as before — the same
narrow opt-in as the protocols above (BE-0351).
SettledCacheInvalidator
¶
Bases: Protocol
A backend whose settle-proof cache must be dropped by something outside its own actuators.
AdbDriver._settle() caches a key proven stable so a later call can skip re-polling — but that
proof describes a specific screen, and only the driver's own actuators (_act, _device_act,
type_text) know to invalidate it when they change one. An app relaunch or a crawl reset
replaces the screen through the platform's own launch/kill commands, never through this driver,
so nothing would otherwise tell the cache its proof no longer applies — and if the new screen's
projection happens to coincide with the stale one (unremarkable: many scenarios start and end on
the same home screen), _settle would trust a single read of a screen it never actually proved
at rest. A lifecycle path that replaces the screen outside the driver calls
invalidate_settled_cache() to close that door too. Not implementing this means "no such cache
to invalidate", which keeps every other backend (FakeDriver, Playwright, XCUITest) exactly as
before — the same narrow opt-in as the protocols above (BE-0351).
BackendLifecycle
¶
Bases: Protocol
The full set of lifecycle hooks backends run around a single run (BE-0141).
A run launches, tears down, and resets a backend, but those steps are platform-shaped: the web
(Playwright) backend navigates / closes / resets a browser context, the XCUITest backend waits
for its on-device runner to answer (and probes its health once during a cold spawn, BE-0319), and
the fake backend needs none of them. These hooks are therefore split disjointly across backends —
no single driver implements the whole set — so this is a typing umbrella for the call sites, not
a conformance target: the platform_lifecycle environments reach each hook through
cast(BackendLifecycle, driver) under the platform invariant that already scopes the driver,
which turns "the hook exists" into a mypy-checked fact (a renamed or dropped hook fails
make check instead of at runtime) without forcing a lifecycle-free backend to stub no-op methods. @runtime_checkable
mirrors EvidenceProvider, but a structural isinstance holds only for a class implementing the
whole set — which the concrete drivers, owning disjoint subsets, deliberately do not.
SelectorError
¶
Bases: Exception
Selector resolution failed.
UnsupportedAction
¶
Bases: Exception
The actuator backend cannot perform this action.
For example, a multi-touch gesture on a single-touch backend. The tool surfaces it as a step failure with a clear reason rather than letting it pass silently.
ManualStepRequired
¶
Bases: UnsupportedAction
A recorded manual takeover step has no deterministic run-time equivalent (BE-0185).
Raised at run time so a human-takeover marker (a CAPTCHA, a biometric prompt) fails loudly and
visibly with its label rather than a silent pass or a hang — the honest boundary for an operation
only a human can perform. A subclass of UnsupportedAction so the run loop surfaces it as a
clean, labeled step failure like any other action the environment cannot perform.
ElementNotFound
¶
Bases: SelectorError
No candidate matched. A wait times out; an immediate action fails.
AmbiguousSelector
¶
Bases: SelectorError
2+ candidates with no way to disambiguate; needs within or index.
ElementNotTappable
¶
Bases: Exception
The selector resolved uniquely, but the element could not be reached at its own point.
Obstructed by another on-screen element, or the platform's own hit-test refused it — even
after the bounded scroll safety net tried to clear the obstruction. Distinct from
SelectorError: resolution succeeded. Only reachability failed.
BackendCrashError
¶
Bases: RuntimeError
The backend's driver process crashed mid-scenario and could not be recovered in place.
Distinct from a test outcome and from a transient blip a driver's own retry absorbs: it names an
honest "the backend died" — the resident XCUITest runner's XCTest host, an adb server, a browser
process — where the crash outlived the driver's in-place recovery budget, so the current
scenario's state is gone. The run pipeline treats it as backend infrastructure, not a verdict
(prime directive 1): it discards the dead lease, leases a fresh device (a cold respawn), and
re-runs the whole scenario from the start, bounded — a genuinely crash-inducing app still fails
loudly once the retries are spent, so flakiness is never absorbed into a pass (BE-0049). Backends
raise a subclass (e.g. XcuitestRunnerCrashError); the pipeline catches this base so the recovery
stays backend-agnostic (prime directive 3).
Queryable
¶
Bases: Protocol
Just the current-screen read a wait needs — the query surface, not a full Driver.
default_wait_for reads one screen and matches; a shared read base like CoordinateTreeDriver
supplies exactly that without implementing the whole actuator surface, so typing the helper to
this narrow protocol lets both a full Driver and such a base delegate to it.
permission_capability(service)
¶
The per-service device-control token for a permission service (BE-0276).
One token per vocabulary entry rather than a single deviceControl.permissions token, so a
backend that honors only part of the vocabulary (iOS: everything but notifications) can
advertise exactly that subset and preflight names the unsupported service individually.
native_z_from_json(value)
¶
Read a persisted nativeZ back off JSON, degrading anything unrepresentable to None.
The one rule every reader of a written elements.json or golden file shares, so a value that
round-trips through evidence means the same thing as one straight off a driver. nativeZ is
diagnostic only (BE-0355) and no assertion reads it, so a malformed value degrades to the same
honest absence an uninstrumented app reports instead of failing a load that would otherwise
succeed. bool is excluded deliberately: it is an int subclass, and True is not a position.
id_candidates(v)
¶
A single id/pattern or a list of OR candidates, normalized to a list (BE-0221).
validate_id_candidates(field, value)
¶
Reject a malformed id / idMatches OR-candidate list; a no-op for a string or None (BE-0221).
Shared by the scenario Selector model and config's readyWhen (a base.Selector) so a
candidate list is checked the same way wherever it is authored. A list must be non-empty with no
blank entry, and if it contains any dotted (SPEC-form) candidate, that candidate must lead:
single-id consumers — the resolver's representative pick, audit coverage bucketing
(namespace_of splits on .), the XCUITest / Playwright codegen emitters — take candidate[0], so
a dotted-but-not-first list resolves fine at runtime but silently skews them. Failing at load
beats debugging a skewed report. An all-underscore list (no dotted candidate) is accepted as-is.
Raises:
| Type | Description |
|---|---|
ValueError
|
the list is empty / has a blank entry, or a dotted candidate follows a non-dotted first one. |
matches(el, sel)
¶
Whether an element satisfies a selector's per-element conditions (all AND-ed).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
el
|
Element
|
One element from a |
required |
sel
|
Selector
|
The selector to test. Only the per-element fields are checked here
( |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when every per-element field set on the selector matches the element. |
contains(outer, inner)
¶
Whether inner's frame is spatially contained in outer's (edges inclusive).
find_all(elements, sel)
¶
Every element matching the selector — backs idMatches resolution and count assertions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
elements
|
list[Element]
|
One |
required |
sel
|
Selector
|
The selector to match. |
required |
Returns:
| Type | Description |
|---|---|
list[Element]
|
The matching elements, in |
deadline_ticks(timeout, poll_init, poll_max=None)
¶
Yield once per poll to a monotonic deadline, sleeping with capped backoff between ticks.
The one deadline/backoff skeleton the condition waits share (BE-0118, BE-0256): wait_until
here and the platform-lifecycle readiness waits (await_ready / await_boot) each run their
own check body on every yield and decide what to return, while this owns only the monotonic
deadline, the exponential backoff (poll_init doubling up to poll_max), and the
never-sleep-past-the-deadline sleep — a condition wait with no fixed up-front sleep, so a
timeout means the same real seconds regardless of the caller. A fixed interval is
poll_max is None (or equal to poll_init); the first yield fires before any sleep.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout
|
float
|
Seconds from the first tick before the deadline passes. |
required |
poll_init
|
float
|
The first inter-tick sleep, doubling each tick. |
required |
poll_max
|
float | None
|
The backoff ceiling; a fixed |
None
|
wait_until(driver, sel, timeout, poll=0.2)
¶
Poll driver.wait_for(sel) against a monotonic deadline until it matches.
A condition wait with no fixed sleep, mirroring the orchestrator's discipline — it turns the
backend's single-shot wait_for into a timeout-honouring wait over deadline_ticks, so a
timeout means the same real seconds regardless of which backend drives.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
driver
|
Driver
|
The backend whose single-shot |
required |
sel
|
Selector
|
The selector to wait for. |
required |
timeout
|
float
|
Seconds to keep polling before giving up. |
required |
poll
|
float
|
Seconds slept between checks. |
0.2
|
Returns:
| Type | Description |
|---|---|
bool
|
True once the selector matches; False if |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
resolve_unique(elements, sel)
¶
Resolve a selector to exactly one element for a single action.
A single action requires a unique match, so an ambiguous selector fails rather than acting on
"whatever matched first" — the determinism core (BE-0001). Candidates that report identical
content (identifier, label, traits, value, and frame all equal — a known XCUITest duplicate
registration for a standard UIAlertController button) are collapsed to one first, since
nothing distinguishes them for the caller to disambiguate on; a genuinely different-content
match still counts toward ambiguity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
elements
|
list[Element]
|
One |
required |
sel
|
Selector
|
The selector to resolve. |
required |
Returns:
| Type | Description |
|---|---|
Element
|
The one element the selector resolves to. |
Raises:
| Type | Description |
|---|---|
ElementNotFound
|
Nothing matched, or |
AmbiguousSelector
|
Two or more content-distinct candidates matched — with |
default_wait_for(driver, sel)
¶
The single-shot wait_for body every real backend delegates to (BE-0118, BE-0251).
Whether sel matches the driver's current screen, checked once — the shared wait_until
owns the deadline poll, so a backend never loops here. Hoisted into one definition so the
identical driver bodies can't silently diverge; a backend that can wait natively still overrides
wait_for rather than calling this.
Returns:
| Type | Description |
|---|---|
bool
|
True when at least one element matches the selector right now. |
frame_center(frame)
¶
The center point of an already-resolved element frame (BE-0251).
Takes the resolved (x, y, w, h) so it stays pure geometry — each backend keeps its own
selector-to-frame resolution and routes only the arithmetic through here.
topmost_at_point(elements, point, target)
¶
The element (if any) that covers point and is not target itself or its descendant.
Used where a backend has no native "is this point actually reachable" primitive (unlike iOS's
isHittable or the web's document.elementFromPoint): document order — the order elements
already comes in — is a paint-order proxy, a later element having been drawn after (so on top
of) an earlier one in the ordinary case. A non-None result means an unrelated element
genuinely covers target's point; None means nothing does, as far as this proxy can tell.
target must be one of the objects in elements (found by identity, is, not equality) — every
caller resolves it from the very same tree it now re-scans. The search looks only after
target's own position, which is what makes a frame-containment check for the ancestor
direction unnecessary: a real ancestor is always emitted before its descendants in a pre-order
document walk, so it can never appear after target and never needs excluding by geometry. A
naive full-list scan would have to guess "ancestor vs. an unrelated, larger overlay" from frame
containment alone — indistinguishable, since Element carries no parent/child pointers — and
that guess would misjudge the single most common real case this function exists for: a
same-size-or-larger backdrop, sticky header, or toast drawn after (so on top of) a smaller
target, which geometrically contains the target's frame exactly the way a real container
would. Restricting the scan to same-or-later elements sidesteps that ambiguity entirely instead
of resolving it wrong.
A descendant (nested inside target's own frame, e.g. an icon inside a button) is still excluded
by containment (contains(target frame, candidate frame)) — tapping through it still taps
target, and unlike an ancestor, a descendant always comes after target, so it is the one
case this scan does need to filter out geometrically. This is a heuristic, not a real z-index:
it can misjudge a layout whose actual paint order diverges from document order (e.g. an Android
View.elevation reordering draw order without reordering the accessibility tree), and two
unrelated elements sharing target's exact frame are indistinguishable from a same-size
wrapper/descendant pair — callers that rely on it should say so.
redirect_candidates(elements, target)
¶
The named descendants a refused actuation on target could be redirected to, in document order.
The mirror image of topmost_at_point: that function scans the same after-target slice and
throws away exactly what this one keeps. A platform can report a container inflated over the
control it wraps — a SwiftUI Stepper whose accessibility element spans its whole form row, say —
and refuse a tap on the container while the control inside it is perfectly reachable. These are
the elements a caller may then offer the platform instead.
Three conditions, each ruling out a way the offer could be wrong:
- After
targetin document order.Elementcarries no parent pointer, so geometry alone cannot tell a descendant from an ancestor or from an unrelated overlay that happens to enclose the same frame. A pre-order walk always emits an ancestor before its descendants, so the slice does the work no frame check can — the same reasoningtopmost_at_pointspells out. - Inside
target's frame (contains, edge-inclusive). An equal frame counts: a control registered twice at one place is a redirect target as legitimate as a smaller child, and it still has to satisfy the last condition. - Carrying an identifier. The offer is then always an element the caller could have named directly, which is what keeps a redirect from becoming a guess the scenario's author cannot predict — and what lets a refusal print the candidates it declined to choose between.
target must be one of the objects in elements, found by identity (is) rather than equality,
the way every caller already resolves it from the very tree it now re-scans. A target absent
from the list has no descendants to offer, so the result is empty rather than an error.
raise_if_covered(elements, el, sel)
¶
Raise ElementNotTappable if topmost_at_point finds something covering el's own point.
Shared by every backend that falls back to the document-order proxy rather than a native
hit-test (adb's two call sites, FakeDriver, XcuitestLiveDriver) — one place for the check,
the message, and the covering element's own identifier/label/frame, so a failure names what
covered the target instead of leaving a caller to reproduce the screen by hand to find out.
gesture_anchor(frame)
¶
A two-finger gesture's center and finger half-distance for a resolved frame (BE-0251).
The half-distance is a quarter of the smaller side, so the two fingers (and a pinch-out up to ~2x) stay within the element's bounds rather than landing on a neighbour.
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
|