Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bajutsu/capability_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
- `selectOption` needs `selectOption` (BE-0191): a web-only action that sets a native `<select>`;
iOS / Android backends raise `UnsupportedAction`, so a scenario with this step is rejected before
any device work on those platforms, exactly like `pinch`/`rotate` on idb.
- `select` / `copy` need `textSelection` (BE-0280): select-all + clipboard copy on the focused
field. idb is coordinate-only with no select-all handle, so it raises `UnsupportedAction` and does
not advertise the token — a scenario selecting or copying is rejected up front on idb, exactly
like `selectOption`. `delete` / `clear` are not gated: they actuate `delete_text` (a run of
backspaces), which every backend backs.
- a `visual` assertion needs `screenshot`.
- a device-control step needs the capability token for its own operation (BE-0212 split the coarse
`deviceControl` of BE-0128 into per-operation tokens): `setLocation` needs
Expand Down Expand Up @@ -103,6 +108,19 @@ def _select_option_locations(sc: Scenario) -> list[str]:
return [path for path, step in _walk_steps(sc.steps) if step.select_option is not None]


def _text_selection_locations(sc: Scenario) -> list[str]:
"""The paths where a `select` or `copy` step appears (BE-0280).

`delete` / `clear` are excluded: they actuate `delete_text`, which every backend backs, so they
need no capability. Only select-all / copy have no idb actuation.
"""
return [
path
for path, step in _walk_steps(sc.steps)
if step.select is not None or step.copy_ is not None
]


def _visual_locations(sc: Scenario) -> list[str]:
"""The paths where a visual assertion appears."""
return [path for path, a in _assertions_with_path(sc) if a.visual is not None]
Expand Down Expand Up @@ -160,6 +178,11 @@ def _step_locations(matches: Callable[[Step], bool]) -> Callable[[Scenario], lis
"selectOption (web <select> switch; not supported on iOS / Android)",
_select_option_locations,
),
_Requirement(
base.Capability.TEXT_SELECTION,
"select / copy (select-all + clipboard copy; not supported on idb)",
_text_selection_locations,
),
_Requirement(
base.Capability.SCREENSHOT,
"visual assertion",
Expand Down
1 change: 1 addition & 0 deletions bajutsu/drivers/adb.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ def driver_interval(self, kind: str, path: Path) -> intervals.Interval | None:
base.Capability.ELEMENTS,
base.Capability.SCREENSHOT,
base.Capability.MULTI_TOUCH,
base.Capability.TEXT_SELECTION,
base.Capability.DC_SET_LOCATION,
base.Capability.DC_CLIPBOARD,
}
Expand Down
5 changes: 5 additions & 0 deletions bajutsu/drivers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ class Capability:
MULTI_TOUCH = "multiTouch" # two-finger gestures (pinch / rotate); idb is single-touch
WEBVIEW = "webView" # DOM query/tap inside an embedded WKWebView (BE-0037)
SELECT_OPTION = "selectOption" # set a native <select> by value; web only (BE-0191)
# `select`/`copy` on the focused field (BE-0265). A backend that can select and copy natively
# advertises this; idb, coordinate-only with no select-all handle, does not and raises
# UnsupportedAction — the same actuate-or-raise promise as MULTI_TOUCH (BE-0280). `delete` /
# `clear` need no token: every backend actuates `delete_text` (a run of backspaces).
TEXT_SELECTION = "textSelection"
# The `DeviceControl` family, one token per operation (BE-0212, split from the coarse
# `deviceControl` of BE-0128). A backend advertises exactly the operations it can honor, so
# preflight gates each device-control step on its own operation — the Android emulator backs
Expand Down
1 change: 1 addition & 0 deletions bajutsu/drivers/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def screenshot(self, path: str) -> None:
base.Capability.ELEMENTS,
base.Capability.MULTI_TOUCH,
base.Capability.SELECT_OPTION,
base.Capability.TEXT_SELECTION,
}
)

Expand Down
49 changes: 28 additions & 21 deletions bajutsu/drivers/idb.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import logging
import subprocess
import time
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from typing import Any

from bajutsu import simctl
Expand Down Expand Up @@ -104,18 +104,25 @@ def swipe_cmd(udid: str, x1: float, y1: float, x2: float, y2: float) -> list[str
]


