Skip to content

Commit f443d78

Browse files
thymikeeclaude
andcommitted
refactor(ios): drop the timed read and frame tolerance from the back button check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent cee9941 commit f443d78

5 files changed

Lines changed: 18 additions & 76 deletions

File tree

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

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,6 @@ extension RunnerTests {
291291
after.backButtonLabels != before.backButtonLabels
292292
}
293293

294-
/// One read of the bar state, with the time the read started.
295-
struct TimedNavigationBarBackState {
296-
let state: NavigationBarBackState
297-
let startedMs: Double
298-
}
299-
300294
/// The pause between two reads of the bar, and how long reads may start, both before the tap
301295
/// while the bar settles and after it while the pop is confirmed. Only a bar that never holds
302296
/// still, or a tap that did not navigate, spends the whole budget.
@@ -305,7 +299,7 @@ extension RunnerTests {
305299

306300
static let navigationBarBackUnverified = ErrorPayload(
307301
code: RunnerWireErrorCode.backSwipeUnverified,
308-
message: "back pressed the navigation bar back button, but the navigation bar did not change within 1 s. This is an unknown outcome.",
302+
message: "back pressed the navigation bar back button, but the navigation bar's back buttons did not change within 1 s. This is an unknown outcome.",
309303
hint: "Take a snapshot to see where the app is."
310304
)
311305

@@ -329,16 +323,16 @@ extension RunnerTests {
329323
case hung
330324
}
331325

332-
/// Reads the hittable back buttons, then the bar state until two reads in a row, at least one read
333-
/// interval apart, agree and the button's frame is in them, so a push still animating is not read
326+
/// Reads the hittable back buttons, then the bar state until two reads in a row, one read interval
327+
/// apart, agree and the button's frame is in them, so a push still animating is not read
334328
/// as the state a pop changes. Each round re-reads the buttons. Reads start only until `deadlineMs`.
335329
@MainActor
336330
private func stillNavigationBarBackButton(
337331
app: XCUIApplication,
338332
geometry: SurfaceGeometry,
339333
deadlineMs: Double
340334
) -> NavigationBarBackLookup {
341-
var previous: TimedNavigationBarBackState?
335+
var previous: NavigationBarBackState?
342336
while true {
343337
let button: CGRect
344338
switch navigationBarBackButtonFrames(app: app, deadlineMs: deadlineMs) {
@@ -348,37 +342,21 @@ extension RunnerTests {
348342
case .read(let frames) where frames.count > 1: return .notFound("ambiguous")
349343
case .read(let frames): button = frames[0]
350344
}
351-
let startedMs = currentUptimeMs()
352-
let current: TimedNavigationBarBackState
345+
let current: NavigationBarBackState
353346
switch readNavigationBarBackState(app: app, phase: .lookup, deadlineMs: deadlineMs) {
354347
case .hung: return .hung
355348
case .unreadable: return .notFound("unreadable")
356-
case .read(let state): current = TimedNavigationBarBackState(state: state, startedMs: startedMs)
349+
case .read(let state): current = state
357350
}
358-
if let previous,
359-
Self.navigationBarBackStateHeldStill(from: previous, to: current),
360-
Self.navigationBarBackState(current.state, holdsButton: button, geometry: geometry) {
361-
return .still(current.state, button: button)
351+
if current == previous, Self.navigationBarBackState(current, holdsButton: button, geometry: geometry) {
352+
return .still(current, button: button)
362353
}
363354
if currentUptimeMs() >= deadlineMs { return .notFound("moving") }
364355
previous = current
365356
sleepFor(Self.navigationBarBackReadInterval)
366357
}
367358
}
368359

369-
/// Whether two reads show a still bar: they agree, and they started at least one read interval
370-
/// apart, so two reads drawn in one animation frame never pass for a still bar.
371-
static func navigationBarBackStateHeldStill(
372-
from earlier: TimedNavigationBarBackState,
373-
to later: TimedNavigationBarBackState
374-
) -> Bool {
375-
later.state == earlier.state && later.startedMs - earlier.startedMs >= navigationBarBackReadInterval * 1000
376-
}
377-
378-
/// How far, in points, each edge of a snapshot frame placed in screen space may sit from the live
379-
/// frame, so fractional frame values that XCTest rounds differently still match.
380-
static let navigationBarBackFrameTolerance: CGFloat = 0.5
381-
382360
/// Whether `state` holds the live button. A live frame is in screen points, and a snapshot frame
383361
/// is in the window's own space, which a placed iPad window starts off the screen origin (#3899).
384362
static func navigationBarBackState(
@@ -388,10 +366,7 @@ extension RunnerTests {
388366
) -> Bool {
389367
state.backButtonFrames.contains { frame in
390368
let origin = geometry.screenPoint(from: SurfacePoint<WindowContentSpace>(frame.origin)).point
391-
let placed = CGRect(origin: origin, size: frame.size)
392-
return [
393-
(placed.minX, button.minX), (placed.minY, button.minY), (placed.maxX, button.maxX), (placed.maxY, button.maxY),
394-
].allSatisfy { abs($0.0 - $0.1) <= navigationBarBackFrameTolerance }
369+
return CGRect(origin: origin, size: frame.size) == button
395370
}
396371
}
397372

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

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -689,49 +689,16 @@ extension RunnerTests {
689689
XCTAssertTrue(Self.navigationBarBackState(state, holdsButton: snapshotButton, geometry: fullScreen))
690690
}
691691

692-
/// A snapshot frame placed in screen space matches a live frame up to half a point per edge, so
693-
/// fractional frame values do not defeat the match; a button a point away is another button.
694-
func testNavigationBarBackStateHoldsALiveButtonWithinHalfAPointPerEdge() {
695-
let state = NavigationBarBackState(
696-
backButtonLabels: [["Root"]],
697-
backButtonFrames: [CGRect(x: 8.33, y: 54.67, width: 44.33, height: 44)]
698-
)
699-
let placed = SurfaceGeometry(
700-
windowOnScreen: SurfaceRect(CGRect(x: 224.5, y: 128, width: 386, height: 954)),
701-
panelNativeSize: nil,
702-
orientation: RunnerInterfaceOrientation.portrait
703-
)
704-
705-
XCTAssertTrue(Self.navigationBarBackState(state, holdsButton: CGRect(x: 233, y: 182.5, width: 44, height: 44), geometry: placed))
706-
XCTAssertTrue(Self.navigationBarBackState(state, holdsButton: CGRect(x: 232.5, y: 183, width: 44.5, height: 43.5), geometry: placed))
707-
XCTAssertFalse(Self.navigationBarBackState(state, holdsButton: CGRect(x: 234, y: 182.5, width: 44, height: 44), geometry: placed))
708-
XCTAssertFalse(Self.navigationBarBackState(state, holdsButton: CGRect(x: 233, y: 182.5, width: 45, height: 44), geometry: placed))
709-
}
710-
711-
/// Two agreeing reads of the bar show a still bar only when they started at least one read
712-
/// interval apart: two reads drawn in one animation frame of a push agree, but prove nothing.
713-
func testNavigationBarBackStateHeldStillOnlyAcrossOneReadInterval() {
714-
let bar = NavigationBarBackState(backButtonLabels: [["Root"]], backButtonFrames: [CGRect(x: 8, y: 54, width: 44, height: 44)])
715-
let moved = NavigationBarBackState(backButtonLabels: [["Root"]], backButtonFrames: [CGRect(x: 40, y: 54, width: 44, height: 44)])
716-
let intervalMs = Self.navigationBarBackReadInterval * 1000
717-
let first = TimedNavigationBarBackState(state: bar, startedMs: 1000)
718-
719-
let sameFrame = TimedNavigationBarBackState(state: bar, startedMs: 1000 + intervalMs / 2)
720-
XCTAssertFalse(Self.navigationBarBackStateHeldStill(from: first, to: sameFrame))
721-
let oneIntervalLater = TimedNavigationBarBackState(state: bar, startedMs: 1000 + intervalMs)
722-
XCTAssertTrue(Self.navigationBarBackStateHeldStill(from: first, to: oneIntervalLater))
723-
let movedLater = TimedNavigationBarBackState(state: moved, startedMs: 1000 + intervalMs)
724-
XCTAssertFalse(Self.navigationBarBackStateHeldStill(from: first, to: movedLater))
725-
}
726-
727692
/// After the tap, only a change of the bar count, the back button count, or a back button label
728-
/// shows a pop. A back button that only moved, such as by a large-title collapse, shows none.
693+
/// shows a pop. A back button that only moved, such as by a large-title collapse, shows none, but
694+
/// it is never a still bar before the tap.
729695
func testNavigationBarBackPoppedOnlyOnABarOrBackButtonChange() {
730696
let button = CGRect(x: 8, y: 54, width: 44, height: 44)
731697
let detail = NavigationBarBackState(backButtonLabels: [["Root"]], backButtonFrames: [button])
732698

733699
let moved = NavigationBarBackState(backButtonLabels: [["Root"]], backButtonFrames: [button.offsetBy(dx: 0, dy: 30)])
734700
XCTAssertFalse(Self.navigationBarBackPopped(from: detail, to: moved))
701+
XCTAssertNotEqual(moved, detail)
735702
let relabeled = NavigationBarBackState(backButtonLabels: [["Settings"]], backButtonFrames: [button])
736703
XCTAssertTrue(Self.navigationBarBackPopped(from: detail, to: relabeled))
737704
let atRoot = NavigationBarBackState(backButtonLabels: [[]], backButtonFrames: [])

‎apple/runner/RUNNER_PROTOCOL.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,10 @@ than silently applied:
137137
`backInApp` is the only back command. On iOS the runner first reads the current tree, in the same
138138
call, for the navigation bar's system back button: a button with the identifier `BackButton` inside a
139139
navigation bar. It taps that button with a synthesized tap only when exactly one live, hittable
140-
candidate is found in a readable tree, and two reads in a row of the bar state, started at least
141-
0.05 s apart, agree and contain that button's frame; each round re-reads the buttons. The match
140+
candidate is found in a readable tree, and two reads in a row of the bar state, with a 0.05 s pause
141+
between them, agree and contain that button's frame; each round re-reads the buttons. The match
142142
places the snapshot frames at the window's screen origin, because a placed iPad window reports them
143-
from its own top-left, and allows 0.5 pt per edge for fractional frame values. No other button
143+
from its own top-left. No other button
144144
counts, whatever its label.
145145
The bar state is the labels of each navigation bar's `BackButton` children, one list per bar in
146146
bar order, and the frames of those buttons; one XCTest snapshot per bar. The still check before the
@@ -180,7 +180,7 @@ fills its screen; otherwise it refuses with `APP_SCREEN_UNRESOLVED`. Its failure
180180
| `APP_SCREEN_WINDOW_UNRESOLVED`, `APP_SCREEN_UNRESOLVED`, `APP_SCREEN_CAPTURE_UNRENDERABLE`, `APP_SCREEN_OFF_LIT_PANEL` | The screen could not be sampled before the swipe. | Nothing |
181181
| `BACK_SWIPE_NOT_DISPATCHED` | The swipe was refused before any touch went down, an XCTest query on the navigation bar hung before the tap, or the platform has no back command (visionOS; the daemon refuses it first). | Nothing |
182182
| `BACK_SWIPE_NO_CHANGE` | The swipe was sent and the sampled grid (48×96 grayscale cells, with the top 7 rows always left out; in portrait they hold the status bar and Dynamic Island, and in landscape the iPhone status bar is hidden and the Dynamic Island sits at a side edge, outside those rows) changed in at most 1% of the remaining cells by more than 24 of 256 levels. | The swipe |
183-
| `BACK_SWIPE_UNVERIFIED` | The swipe may have run, or the back button was tapped and the bar did not change within 1 s or a query on it hung; the outcome is unknown. | Possibly the swipe, or the tap |
183+
| `BACK_SWIPE_UNVERIFIED` | The swipe may have run, or the back button was tapped and the bar's back buttons did not change within 1 s or a query on it hung; the outcome is unknown. | Possibly the swipe, or the tap |
184184

185185
The swipe is first resolved up to its contact. Then the settle window is a run of samples, with a
186186
0.05 s pause between captures plus each capture's own time, that agree for as long as the gap the swipe will be credited across: a contact

‎packages/command-surface/src/system/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = {
286286
const appStateCommandDescription =
287287
'Show foreground app/activity (Android and HarmonyOS; iOS and macOS answer per session)';
288288
const backCommandDescription =
289-
'Navigate back in the app or through system navigation. The in-app mode targets the app navigation stack; system targets platform back behavior. On iOS both modes press the navigation bar system back button (identifier BackButton) only when exactly one hittable one is found in a readable accessibility tree, and succeed only when the number of navigation bars, the number of back buttons, or a back button title then changes (a bar title or a moved back button alone never counts, so a pop that keeps the same back button title and bar count reports back_swipe_unverified); with no such button, two candidates, or an unreadable tree, they swipe from the left edge instead (on an iPad whose app window cannot be resolved, only while the app fills the screen) and succeed only when the app screen changes. Reason back_swipe_no_change: the swipe was sent and the screen did not change; a visible back or close control is an alternative. Reason back_swipe_unverified: the button was pressed but the navigation bar did not change within 1 s, or the swipe may or may not have navigated, because the screen kept changing before it or could not be sampled after it; the resulting screen is unconfirmed. Reason back_swipe_not_dispatched, or a screen that cannot be sampled before the swipe, means nothing was sent. An app with a left-edge drawer may open the drawer instead. A visible back or close control can be targeted directly by its ref.';
289+
"Navigate back in the app or through system navigation. The in-app mode targets the app navigation stack; system targets platform back behavior. On iOS both modes press the navigation bar system back button (identifier BackButton) only when exactly one hittable one is found in a readable accessibility tree, and succeed only when the number of navigation bars, the number of back buttons, or a back button title then changes (a bar title or a moved back button alone never counts, so a pop that keeps the same back button title and bar count reports back_swipe_unverified); with no such button, two candidates, or an unreadable tree, they swipe from the left edge instead (on an iPad whose app window cannot be resolved, only while the app fills the screen) and succeed only when the app screen changes. Reason back_swipe_no_change: the swipe was sent and the screen did not change; a visible back or close control is an alternative. Reason back_swipe_unverified: the button was pressed but the navigation bar's back buttons did not change within 1 s, or the swipe may or may not have navigated, because the screen kept changing before it or could not be sampled after it; the resulting screen is unconfirmed. Reason back_swipe_not_dispatched, or a screen that cannot be sampled before the swipe, means nothing was sent. An app with a left-edge drawer may open the drawer instead. A visible back or close control can be targeted directly by its ref.";
290290
const homeCommandDescription =
291291
'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.';
292292
const orientationCommandDescription =

0 commit comments

Comments
 (0)