* feat(interaction): accept fill <target> "" as the clear-field primitive
Emptying an input was not expressible: `fill` refused the empty string
("Expected text to be a non-empty string"), `type` only appends, and `keyboard`
has no delete verb. Clearing a field before typing is a routine QA step, so the
only route was the app's own clear button or N locale-dependent keyboard delete
presses read out of a snapshot.
`fill <target> ""` now means "replace with nothing". Both platforms already own
the clear half of replace, so this is the validation and reporting that stood in
front of it, not a new interaction:
- `stringField` takes an opt-in `allowEmpty`, used only by `fill`'s `text`.
`requiredField` still refuses a MISSING text, so `fill @e57` stays an error
rather than silently erasing the field — `readFillTargetFromPositionals` now
reports `undefined` for "no text argument" instead of collapsing it to `''`.
`type` keeps refusing an empty text: appending nothing is not a clear.
- The Apple runner's empty-text early return skipped the clear while reporting
"typed". For a replacement it now runs `clearTextInput` and verifies the field
came back empty (secure fields stay unverifiable, as elsewhere).
- Android already clears before typing and skips an empty shell/IME write, but
its verifier read a cleared field's absent `text` attribute as a mismatch
against `''`. An empty expectation now accepts null or "".
Whitespace-only text keeps its established per-shape rules; only `''` is new.
Closes #2063
* fix(interaction): fail the empty-fill clear closed on every backend
Addresses the P1 review on #2066, then closes the same fail-open class
on the backends the PR did not reach:
- Android: an empty expectation no longer matches when the verification
scan observed NO input node at all — actual is null both for a cleared
field and for a wrong point/lost focus, and three empty samples of
nothing were a stable success for a clear that never touched a field.
- Apple runner: when the empty-replacement path cannot resolve a clear
target (including the synthesized first-responder route, whose target
carries no element), it returns the typed TEXT_INPUT_NOT_FOCUSED
failure instead of falling through to the vacuous-typing
verified-success return. Regression runs in the ios.yml XCTest lane.
- webdriver: fill is tap + sendKeys and owns no clear mechanism, so an
empty fill refuses as UNSUPPORTED_OPERATION before touching the
device, instead of reporting a clear it cannot perform.
- linux + web coordinate fill: typing zero characters over the
select-all selection left the old value intact; the empty fill now
deletes the selection.
- recording: an empty --record-as literal matches inside every string;
it now parameterizes only the fill's own text field instead of
rewriting every empty field and empty evidence label in the entry.
(The session-wide echo registry already excluded empty literals.)
- help: the text-entry topic taught agents that fill "" is not a
clear-field command; it now states the new contract.
Each new test was observed red against the pre-fix code.
* fix(android): read hint-showing from the helper so a cleared field verifies
Live Pixel 9 emulator, adb-shell channel: clearing the Settings search
field succeeded on the device but reported 'Android fill verification
failed', because a cleared EditText dumps its HINT as text — getText()
returns the hint for an empty field on modern Android, so 'Search
settings' read back as a residual value. This is the same
placeholder-as-value trap the Apple runner already handles with
treatingPlaceholderAsEmpty.
The helper now emits hint-showing (isShowingHintText, API 26+), the
hierarchy parser carries it, and fill verification matches against the
field's VALUE — hint-only text is an empty value, for empty and
non-empty expectations alike. A field whose real value equals its hint
string keeps failing the clear check: only the authoritative flag, never
the text, says it is a hint. Raw uiautomator dumps carry no such fact
and keep the fail-closed behavior.
Live evidence, both admission channels, after this fix: test-ime and
adb-shell clears both report Filled 0 chars with the field back on its
placeholder; the pre-fix adb-shell run failed closed (never a false
success).
* fix(interaction): close the adversarial-review findings on the empty-fill clear
- android adb-shell: the delete burst is sized from the value being
REMOVED (pre-mutation read; the attempt's cap when unreadable), not
from the empty incoming text, which sent the 12/24-delete minimums and
could never empty a field longer than 36 characters.
- android: the unconfirmed soft-success no longer applies to an empty
expectation — nothing app-formats the empty value, so residue after a
clear is a failed clear, and the soft-success also skipped the second,
bigger delete burst.
- android masked fields: an empty expectation accepts an observed masked
node with no dump text (a masked field WITH content dumps its bullet
run), so clearing a password field no longer fails after the clear
worked — matching iOS, where a secure-field clear succeeds unverified.
- find: 'find <q> fill ""' now reaches the fill leaf as the clear
request on both the CLI reader and the daemon positional parse; a
MISSING value keeps its refusal at each producer, so the typed
value: string contract is unchanged.
- maestro export: a recorded clear exports as tapOn + eraseText instead
of a vacuous inputText: "" (with the 50-character-default warning).
- the missing-text refusals teach the clear form: (use "" to clear
the field).
Full unit suite green (1061 files); each behavioral fix carries a test
observed red against the prior code.
* refactor(interaction,android): extract the fill parse and shell-attempt branches
The review commits pushed parseFillTarget and fillAndroid over the
complexity gate (13 cyclomatic each). Each fill target shape parses in
its own function sharing one missing-text response, and the adb-shell
attempt (clear sizing + clear + type + verify) moves out of the fill
loop. Behavior-preserving; the existing tests cover every branch.
* refactor(interaction,android): one owner per empty-fill fact
Design pass after review: the missing-vs-empty rule and the observed-
value rule each had several owners; now each has one.
- parseFillTarget decodes ONCE through readFillTargetFromPositionals —
which already owns shape detection and documents the undefined-vs-''
contract on DecodedFillTarget — and keeps only what the wire owns:
versioned-ref admission, the selector whitespace rule, and the daemon
responses. This deletes the point branch's duplicated slicing, the
hasFillText guard, and the three per-shape parse functions.
- observedAndroidValue() is the single statement of Android's value
rule (absent attribute and hint-only text are the empty value); the
text branch, the match rule, and the masked branch all consume it.
The masked branch thereby gains the hint-showing collapse it was
missing, and isAcceptableAndroidFillMatch narrows to plain strings.
- The empty-text-is-clear contract is stated once, on Interactor.fill
in contracts, instead of implied per backend.
Behavior-preserving except the masked+hint gain; the existing tests
cover every branch (494 Android, 15 fill-target).
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Android Snapshot Helper
Small instrumentation APK used to capture Android accessibility snapshots without relying on
uiautomator dump's fixed idle wait behavior. The helper enables Android's interactive-window
retrieval flag and serializes every accessible window root returned by UiAutomation.getWindows()
so keyboards and system overlays can appear in the same snapshot. If interactive window roots are
unavailable, it falls back to the active-window root.
The helper is intentionally provider-neutral. Local adb, cloud ADB tunnels, and remote device
providers can all install and run the same APK as long as they can execute ADB-style operations.
Released helper APKs use the committed debug.keystore; do not rotate it casually, because Android
requires a stable signing certificate for adb install -r upgrades.
Build
VERSION="$(node -p 'require("./package.json").version')"
AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh "$VERSION" .tmp/android-snapshot-helper
The build uses Android SDK command-line tools directly. It expects ANDROID_HOME or
ANDROID_SDK_ROOT to point at an SDK with platforms/android-36 and matching build tools.
pnpm prepack builds the npm-bundled helper into android/snapshot-helper/dist; npm users get
that APK in the package and the first helper-backed snapshot installs it automatically when
missing or outdated.
Run
VERSION="$(node -p 'require("./package.json").version')"
adb install -r -t ".tmp/android-snapshot-helper/agent-device-android-snapshot-helper-$VERSION.apk"
adb shell am instrument -w \
-e waitForIdleTimeoutMs 500 \
-e waitForIdleQuietMs 100 \
-e timeoutMs 8000 \
-e maxDepth 128 \
-e maxNodes 5000 \
com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation
maxDepth also caps recursive traversal depth inside the helper.
The -t install flag is required because the helper is a test-only instrumentation APK.
Devices or providers that block test-package installs must allow this package before helper capture
can run.
waitForIdleTimeoutMs defaults to 500, which is a maximum wait, not a fixed sleep. Direct helper
invocations can pass 0 when immediate capture during ongoing animation is preferred. Root
acquisition has a separate 500 ms stabilization bound that is used only when no root is available or
an active/focused window is temporarily missing its root; complete captures pay no additional wait.
One-Shot Modes
Passing -e mode snapshot|viewport|gesture selects what a single instrumentation run does; snapshot
is the default and matches the Run section above.
# Read the interactive-window viewport without capturing a snapshot.
adb shell am instrument -w -e mode viewport \
com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation
# Inject a planned touch gesture, described by a base64 JSON payload.
adb shell am instrument -w -e mode gesture -e payloadBase64 "$PAYLOAD" \
com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation
The gesture payload is a base64-encoded JSON object using protocol android-touch-plan-v1:
kind:swipe(one pointer) ortransform(two pointers, e.g. pinch/rotate)durationMs: integer,0-120000pointers: array of{ pointerId, samples },pointerIds ordered from0, one pointer forswipeand exactly two fortransform- each pointer's
samplesis{ offsetMs, x, y }[]with at least two entries;offsetMsvalues must be strictly increasing (equal offsets are only allowed whendurationMsis0), the first sample'soffsetMsmust be0, and the last must equaldurationMs. Fortransformgestures, both pointers must share the sameoffsetMssequence.
Persistent Session
Passing -e sessionPort <port> keeps the instrumentation alive after startup and serves repeated
commands over a local TCP server on 127.0.0.1:<port>, instead of exiting after one snapshot. This
avoids paying UiAutomation connect/teardown cost per call. Each command uses one short-lived
connection: the client connects, sends a single command line, reads the response, and the server
closes that connection; the process stays alive for the next connection:
snapshot <requestId>— capture and return an XML snapshot, same semantics as the default modeviewport <requestId>— return interactive-window viewport boundsgesture <requestId> <payloadBase64>— inject a planned touch gesture (same payload as the one-shotgesturemode)quit <requestId>— acknowledge and stop the session
viewport and gesture responses are headers-only (no body): kind, injectedEvents,
elapsedMs for gesture, and x, y, width, height for viewport. snapshot responses carry
the XML body after the header block, as described below. The response protocol literal is always
android-snapshot-helper-v1, regardless of session or one-shot transport.
Output Contract
The APK emits instrumentation status records using
agentDeviceProtocol=android-snapshot-helper-v1.
The XML node attributes intentionally mirror acquisition facts decoded by the host, including
visible-to-user, drawing-order, bounds, text/description/id, interaction booleans, and window
metadata on window roots. The helper emits drawing-order on Android API 24+ and omits it on API
23, where the platform API is unavailable. The host keeps that fact as private capture evidence;
the daemon uses it to annotate covered actions without adding it to normalized snapshot nodes.
Each XML chunk is sent with:
outputFormat=uiautomator-xmlchunkIndexchunkCountpayloadBase64
The final instrumentation result for the default snapshot mode includes:
ok=truehelperApiVersion=2waitForIdleTimeoutMswaitForIdleQuietMstimeoutMsmaxDepthmaxNodesrootPresentcaptureMode(interactive-windowsoractive-window)windowCountnodeCounttruncatedelapsedMs
viewport and gesture one-shot results carry agentDeviceProtocol/helperApiVersion/
outputFormat plus the mode-specific fields described under "One-Shot Modes" above, instead of the
snapshot-mode fields listed here.
Failures return ok=false, errorType, and message in the final result.
The release manifest is a stable provider contract for the current helper protocol. Providers should
resolve the APK from apkUrl, verify sha256, install using installArgs, and run
instrumentationRunner. installArgs must start with install; extra arguments are limited to the
allowlisted adb install flags -r, -t, -d, and -g, and the consumer appends the APK path.