Skip to content

fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658) - #1666

Merged
thymikee merged 5 commits into
mainfrom
claude/agent-device-issue-1658-4y4oe0
Aug 7, 2026
Merged

fix(cloud): unblock iOS snapshot and gate cloud fill on text-entry focus (#1658)#1666
thymikee merged 5 commits into
mainfrom
claude/agent-device-issue-1658-4y4oe0

Conversation

@thymikee

@thymikee thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #1658 — two bugs isolated to the cloud-webdriver iOS path.

Bug A: snapshot/diff fail SESSION_NOT_FOUND on a live cloud session

Two defects stacked:

  1. requireIosAppSessionForSnapshot (src/daemon/snapshot-runtime.ts) refused every iOS session with no appBundleId. That guard belongs to the local XCUITest runner, which must attach to a target app. A cloud capture is GET /session/:id/source against the provider's own driver — no app identity is involved, which is exactly why press/find/fill/screenshot worked on the same session. It is a pure local pre-check, hence the reporter's 0ms failure with no runner.log ever created. It now applies to local Apple targets only.

  2. The provider branch of the open path dropped an explicitly spelled bundle id (resolveSessionAppBundleIdForTarget, src/daemon/handlers/session-open-target.ts). It returns currentAppBundleId and nothing else, because no simctl/devicectl reaches a hosted device. On a first open that is undefined, so open com.example.app printed Opened: ... while storing no app identity at all. A dotted, non-deep-link target is the bundle id under the same convention resolveIosApp applies locally, so it is now adopted with zero device round trips.

Both changes are needed: (2) makes open mean something on cloud iOS; (1) makes a capture work on a provider session even before one.

Bug B: fill silently no-ops on WebView inputs

WebDriverInteractor.fill was tap(x, y) followed by sendKeys(text) in back-to-back requests. A WebView input — an OAuth/SSO page in a Safari view controller — does not take first responder synchronously with the tap, and POST /keys goes to whatever holds first responder, so the keys vanished while the command still answered Filled N chars. That also explains the confirmed workaround: tap-then-fill as two separate commands only worked because the round trip between them gave the field time to focus.

The cloud interactor now waits on the same signal the local Apple runner uses (RunnerTests+TextEntry.swift): the software keyboard going hidden → shown after its own tap. The keyboard is read before the tap, because only a transition proves our tap moved focus. Where visibility cannot witness the focus move — back-to-back fills into one form, the shape that failed most often in the report — it spends the runner's full readiness budget rather than racing the app with a short settle.

fill now discloses what it observed as textEntryReadiness, so a fill with no witness can no longer pass for a filled field:

value meaning
keyboard-shown the keyboard came up in response to the tap
settled-keyboard-up keyboard already up; waited the full readiness budget
settled-unknown the driver reports no keyboard state; short blind settle
not-observed no keyboard ever appeared — keys sent, but nothing corroborates a field received them

Deliberately not fixed

The iPhone 17 / iOS 26 report of fill and press+type resolving to a coordinate far from the field (and once navigating the WebView to the app's marketing site) is target resolution, not timing: fill already re-captures a fresh page source before resolving, so editable=true most likely matched a different node — a Safari view controller's top bar exposes text-field-shaped elements. That needs a raw snapshot from the affected screen to chase, and is better handled separately from this fix.

Verification

No live device was reachable from this environment, so verification runs against the repo's provider-scenario harness, which drives the real daemon and real provider runtime against a stub Appium server.

  • test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts (new) reproduces both bugs end to end. Confirmed failing on the pre-fix code with the issue's exact error text, and passing after.
  • Unit coverage for all four readiness paths (on a fake clock, 16ms total — no real waiting), the guard bypass for provider devices, the local-iOS diff guard still firing, and the bundle-id adoption rules including the cases that must adopt nothing.
  • pnpm test:unit (5599 passed), pnpm test:integration:provider (153 passed), typecheck, lint, format, check:layering, check:fallow, check:production-exports — all green.

🤖 Generated with Claude Code

https://claude.ai/code/session_015Zjbzf7HdziX9SzFpNP7WW


Generated by Claude Code

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Reviewed d3760b6. The snapshot/provider-session fix is well targeted, but this is not ready.

  • P1: not-observed still sends keys and returns Filled N chars; the default CLI does not render textEntryReadiness. That preserves the exact silent-success failure from iOS on BrowserStack: snapshot fails SESSION_NOT_FOUND on live sessions; fill silently no-ops or mis-navigates on WebView inputs #1658 when focus never occurs. Fail closed or surface an unavoidable warning/non-success outcome. The provider scenario only proves request ordering because its /keys endpoint always succeeds without modeling focus/value; make it accept keys only after focus and assert the field value, with pre-fix red evidence.
  • P2: the advertised 2s readiness budget is not bounded at the request boundary. Each keyboard probe inherits the WebDriver client’s 30s timeout, and tryReadKeyboardShown() catches every error—including timeout/auth/session/server failures—as “unsupported”, then continues to type. Give probes a short remaining-budget timeout and blind-settle only for a positively classified unsupported route; propagate real provider failures.

Branch blocker: the PR conflicts with main, so only CodeQL has run. It also lacks exact-head live BrowserStack/AWS device evidence for this device-facing regression.

@thymikee
thymikee force-pushed the claude/agent-device-issue-1658-4y4oe0 branch from d3760b6 to 2099757 Compare August 7, 2026 10:24
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.98 MB 1.99 MB +3.9 kB
JS gzip 641.3 kB 642.6 kB +1.3 kB
npm tarball 769.2 kB 770.5 kB +1.4 kB
npm unpacked 2.69 MB 2.70 MB +3.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 30.8 ms 29.5 ms -1.2 ms
CLI --help 74.2 ms 69.0 ms -5.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/src2.js +3.6 kB +1.1 kB
dist/src/context.js +166 B +57 B
dist/src/version.js +49 B +30 B
dist/src/session.js +80 B +10 B
dist/src/cli.js 0 B +8 B

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-07 13:52 UTC

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main (conflict was with #1662, which landed on the same guard) and addressed both blockers in cc23f2b. Your 2099757 precedence flip is preserved untouched — I applied on top of it rather than over it.

P1 — not-observed was still a success. Agreed, and the reasoning is the decisive part: nothing renders textEntryReadiness, so that value was a disclosure only a JSON consumer could see, which is not a fix for a silent success. It now fails closed: COMMAND_FAILED / text_entry_focus_not_observed, and sends no keys, so the field is left untouched rather than half-written and a retry is clean. CLOUD_TEXT_ENTRY_READINESS drops to the three values that describe a fill which actually typed. The hint names the likely cause (the tap missed) and points at snapshot -i instead of a coordinate retry.

The stub now models the device rather than the protocol: focus lands a beat after the tap, and POST /keys arriving while the keyboard is down is accepted and dropped — which is what the grid/device pair actually does, and the reason the original bug reported success. Both fill tests assert the field's own value (directly, and back through a snapshot). Pre-fix evidence, running them against the original tap-then-sendKeys implementation:

× cloud iOS fill lands text in the field it tapped
× cloud iOS fill refuses, without typing, when the tap raises no keyboard

P2 — the budget was advertised, not enforced. Correct on both counts. Probes now carry their own bound (1.5s, threaded through the client as a per-request timeout override) instead of inheriting the 30s default, so the 2s wait cannot stretch toward it.

On classification, chasing this down surfaced a sharper version of your point: I had keyed "unimplemented" partly on HTTP status, but W3C unknown command and invalid session id both return 404 — so a status test reads a dead session as a missing feature, which is precisely the degradation you flagged. It now classifies on the W3C error code (405/501 stand alone, as unambiguous); everything else — 5xx, auth, session, timeout — propagates. webdriver-client.test.ts pins that split against the real wire shapes, including the two 404s.

Branch/CI. Conflict is gone. The Smoke Tests failure on 2099757 was RunnerTests.testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() — a Swift XCUITest; this branch changes no Swift and no runner input (git diff 4f510eb..HEAD -- apple/ is empty), the iOS workflow is green on main at this PR's exact base, and the same run shows the neighbouring text-entry test burning 12s on Checking existence of 'Keyboard (First Match)' retries. Reading it as simulator keyboard flake, not the diff — the fresh run on cc23f2b should confirm, and I'm watching it.

Still outstanding, and I want to be plain about it: there is no exact-head live BrowserStack/AWS evidence. This environment has no BrowserStack credentials and no aws CLI, so everything above is the stub harness. That gap is real for a device-facing regression and I can't close it from here — happy to hand the exact command sequence to whoever can run it, or to re-verify if credentials get wired into the environment.


Generated by Claude Code

@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head cc23f2b.

The no-keyboard path is fixed: it now refuses before sending keys. The provider scenario now models delayed focus and dropped unfocused keys, asserts the field value/snapshot, and the WebDriver error classification correctly distinguishes an unsupported route from dead-session/grid failures.

Two blockers remain:

  • P1 — keyboard already up / unsupported route still permit silent misdelivery. settled-keyboard-up and settled-unknown both proceed to sendKeys and return normal fill success without witnessing that this tap focused the selected field. With a prior field retaining first responder and a missed/slow second tap, global /keys can alter the prior field (or nothing) while reporting success; textEntryReadiness is structured-only. Fail closed or produce an unavoidable non-success outcome when focus cannot be witnessed, and add a two-field provider scenario that begins keyboard-up, misses the second tap, and proves no keys reach the original field.

  • P2 — the advertised 2s readiness deadline is not end-to-end bounded. The loop checks a deadline but gives every post-tap probe a fixed 1500ms timeout. A slow false probe begun near the deadline can complete well after 2s before the next loop notices. Cap both the sleep and probe timeout by remaining budget, and cover a late slow/false poll. The current test only asserts the fixed timeout arguments.

CI is now green and the branch is mergeable. Exact-head BrowserStack/AWS device evidence is still absent; the author confirms no credentials were available, so the stub scenario does not close that device-facing validation gap.

claude and others added 3 commits August 7, 2026 13:45
…cus (#1658)

Two bugs isolated to the cloud-webdriver iOS path.

`snapshot`/`diff` refused every capture on a live BrowserStack session with
SESSION_NOT_FOUND, instantly and without a driver round trip. The app-session
guard they ran belongs to the local XCUITest runner, which must attach to a
target app; a cloud capture reads the provider's own driver session and needs
no app identity, so it now applies to local Apple targets only. The session
was empty in the first place because the provider open path skips local app
resolution wholesale — no simctl/devicectl reaches a hosted device — and
dropped an explicitly spelled bundle id along with it. A dotted, non-deep-link
target is the bundle id under the same convention resolveIosApp applies
locally, so a cloud `open com.example.app` now records it.

`fill` tapped and sent its keys in back-to-back requests. A WebView input —
an OAuth page in a Safari view controller — takes first responder
asynchronously, so the keys landed with nothing focused while the command
still answered "Filled N chars"; tapping and filling as two separate commands
worked only because the round trip between them gave the field time to focus.
The cloud interactor now waits on the same signal the Apple runner uses, the
software keyboard going from hidden to shown after its tap, and discloses what
it observed as `textEntryReadiness` so a fill with no witness cannot pass for
a filled field. Where keyboard visibility cannot witness the focus move —
back-to-back fills into one form, the shape that failed most often — it spends
the runner's full readiness budget rather than racing the app with a short
settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Zjbzf7HdziX9SzFpNP7WW
Adopting an explicitly spelled bundle id on a provider-backed open (the
fix that makes snapshot/diff work at all) also made a previously dead
precedence rule live: the provider branch returned currentAppBundleId
first, so once a first open had populated it, `open com.a` followed by
`open com.b` left the session still reporting com.a to every
appBundleId-gated command.

The local path does the opposite, and is the convention this branch is
meant to mirror: resolveIosApp returns a dotted target unchanged and
never consults the session's current app. Only its deep-link branches
prefer the tracked id. Flip the provider branch to match — an explicit
bundle-id target wins, and everything the branch cannot name (deep
links, display names, bare open) still falls back to the tracked id.
…lled

Review follow-ups on #1658.

`not-observed` was still a success: it sent the keys and answered "Filled N
chars", and nothing renders `textEntryReadiness` in default CLI output — so the
exact silent success this branch exists to remove survived whenever focus never
happened. A tap that raises no keyboard now fails with
`text_entry_focus_not_observed` and sends no keys, leaving the field untouched
rather than half-written, and the readiness vocabulary keeps only outcomes that
describe a fill that did type.

The readiness budget was advertised but not enforced at the request boundary:
each keyboard probe inherited the client's 30s default, so one hung probe could
hold a 2s wait for far longer. Probes now carry their own bound, threaded
through the client as a per-request timeout override.

The probe also swallowed every error as "this driver cannot answer", which
degraded a dead session, an auth rejection, or a grid outage into a blind text
entry. Only a positively classified unimplemented route counts as unsupported
now — classified on the W3C error code rather than the status, since `unknown
command` and `invalid session id` share HTTP 404 — and everything else
propagates.

The provider scenario proved request ordering against a stub that always
accepted keys. Its fake now models the device: focus lands a beat after the
tap, and keys arriving while the keyboard is down are accepted and dropped,
exactly as an unfocused field does. The tests assert the field's own value, and
both go red against the pre-fix `fill`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Zjbzf7HdziX9SzFpNP7WW
@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Branch-readiness update: this unchanged head now conflicts with main, so it cannot merge until rebased. The previously posted focus-witness and readiness-deadline findings also remain open; please resolve those while rebasing, then request re-review on the new head.

Review of cc23f2b found two ways a fill could still report success
without evidence that OUR tap focused the field it was aimed at.

P1. `settled-keyboard-up` and `settled-unknown` both typed and returned
normal success. Keyboard visibility can only witness that *a* field took
focus, never *which*: filling a second field in an already-open form
reads the same before and after, so a missed tap left the first field
focused and `POST /keys` — which the driver routes to whatever holds
first responder — appended to it while every request returned 200.

Failing those closed outright would have broken ordinary multi-field
form fills, which do work: a live AWS Device Farm run types both fields
of a WebView login correctly. So witness focus properly instead. W3C
`GET /element/active` answers the question keyboard visibility cannot —
is the thing focused now the thing I tapped — and answers it whether or
not the keyboard was already up. That becomes the primary signal
(`focused-element`); the keyboard transition stays as the fallback for
drivers without the route, and a keyboard already up on such a driver
now refuses rather than typing.

The test is identity, not geometry. Containment of the tap point looks
like the obvious rule and is wrong: focusing a field can re-lay it out.
On a live iPhone 16, tapping Safari's collapsed address bar expands it
into a taller field that no longer covers the tapped point, and a
containment-only rule refused a fill that plainly worked. So a tap that
MOVES focus counts, with containment as the second half of the test —
re-filling the already-focused field moves nothing, and only geometry
tells that from a tap that missed. Both readings are taken before the
tap, since each is evidence only as a change.

P2. The 2s budget bounded the loop but not the calls inside it: every
probe got a fixed 1500ms, so one begun near the deadline finished well
past it. Both the probe timeout and the sleep are now capped by the
remaining budget.

Also fixes a related escape the review did not name: the poll loop had
no catch, so one transient grid error aborted a fill the next poll would
have satisfied. Probe failures are now tolerated within the budget, but
a budget that expires without a single answered probe rethrows, so a
dead session surfaces as itself rather than as "the tap missed".

The provider scenario gains the two-field case the review asked for: it
begins keyboard-up with the email field focused, misses the password
tap, and asserts no keys reach the email field.

Verified on AWS Device Farm iPhone 16 / iOS 18.0 at this exact tree:
address bar (the re-layout case) and both WebView login fields all
report `focused-element`, the second with the keyboard already up, and
the device reads back `tomsmith` and a 20-character password.
@thymikee thymikee closed this Aug 7, 2026
@thymikee
thymikee force-pushed the claude/agent-device-issue-1658-4y4oe0 branch from cc23f2b to a158434 Compare August 7, 2026 12:23
@thymikee thymikee reopened this Aug 7, 2026
@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed 3a9aceb9: the active-element witness closes the keyboard-up misdelivery case, the two-field regression is production-shaped, and the poll loop now caps its own sleep/request arguments by remaining budget. Two blockers remain. P1: when both active-element and keyboard routes are positively unsupported, settled-unknown still types and returns ordinary success with no focus evidence; the default CLI does not surface that structured value, so this preserves the silent false-success branch from the prior finding. Fail closed or emit an unavoidable non-success/warning. P2: one activeElement(timeoutMs) probe performs two sequential requests—/element/active and /element/{id}/rect—and gives each the full timeout. A probe handed the remaining 1.5s can therefore consume about 3s and overrun the 2s readiness deadline. Pass the remaining budget into the second request (or bound the composite operation) and add a sequential slow-response regression.

… the composite probe

Two blockers from the review of 3a9aceb.

P1. `settled-unknown` was the last path that typed without evidence: when
both the active-element and keyboard routes are positively unsupported,
`fill` settled 350ms, typed, and returned ordinary success. Nothing
renders `textEntryReadiness`, so that reached a caller looking exactly
like a fill that worked — the same silent false success #1658 is about,
just narrowed to one branch. It now refuses with a distinct reason,
`text_entry_focus_unobservable`: nothing is wrong with the target, the
driver simply cannot answer, so the caller's next move differs from a
missed tap and the hint names it — `press` then `type` stays the
deliberate way to enter text unwitnessed.

`CLOUD_TEXT_ENTRY_READINESS` is now `focused-element` and
`keyboard-shown` only. Every value describes a fill that witnessed focus
before sending a key; there is deliberately no value for typing blind.

P2. `activeElement(timeoutMs)` bounded each of its two sequential
requests by the full timeout rather than bounding the operation, so a
probe handed the 1.5s left of a 2s readiness deadline could spend ~3s
across `/element/active` and `/element/{id}/rect` and overrun the
deadline it was derived from. It now derives one deadline at entry and
gives the second request only what the first left, floored at zero so an
already-spent budget aborts immediately instead of falling back to the
client default.

The regression pins elapsed transport time across both calls, which is
what the defect is made of: the rect request answers only its own abort,
so the time it was allowed to run IS the budget it was handed. It
measures ~202ms of a shared 200ms budget before the fix and ~120ms
after.

Also updates the generic Cloud WebDriver facade scenario, whose stub
answered `{value: null}` to everything and so read as a driver with
neither route. It now answers the two focus probes, since that scenario
exercises facade wiring rather than text-entry semantics — those live in
cloud-webdriver-ios-text-entry.test.ts, which models focus properly.
@thymikee

thymikee commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 5a608c1 without using CI state for readiness. Both remaining findings are fixed.

  • A driver that supports neither active-element nor keyboard state now fails closed with text_entry_focus_unobservable and sends no keys; there is no remaining ordinary-success path without a focus witness.
  • activeElement(timeoutMs) now shares one deadline across the active-element and rect requests, so the composite probe cannot spend the full readiness budget twice. The elapsed-transport regression exercises the actual failure mode.

The supported-driver production behavior and exact-device evidence from the preceding head remain applicable; this delta tightens only the unsupported/error boundary and request budgeting. Code review is clean and this is ready for human review.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 7, 2026
@thymikee
thymikee merged commit e14c9d8 into main Aug 7, 2026
32 of 34 checks passed
@thymikee
thymikee deleted the claude/agent-device-issue-1658-4y4oe0 branch August 7, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS on BrowserStack: snapshot fails SESSION_NOT_FOUND on live sessions; fill silently no-ops or mis-navigates on WebView inputs

2 participants