def _send_text_via_companion(udid: str, text: str) -> None:
"""Send `text` to the focused field over the fb-idb gRPC client (BE-0155).
# USB HID usage id for the keyboard Delete/Backspace key. fb-idb's `client.key_sequence` sends raw
# HID key events, so a backspace is this keycode — not the `\b` control character, which fb-idb's
# text keymap (`idb.common.hid.KEY_MAP`) has no entry for and rejects with "No keycode found for".
_HID_KEY_DELETE = 42

fb-idb's `idb ui text` takes the text as a required positional argument, so a secret
or OTP typed through the CLI sits on the `idb` process's argv, where a co-tenant on the
host could read it via `ps`/`/proc`. The CLI subcommand is only a thin wrapper around
`client.text()`, so we call that directly instead: the value travels to `idb_companion`
over gRPC and never lands on any command line. Backs both `type` (raw text) and `delete`
(backspace control characters).

Runs its own event loop via `asyncio.run`: the idb driver is synchronous and is only
ever called from threads with no running loop (the runner and the crawl workers).
def _with_companion_client(udid: str, action: Callable[[Any], Awaitable[None]]) -> None:
"""Connect to `idb_companion` over the fb-idb gRPC client and run `action` on it (BE-0155).

fb-idb's `idb ui text` takes the text as a required positional argument, so a secret or OTP typed
through the CLI sits on the `idb` process's argv, where a co-tenant on the host could read it via
`ps`/`/proc`. The CLI subcommands are only thin wrappers around the gRPC `client`, so we call the
client directly instead: the value travels to `idb_companion` over gRPC and never lands on any
command line. `action` receives the connected client, keeping the connection and event-loop
boilerplate here while each caller chooses the right gRPC call (`text` for typing, `key_sequence`
for backspaces).

Runs its own event loop via `asyncio.run`: the idb driver is synchronous and is only ever called
from threads with no running loop (the runner and the crawl workers).
"""
import shutil

Expand All @@ -135,32 +142,32 @@ def _send_text_via_companion(udid: str, text: str) -> None:

from idb.grpc.management import ClientManager

async def _send() -> None:
async def _run() -> None:
manager = ClientManager(
companion_path=companion_path,
logger=logging.getLogger("bajutsu.idb.companion"),
)
async with manager.from_udid(udid=udid) as client:
await client.text(text=text)
await action(client)

asyncio.run(_send())
asyncio.run(_run())


def _type_text_via_companion(udid: str, text: str) -> None:
"""Type `text` into the focused field over the fb-idb gRPC companion path (BE-0155)."""
_send_text_via_companion(udid, text)
_with_companion_client(udid, lambda client: client.text(text=text))


def _delete_text_via_companion(udid: str, count: int) -> None:
"""Backspace `count` times on the focused field over the fb-idb gRPC companion path (BE-0265).

