@@ -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 (
0 commit comments