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 @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading