Skip to content

fix(ios): stop a tap's post-gesture lookup from recording an XCTest failure (#3060) - #3237

Merged
thymikee merged 1 commit into
mainfrom
fix/ios-tap-corroboration-type-mismatch
Oct 6, 2026
Merged

thymikee merged 1 commit into
mainfrom
fix/ios-tap-corroboration-type-mismatch

Conversation

@thymikee

@thymikee thymikee commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3060.

A tap holds a handle bound to the query that resolved its element, so reading that handle re-runs the query.
Flutter reports a password field as TextField by one accessibility channel and Other by another, so once
focused it stops answering the query that found it; XCTest records a no-matches failure and
didRecordXCTestFailure fails the command and invalidates the target — for a tap that already landed. ADR 0005
keeps a tap's outcome independent of this bookkeeping; three post-gesture reads did not follow it.

rememberTextEntryTap re-derived isTextEntryElement after the gesture though both tap routes classify before
dispatching, so it could only agree or die. Its parameter is now the caller's classification; the coordinate
route's comes from queryTextInputs, which enumerates only text-entry types. Readiness no longer classifies,
and its frame comes from snapshot() instead — same answer, throwing channel, records nothing, as
probeTextEntryInput and withElement already do. Still read after the wait, so the focus repeat aims at
where the field is then. Runner-only, 4 files; a tap that did not land still fails.

Validation

Head 3998583 (comment-only amendment over a6f6af1, which carried the live A/B). pnpm check:affected --run green; iOS PR lane 97/97 locally on iOS 27.1, and CI runs these cases
on iOS 26.2.

Reproduced and fixed live on a Flutter login screen, same app and daemon, only the runner binary differing.
click label=Password on base failed at 7.34 s with XCTEST_RECORDED_FAILURE, 2 invalidations, and a runner
restart per failing tap; its runner.log carries the report's error verbatim — no-matches on Element at index 1 from input {(TextField)}, legacy-TextField-vs-modern-Other, traits 146029150208 — recorded at
TextEntry.swift:268, the read removed here. The fix returns Tapped at 0.92 s with none of those, and a
following type lands six masked characters. press and click text= are clean on both sides; the full matrix
and caveats are in my review comments.

Both new tests fail on base with that signature and pass on the fix. Unclaimed: the speedup is the avoided
restart, not a re-timed tap. One real behavior delta — where base's post-gesture type read answered a non-text
type it skipped readiness entirely, and readiness now runs its wait there; on the measured Flutter shape both
sides performed no repeat (2 synthesized dispatches per 2 taps in both). fill @e6 is type's own reads —
#3238.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.07 MB 5.07 MB -3.3 kB
Package (unpacked) 5.07 MB 5.07 MB -3.3 kB
Package (download) 1.52 MB 1.52 MB -1.4 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.9 ms 25.7 ms -0.2 ms
CLI --help 75.7 ms 77.5 ms +1.8 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Re-trigger cubic

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

The runner change looks reasonable, but the reported failure was not reproduced on the Flutter shape that triggered it, so I can't call it ready at b9a59ea.

The fix classifies the tapped element from its snapshot type before the gesture. The fixture hides a real UITextField, so its snapshot type stays .textField and that check passes trivially. On Flutter, TextInputSemanticsObject reports TextField through the legacy attributes and Other through the modern one. If probeTextEntryInput sees .other there, isTextEntryElementType returns false and tappedInput is nil. rememberTextEntryTap(nil) then clears the witness with no log line. The tap reports success, but the next bare type loses its authorization. The fixture cannot produce this disagreement, so the issue's exact failure is unproven.

Could you run this on an iOS Simulator with a Flutter login screen (an email TextField and a password TextField with obscureText)? Run open --relaunch, snapshot -i, click @<password>, press <x> <y> at the field centre, then a bare type abc123. It passes if both taps return tapped in about 2 s or less, runner.log has no XCTEST_RECORDED_FAILURE and no AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE, and the bare type puts the masked text into the password field (a screenshot shows this). If the witness is dropped, should the class come from the query type that resolved the element? queryTextInputs already filters by text-input type, so it does not depend on the snapshot's modern type.

