Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPenalizedCoordinateTapOnNonTextControlDoesNotAuthorizeBareType \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedTextCommitProgressWalksExpectedPrefixOnly \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,13 @@ extension RunnerTests {
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))
XCTAssertFalse(
// A precondition, not a product claim. The fixture's empty `inputView` is what keeps the
// keyboard down, but nothing in this bundle owns the simulator's own keyboard settings, so an
// ambient flip that raised one here would be an environment fact — and reporting it as a
// failed assertion is what made this read as a product regression on unrelated PRs (#1874).
try XCTSkipIf(
isKeyboardVisible(app: app),
"the test must exercise a focused responder with the software keyboard hidden"
"software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path"
)

let failureCountBefore = currentXCTestFailureCount()
Expand All @@ -407,6 +411,56 @@ extension RunnerTests {
XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard")
}

// `waitForTextEntryReadiness`'s hardware-keyboard fallback returns early only on confirmed
// focus (#1874), and `keyboardFocusConfirmed` reads that from the app-wide focus predicate this
// bundle otherwise refuses to trust. Two XCTest facts it rests on, neither a repository
// invariant: the predicate reports a responder that shows NO software keyboard at all, and it
// names the element well enough to tell the tapped field from another one. The fixture field is
// the exact shape the fallback exists for — a real responder with an empty `inputView` — so this
// is where both are observable. If either regressed, readiness would silently stop taking the
// fallback and spend the full readinessTimeout on every hardware-keyboard field, which no other
// assertion would notice.
func testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus() throws {
app.launchArguments = ["--agent-device-text-entry-regression"]
app.launch()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))

let textField = app.textFields["agent-device-hardware-keyboard-input"]
XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout))
let otherElement = app.staticTexts["Agent Device Runner"]
XCTAssertTrue(otherElement.waitForExistence(timeout: appExistenceTimeout))
XCTAssertFalse(
keyboardFocusConfirmed(app: app, element: textField),
"an untapped field must not confirm focus, or the fallback would fire immediately"
)

let tapCommand = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-focus-confirmation","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"#
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))
try XCTSkipIf(
isKeyboardVisible(app: app),
"software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path"
)

let deadline = Date().addingTimeInterval(TextEntryTiming.readinessTimeout)
var confirmed = keyboardFocusConfirmed(app: app, element: textField)
while !confirmed && Date() < deadline {
sleepFor(TextEntryTiming.pollInterval)
confirmed = keyboardFocusConfirmed(app: app, element: textField)
}
XCTAssertTrue(confirmed, "a tapped responder must confirm its own keyboard focus")
XCTAssertFalse(
keyboardFocusConfirmed(app: app, element: otherElement),
"focus held by another element must read as a refusal, never as this element's focus"
)
}

func testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand() throws {
app.launchArguments = [
"--agent-device-text-entry-regression",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import XCTest

// How long the synthesized text-entry commit wait keeps looking. Split out of
// RunnerTests+SynthesizedTextEntry.swift only to keep that file inside its size budget; the two
// commit waits there are the sole users, and where this is tested.
extension RunnerTests {
/// One commit wait's running deadline.
///
/// A synthesized burst can be *throttled* rather than dropped: on a loaded simulator the
/// characters keep landing, just slowly, and everything touching the input system slows with
/// them (#1874). A flat wall-clock budget cannot tell that apart from a wedged pipeline, so it
/// condemned both at the same instant — and the throttled case is a working command reported as
/// `TEXT_INPUT_COMMIT_NOT_OBSERVED`, which is what turned an environment episode into a red
/// lane on branches touching no iOS code.
///
/// So time is granted against *progress* — the observed value's expected-prefix growing, the
/// same length-only evidence `logCommitCadence` already emits — with an absolute `ceiling`, so a
/// pipeline delivering one character per stall window cannot hold a command open forever. A
/// pipeline making no progress at all still expires at exactly the `stallBudget` the flat
/// deadline used, so nothing that fails today starts passing merely by waiting longer.
///
/// Started by the poll loop from its own first `now()`, and held as a local `var` there. The
/// wait's setup reads the field's placeholder first, an AX round-trip that takes seconds on
/// exactly the loaded host this exists for; charging that to the deadline made the wait give up
/// sooner than the flat budget it replaced.
struct SynthesizedCommitDeadline {
private let startedAt: Date
private let stallBudget: TimeInterval
private let ceiling: TimeInterval
// Nothing has landed yet, and an observation of "still nothing" must not read as progress.
private var bestPrefixLength = 0
private var lastProgressAt: Date

init(startedAt: Date, stallBudget: TimeInterval, ceiling: TimeInterval) {
self.startedAt = startedAt
self.stallBudget = stallBudget
self.ceiling = ceiling
self.lastProgressAt = startedAt
}

/// Records one observation's expected-prefix length. Only forward movement counts: a shorter
/// read — the app clearing the field mid-flight, or a value that could not be read at all,
/// which measures as 0 — is not evidence the burst is still landing, so it neither buys time
/// nor takes any back.
mutating func record(expectedPrefixLength: Int, at now: Date) {
guard expectedPrefixLength > bestPrefixLength else { return }
bestPrefixLength = expectedPrefixLength
lastProgressAt = now
}

func isExpired(at now: Date) -> Bool {
now.timeIntervalSince(startedAt) >= ceiling
|| now.timeIntervalSince(lastProgressAt) >= stallBudget
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,20 @@ extension RunnerTests {
case notObserved
}

/// The commit wait's decision, with observation, pacing and the clock injected so the deadline
/// branch is exercisable without a simulator (the macOS host lane runs this; the member wrapper
/// below binds the real XCUI reads).
/// The commit wait's decision, with observation, pacing and the clock injected so both deadline
/// branches are exercisable without a simulator (the macOS host lane runs this; the member
/// wrapper below binds the real XCUI reads).
///
/// The deadline is a local `var`, started from this loop's own first `now()` and advanced from
/// the same observation the progress check reads, so "did the burst move" and "is time up" are
/// two statements in one loop. The budget defaults to the shipped one, so only a test that is
/// asking about time has to name it.
static func awaitSynthesizedCommitOutcome(
expectedText: String,
placeholder: String?,
isExpired: () -> Bool,
stallBudget: TimeInterval = TextEntryTiming.synthesizedCommitStallTimeout,
ceiling: TimeInterval = TextEntryTiming.synthesizedCommitCeiling,
now: () -> Date,
observe: () -> String?,
waitForNextObservation: () -> Void
) -> SynthesizedTextCommitOutcome {
Expand All @@ -276,15 +283,24 @@ extension RunnerTests {
if Self.textMatchesPlaceholder(expectedText, placeholder: placeholder) {
return .notObserved
}
var deadline = SynthesizedCommitDeadline(startedAt: now(), stallBudget: stallBudget, ceiling: ceiling)
// The deadline is checked AFTER an observation, never before one, so the last thing that
// happens before condemning a commit is a read. Checking first would condemn a commit that
// landed during the final poll sleep — the exact loaded-host timing this wait exists for.
while true {
switch synthesizedTextCommitProgress(observedText: observe(), expectedText: expectedText) {
let observedText = observe()
switch synthesizedTextCommitProgress(observedText: observedText, expectedText: expectedText) {
case .committed, .diverged:
return .settled
case .pending:
if isExpired() { return .notObserved }
// One clock sample, so the instant an observation is recorded at is the instant it is
// judged against.
let sampledAt = now()
deadline.record(
expectedPrefixLength: Self.commonPrefixLength(observedText ?? "", expectedText),
at: sampledAt
)
if deadline.isExpired(at: sampledAt) { return .notObserved }
waitForNextObservation()
}
}
Expand Down Expand Up @@ -323,23 +339,35 @@ extension RunnerTests {
static func awaitSynthesizedReplacementCommitOutcome(
expectedText: String,
placeholder: String?,
isExpired: () -> Bool,
stallBudget: TimeInterval = TextEntryTiming.synthesizedCommitStallTimeout,
ceiling: TimeInterval = TextEntryTiming.synthesizedCommitCeiling,
now: () -> Date,
observe: () -> String?,
waitForNextObservation: () -> Void
) -> SynthesizedTextCommitOutcome {
if Self.textMatchesPlaceholder(expectedText, placeholder: placeholder) {
return .notObserved
}
var deadline = SynthesizedCommitDeadline(startedAt: now(), stallBudget: stallBudget, ceiling: ceiling)
while true {
if observe() == expectedText {
let observedText = observe()
if observedText == expectedText {
return .settled
}
if isExpired() { return .notObserved }
// Prefix growth cannot settle this wait — a value with a hole in the middle is still a
// failure, see the doc comment above — but it is the same evidence that the burst is still
// landing, so it buys the same time here as it does in append mode.
let sampledAt = now()
deadline.record(
expectedPrefixLength: Self.commonPrefixLength(observedText ?? "", expectedText),
at: sampledAt
)
if deadline.isExpired(at: sampledAt) { return .notObserved }
waitForNextObservation()
}
}

/// The placeholder/deadline/observe/pacing ingredients shared by the append route
/// The placeholder/observe/pacing ingredients shared by the append route
/// (`awaitSynthesizedFirstResponderCommit`) and the replacement route
/// (`awaitSynthesizedReplacementCommit`). What must NOT be shared is which outcome function
/// consumes them: see `awaitSynthesizedReplacementCommitOutcome`'s doc comment for why append
Expand All @@ -355,16 +383,13 @@ extension RunnerTests {
expectedText: String
) -> (
placeholder: String?,
isExpired: () -> Bool,
observe: () -> String?,
waitForNextObservation: () -> Void
) {
let placeholder = resolveTextEntryElement(app: app, target: target)?.placeholderValue
let deadline = Date().addingTimeInterval(TextEntryTiming.synthesizedCommitTimeout)
let waitStartedAt = Date()
return (
placeholder: placeholder,
isExpired: { Date() >= deadline },
observe: {
let observedText = self.editableTextValue(
for: self.resolveTextEntryElement(app: app, target: target),
Expand Down Expand Up @@ -412,7 +437,7 @@ extension RunnerTests {
let outcome = Self.awaitSynthesizedCommitOutcome(
expectedText: expectedText,
placeholder: ingredients.placeholder,
isExpired: ingredients.isExpired,
now: { Date() },
observe: ingredients.observe,
waitForNextObservation: ingredients.waitForNextObservation
)
Expand Down Expand Up @@ -448,7 +473,7 @@ extension RunnerTests {
let outcome = Self.awaitSynthesizedReplacementCommitOutcome(
expectedText: expectedText,
placeholder: ingredients.placeholder,
isExpired: ingredients.isExpired,
now: { Date() },
observe: ingredients.observe,
waitForNextObservation: ingredients.waitForNextObservation
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ extension RunnerTests {
static let pollInterval: TimeInterval = 0.02
static let warmupValueTimeout: TimeInterval = 0.4
static let verificationStabilityWindow: TimeInterval = 0.2
static let synthesizedCommitTimeout: TimeInterval = 3.0
/// How long the commit wait tolerates seeing NO further progress toward the expected value.
/// Numerically the flat deadline this replaced, so a pipeline that delivers nothing is
/// condemned at exactly the same instant it always was (see `SynthesizedCommitDeadline`).
static let synthesizedCommitStallTimeout: TimeInterval = 3.0
/// The commit wait's absolute bound, however long characters keep arriving. Sits well inside
/// the daemon's per-command budget (`RUNNER_COMMAND_TIMEOUT_MS`, 45s), which also has to cover
/// focus, clear and verification around this wait.
static let synthesizedCommitCeiling: TimeInterval = 10.0
static let synthesizedCommitPollInterval: TimeInterval = 0.2
}

Expand Down Expand Up @@ -384,7 +391,7 @@ extension RunnerTests {
var latest = resolveTextEntryElement(app: app, target: target)
let keyboardVisibleAtEntry = isKeyboardVisible(app: app)
let deadline = Date().addingTimeInterval(timeout)
let hardwareKeyboardFallback = Date().addingTimeInterval(
var hardwareKeyboardFallback = Date().addingTimeInterval(
min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
)
var sawSoftwareKeyboard = false
Expand All @@ -404,8 +411,19 @@ extension RunnerTests {
return latest
}
sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
return latest
// A responder that takes no software keyboard (hardware keyboard connected, or a custom
// `inputView`) would otherwise burn the whole readinessTimeout waiting for one that is never
// coming. Leaving that window a bare wall-clock guess made readiness a function of ambient
// simulator state (#1874): on a loaded host the keyboard is merely late, and returning here
// handed the caller an element that had not taken focus yet. Ask the target itself instead,
// and re-arm rather than re-asking every poll — the query is cheap, not free.
if !sawSoftwareKeyboard, Date() >= hardwareKeyboardFallback, let candidate = latest {
if keyboardFocusConfirmed(app: app, element: candidate) {
return candidate
}
hardwareKeyboardFallback = Date().addingTimeInterval(
TextEntryTiming.hardwareKeyboardFallbackTimeout
)
}
sleepFor(TextEntryTiming.pollInterval)
}
Expand Down Expand Up @@ -464,6 +482,41 @@ extension RunnerTests {
return !wasVisibleAtEntry && isKeyboardVisible(app: app)
}

/// Positive evidence that this element — the one readiness is about to hand its caller — holds
/// keyboard focus. Readable even with no software keyboard on screen, which is what lets the
/// hardware-keyboard fallback stop guessing from a wall clock.
///
/// This is the same app-wide predicate `focusedTextInput` refuses to trust on iOS, used the
/// other way round. There, the query PICKS the target, so a stale or unrelated match becomes
/// the field that gets typed into. Here the target is already chosen and the query only
/// corroborates it: at most one element holds keyboard focus, so an answer that is not this
/// element is a refusal, not a substitution. Every way of being wrong therefore ends as `false`
/// and costs the remaining readiness timeout — exactly what the wait would spend with no
/// fallback at all.
func keyboardFocusConfirmed(app: XCUIApplication, element: XCUIElement) -> Bool {
#if os(iOS)
return safely("TEXT_ENTRY_FOCUS_CONFIRMED", false) {
// An element that no longer resolves reads as identifier "" and frame `.zero`, which would
// match any focused element that also reports an empty frame. Require a real frame first,
// so a dead handle cannot corroborate anything.
let frame = element.frame
guard !frame.isEmpty else {
return false
}
let focused = app
.descendants(matching: .any)
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
.firstMatch
guard focused.exists else {
return false
}
return focused.identifier == element.identifier && focused.frame == frame
}
#else
return false
#endif
}

private func keyboardElementExists(app: XCUIApplication) -> Bool {
#if os(iOS)
return safely("KEYBOARD_EXISTS", false) { app.keyboards.firstMatch.exists }
Expand Down
Loading
Loading