Skip to content

Commit 90fa513

Browse files
thymikeeclaude
andcommitted
refactor(ios): let one reader own the reuse of a text input read
Every resolver now returns ResolvedTextEntryInput, which owns the exists-first and snapshot-first reads. TextEntryReader hands a read out again until the next dispatch or pause, replacing the post-focus and undispatched reads tracked at each call site. The point lookup's snapshots answer identity, so its consumers stop probing candidates again. A fill reads the counted field 17 times instead of 19. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent a430292 commit 90fa513

7 files changed

Lines changed: 169 additions & 242 deletions

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ extension RunnerTests {
352352

353353
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
354354
let point = CGPoint(x: x, y: y)
355-
let textInputCandidates = textInputCandidatesAt(app: app, point: point)
355+
let textInputCandidates = textInputCandidatesAt(app: app, point: point).map(\.element)
356356
for element in textInputCandidates where prefersExpandedTextRead(element) {
357357
if let text = readableText(for: element) {
358358
return text

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedCommitDeadline.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,15 @@ extension RunnerTests {
138138
observe: () -> TextValueRead,
139139
waitForNextObservation: () -> Void
140140
) {
141-
let field = resolveTextEntryElement(app: app, target: target)
141+
let field = resolveTextEntryInput(app: app, target: target)?.attributes
142142
let placeholder = field?.placeholderValue
143143
let waitStartedAt = Date()
144144
return (
145145
placeholder: placeholder,
146146
fieldIsUnreadable: textValueRead(for: field) == .unreadable,
147147
observe: {
148148
let observed = self.textValueRead(
149-
for: self.resolveTextEntryElement(app: app, target: target),
149+
for: self.resolveTextEntryInput(app: app, target: target)?.attributes,
150150
treatingPlaceholderAsEmpty: true
151151
)
152152
var observedText: String?

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift‎

Lines changed: 93 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ extension RunnerTests {
158158
/// this is set the point no longer names the field. It identifies the field for reads and
159159
/// clears, never for routing.
160160
let inputAtRefreshPoint: TextInputAtPoint?
161-
/// `element` as the focus step read it after its last dispatch. The entry's first resolution
162-
/// uses this read instead of querying the input again. A retargeted target has none.
161+
/// `element` as the focus step read it after its last dispatch: a `TextEntryReader` hands it
162+
/// out as its first read. A target moved to another read has none.
163163
let postFocusRead: ResolvedTextEntryInput?
164164

165165
init(
@@ -180,57 +180,64 @@ extension RunnerTests {
180180
self.postFocusRead = postFocusRead
181181
}
182182

183-
func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
184-
guard let nextElement else {
185-
return self
186-
}
187-
let input = ResolvedTextEntryInput(reading: nextElement)
188-
return retargeted(to: input.element, snapshot: input.snapshot)
189-
}
190-
191-
/// `withElement` for an input a resolution just read, without querying it again.
183+
/// The target moved to the input a resolution read: its element, a refresh point at its center,
184+
/// and its identity while the target is unbound.
192185
func withResolvedInput(_ input: ResolvedTextEntryInput?) -> TextEntryTarget {
193186
guard let input else {
194187
return self
195188
}
196-
return retargeted(to: input.element, snapshot: input.snapshot)
197-
}
198-
199-
private func retargeted(to nextElement: XCUIElement, snapshot: XCUIElementSnapshot?) -> TextEntryTarget {
200-
let frame = snapshot?.frame ?? .zero
201-
let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
189+
let frame = input.snapshot?.frame ?? .zero
202190
return TextEntryTarget(
203-
element: nextElement,
204-
refreshPoint: point,
191+
element: input.element,
192+
refreshPoint: frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY),
205193
prefersFocusedElement: prefersFocusedElement,
206194
fromTapWitness: fromTapWitness,
207-
boundIdentity: boundIdentity ?? snapshot.map {
208-
TextEntryInputIdentity(elementType: $0.elementType, identifier: $0.identifier)
209-
},
195+
boundIdentity: boundIdentity ?? input.identity,
210196
inputAtRefreshPoint: inputAtRefreshPoint
211197
)
212198
}
213199
}
214200

215-
/// A text input one resolution found, with the snapshot the resolution read it through. Reads
216-
/// that follow the resolution take the input's attributes from this snapshot. Each attribute read
217-
/// from the live element runs its query again, and each query snapshots the whole application:
218-
/// one read took about 90 ms on Bluesky's React Native Search screen (iPhone 17 Pro, iOS 27.0).
201+
/// A text input element read through one snapshot. Later reads take the input's attributes from
202+
/// the snapshot: each attribute read from the live element runs its query again, and each query
203+
/// snapshots the whole application, about 90 ms on Bluesky's React Native Search screen (iPhone 17
204+
/// Pro, iOS 27.0). Where a live read of an element that stopped answering its query, such as a
205+
/// removed input or a focused Flutter password field (#3060), records an XCTest failure that ends
206+
/// the runner session, the snapshot only throws.
219207
struct ResolvedTextEntryInput {
220208
let element: XCUIElement
221-
/// Nil when the resolution proved the input exists but could not snapshot it.
209+
/// Nil when the element exists but could not be snapshotted.
222210
let snapshot: XCUIElementSnapshot?
223211

224212
init(element: XCUIElement, snapshot: XCUIElementSnapshot?) {
225213
self.element = element
226214
self.snapshot = snapshot
227215
}
228216

229-
/// Reads `element` once, without resolving a target. Reading `frame` from an input the app has
230-
/// removed records an XCTest failure; a snapshot only throws, after XCTest waits about two
231-
/// seconds for a match, so it is skipped when the input is already gone.
232-
init(reading element: XCUIElement) {
233-
self.init(element: element, snapshot: element.exists ? try? element.snapshot() : nil)
217+
/// Reads a handle that may name an input the app has removed; nil when it names none. The
218+
/// snapshot of a missing element throws only after XCTest waits about two seconds for a match,
219+
/// so existence is read first.
220+
init?(reading element: XCUIElement) {
221+
var exists = false
222+
var snapshot: XCUIElementSnapshot?
223+
let exception = RunnerObjCExceptionCatcher.catchException {
224+
exists = element.exists
225+
snapshot = exists ? try? element.snapshot() : nil
226+
}
227+
guard exception == nil, exists else { return nil }
228+
self.init(element: element, snapshot: snapshot)
229+
}
230+
231+
/// Reads a handle its query has just listed, so the snapshot is read first and existence only
232+
/// when the snapshot fails; nil when it names no element.
233+
init?(readingListed element: XCUIElement) {
234+
let snapshot = try? element.snapshot()
235+
guard snapshot != nil || element.exists else { return nil }
236+
self.init(element: element, snapshot: snapshot)
237+
}
238+
239+
var identity: TextEntryInputIdentity? {
240+
snapshot.map { TextEntryInputIdentity(elementType: $0.elementType, identifier: $0.identifier) }
234241
}
235242

236243
var attributes: any XCUIElementAttributes {
@@ -241,6 +248,38 @@ extension RunnerTests {
241248
}
242249
}
243250

251+
/// A text entry target and its input as last read. `input()` hands the last read out again until
252+
/// `dispatch` runs, and resolves the target again after that, so a bound target refuses an input
253+
/// that took its place. Each resolution moves the target to the input it read. Every post and every
254+
/// pause between reads goes through `dispatch`; the pause a typing plan takes itself follows a post.
255+
struct TextEntryReader {
256+
private(set) var target: TextEntryTarget
257+
private var lastRead: ResolvedTextEntryInput?
258+
private let resolve: (TextEntryTarget) -> ResolvedTextEntryInput?
259+
260+
init(target: TextEntryTarget, resolve: @escaping (TextEntryTarget) -> ResolvedTextEntryInput?) {
261+
self.target = target.withResolvedInput(target.postFocusRead)
262+
lastRead = target.postFocusRead
263+
self.resolve = resolve
264+
}
265+
266+
mutating func input() -> ResolvedTextEntryInput? {
267+
if let lastRead {
268+
return lastRead
269+
}
270+
let read = resolve(target)
271+
target = target.withResolvedInput(read)
272+
lastRead = read
273+
return read
274+
}
275+
276+
/// Runs `dispatch`, which posts to the app or pauses, so the next `input()` resolves again.
277+
mutating func dispatch<T>(_ dispatch: () -> T) -> T {
278+
lastRead = nil
279+
return dispatch()
280+
}
281+
}
282+
244283
/// What one snapshot of a candidate element proved.
245284
enum TextEntryInputProbe: Equatable {
246285
case input(TextEntryInputIdentity)
@@ -327,49 +366,30 @@ extension RunnerTests {
327366
}
328367
}
329368

330-
/// Resolves the target's input. A bound target accepts only an element whose snapshot carries
331-
/// its bound identity, so every post, read-back, verification poll, and repair refuses an input
332-
/// that took the bound one's place.
333-
func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
334-
guard target.boundIdentity != nil else {
335-
return resolveUnboundTextEntry(app: app, target: target)?.element
336-
}
337-
return resolveTextEntryInput(app: app, target: target)?.element
338-
}
339-
340-
/// `resolveTextEntryElement` with the snapshot the resolution read. A bound target's resolution
341-
/// already snapshots the input to check its identity. An unbound target's resolution keeps the
342-
/// snapshot its point lookup read, and otherwise takes one snapshot after it finds the input.
369+
/// Resolves the target's input, with the snapshot the resolution read it through. A bound target
370+
/// accepts only an input whose snapshot carries its bound identity, so every post, read-back,
371+
/// verification poll, and repair refuses an input that took the bound one's place.
343372
func resolveTextEntryInput(app: XCUIApplication, target: TextEntryTarget) -> ResolvedTextEntryInput? {
344373
guard let boundIdentity = target.boundIdentity else {
345-
switch resolveUnboundTextEntry(app: app, target: target) {
346-
case .lookup(let input)?:
347-
return input
348-
case .element(let element)?:
349-
return ResolvedTextEntryInput(element: element, snapshot: snapshotTextEntryInput(element).snapshot)
350-
case nil:
351-
return nil
352-
}
374+
return resolveUnboundTextEntryInput(app: app, target: target)
353375
}
354376
func boundInput(_ candidate: XCUIElement) -> ResolvedTextEntryInput? {
355-
guard candidate.exists else { return nil }
356-
let read = snapshotTextEntryInput(candidate)
357-
guard read.probe == .input(boundIdentity) else { return nil }
358-
return ResolvedTextEntryInput(element: candidate, snapshot: read.snapshot)
377+
guard let input = ResolvedTextEntryInput(reading: candidate), input.identity == boundIdentity else { return nil }
378+
return input
359379
}
360380
if target.prefersFocusedElement, let focused = focusedTextInput(app: app), let input = boundInput(focused) {
361381
return input
362382
}
363383
if let element = target.element, let input = boundInput(element) {
364384
return input
365385
}
366-
if let input = target.inputAtRefreshPoint, let snapshot = textInputSnapshotIfStillResolves(input) {
367-
return ResolvedTextEntryInput(element: input.element, snapshot: snapshot)
386+
if let input = target.inputAtRefreshPoint, let read = textInputIfStillResolves(input) {
387+
return read
368388
}
369389
if target.inputAtRefreshPoint == nil,
370390
let refreshPoint = target.refreshPoint,
371391
case .matches(let candidates) = probeTextInputs(app: app, point: refreshPoint),
372-
let match = candidates.lazy.compactMap(boundInput).first {
392+
let match = candidates.first(where: { $0.identity == boundIdentity }) {
373393
return match
374394
}
375395
if let focused = focusedTextInput(app: app), let input = boundInput(focused) {
@@ -407,7 +427,7 @@ extension RunnerTests {
407427
case .absent:
408428
return true
409429
case .matches(let candidates):
410-
return candidates.allSatisfy { probeTextEntryInput($0) != .input(boundIdentity) }
430+
return candidates.allSatisfy { $0.identity != boundIdentity }
411431
case .unavailable:
412432
return false
413433
}
@@ -421,85 +441,33 @@ extension RunnerTests {
421441
return app.descendants(matching: identity.elementType).matching(identifier: identity.identifier).element
422442
}
423443

424-
/// Reads the frame a post-focus point is computed from, through the channel that records nothing.
425-
/// A tap holds a handle bound to the query that resolved its element, and asking that handle for
426-
/// `element.frame` re-runs the query: an element that stopped answering it once it took focus — a
427-
/// Flutter password field whose two accessibility channels disagree when focused (#3060) — makes
428-
/// XCTest record "No matches found for …", which ends the runner session for work the dispatch had
429-
/// already landed. `snapshot()` answers the same query through the throwing channel and records
430-
/// nothing, which is already how `probeTextEntryInput` and `withElement` reach an element. Nil means
431-
/// no trustworthy frame: callers must degrade to a route that needs no point rather than dispatch
432-
/// one computed from where the element used to be.
433-
func textEntrySnapshotFrame(_ element: XCUIElement) -> CGRect? {
434-
textEntrySnapshot(element)?.frame
435-
}
436-
437-
/// The snapshot `textEntrySnapshotFrame` reads its frame from.
438-
func textEntrySnapshot(_ element: XCUIElement) -> XCUIElementSnapshot? {
439-
// Skipped when the input is already gone, so a removed field does not pay the snapshot's
440-
// ~2-second wait for a match before its point degrades (the same guard `withElement` makes).
441-
safely("TEXT_ENTRY_SNAPSHOT_FRAME", { element.exists ? try? element.snapshot() : nil })
442-
}
443-
444444
/// Snapshots one candidate: its identity, a proven no-match, or a failure that proves nothing.
445445
func probeTextEntryInput(_ element: XCUIElement) -> TextEntryInputProbe {
446-
snapshotTextEntryInput(element).probe
447-
}
448-
449-
/// `probeTextEntryInput` with the snapshot it read. The snapshot is nil unless the probe is `.input`.
450-
func snapshotTextEntryInput(_ element: XCUIElement) -> (probe: TextEntryInputProbe, snapshot: XCUIElementSnapshot?) {
451446
var probe = TextEntryInputProbe.unavailable
452-
var snapshot: XCUIElementSnapshot?
453447
let (_, exception) = catchingObjCException(fallback: ()) {
454448
do {
455-
let read = try element.snapshot()
456-
probe = .input(TextEntryInputIdentity(elementType: read.elementType, identifier: read.identifier))
457-
snapshot = read
449+
let snapshot = try element.snapshot()
450+
probe = .input(TextEntryInputIdentity(elementType: snapshot.elementType, identifier: snapshot.identifier))
458451
} catch {
459452
probe = Self.textEntryInputProbe(snapshotError: error)
460453
}
461454
}
462-
return exception == nil ? (probe, snapshot) : (.unavailable, nil)
463-
}
464-
465-
/// What an unbound target's resolution found: a handle it has not read, or the input its point
466-
/// lookup found, with the snapshot the lookup read.
467-
private enum UnboundTextEntryResolution {
468-
case element(XCUIElement)
469-
case lookup(ResolvedTextEntryInput)
470-
471-
var element: XCUIElement {
472-
switch self {
473-
case .element(let element):
474-
return element
475-
case .lookup(let input):
476-
return input.element
477-
}
478-
}
455+
return exception == nil ? probe : .unavailable
479456
}
480457

481458
/// Resolution for a target not bound to an input yet: the first element that exists.
482-
private func resolveUnboundTextEntry(app: XCUIApplication, target: TextEntryTarget) -> UnboundTextEntryResolution? {
483-
if target.prefersFocusedElement {
484-
if let focused = focusedTextInput(app: app) {
485-
return .element(focused)
486-
}
487-
if let element = target.element, element.exists {
488-
return .element(element)
489-
}
490-
} else {
491-
if let element = target.element, element.exists {
492-
return .element(element)
493-
}
459+
private func resolveUnboundTextEntryInput(app: XCUIApplication, target: TextEntryTarget) -> ResolvedTextEntryInput? {
460+
if target.prefersFocusedElement, let focused = focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(readingListed:)) {
461+
return focused
462+
}
463+
if let element = target.element, let input = ResolvedTextEntryInput(reading: element) {
464+
return input
494465
}
495466
if let refreshPoint = target.refreshPoint,
496467
let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
497-
return .lookup(refreshed)
468+
return refreshed
498469
}
499-
if let focused = focusedTextInput(app: app) {
500-
return .element(focused)
501-
}
502-
return nil
470+
return focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(readingListed:))
503471
}
504472

505473
private func moveCaretToEnd(app: XCUIApplication, element: XCUIElement) {
@@ -517,7 +485,7 @@ extension RunnerTests {
517485
// zero-size one (how a departed handle reads), there is no point to dispatch — an edge-tap
518486
// from a frame the field moved after records a failure or navigates away, the shape
519487
// `clearTextInput`'s comment refuses — so degrade to the caller's point-free delete burst.
520-
guard let frame = textEntrySnapshotFrame(element), !frame.isEmpty else {
488+
guard let frame = ResolvedTextEntryInput(reading: element)?.snapshot?.frame, !frame.isEmpty else {
521489
return
522490
}
523491
_ = tapAt(

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,17 @@ extension RunnerTests {
153153
guard let element else {
154154
return TextEntryTarget(element: nil, refreshPoint: requestedPoint, prefersFocusedElement: false)
155155
}
156-
let snapshot = textEntrySnapshot(element)
156+
let read = ResolvedTextEntryInput(reading: element)
157157
return TextEntryTarget(
158158
element: element,
159-
refreshPoint: textEntryRefreshPoint(frame: snapshot?.frame, fallbackTo: requestedPoint),
159+
refreshPoint: textEntryRefreshPoint(frame: read?.snapshot?.frame, fallbackTo: requestedPoint),
160160
prefersFocusedElement: false,
161-
postFocusRead: snapshot.map { ResolvedTextEntryInput(element: element, snapshot: $0) }
161+
postFocusRead: read?.snapshot == nil ? nil : read
162162
)
163163
}
164164

165165
/// The field's current center for a refresh point that survives a focus move, or `requestedPoint`
166-
/// when the field reads with an empty frame. Read through `textEntrySnapshot`, not
166+
/// when the field reads with an empty frame. Read through a snapshot, not
167167
/// `element.frame`: this runs after the focus tap, and a field that stopped answering its
168168
/// resolving query on focus (#3060) turns that read into a recorded failure which ends the runner
169169
/// session for a focus the dispatch had already landed. When the read could not answer at all,

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ extension RunnerTests {
3131
timeout: TimeInterval = TextEntryTiming.readinessTimeout
3232
) -> XCUIElement? {
3333
#if os(iOS)
34-
let candidate = resolveTextEntryElement(app: app, target: target)
34+
let candidate = resolveTextEntryInput(app: app, target: target)?.element
3535
let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
3636
let deadline = Date().addingTimeInterval(timeout)
3737
var hardwareKeyboardFallback = Date().addingTimeInterval(
@@ -66,7 +66,7 @@ extension RunnerTests {
6666
}
6767
return candidate
6868
#else
69-
return resolveTextEntryElement(app: app, target: target)
69+
return resolveTextEntryInput(app: app, target: target)?.element
7070
#endif
7171
}
7272

@@ -81,7 +81,7 @@ extension RunnerTests {
8181
/// the element has stopped answering its query; `didRecordXCTestFailure` turns that into
8282
/// `XCTEST_RECORDED_FAILURE` plus an invalidated target for a tap the dispatch had already landed.
8383
/// `snapshot()` answers the same question through the throwing channel and records nothing, which is why
84-
/// `probeTextEntryInput` and `withElement` already read elements this way. It is read again after the
84+
/// `ResolvedTextEntryInput` already reads elements this way. It is read again after the
8585
/// wait rather than reused, because focusing a field moves the layout: the repeat aims at where the
8686
/// field is then, which is the moment the read it replaced ran at.
8787
func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) {

0 commit comments

Comments
 (0)