diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index 944db7818c..d54cab9b6d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -67,6 +67,9 @@ @interface AgentDeviceRunnerViewController : UIViewController @property(nonatomic, assign) NSUInteger replacementAlertActions; @property(nonatomic, strong) UILabel *textEntryWriteBackStatus; @property(nonatomic, strong) UILabel *textEntryDigitSlots; +// Filled from the fixture field's own delegate callbacks so a test can tell that a gesture really +// took focus from an input that has stopped answering the accessibility query that found it. +@property(nonatomic, strong) UILabel *textEntryFocusWitness; @property(nonatomic, assign) NSUInteger textEntryWriteBacks; @property(nonatomic, copy, nullable) NSString *textEntryRenderedValue; @property(nonatomic, assign) NSTimeInterval textEntryLastEditTime; @@ -340,6 +343,20 @@ - (void)agentDeviceTextEntryDidChange:(UITextField *)textField { } } +// Stops answering the element-type query that found it, the moment it takes focus, under +// `--agent-device-text-entry-unqueryable-on-focus`. #3060's Flutter password field reaches this shape +// because its two accessibility channels disagree once focused — `TextField` through the legacy +// attributes, `Other` through the modern ones — and what either channel leaves behind is the same: +// the tap's element answers the query that resolved it up to the gesture and refuses it after, so a +// read of it after dispatching records an XCTest failure instead of describing the tap that already +// landed. The field keeps first-responder, so a test still needs a witness that is not the +// accessibility tree to tell the gesture landed: `agent-device-text-entry-focus` is a label this +// method fills in from the field's own delegate callback. +- (void)agentDeviceTextEntryBecomesUnqueryable:(UITextField *)textField { + self.textEntryFocusWitness.text = @"focus"; + textField.hidden = YES; +} + // Moves the field up by its own height plus the gap below it when it gains focus, the way keyboard // avoidance or a bottom sheet extending above the keyboard does, so the neighbouring field slides // into the point the focus tap hit. @@ -417,6 +434,19 @@ - (void)viewDidLoad { [textField.widthAnchor constraintEqualToConstant:240], [textField.heightAnchor constraintEqualToConstant:AgentDeviceTextEntryFieldHeight], ]]; + if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unqueryable-on-focus"]) { + self.textEntryFocusWitness = [[UILabel alloc] init]; + self.textEntryFocusWitness.accessibilityIdentifier = @"agent-device-text-entry-focus"; + self.textEntryFocusWitness.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.textEntryFocusWitness]; + [NSLayoutConstraint activateConstraints:@[ + [self.textEntryFocusWitness.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.textEntryFocusWitness.topAnchor constraintEqualToAnchor:textField.bottomAnchor constant:24], + ]]; + [textField addTarget:self + action:@selector(agentDeviceTextEntryBecomesUnqueryable:) + forControlEvents:UIControlEventEditingDidBegin]; + } if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-moves-on-focus"]) { textField.text = @"stale"; [textField addTarget:self diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift index 94f8f5eaa6..01ab400bfc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift @@ -6,9 +6,16 @@ import XCTest // is RunnerTests+TextEntryReadiness.swift's question, and this file asks it rather than answering // it. extension RunnerTests { + /// Books the tap as the address for the next bare `type`. `element` is a text input the caller + /// classified BEFORE dispatching, or nil when the tap hit nothing worth typing into; that is the whole + /// contract, and this function must not re-check it. A tap holds a handle bound to the query that + /// resolved the element, so asking the handle re-runs that query, and an element that stopped answering + /// it — a Flutter password field once focus makes its accessibility attributes disagree — makes XCTest + /// record a failure. That failure ends the runner session for a tap the dispatch had already landed + /// (#3060), and ADR 0005 already keeps a tap's outcome independent of this bookkeeping. @MainActor func rememberTextEntryTap(_ element: XCUIElement?) { - guard let element, isTextEntryElement(element) else { + guard let element else { clearRememberedTextEntryTap() return } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift index 3404d0adcd..d87c3f7cdf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryReadiness.swift @@ -80,21 +80,33 @@ extension RunnerTests { #endif } + /// Gives a tapped text input one readiness window to take focus, then taps it once more if focus did + /// not arrive. The caller has already decided this element is a text input, so this function must not + /// re-derive that fact from the element: both tap routes classify BEFORE dispatching, and asking the + /// handle afterwards re-runs its query. On a Flutter password field that re-run is exactly what fails + /// (#3060): XCTest refuses the element with "computed TextField from legacy attributes vs Other from + /// modern attribute", so a post-dispatch classification would also classify differently from the caller. + /// + /// The frame still has to be read, and reading it through `element.frame` records an XCTest failure when + /// the element has stopped answering its query; `didRecordXCTestFailure` turns that into + /// `XCTEST_RECORDED_FAILURE` plus an invalidated target for a tap the dispatch had already landed. + /// `snapshot()` answers the same question through the throwing channel and records nothing, which is why + /// `probeTextEntryInput` and `withElement` already read elements this way. It is read again after the + /// wait rather than reused, because focusing a field moves the layout: the repeat aims at where the + /// field is then, which is the moment the read it replaced ran at. func waitForTextEntryReadinessAfterTap(app: XCUIApplication, element: XCUIElement) { #if os(iOS) - switch element.elementType { - case .textField, .secureTextField, .searchField, .textView: - if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil { - return - } - let frame = element.frame - if !frame.isEmpty { - _ = tapAt(app: app, x: frame.midX, y: frame.midY) - _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) - } - default: + if waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) != nil { return } + guard let settled = safely("TEXT_ENTRY_READINESS_FRAME", { try? element.snapshot() }) else { + return + } + let frame = settled.frame + if !frame.isEmpty { + _ = tapAt(app: app, x: frame.midX, y: frame.midY) + _ = waitForFocusedTextInput(app: app, timeout: TextEntryTiming.readinessTimeout) + } #endif } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift index cd26bde4ed..fc68c7ee28 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift @@ -114,6 +114,106 @@ extension RunnerTests { "focus held by another element must read as a refusal, never as this element's focus" ) } + + // #3060: a Flutter password field reports `TextField` through the legacy accessibility attributes and + // `Other` through the modern ones once it takes focus, so it stops answering the type-bound query that + // resolved it. The runner classified its resolved element AFTER dispatching, which re-ran that query, + // and XCTest answered `No matches found for Element at index 1 from input {(TextField)}` — a recorded + // failure that failed the command and restarted the runner for a tap the dispatch had already landed. + // The fixture reaches the same consequence by leaving the tree on focus, and its app-side delegate + // witness lets the test prove the gesture took focus when the accessibility tree no longer can. + + /// The route the issue measured: `click @ref` and `press ` both reach a coordinate tap, whose + /// probe handle is bound to the index it held in the type-bound query — the shape of the log's + /// `Element at index 1 from input {(TextField)}`. A tap that lands must report `tapped` and leave no + /// recorded failure for the dispatch path to convert into `XCTEST_RECORDED_FAILURE` and a runner + /// restart, while still booking the witness that the next bare `type` is addressed by. + /// + /// The witness assertion pins how it is booked, not that a following `type` uses it: this fixture's + /// field is gone from the tree by then, so the next `type` would refuse the handle. What the tap must + /// not need is a read of the handle to decide whether to book it. + @MainActor + func testCoordinateTapOnInputThatLeavesTheTreeOnFocusReportsTapped() throws { + app.launchArguments = [ + "--agent-device-text-entry-regression", + "--agent-device-text-entry-unqueryable-on-focus", + ] + app.launch() + defer { + clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") + 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)) + let frame = field.frame + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + clearSnapshotXCTestChannelPenalty(reason: "fresh-runner") + + let failures = currentXCTestFailureCount() + let tap = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"tap-unqueryable-on-focus","x":\#(frame.midX),"y":\#(frame.midY),"synthesized":true}"# + ) + let response = try execute(command: tap) + + XCTAssertEqual(response.data?.message, "tapped", String(describing: response.error)) + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + // The app's own delegate callbacks, not the accessibility tree: without this, "nothing recorded a + // failure" would also be satisfied by a gesture that never reached the field at all. + XCTAssertEqual( + app.staticTexts["agent-device-text-entry-focus"].label, + "focus", + "the tap must have taken focus from the field it resolved" + ) + XCTAssertNotNil( + textEntryTapWitness, + "the tap still authorizes the next bare type from what it knew before dispatching" + ) + } + + /// The same invariant on the selector route, which read the element three times after its gesture: the + /// type again inside `rememberTextEntryTap`, then the type and the frame in the readiness wait. On this + /// fixture the route's identifier-bound handle stops resolving once the field hides, so each of those + /// recorded a failure the command inherits. `click @ref` and `press ` in the report both reached + /// the coordinate route above, so this holds the second route to the same rule rather than reproducing + /// the reported lookup. + @MainActor + func testSelectorTapOnInputThatLeavesTheTreeOnFocusRecordsNoFailure() 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)) + XCTAssertTrue( + app.textFields["agent-device-hardware-keyboard-input"].waitForExistence(timeout: appExistenceTimeout) + ) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + + let failures = currentXCTestFailureCount() + let tap = try runnerCommandFixture( + #"{"appBundleId":"com.callstack.agentdevice.runner","command":"tap","commandId":"selector-tap-unqueryable-on-focus","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input","synthesized":true}"# + ) + let response = try execute(command: tap) + + XCTAssertEqual(response.data?.message, "tapped", String(describing: response.error)) + XCTAssertFalse(didRecordXCTestFailure(since: failures)) + XCTAssertEqual( + app.staticTexts["agent-device-text-entry-focus"].label, + "focus", + "the tap must have taken focus from the field it resolved" + ) + XCTAssertNotNil(textEntryTapWitness) + } #endif } #endif