CI is green: 19 checks, 0 failing at b9a59ea, and the swift-runner lanes that exercise the changed files pass. There are no conflicts. I did not run the XCTest cases, so the red/green claim on reverted source is unverified. I also had no Flutter app, so I could not check what snapshot().elementType reports before focus. On simulators with a hardware keyboard, the selector route may still spend about 4 s and repeat the tap as before. I did not measure that. Before merge, this needs the live Flutter password-field run showing no recorded failure and the text landing.

@thymikee
thymikee force-pushed the fix/ios-tap-corroboration-type-mismatch branch 2 times, most recently from ea437cb to a6f6af1 Compare October 5, 2026 20:22
@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

You were right about b9a59ea, and the new head (a6f6af1) takes your suggestion.

The witness can no longer be dropped by a type read. rememberTextEntryTap does not classify at all
now — the coordinate route passes the element its probe already found, and queryTextInputs enumerates only
textFields/secureTextFields/searchFields/textViews, so the class comes from the query that resolved
the element, exactly as you proposed. Nothing after the gesture reads a type. On the selector route the
classification stays where base already had it (before dispatch, unchanged), so booking is a superset of
base: base required the pre- and post-gesture reads to agree, so no tap base authorised is now refused.

Your question about what snapshot().elementType reports — measured, and it changed the design. The
Flutter tree reads @e3 [other] "Email" and @e4 [other] "Password", before any focus. So the guard I had
built on the snapshot's type would have classified that field as a non-text input and returned early on
exactly the field the issue is about, silently stopping the focus repeat from happening. That guard is gone:
readiness no longer classifies, because the caller decided before dispatch. Net effect: 4 files, +162/−12,
and RunnerTests+TextEntry.swift is back to base.

The live run does not reproduce the failure, and that is worth saying plainly. I installed Flutter and
built a 3.47.6 login screen (email TextField + password TextField with obscureText: true), then drove
your exact sequence through this worktree's CLI twice — base runner, then fix runner, same app and same
daemon, only AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH differing (the daemon fingerprints runner source, so base
could only be exercised with base actually checked out). On iOS 27.1 the base runner does not fail: its
post-tap lookup resolves (Find the "Password" SecureTextField), so the field keeps answering the query that
found it. The reporter's iOS 26.x log shows that same lookup recording No matches found for Element at index 1 from input {(TextField)}. So the failure is runtime-specific here, and Xcode 27.1 offers no iOS 26 runtime
to download, which means I cannot produce the red side on this host at all.

What the run does establish on the Flutter shape: tapped for click @password and for press <x> <y>, no
XCTEST_RECORDED_FAILURE, no AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE, and the bare type landed six
masked characters (screenshotted, not just the CLI's reply).

I won't over-read the timings either: click @password measured 4.09 s on base and 1.59 s on the fix, but
both went out as coordinate dispatches (SYNTHESIZED_DISPATCH kind=tap point=), so neither ran readiness and
I can't attribute that gap to this change. The ~4 s selector-route cost you flagged is untouched by design —
same wait, same repeat, same aim.

So the red side is the fixture characterisation tests, which force the same failing read by taking the field
out of its query on focus: they fail on base with the report's own signature, recorded at
RunnerTests+TextEntry.swift:268 and RunnerTests+TextEntryReadiness.swift:85, and pass on the fix. CI runs
them on iOS 26.2, which is the reporter's runtime family, so that lane is closer to the failure than anything
I can run locally. If you want the Flutter run on a 26.x runtime before merge, I need a host with one — say
the word and I'll write up the exact commands and the app to reproduce it.

@thymikee thymikee changed the title fix(ios): settle a tap's text-input class before dispatching it fix(ios): stop a tap's post-gesture lookup from recording an XCTest failure (#3060) Oct 5, 2026
@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Correction to my previous comment: it does reproduce, and I was looking at the wrong route.

I ran your sequence again driving the selector route (click label=Password) instead of click @<ref>. A ref is resolved to coordinates, so click @password goes out as a coordinate dispatch and
never reaches the code that fails — that is why base looked clean for me. The selector route reproduces
the issue exactly, on iOS 27.1, with the Flutter app:

base runner fix runner
click label=Password XCTEST_RECORDED_FAILURE, 8.98 s Tapped, 2.79 s
same tap after relaunch XCTEST_RECORDED_FAILURE, 7.54 s Tapped, 0.94 s
runner invalidations 2 × reason=xctest_recorded_failure 0
failing lookup in runner.log RunnerTests+TextEntry.swift:268: Failed to get matching snapshot: No matches found for Element at index 1 from input {(TextField)} none

That is the report's error text, at the line this change removes, at 7.5–9.0 s against its measured 7–14 s
band; the fixed runner answers in under a second and books the witness. The cost is visible directly in
runner.log: the base session went through 3 runner startups and 3 LISTENER_READY lines for a two-tap run,
the fixed session 1 each — the restart you are describing, and the reason an interaction that should cost
~0.1 s costs seconds. Both sides show external_app_relaunch invalidations from my own relaunches, so that
count is not part of the difference.

This also moves your open question forward. Base's element.elementType read got far enough to re-run the
query and record, i.e. it resolved through the channel that matched TextField, while the daemon's snapshot
of the very same field renders @e4 [other] "Password" — visible before any focus. I still have not measured
snapshot().elementType directly on this field, but the two channels demonstrably do not agree, which is why
the snapshot-type readiness guard in the earlier cut was wrong to trust. It is gone.

On the concern that removing the classification would add work: neither side performed a focus repeat on this
shape (2 synthesized dispatches for 2 commands in both), so the change adds no gesture here, and the fixed tap
comes back in 0.94 s. I have corrected the PR body, whose Validation section claimed the live run did not
reproduce.

@thymikee

thymikee commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Second correction — my "selector route" wording was wrong. Here is what I actually measured.

Same fresh-open layout on both runners, varying only how the field is addressed (each cell starts from
open --relaunch; timings exclude runner startup):

addressing the password field base fix
click label=Password XCTEST_RECORDED_FAILURE, 14.9 s cold / 7.34 s warm Tapped, 2.02 s / 0.92 s
click text=Password Tapped, 0.97 s Tapped, 0.94 s
press 201 473 Tapped, 0.87 s Tapped, 0.82 s
press 201 473 after snapshot -i Tapped, 0.85 s Tapped, 0.81 s

So it is not selector-versus-coordinate — label addressing reproduces, coordinates do not, which is why my
first run (click @ref and press) looked clean on base.

Base's recorded error is the report's, down to the attributes:

RunnerTests+TextEntry.swift:268: Failed to get matching snapshot: No matches found for Element at index 1
from input {(TextField)}
Automation type mismatch: computed TextField from legacy attributes vs Other from modern attribute.
  XC_kAXXCAttributeAutomationType = 0; XC_kAXXCAttributeElementType = TextInputSemanticsObject;
  XC_kAXXCAttributeTraits = 146029150208;

preceded by Find: Descendants matching type TextField / Find: Element at index 1, then
TARGET_CACHE_INVALIDATE reason=xctest_recorded_failure. Same index, same query, same trait value, same
legacy-vs-modern disagreement you described — recorded by isTextEntryElement, the read this change removes.
Base restarted the runner for each failing tap (3 startups and 3 LISTENER_READY lines for that run against 1
each on the fix), which is what turns a sub-second tap into 7–15 s. Both sides also show
external_app_relaunch invalidations from my own relaunches, so that count is not part of the difference.

The fix returns Tapped for every addressing form with 0 recorded-failure invalidations, and a following type
lands six masked characters. I have no explanation for why text=Password and raw coordinates escape it on base
while label=Password does not; I am reporting the discriminator, not a theory of it.

Withdrawing one number from my last comment: 2.79 s for the fixed click label=Password included a cold runner
start; warm it is 0.92–0.94 s.

…ailure (#3060)

A tap holds a handle bound to the query that resolved its element, so reading that handle
re-runs the query. Flutter's TextInputSemanticsObject reports TextField through the legacy
accessibility attributes and Other through the modern ones once the field is involved, so a
focused password field stops answering the query that found it, XCTest records "No matches
found for Element at index 1 from input {(TextField)}", and didRecordXCTestFailure turns that
into XCTEST_RECORDED_FAILURE plus an invalidated target. The tap had already landed: the
report measured 7-14s per tap, all of it runner restart.

rememberTextEntryTap re-derived isTextEntryElement after the gesture even though both tap
routes had already classified the element before dispatching, so the check could only agree or
die. Its parameter is now the caller's classification, which for the coordinate route comes
from queryTextInputs -- it enumerates only text-entry element types, so the classification does
not depend on the snapshot's modern type. The post-tap readiness wait read element.elementType
and element.frame, the two reads that record; the frame now comes from snapshot(), which
answers the query through the throwing channel and records nothing, which is already how
probeTextEntryInput and withElement reach an element. Readiness no longer classifies at all:
the caller decided, and re-deriving it post-dispatch would classify differently from the
caller because elementType answers through the legacy attributes while snapshot().elementType
answers through the modern ones. The frame is still read after the wait, so the focus repeat
aims at where the field is now rather than where the tap started.

Runner-only, no CLI/daemon/API change. A tap that did not land still fails and a genuinely
recorded failure still invalidates and surfaces.
@thymikee
thymikee force-pushed the fix/ios-tap-corroboration-type-mismatch branch from a6f6af1 to 3998583 Compare October 5, 2026 21:04
@thymikee

thymikee commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

This PR is ready on the code. I re-read 3998583 against the earlier findings, and they are now fixed: a tap's post-gesture lookup no longer records an XCTest failure, and the two XCTest cases cover that path.

Not blocking: the new doc blocks on waitForTextEntryReadinessAfterTap and rememberTextEntryTap (TextEntryFocus.swift:9) read as long incident narratives, and each could be one sentence of contract (the caller classifies before dispatch, and the handle is not read through a recording channel after the gesture). You can take or leave this.

No conflicts. The checks do not show a result yet: Smoke Tests, Analyze (java-kotlin) and Repo Guards were cancelled with no logs, so nothing points at this diff. Analyze (java-kotlin) and Repo Guards do not touch the Swift runner, but Smoke Tests can reach the iOS tap path this PR changes. Please re-run all three on 3998583 and get them green before merge.

For the record, I did not run the two XCTest cases. That they fail on base comes from reading the base code path. The live Flutter numbers (iOS 27.1, label=Password reproduces, coordinates and text= do not) are your report, and I did not re-run them. We still do not know why only label addressing reproduces. Cubic has not reviewed this head, and there are no open review threads.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 6, 2026
@thymikee
thymikee merged commit c53e88b into main Oct 6, 2026
16 of 19 checks passed
@thymikee
thymikee deleted the fix/ios-tap-corroboration-type-mismatch branch October 6, 2026 05:29
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-06 05:30 UTC

thymikee added a commit to okwasniewski/agent-device that referenced this pull request Oct 6, 2026
* origin/main: (77 commits)
  fix(apple-runner): fence prep spawns behind a start-owned admission (callstack#3239)
  0.21.22
  test(apple): own the simctl settings plan tests in simctl-settings.test.ts (callstack#3244)
  fix(limrun): report the session device id in iOS settings refusals (callstack#3243)
  0.21.21
  feat(remote): add a host-allocated macos-app lease backend (callstack#3236)
  test(android): bound the screenshot write wait by wall time, not event-loop turns (callstack#3250)
  feat(recording): cap the touch overlay frame rate at the caller's --fps (callstack#3241)
  fix(ad-script): let .ad scripts carry scroll --until and wait capture flags (callstack#3197) (callstack#3234)
  feat(provider-webdriver): keyboard enter, dismiss, and status over WebDriver (callstack#3233)
  feat(selectors): match role= against snapshot kind with a node-scoped alias window (callstack#3232)
  fix(provider-webdriver): read field values, placeholders, secure fields, and checked state from page source (callstack#3231)
  feat(replay): accept --test-ime on test and replay so flow-owned Android opens opt into the test IME (callstack#3235)
  refactor(daemon): route daemon-level diagnostics through one scope helper (callstack#3242)
  docs(adr): correct ADR 0031 pointer event delivery evidence (callstack#3245)
  fix(ios): stop a tap's post-gesture lookup from recording an XCTest failure (callstack#3060) (callstack#3237)
  fix(recording): render the touch overlay at most 30 fps and inside the record request (callstack#3219)
  fix(daemon): keep an idle daemon alive only for retained leases (callstack#3227)
  fix(provider-webdriver): send an empty JSON object on bodyless POSTs (callstack#3230)
  Feat/maestro repeat while (callstack#3214)
  ...
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: tapping a Flutter password field records an XCTest failure and restarts the runner, although the tap lands

1 participant