コンテンツにスキップ

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.

set_interruption_policy(rules, candidates)

Hand the backend the buttons it may press on an interrupting alert.

drain_interruptions()

The labels the backend answered since the last call, oldest first.

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 query() snapshot.

required
sel Selector

The selector to test. Only the per-element fields are checked here (id / idMatches / label / labelMatches / traits / value); within (a cross-element spatial constraint, resolved by find_all) and index (a positional pick among matches, applied by resolve_unique) are ignored. id / idMatches may be a list of candidates, satisfied when the element matches any one (BE-0221).

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 query() snapshot.

required
sel Selector

The selector to match. within scopes the result to elements spatially contained in a container the within selector resolves to: the accessibility tree is flat, so "parent" is geometric — a candidate qualifies when its frame sits inside a container's, and within may nest.

required

Returns:

Type Description
list[Element]

The matching elements, in elements order.

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 poll_init interval when omitted.

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 wait_for is polled.

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 timeout elapses first.

Raises:

Type Description
ValueError

poll is negative (a caller error surfaced loudly rather than left to time.sleep's opaque exception).

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 query() snapshot of the on-screen elements.

required
sel Selector

The selector to resolve. index is honored only as a last resort, picking the nth of several content-distinct candidates (negative values count from the end) — with any other-trait ties among them dropped first, unless the selector itself targets other or every candidate is one — from the same filtered set the ambiguity count below reports, not the raw find_all result.

required

Returns:

Type Description
Element

The one element the selector resolves to.

Raises:

Type Description
ElementNotFound

Nothing matched, or index is out of range.

AmbiguousSelector

Two or more content-distinct candidates matched — with other-trait ties dropped first when the selector doesn't itself target other and at least one non-other candidate remains — and no index disambiguates.

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 target in document order. Element carries 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 reasoning topmost_at_point spells 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]

(cx, cy, half) — the frame center and min(w, h) / 4.