Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <x> <y>` 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 <x> <y>` 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
Loading