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
3 changes: 2 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testPenalizedCoordinateTapOnNonTextControlDoesNotAuthorizeBareType \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedTextCommitProgressWalksExpectedPrefixOnly \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyUsesSynthesizedFirstResponderAfterHiddenKeyboardTap \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareSubmitKeyRefusesWhenPrivateSynthesisIsUnavailable \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testEmptyReplacementWithoutResolvableTargetFailsClosed \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTapTextInputProbeSkipsPenalizedXCTestChannel \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,76 +345,6 @@ extension RunnerTests {
#endif

#if os(iOS)
func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws {
let command = try runnerCommandFixture(
#"{"command":"type","commandId":"type-without-focus","text":"hello"}"#
)

let response = executeTypeCommand(
activeApp: XCUIApplication(bundleIdentifier: "com.example.agentdevice.missing-input"),
command: command
)

XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertEqual(
response.error?.hint,
"Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing."
)
}

func testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() throws {
// The fixture uses a real text responder with an empty input view to model hardware-keyboard input.
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 frame = textField.frame
XCTAssertFalse(frame.isEmpty)

let tapCommand = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-hardware-keyboard-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"#
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))
// 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),
"software keyboard is up: this simulator cannot exercise the hidden-keyboard responder path"
)

let failureCountBefore = currentXCTestFailureCount()
let typeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-hardware-keyboard","text":"hardware-keyboard"}"#
)
let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand)

XCTAssertTrue(typeResponse.ok, String(describing: typeResponse.error))
XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore))
XCTAssertEqual(typeResponse.data?.textEntryRoute, "synthesized-first-responder")
XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard")

let secondFailureCountBefore = currentXCTestFailureCount()
let secondTypeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-hardware-keyboard-again","text":"-again"}"#
)
let secondTypeResponse = executeTypeCommand(activeApp: app, command: secondTypeCommand)

XCTAssertFalse(secondTypeResponse.ok)
XCTAssertEqual(secondTypeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertFalse(didRecordXCTestFailure(since: secondFailureCountBefore))
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
Expand Down Expand Up @@ -464,38 +394,6 @@ extension RunnerTests {
"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",
"--agent-device-text-entry-disappear-after-input",
]
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 tapCommand = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-disappearing-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"#
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))

let failureCountBefore = currentXCTestFailureCount()
let typeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-disappearing-input","text":"ab","delayMs":50}"#
)
let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand)

XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore))
XCTAssertFalse(typeResponse.ok)
XCTAssertEqual(typeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertFalse(textField.exists)
}
#endif

func testInjectedTapRecordedFailureGateIsTapOnlyAndCountGated() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import XCTest

// The synthesized text-entry commit wait, end to end: the deadline that bounds it, the two waits
// that run it, the observation/pacing they poll through, and the value-free cadence line that path
// is allowed to log. Split from RunnerTests+SynthesizedTextEntry.swift, which keeps the
// private-XCTest synthesis boundary and the route policies; the pure outcome functions these wrap
// stay there next to the rules they encode. Everything that touches the polled field value now
// lives in this one file, which is the surface apple-runner-log-redaction.test.ts guards.
// The synthesized replacement commit wait, end to end: the deadline that bounds it, the wait that
// runs it, the observation/pacing it polls through, and the value-free cadence line that path is
// allowed to log. RunnerTests+SynthesizedTextEntry.swift keeps the private-XCTest synthesis
// boundary, the route policies, and the pure outcome function this wraps. Everything that touches
// the polled field value lives in this one file, which is the surface
// apple-runner-log-redaction.test.ts guards.
extension RunnerTests {
/// One commit wait's running deadline.
///
Expand Down Expand Up @@ -57,54 +57,17 @@ extension RunnerTests {
}
}