Sends the backspace control character through the same `client.text()` path `type` uses. Whether
the field honors that control character as a hardware backspace is the per-backend build-time
triage the proposal calls out; a companion HID key-event call is the fallback if it does not.
`select` / `copy` are not offered on idb — it is coordinate-only, so those raise
UnsupportedAction and route to XCUITest, mirroring multi-touch.
Sends `count` Delete/Backspace HID key events via `client.key_sequence`, not the backspace control
character through `client.text()`: fb-idb's text keymap has no entry for that control character and
rejects it with "No keycode found for" (BE-0280). The HID key-event path is the hardware backspace
the field honors natively. `select` / `copy` are not offered on idb — it is coordinate-only, so
those raise UnsupportedAction and route to XCUITest, mirroring multi-touch.
"""
_send_text_via_companion(udid, "\b" * count)
_with_companion_client(udid, lambda client: client.key_sequence([_HID_KEY_DELETE] * count))


def screenshot_cmd(udid: str, path: str) -> list[str]:
Expand Down
1 change: 1 addition & 0 deletions bajutsu/drivers/playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ def network_collector(self, mocks: list[Mock] | None = None) -> WebNetworkCollec
base.Capability.NETWORK,
base.Capability.MULTI_TOUCH,
base.Capability.SELECT_OPTION,
base.Capability.TEXT_SELECTION,
}
)

Expand Down
1 change: 1 addition & 0 deletions bajutsu/drivers/xcuitest.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ class XcuitestDriver:
base.Capability.SEMANTIC_TAP,
base.Capability.CONDITION_WAIT,
base.Capability.MULTI_TOUCH,
base.Capability.TEXT_SELECTION,
}
)
| base.DEVICE_CONTROL_ALL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
Expand All @@ -18,6 +23,11 @@ import androidx.compose.ui.unit.dp
// iOS ConformanceView.readyID and the on-device harness's _READY_ID (they must stay in step).
const val CONFORMANCE_READY_ID = "conformance.ready"

// The always-present editable field the text-editing and tap_point contract invariants act on
// (BE-0280) — mirrors the iOS ConformanceView.fieldID and the web _render field. Present on every
// conformance screen like the marker, with a fixed size so the coordinate tap has a known center.
const val CONFORMANCE_FIELD_ID = "conformance.field"

// BE-0114 / BE-0270: the on-device realization of a driver-conformance screen for the adb backend,
// the Compose twin of the iOS ConformanceView. The conformance suite seeds an arbitrary set of
// identifiers — duplicated (an ambiguous selector), empty (a zero-match), or unique — and each
Expand All @@ -41,6 +51,23 @@ fun ConformanceScreen(identifiers: List<String>) {
// in conformance mode, rather than inferring it from the absence of ids — which a transient,
// near-empty tree during a relaunch could satisfy too early.
Text("ready", Modifier.aid(CONFORMANCE_READY_ID))
// The editable field, always present so the text-editing / tap_point invariants have a real
// field on every screen. A BasicTextField surfaces its content as the node's text (the value
// the adb driver reads back); the fixed size gives the coordinate tap a known center.
var fieldText by remember { mutableStateOf("") }
BasicTextField(
value = fieldText,
onValueChange = { fieldText = it },
// Mirror the text into content-desc: the adb driver maps a node's `text` to `label` and
// content-desc to `value`, and the contract reads `value` (as the iOS AXValue and the web
// input value do), so without this the typed text would land in `label` and the round-trip
// length change the contract observes would be invisible.
modifier = Modifier
.size(width = 280.dp, height = 44.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.aid(CONFORMANCE_FIELD_ID)
.stateValue(fieldText),
)
// Duplicates are the point (the ambiguous-selector case), so the children are keyed by
// position (Column's default), never by identifier — keying by id would collapse repeats.
identifiers.forEach { identifier ->
Expand Down
43 changes: 43 additions & 0 deletions demos/showcase/ios/swiftui/Sources/ConformanceView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import UIKit // UIApplication / UIResponder — dismiss iOS transient UI on a screen reseed (see onChange)

// BE-0114: the on-device realization of a driver-conformance screen. The conformance suite seeds
// an arbitrary set of accessibility identifiers — duplicated (an ambiguous selector), empty (a
Expand All @@ -17,11 +18,40 @@ struct ConformanceView: View {
/// near-empty a11y tree could satisfy too early).
static let readyID = "conformance.ready"

/// The always-present editable field the text-editing and `tap_point` contract invariants act on
/// (BE-0280) — mirrors the web `_render` field and the Compose `CONFORMANCE_FIELD_ID`. Present on
/// every conformance screen like the marker (not seeded per screen), with a fixed frame so the
/// coordinate tap has a known center to aim at.
static let fieldID = "conformance.field"

/// Backs the editable field. The empty placeholder is deliberate: an iOS text field reports its
/// placeholder as the accessibility value when empty, which would make the field read non-empty
/// before any typing and hide the round-trip length change the contract observes.
@State private var fieldText = ""

/// Focus on the editable field, so a screen reseed can resign it and dismiss any transient iOS UI
/// the previous text-editing test left up (BE-0280) — see the `onChange` teardown below.
@FocusState private var fieldFocused: Bool

var body: some View {
// Duplicates are the point (the ambiguous-selector case), so the row identity is the
// position, never the identifier — a `\.self` id would collapse repeated identifiers.
VStack(spacing: 8) {
Text("ready").accessibilityID(Self.readyID)
// The editable field, always present so the text-editing / tap_point invariants have a
// real field on every screen. A fixed frame gives the coordinate tap a known center.
TextField("", text: $fieldText)
.textFieldStyle(.roundedBorder)
.frame(width: 280, height: 44)
.focused($fieldFocused)
.accessibilityID(Self.fieldID)
// No explicit `.accessibilityValue(fieldText)`: a SwiftUI TextField already surfaces
// its bound text as its accessibility value natively (as SearchView's field does), so
// the idb / XCUITest `query()` reads back the round-trip length change the contract
// observes. An explicit `.accessibilityValue` bound to the per-keystroke `fieldText`
// made SwiftUI re-create the field's accessibility element on every change, so a
// handle resolved just before a keystroke went stale under the element — the contract's
// text-selection flow then failed with a stale handle (BE-0280).
ForEach(Array(identifiers.enumerated()), id: \.offset) { _, identifier in
// A generous, opaque hit area: the conformance contract pinches/rotates one of these
// (the MULTI_TOUCH case), and XCUITest's two-finger gestures need real room between
Expand All @@ -34,5 +64,18 @@ struct ConformanceView: View {
.accessibilityID(identifier)
}
}
// A reseed (the suite writing a new spec) means the previous screen's test is done. A
// text-editing test leaves iOS transient UI up — the keyboard, and after select-all/copy the
// system edit menu (a `PopoverDismissRegion` backdrop) — that floats *above* this view, so
// re-rendering the content alone does not clear it; it would obscure the next screen's marker,
// and the reseed readiness probe would see only `PopoverDismissRegion` (BE-0280). Resigning the
// field and ending editing app-wide dismisses both, deterministically, at the screen boundary —
// no fixed sleep, and the backend-agnostic contract stays free of any iOS-specific teardown.
.onChange(of: identifiers) {
fieldFocused = false
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
)
}
}
}
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,12 @@ The contract (`tests/driver_conformance.py`) is the "done" definition a new back
- a zero-match selector fails rather than reporting success;
- selector failures share one error type (`SelectorError`), uniform across backends;
- a unique match acts without error, and `query()` reports the on-screen elements;
- `capabilities()` matches observed behavior — the `QUERY` / `ELEMENTS` baseline is declared, and
multi-touch gestures work exactly when `MULTI_TOUCH` is declared (else raise `UnsupportedAction`);
- `capabilities()` matches observed behavior — the `QUERY` / `ELEMENTS` baseline is declared,
multi-touch gestures work exactly when `MULTI_TOUCH` is declared, and select-all / clipboard copy
work exactly when `TEXT_SELECTION` is declared (else each raises `UnsupportedAction`, BE-0280);
- text editing round-trips on the focused field (typing then deleting reduces its reported length),
and `tap_point` — a raw coordinate tap, the alert-dismissal path — focuses the field when aimed at
its center, the same observable effect as a semantic tap (BE-0280);
- `wait_for` is a single-shot check of the current screen, with the shared `wait_until` loop
turning it into a condition wait with no fixed sleep.

Expand Down
5 changes: 4 additions & 1 deletion docs/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ resolution, and the **preflight capability check** (below).
| `conditionWait` | native condition waiting | — | — | ✅ | ✅ |
| `network` | native network monitoring | — | — | ✅ | — |
| `multiTouch` | two-finger gestures (pinch / rotate) | — | ✅ | ✅ | ✅ |
| `textSelection` | select-all + clipboard copy on the focused field | — | ✅ | ✅ | ✅ |
| `deviceControl.setLocation` | set the simulated GPS location | ✅ | ✅ | — | — |
| `deviceControl.clipboard` | read / write / clear the clipboard | ✅ | ✅ | — | — |
| `deviceControl.push` | deliver a push notification | ✅ | — | — | — |
Expand Down Expand Up @@ -94,7 +95,9 @@ through (prime directive #2: fail fast and clearly). It is a pure function of (s
set) — no device, no clock — and per-scenario: only the offending scenarios fail, the rest run.

The check gates only the **hard** requirements the capability set cleanly decides: `pinch` /
`rotate` need `multiTouch`, a `visual` assertion needs `screenshot`, and each device-control step
`rotate` need `multiTouch`, `select` / `copy` need `textSelection` (select-all + clipboard copy;
idb is coordinate-only and refuses both — `delete` / `clear` stay ungated, as every backend backs
`delete_text`), a `visual` assertion needs `screenshot`, and each device-control step
needs the token for its own operation — `setLocation` needs `deviceControl.setLocation`, the
clipboard steps need `deviceControl.clipboard`, `push` needs `deviceControl.push`, and so on
(BE-0212 split the coarse `deviceControl` family of BE-0128 into these per-operation tokens). Every
Expand Down
Loading
Loading