Skip to content

Commit 737fa35

Browse files
thymikeeclaude
andcommitted
refactor(ios): verify and repair an append through the text entry reader
Append verification, the repair decision, and the repair itself read through TextEntryReader, so a verification starts from the read its check took and the repair dispatches through the reader. The clear's caret tap takes its frame from the read it clears. A read that raises logs its exception, and every input comes from a named read. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 90fa513 commit 737fa35

3 files changed

Lines changed: 46 additions & 51 deletions

File tree

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

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -209,27 +209,30 @@ extension RunnerTests {
209209
/// Nil when the element exists but could not be snapshotted.
210210
let snapshot: XCUIElementSnapshot?
211211

212-
init(element: XCUIElement, snapshot: XCUIElementSnapshot?) {
212+
private init(element: XCUIElement, snapshot: XCUIElementSnapshot?) {
213213
self.element = element
214214
self.snapshot = snapshot
215215
}
216216

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.
217+
/// Reads a handle that may name an input the app has removed; nil when it names none or the read
218+
/// raised. The snapshot of a missing element throws only after XCTest waits about two seconds for
219+
/// a match, so existence is read first.
220220
init?(reading element: XCUIElement) {
221221
var exists = false
222222
var snapshot: XCUIElementSnapshot?
223-
let exception = RunnerObjCExceptionCatcher.catchException {
223+
if let exception = RunnerObjCExceptionCatcher.catchException({
224224
exists = element.exists
225225
snapshot = exists ? try? element.snapshot() : nil
226+
}) {
227+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_READ_IGNORED_EXCEPTION=%@", exception)
228+
return nil
226229
}
227-
guard exception == nil, exists else { return nil }
230+
guard exists else { return nil }
228231
self.init(element: element, snapshot: snapshot)
229232
}
230233

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.
234+
/// Reads a handle its query has just listed, inside the caller's exception containment, so the
235+
/// snapshot is read first and existence only when the snapshot fails; nil when it names no element.
233236
init?(readingListed element: XCUIElement) {
234237
let snapshot = try? element.snapshot()
235238
guard snapshot != nil || element.exists else { return nil }
@@ -323,8 +326,8 @@ extension RunnerTests {
323326
}
324327
}
325328

329+
/// Clears `input`, which must have been read with nothing dispatched since.
326330
func clearTextInput(app: XCUIApplication, _ input: ResolvedTextEntryInput) {
327-
let element = input.element
328331
// Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the
329332
// field is empty. Why skip: the edge-tap computes a point from the element frame, which can
330333
// be stale after the field repositions on focus (e.g. the Settings search bar jumps
@@ -340,10 +343,10 @@ extension RunnerTests {
340343
// Read before the caret tap, which dispatches to the app.
341344
let count = estimatedDeleteCount(for: input.attributes)
342345
#if !os(tvOS)
343-
moveCaretToEnd(app: app, element: element)
346+
moveCaretToEnd(app: app, input: input)
344347
#endif
345348
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
346-
element.typeText(deletes)
349+
input.element.typeText(deletes)
347350
}
348351

349352
func isTextEntryElement(_ element: XCUIElement) -> Bool {
@@ -457,7 +460,7 @@ extension RunnerTests {
457460

458461
/// Resolution for a target not bound to an input yet: the first element that exists.
459462
private func resolveUnboundTextEntryInput(app: XCUIApplication, target: TextEntryTarget) -> ResolvedTextEntryInput? {
460-
if target.prefersFocusedElement, let focused = focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(readingListed:)) {
463+
if target.prefersFocusedElement, let focused = focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(reading:)) {
461464
return focused
462465
}
463466
if let element = target.element, let input = ResolvedTextEntryInput(reading: element) {
@@ -467,25 +470,26 @@ extension RunnerTests {
467470
let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
468471
return refreshed
469472
}
470-
return focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(readingListed:))
473+
return focusedTextInput(app: app).flatMap(ResolvedTextEntryInput.init(reading:))
471474
}
472475

473-
private func moveCaretToEnd(app: XCUIApplication, element: XCUIElement) {
476+
private func moveCaretToEnd(app: XCUIApplication, input: ResolvedTextEntryInput) {
474477
#if os(tvOS)
475478
return
476479
#else
477480
// Both the point AND its dispatch must stay off the handle: this runs AFTER the command's own
478481
// focus tap, and an input that stopped answering its resolving query on focus (#3060) makes
479482
// every post-focus handle touch a recorder — `element.frame` records directly, and an
480483
// element-anchored `coordinate(...).tap()` re-runs that same query to resolve its anchor when
481-
// the action executes. A fresh `snapshot()` proves the handle answered moments earlier, not
482-
// that it still answers at tap-dispatch time. So the point comes from the snapshot frame and
484+
// the action executes. The input's snapshot, read with nothing dispatched since, proves the handle
485+
// answered moments earlier, not that it still answers at tap-dispatch time. So the point comes
486+
// from the snapshot frame and
483487
// goes out app-relative through `tapAt`, which resolves no element at all — the shape
484488
// `waitForTextEntryReadinessAfterTap` settled on in #3237. With no trustworthy frame, or a
485489
// zero-size one (how a departed handle reads), there is no point to dispatch — an edge-tap
486490
// from a frame the field moved after records a failure or navigates away, the shape
487491
// `clearTextInput`'s comment refuses — so degrade to the caller's point-free delete burst.
488-
guard let frame = ResolvedTextEntryInput(reading: element)?.snapshot?.frame, !frame.isEmpty else {
492+
guard let frame = input.snapshot?.frame, !frame.isEmpty else {
489493
return
490494
}
491495
_ = tapAt(

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

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,8 @@ extension RunnerTests {
326326
mode: repairMode
327327
)
328328
}
329-
// A stopped plan must answer before touching the target: the input can be gone by now, and the
330-
// element the last post returned is then unreadable. That failure is already typed.
329+
// A stopped plan must answer before touching the target: the input can be gone by now. That
330+
// failure is already typed.
331331
if let failure = planFailure {
332332
return failure
333333
}
@@ -423,10 +423,8 @@ extension RunnerTests {
423423
reader: inout TextEntryReader,
424424
expectedText: String?
425425
) -> TextEntryResult {
426-
let resolved = reader.input()
427-
let target = reader.target
428-
if target.boundIdentity != nil, resolved == nil {
429-
guard boundTextEntryInputIsGone(app: app, target: target) else {
426+
if reader.input() == nil, reader.target.boundIdentity != nil {
427+
guard boundTextEntryInputIsGone(app: app, target: reader.target) else {
430428
return TextEntryResult(
431429
verified: nil,
432430
repaired: false,
@@ -441,8 +439,7 @@ extension RunnerTests {
441439
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
442440
}
443441
let initialResult = verifyTextEntry(
444-
app: app,
445-
target: target,
442+
reader: &reader,
446443
expectedText: expectedText,
447444
repaired: false
448445
)
@@ -453,28 +450,25 @@ extension RunnerTests {
453450
return initialResult
454451
}
455452
guard shouldRepairAppendTextEntry(
456-
app: app,
457-
target: target,
453+
reader: &reader,
458454
expectedText: expectedText
459455
) else {
460456
return verifyTextEntry(
461-
app: app,
462-
target: target,
457+
reader: &reader,
463458
expectedText: expectedText,
464459
repaired: false
465460
)
466461
}
467-
guard Self.textEntryRepairCanTarget(boundIdentity: target.boundIdentity) else {
462+
guard Self.textEntryRepairCanTarget(boundIdentity: reader.target.boundIdentity) else {
468463
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_REPAIR_REFUSED reason=unidentified-input")
469464
return verifyTextEntry(
470-
app: app,
471-
target: target,
465+
reader: &reader,
472466
expectedText: expectedText,
473467
repaired: false
474468
)
475469
}
476470

477-
guard let repairTarget = resolveTextEntryInput(app: app, target: target) else {
471+
guard let repairTarget = reader.input() else {
478472
return initialResult
479473
}
480474
let observedText = editableTextValue(for: repairTarget.attributes) ?? ""
@@ -483,28 +477,26 @@ extension RunnerTests {
483477
expectedText.count,
484478
observedText.count
485479
)
486-
clearTextInput(app: app, repairTarget)
487-
repairTarget.element.typeText(expectedText)
488-
let repairedTarget = target.withResolvedInput(ResolvedTextEntryInput(reading: repairTarget.element))
489-
let repairedResult = verifyTextEntry(
490-
app: app,
491-
target: repairedTarget,
480+
reader.dispatch {
481+
clearTextInput(app: app, repairTarget)
482+
repairTarget.element.typeText(expectedText)
483+
}
484+
return verifyTextEntry(
485+
reader: &reader,
492486
expectedText: expectedText,
493487
repaired: true
494488
)
495-
return repairedResult
496489
#else
497490
return initialResult
498491
#endif
499492
}
500493

501494
private func verifyTextEntry(
502-
app: XCUIApplication,
503-
target: TextEntryTarget,
495+
reader: inout TextEntryReader,
504496
expectedText: String?,
505497
repaired: Bool
506498
) -> TextEntryResult {
507-
let targetInput = resolveTextEntryInput(app: app, target: target)
499+
let targetInput = reader.input()
508500
guard let expectedText else {
509501
return TextEntryResult(
510502
verified: nil,
@@ -531,8 +523,8 @@ extension RunnerTests {
531523
let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
532524
var latestObservedText = observedText
533525
while Date() < stableDeadline {
534-
sleepFor(TextEntryTiming.pollInterval)
535-
let nextInput = resolveTextEntryInput(app: app, target: target)
526+
reader.dispatch { sleepFor(TextEntryTiming.pollInterval) }
527+
let nextInput = reader.input()
536528
guard let nextObservedText = editableTextValue(for: nextInput?.attributes) else {
537529
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
538530
}
@@ -591,16 +583,15 @@ extension RunnerTests {
591583
}
592584

593585
private func shouldRepairAppendTextEntry(
594-
app: XCUIApplication,
595-
target: TextEntryTarget,
586+
reader: inout TextEntryReader,
596587
expectedText: String
597588
) -> Bool {
598589
#if os(iOS)
599590
var latestObservedText: String?
600591
var latestElementType: XCUIElement.ElementType?
601592
let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
602593
repeat {
603-
let input = resolveTextEntryInput(app: app, target: target)
594+
let input = reader.input()
604595
guard let observedText = editableTextValue(for: input?.attributes) else {
605596
return false
606597
}
@@ -619,7 +610,7 @@ extension RunnerTests {
619610
) {
620611
return false
621612
}
622-
sleepFor(TextEntryTiming.pollInterval)
613+
reader.dispatch { sleepFor(TextEntryTiming.pollInterval) }
623614
} while Date() < deadline
624615

625616
guard let latestObservedText else {

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryLatencyTests.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ extension RunnerTests {
2525
/// Bluesky's React Native Search screen (iPhone 17 Pro, iOS 27.0), so a fill's cost there is its
2626
/// query count. The fixture field counts how often XCTest reads its value, which it does once per
2727
/// query. Its value is masked, so the count does not depend on how many read-back polls fit in a
28-
/// stability window. This fill made 19 queries when each resolution's snapshot served the reads
29-
/// after it, and 35 when every attribute was read from the live element.
28+
/// stability window. This fill made 17 queries when each read served the reads after it until the
29+
/// next dispatch, and 35 when every attribute was read from the live element.
3030
@MainActor
3131
func testFillReadsEachResolvedFieldThroughOneSnapshot() throws {
3232
app.launchArguments = [

0 commit comments

Comments
 (0)