/// Blocks until the synthesized bare-type text is observable in the target field, so `type`
/// cannot report ok while trailing characters are still uncommitted on a slow simulator.
///
/// Observation only. A stalled prefix cannot be told apart from a suffix still queued in the
/// event stream, so re-synthesizing the difference risks committing it twice after the command
/// already reported success (#1676 rejected exactly that repair). Reporting `.notObserved` is
/// what the caller does instead: the partial value is the agent's to resolve, and a named
/// failure beats a success that misdescribes the field. Text carrying a submit key is skipped
/// outright: the app may clear or rewrite the field on submit, so `textBefore + typedText` is
/// not the value to wait for.
func awaitSynthesizedFirstResponderCommit(
app: XCUIApplication,
target: TextEntryTarget,
textBefore: String?,
typedText: String
) -> SynthesizedTextCommitOutcome {
guard let textBefore, !typedText.contains("\n"), !typedText.contains("\r") else {
return .unobservable
}
let expectedText = textBefore + typedText
let waitStartedAt = Date()
NSLog("[DEBUG-1874] wait start expectedLen=%ld route=append", expectedText.count)
let ingredients = synthesizedCommitPollingIngredients(app: app, target: target, expectedText: expectedText)
let outcome = Self.awaitSynthesizedCommitOutcome(
expectedText: expectedText,
placeholder: ingredients.placeholder,
now: { Date() },
observe: ingredients.observe,
waitForNextObservation: ingredients.waitForNextObservation
)
NSLog(
"[DEBUG-1874] wait outcome=%@ elapsedMs=%.0f route=append",
String(describing: outcome),
waitStartedAt.timeIntervalSinceNow * -1000
)
return outcome
}

/// Blocks until the synthesized replacement text (`fill`) is observable in the target field, so
/// `fill` cannot report ok while the select-all-and-retype it posted is still uncommitted — or
/// silently wrong — on a slow or channel-penalized simulator (this route runs only when the
/// XCTest channel is already penalized, and never resolves an `XCUIElement`, so it previously had
/// no verification at all).
/// XCTest channel is already penalized, and never resolves an `XCUIElement`).
///
/// Unlike the append route, the expected value is the final text itself — replacement mode
/// clears the field first, so there is no `textBefore` prefix to account for — and unlike the
/// append route, a settled mismatch is always reported rather than trusted: see
/// `awaitSynthesizedReplacementCommitOutcome`'s doc comment.
/// Observation only. A stalled prefix cannot be told apart from a suffix still queued in the
/// event stream, so re-synthesizing the difference risks committing it twice after the command
/// already reported success. The expected value is the
/// final text itself, and a settled mismatch is always reported: see
/// `awaitSynthesizedReplacementCommitOutcome`'s doc comment. Text carrying a submit key is
/// skipped outright: the app may clear or rewrite the field on submit.
func awaitSynthesizedReplacementCommit(
app: XCUIApplication,
target: TextEntryTarget,
Expand Down Expand Up @@ -160,16 +123,9 @@ extension RunnerTests {
)
}

/// 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
/// mode's "trust a diverged value" rule is wrong for replacement mode. Each caller therefore
/// calls its own named outcome function directly, with real argument labels — deliberately not
/// a stored closure/function-value parameter here, which would erase those labels at the call
/// site and make the observe closure unrecognizable to the static content-redaction check in
/// `apple-runner-log-redaction.test.ts` (`extractObserveClosure` locates the labeled closure
/// literal by its text; a closure passed as a plain function value carries no such label).
/// The placeholder/observe/pacing ingredients the commit wait polls through. The observe closure
/// stays a labeled closure literal here: the static content-redaction check in
/// `apple-runner-log-redaction.test.ts` (`extractObserveClosure`) locates it by that text.
private func synthesizedCommitPollingIngredients(
app: XCUIApplication,
target: TextEntryTarget,
Expand All @@ -189,7 +145,7 @@ extension RunnerTests {
treatingPlaceholderAsEmpty: true
)
// Cadence evidence stays value-free: the polled value is user content typed through
// `type`/`fill` and must never reach runner.log. Lengths and the expected-prefix walk
// `fill` and must never reach runner.log. Lengths and the expected-prefix walk
// are enough to distinguish throttling (prefix grows slowly) from a wedge (it freezes).
Self.logCommitCadence(
elapsedMs: Int(waitStartedAt.timeIntervalSinceNow * -1000),
Expand Down
Loading
Loading