Skip to content

Commit 705ef4b

Browse files
committed
refactor(ios-runner): post a charged text-entry plan through one executor
Both synthesized routes now run the plan through runSynthesizedTextPlan: the executor slices the next characters per step, takes the wait the plan carries, and hands a warmup step to the route's own read-back. The plan records no shape flag; the burst/warmup/paced phase naming derives from the steps. A post that stops the plan answers with its typed failure before the target reads the last element, so a vanished input cannot turn a focused failure into a snapshot error.
1 parent 43ea03c commit 705ef4b

5 files changed

Lines changed: 249 additions & 179 deletions

File tree

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

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,6 @@ extension RunnerTests {
107107
}
108108

109109
let steps: [Step]
110-
/// Recorded by the builder so the dispatch loop never re-decides the shape from `delaySeconds`.
111-
let isSpaced: Bool
112110

113111
/// One post per character, `delaySeconds` charged after every post but the last. A replacement
114112
/// selects once, on its first post; an append never selects.
@@ -137,12 +135,11 @@ extension RunnerTests {
137135
}
138136
}
139137

140-
/// The peeled first character and the rest, when the plan warms the field up before posting it.
141-
var warmupSplit: (first: Step, rest: Step)? {
142-
guard steps.count == 2, let first = steps.first, first.warmsUpField else {
143-
return nil
144-
}
145-
return (first, steps[1])
138+
/// Whether the plan hands the text over one character at a time, which is what a `--delay-ms`
139+
/// request builds. A burst is a single post and a repair peels a warmup character, so both keep
140+
/// a phase log per post; a paced plan logs one phase for the whole delivery instead.
141+
var pacesEveryCharacter: Bool {
142+
steps.count > 1 && !steps.contains(where: \.warmsUpField)
146143
}
147144
}
148145

@@ -172,8 +169,7 @@ extension RunnerTests {
172169
characterCount: characterCount,
173170
delaySeconds: delaySeconds,
174171
replacesExistingTextOnFirstPost: selectsExistingText
175-
),
176-
isSpaced: true
172+
)
177173
)
178174
}
179175
guard peelsWarmupCharacter && characterCount > 1 else {
@@ -183,8 +179,7 @@ extension RunnerTests {
183179
characterCount: characterCount,
184180
replacesExistingText: selectsExistingText
185181
),
186-
],
187-
isSpaced: false
182+
]
188183
)
189184
}
190185
return SynthesizedTextPlan(
@@ -195,8 +190,7 @@ extension RunnerTests {
195190
warmsUpField: true
196191
),
197192
SynthesizedTextPlan.Step(characterCount: characterCount - 1),
198-
],
199-
isSpaced: false
193+
]
200194
)
201195
}
202196

@@ -225,6 +219,56 @@ extension RunnerTests {
225219
}
226220
}
227221

