diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index 7b369fab00..badf290348 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -243,7 +243,7 @@ extension RunnerTests { } } - func clearTextInput(_ element: XCUIElement) { + func clearTextInput(app: XCUIApplication, _ element: XCUIElement) { // Skip the clear (delete burst + moveCaretToEnd edge-tap) ONLY when we can confirm the // field is empty. Why skip: the edge-tap computes a point from the element frame, which can // be stale after the field repositions on focus (e.g. the Settings search bar jumps @@ -257,7 +257,7 @@ extension RunnerTests { return } #if !os(tvOS) - moveCaretToEnd(element: element) + moveCaretToEnd(app: app, element: element) #endif let count = estimatedDeleteCount(for: element) let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count) @@ -357,6 +357,21 @@ extension RunnerTests { return app.descendants(matching: identity.elementType).matching(identifier: identity.identifier).element } + /// Reads the frame a post-focus point is computed from, through the channel that records nothing. + /// A tap holds a handle bound to the query that resolved its element, and asking that handle for + /// `element.frame` re-runs the query: an element that stopped answering it once it took focus — a + /// Flutter password field whose two accessibility channels disagree when focused (#3060) — makes + /// XCTest record "No matches found for …", which ends the runner session for work the dispatch had + /// already landed. `snapshot()` answers the same query through the throwing channel and records + /// nothing, which is already how `probeTextEntryInput` and `withElement` reach an element. Nil means + /// no trustworthy frame: callers must degrade to a route that needs no point rather than dispatch + /// one computed from where the element used to be. + func textEntrySnapshotFrame(_ element: XCUIElement) -> CGRect? { + // Skipped when the input is already gone, so a removed field does not pay the snapshot's + // ~2-second wait for a match before its point degrades (the same guard `withElement` makes). + safely("TEXT_ENTRY_SNAPSHOT_FRAME", { element.exists ? try? element.snapshot() : nil })?.frame + } + /// Snapshots one candidate: its identity, a proven no-match, or a failure that proves nothing. func probeTextEntryInput(_ element: XCUIElement) -> TextEntryInputProbe { var probe = TextEntryInputProbe.unavailable @@ -395,20 +410,29 @@ extension RunnerTests { return nil } - private func moveCaretToEnd(element: XCUIElement) { + private func moveCaretToEnd(app: XCUIApplication, element: XCUIElement) { #if os(tvOS) return #else - let frame = element.frame - guard !frame.isEmpty else { - element.tap() + // Both the point AND its dispatch must stay off the handle: this runs AFTER the command's own + // focus tap, and an input that stopped answering its resolving query on focus (#3060) makes + // every post-focus handle touch a recorder — `element.frame` records directly, and an + // element-anchored `coordinate(...).tap()` re-runs that same query to resolve its anchor when + // the action executes. A fresh `snapshot()` proves the handle answered moments earlier, not + // that it still answers at tap-dispatch time. So the point comes from the snapshot frame and + // goes out app-relative through `tapAt`, which resolves no element at all — the shape + // `waitForTextEntryReadinessAfterTap` settled on in #3237. With no trustworthy frame, or a + // zero-size one (how a departed handle reads), there is no point to dispatch — an edge-tap + // from a frame the field moved after records a failure or navigates away, the shape + // `clearTextInput`'s comment refuses — so degrade to the caller's point-free delete burst. + guard let frame = textEntrySnapshotFrame(element), !frame.isEmpty else { return } - let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) - let target = origin.withOffset( - CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2)) + _ = tapAt( + app: app, + x: frame.minX + max(2, frame.width - 4), + y: frame.minY + max(2, frame.height / 2) ) - target.tap() #endif } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift index 01ab400bfc..3f03e5364a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift @@ -137,7 +137,7 @@ extension RunnerTests { if keyboardVisibleBeforeTap, let target { return TextEntryTarget( element: target, - refreshPoint: textEntryRefreshPoint(for: target) ?? requestedPoint, + refreshPoint: textEntryRefreshPoint(for: target, fallbackTo: requestedPoint), prefersFocusedElement: false ) } @@ -157,18 +157,32 @@ extension RunnerTests { : (waitForTextEntryReadiness(app: app, target: readyTarget) ?? stabilized.element ?? target) return TextEntryTarget( element: element, - refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint, + refreshPoint: textEntryRefreshPoint(for: element, fallbackTo: requestedPoint), prefersFocusedElement: false ) } - private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? { + /// The field's current center for a refresh point that survives a focus move, or `requestedPoint` + /// when the field reads with an empty frame. Read through `textEntrySnapshotFrame`, not + /// `element.frame`: this runs after the focus tap, and a field that stopped answering its + /// resolving query on focus (#3060) turns that read into a recorded failure which ends the runner + /// session for a focus the dispatch had already landed. When the read could not answer at all, + /// the target gets NO refresh point rather than the pre-focus one: this route's first resolve is + /// not yet bound to an identity, and a field that went unqueryable on focus may have moved the + /// layout so the old point names a different input, so fail closed with a typed reason instead of + /// typing into that other field. + private func textEntryRefreshPoint( + for element: XCUIElement?, + fallbackTo requestedPoint: CGPoint? + ) -> CGPoint? { guard let element else { + return requestedPoint + } + guard let frame = textEntrySnapshotFrame(element) else { return nil } - let frame = element.frame guard !frame.isEmpty else { - return nil + return requestedPoint } return CGPoint(x: frame.midX, y: frame.midY) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index ec88d4bbd8..ba12e14521 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -34,7 +34,7 @@ extension RunnerTests { failure: .notFocused ) } - clearTextInput(clearTarget) + clearTextInput(app: app, clearTarget) // nil means the value is unreadable (secure fields), not "not empty": leave `verified` // nil there rather than reporting a mismatch the runner cannot actually observe. let observed = editableTextValue(for: clearTarget, treatingPlaceholderAsEmpty: true) @@ -125,7 +125,7 @@ extension RunnerTests { } if currentText == nil || currentText?.isEmpty == false { let clearStartedAt = Date() - clearTextInput(replacementTarget) + clearTextInput(app: app, replacementTarget) activeTarget = activeTarget.withElement(replacementTarget) logTextEntryPhase( commandId: commandId, @@ -465,7 +465,7 @@ extension RunnerTests { expectedText.count, observedText.count ) - clearTextInput(repairTarget) + clearTextInput(app: app, repairTarget) repairTarget.typeText(expectedText) let repairedTarget = target.withElement(repairTarget) let repairedResult = verifyTextEntry( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift index fc68c7ee28..b06f589e04 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift @@ -214,6 +214,61 @@ extension RunnerTests { ) XCTAssertNotNil(textEntryTapWitness) } + + /// #3238: the report's `fill @e6` row. A coordinate `fill` resolves the input, taps it for focus, + /// and then reads that handle's frame to build the target's refresh point — after the tap. A field + /// that stopped answering its resolving query on focus turns that read into a recorded + /// "No matches found for …", which the dispatch path turns into `XCTEST_RECORDED_FAILURE` plus an + /// invalidated target for a focus the gesture had already delivered. The fill must still terminate + /// with a typed reason — this fixture's field leaves the accessibility tree entirely, so the + /// runner cannot resolve it to type into and refuses rather than typing app-wide — and it must + /// record nothing on the way there. + @MainActor + func testCoordinateFillOnInputThatLeavesTheTreeOnFocusFailsTypedAndRecordsNothing() throws { + app.launchArguments = [ + "--agent-device-text-entry-regression", + "--agent-device-text-entry-unqueryable-on-focus", + ] + app.launch() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout)) + let field = app.textFields["agent-device-hardware-keyboard-input"] + XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) + // The fixture keeps its keyboard down with an empty inputView, but a simulator with the software + // keyboard forced on takes the fill's keyboard-visible branch, which has different resolution. + // That is an environment fact, so the test that pins the hidden-keyboard branch skips on it. + try XCTSkipIf( + isKeyboardVisible(app: app), + "software keyboard is up: this simulator cannot exercise the hidden-keyboard replacement path" + ) + let frame = field.frame + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + + let failures = currentXCTestFailureCount() + let fill = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"fill-unqueryable-on-focus","text":"fresh","textEntryMode":"replace","x":\#(frame.midX),"y":\#(frame.midY)}"# + ) + let response = try execute(command: fill) + + XCTAssertFalse( + didRecordXCTestFailure(since: failures), + String(describing: response.error) + ) + XCTAssertFalse(response.ok, String(describing: response.data)) + XCTAssertEqual(response.error?.code, "TEXT_INPUT_NOT_FOCUSED") + // The app's own delegate callback: the gesture landed and took focus; what the old seam lost was + // the session, not the tap. Without this, a fill that never reached the field would pass too. + XCTAssertEqual( + app.staticTexts["agent-device-text-entry-focus"].label, + "focus", + "the fill's focus tap must have reached the field" + ) + } #endif } #endif