Skip to content

Commit dd58487

Browse files
committed
fix(ios): retry fill's synthesized replacement once when the value never arrives
#2080: `fill id="field-email" ada@example` posted 11 characters and the field settled at 7, sharing one character with the request, frozen across four polls over ~4s. Not lateness — loss. The wait reported it honestly and stopped there, so an expensive lane went red on a PR that touches no iOS code. This route now re-posts once before giving up. Safe here and nowhere else: it opens with select-all, so a second post replaces the whole field rather than appending to whatever committed — the double-commit hazard that made #1676 refuse a retry for bare `type` does not exist when the route owns the field. It is also what the verified `xctest-element` route has always done, via clearTextInput + typeText in verifyTextEntryWithRepairIfNeeded; the synthesized route was the one without a second chance. Bounded at one retry, and the outcome is still reported honestly if it also fails. What is not fixed is why the characters are dropped: 11 posted at typingSpeed 60, 7 landed, `status=0`. That stays open on #2080. testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller now pins the retry through the production caller — the fake synthesizer never writes, so the wait cannot observe and the route posts the select-once-then-pace sequence twice.
1 parent bf26ab1 commit dd58487

2 files changed

Lines changed: 57 additions & 32 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift‎

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -103,41 +103,63 @@ extension RunnerTests {
103103
text: request.text,
104104
delaySeconds: request.delaySeconds
105105
)
106-
for (index, step) in steps.enumerated() {
107-
switch request.synthesizer.enterText(
108-
app: request.app,
109-
text: step.text,
110-
replacingExistingText: step.replacesExistingText
111-
) {
112-
case .fallback:
113-
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable")
114-
guard let point = request.target.refreshPoint else { return .notApplicable }
115-
return .fallback(
116-
focusTextInputForTextEntry(app: request.app, x: point.x, y: point.y)
117-
)
118-
case .raise(let message):
119-
NSException(
120-
name: NSExceptionName.internalInconsistencyException,
121-
reason: message ?? "private XCTest text synthesis failed"
122-
).raise()
123-
case .continueTyping:
124-
break
125-
}
126-
if index + 1 < steps.count {
127-
sleepFor(request.delaySeconds)
106+
107+
/// Posts the whole replacement once. Returns the route outcome when it could not post at all;
108+
/// nil means the events went out and the caller should wait for them.
109+
func post() -> SynthesizedReplacementRouteOutcome? {
110+
for (index, step) in steps.enumerated() {
111+
switch request.synthesizer.enterText(
112+
app: request.app,
113+
text: step.text,
114+
replacingExistingText: step.replacesExistingText
115+
) {
116+
case .fallback:
117+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable")
118+
guard let point = request.target.refreshPoint else { return .notApplicable }
119+
return .fallback(
120+
focusTextInputForTextEntry(app: request.app, x: point.x, y: point.y)
121+
)
122+
case .raise(let message):
123+
NSException(
124+
name: NSExceptionName.internalInconsistencyException,
125+
reason: message ?? "private XCTest text synthesis failed"
126+
).raise()
127+
case .continueTyping:
128+
break
129+
}
130+
if index + 1 < steps.count {
131+
sleepFor(request.delaySeconds)
132+
}
128133
}
134+
return nil
129135
}
136+
137+
if let blocked = post() { return blocked }
130138
// The private synthesize call returns at post time, not commit time (same as bare `type`,
131139
// see awaitSynthesizedFirstResponderCommit) — but this route never resolves an XCUIElement,
132140
// so without this wait it had no way to notice a dropped or still-in-flight character at all
133141
// and reported ok purely because the event posted. Wait here, on the same request.target
134142
// (element nil, refreshPoint set) that gated this route, so each poll re-resolves via the
135143
// refresh point rather than trusting a stale element handle.
136-
let commit = awaitSynthesizedReplacementCommit(
144+
var commit = awaitSynthesizedReplacementCommit(
137145
app: request.app,
138146
target: request.target,
139147
expectedText: request.text
140148
)
149+
// Re-post once when the value never arrived. Safe here and nowhere else: this route opens with
150+
// select-all, so posting it again replaces the whole field rather than appending to whatever
151+
// committed — the double-commit hazard that made #1676 refuse a retry for bare `type` does not
152+
// exist. #2080 is the case it exists for: 11 characters posted, 7 observed, frozen for four
153+
// polls, which is loss rather than lateness, and the wait has no other move.
154+
if commit == .notObserved {
155+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=synthesized-first-responder-replacement-retry")
156+
if let blocked = post() { return blocked }
157+
commit = awaitSynthesizedReplacementCommit(
158+
app: request.app,
159+
target: request.target,
160+
expectedText: request.text
161+
)
162+
}
141163
logTextEntryPhase(
142164
commandId: request.commandId,
143165
phase: "total",

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -417,14 +417,17 @@ extension RunnerTests {
417417
synthesizer: synthesizer
418418
)
419419

420-
XCTAssertEqual(
421-
synthesizer.steps,
422-
[
423-
SynthesizedReplacementStep(text: "a", replacesExistingText: true),
424-
SynthesizedReplacementStep(text: "b", replacesExistingText: false),
425-
SynthesizedReplacementStep(text: "c", replacesExistingText: false),
426-
]
427-
)
420+
// Posted twice: the fake synthesizer never writes into Springboard, so the wait cannot observe
421+
// the value and the route re-posts once (#2080). That retry is safe on this route and nowhere
422+
// else — it opens with select-all, so a second post replaces the field rather than appending to
423+
// whatever committed, which is the double-commit hazard #1676 refused a retry for on bare
424+
// `type`. Each attempt selects once and paces the rest, which is the pacing this pins.
425+
let attempt = [
426+
SynthesizedReplacementStep(text: "a", replacesExistingText: true),
427+
SynthesizedReplacementStep(text: "b", replacesExistingText: false),
428+
SynthesizedReplacementStep(text: "c", replacesExistingText: false),
429+
]
430+
XCTAssertEqual(synthesizer.steps, attempt + attempt)
428431
XCTAssertNil(result.verified)
429432
XCTAssertFalse(result.repaired)
430433
XCTAssertEqual(result.textEntryRoute, "synthesized-first-responder-replacement")
@@ -441,7 +444,7 @@ extension RunnerTests {
441444
// `runner-xctest-local-run-gotchas` memory / ios.yml's `-only-testing:` allowlist), where a
442445
// few extra seconds is a non-issue, and the alternative — asserting `nil` on a wiring path
443446
// that can never actually observe the expected text — would silently reintroduce the exact
444-
// bug this fix closes.
447+
// bug this fix closes. Two deadlines now rather than one, since the retry waits again.
445448
XCTAssertEqual(result.failure, .commitNotObserved)
446449
}
447450

0 commit comments

Comments
 (0)