222+
/// Where one post leaves the plan: move on to the next step, or stop the command with a reason the
223+
/// route has already reported.
224+
enum SynthesizedStepDispatch {
225+
case posted
226+
case stop
227+
}
228+
229+
struct SynthesizedPlanRun {
230+
let postedCharacterCount: Int
231+
/// True when a post stopped the plan before its last step.
232+
let stoppedEarly: Bool
233+
}
234+
235+
/// Posts a plan the delivery budget just charged: each step slices the next characters off the
236+
/// text, and every wait the plan carries is taken. Both synthesized routes post through here so the
237+
/// array a command is refused against cannot drift from the posts the command makes (#2955).
238+
///
239+
/// `waitAfterWarmupCharacter` replaces the charged pause on a warmup step with what the route wants
240+
/// to wait on, handed the characters that post made. The route that has an
241+
/// element reads the peeled character back and waits up to `warmupValueTimeout` for the app to
242+
/// accept it; the route that needed a budget has no element, so its wait is the plan's one poll of
243+
/// a value nobody can observe. Both are waits against the same charge, which is why the read-back
244+
/// belongs to the route and the plan only carries the character it waits for.
245+
@MainActor
246+
func runSynthesizedTextPlan(
247+
_ plan: SynthesizedTextPlan,
248+
text: String,
249+
post: (_ characters: String, _ step: SynthesizedTextPlan.Step) -> SynthesizedStepDispatch,
250+
waitAfterWarmupCharacter: (_ characters: String) -> Void,
251+
didPostStep: (_ step: SynthesizedTextPlan.Step) -> Void = { _ in }
252+
) -> SynthesizedPlanRun {
253+
let characters = Array(text)
254+
var postedCount = 0
255+
for step in plan.steps {
256+
let nextCount = postedCount + step.characterCount
257+
if post(String(characters[postedCount..<nextCount]), step) == .stop {
258+
// The stopped post never delivered its slice, so the run reports what did arrive.
259+
return SynthesizedPlanRun(postedCharacterCount: postedCount, stoppedEarly: true)
260+
}
261+
postedCount = nextCount
262+
didPostStep(step)
263+
if step.warmsUpField {
264+
waitAfterWarmupCharacter(String(characters[postedCount - step.characterCount..<postedCount]))
265+
} else if step.pauseAfterSeconds > 0 {
266+
sleepFor(step.pauseAfterSeconds)
267+
}
268+
}
269+
return SynthesizedPlanRun(postedCharacterCount: postedCount, stoppedEarly: false)
270+
}
271+
228272
@MainActor
229273
func runSynthesizedReplacementRoute(
230274
_ request: SynthesizedReplacementRequest
@@ -254,33 +298,38 @@ extension RunnerTests {
254298
)
255299
)
256300
}
257-
var postedCount = 0
258-
let characters = Array(request.text)
259-
for step in plan.steps {
260-
let nextCount = postedCount + step.characterCount
261-
switch request.synthesizer.enterText(
262-
app: request.app,
263-
text: String(characters[postedCount..<nextCount]),
264-
replacingExistingText: step.replacesExistingText
265-
) {
266-
case .fallback:
267-
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable")
268-
guard let point = request.target.refreshPoint else { return .notApplicable }
269-
return .fallback(
270-
focusTextInputForTextEntry(app: request.app, x: point.x, y: point.y)
271-
)
272-
case .raise(let message):
273-
NSException(
274-
name: NSExceptionName.internalInconsistencyException,
275-
reason: message ?? "private XCTest text synthesis failed"
276-
).raise()
277-
case .continueTyping:
278-
break
279-
}
280-
postedCount = nextCount
281-
if step.pauseAfterSeconds > 0 {
282-
sleepFor(step.pauseAfterSeconds)
283-
}
301+
// A private synthesis channel that is gone mid-plan leaves the command the same
302+
// point-and-focus fallback it had before the plan existed.
303+
let synthesisAvailable = runSynthesizedTextPlan(
304+
plan,
305+
text: request.text,
306+
post: { slice, step in
307+
switch request.synthesizer.enterText(
308+
app: request.app,
309+
text: slice,
310+
replacingExistingText: step.replacesExistingText
311+
) {
312+
case .continueTyping:
313+
return .posted
314+
case .fallback:
315+
return .stop
316+
case .raise(let message):
317+
NSException(
318+
name: NSExceptionName.internalInconsistencyException,
319+
reason: message ?? "private XCTest text synthesis failed"
320+
).raise()
321+
return .stop
322+
}
323+
},
324+
// A replacement never peels a warmup character, so no step asks for a read-back.
325+
waitAfterWarmupCharacter: { _ in }
326+
)
327+
if synthesisAvailable.stoppedEarly {
328+
NSLog("AGENT_DEVICE_RUNNER_TEXT_ENTRY_ROUTE route=verified-fallback reason=synthesis-unavailable")
329+
guard let point = request.target.refreshPoint else { return .notApplicable }
330+
return .fallback(
331+
focusTextInputForTextEntry(app: request.app, x: point.x, y: point.y)
332+
)
284333
}
285334
// The private synthesize call returns at post time, not commit time, and this route never
286335
// resolves an XCUIElement, so without this wait it had no way to notice a dropped or

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ extension RunnerTests {
3939
// Kept inside the 400-character diagnostic bound the host applies to every error string
4040
// (`REDACTED_STRING_MAX_LENGTH` in packages/kernel/src/redaction.ts): a hint truncated at
4141
// that boundary looks actionable and is not, which is the failure the iOS open-command hint
42-
// already refuses to produce. The previous wording cost 460 characters and lost its last
43-
// sentence on the wire.
42+
// already refuses to produce.
4443
return "Fill at most \(SynthesizedDeliveryBudget.maxTextLength(delaySeconds: 0)) characters per command without --delay-ms and append the rest with separate type commands. --delay-ms lowers the limit: each character then gets its own synthesize call and each gap pays the delay, so \(recoveryDelay) ms fits \(recoveryBudget). This route is chosen when the accessibility channel is already degraded, so a longer timeout does not help."
4544
}
4645
}

0 commit comments

Comments
 (0)