mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix(scroll): keep the swipe above the keyboard, refuse when it cannot (#2503)
* fix(ios): clip a scroll's swipe above the keyboard, refuse when it cannot The runner owns the live keyboard frame, so it does the clip and reports what it left: a scroll answers with `keyboardAvoided` and `keyboardMinY` beside its plan, and refuses with `SCROLL_KEYBOARD_OCCLUDES_SURFACE` when the keys leave too little band to swipe in instead of flinging into them. It never dismisses the keyboard, which would drop focus and mutate state that session-action provenance does not record. Scroll's keyboard policy moves to `requiredWhenAvailable`. The probe costs a live AX fetch, but gating it on a healthy tree left the first scroll of a session swiping under the keys, which is the failure this is for. Every scroll logs its decision, including the two ways it avoids reading the keyboard at all. Scroll no longer shares `frameAvoidingKeyboard`, whose 25% fail-open was a tap-reference-frame rule; that path is unchanged for its remaining callers. * chore(gates): run the scroll viewport policy tests on the iOS lane The parity table only detects drift if both halves run in CI. Two of these three were reachable by no lane, so the Swift half of the table was a local assertion. * fix(ios): keep the keyboard clip out of the scroll's rotation basis `resolvedScrollViewport` handed the command one frame for both jobs, and the coordinate rotation reads a frame's HEIGHT to map a `landscapeRight` native x. Clipping an 834pt landscape viewport to 576pt therefore moved the dispatched gesture 258pt sideways off the lane the plan had just been built for: the clip fixed the keyboard and broke the gesture. The resolved viewport now names both frames, and the gesture comes from one dispatch decision, so the band the plan is planned inside and the frame its coordinates rotate against cannot be swapped. The landscape case asserts through that decision and fails on the swap. * fix(ios): report a scroll's clipped band in its response
This commit is contained in:
committed by
GitHub
parent
5cf6414fd2
commit
ab0c7a4328
@@ -165,6 +165,10 @@ jobs:
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testEmptyReplacementWithoutResolvableTargetFailsClosed \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextEntryTapWitnessIsBoundToTargetIdentity \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCoordinateTapTextInputProbeSkipsPenalizedXCTestChannel \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testScrollViewportKeyboardClipMatchesGoldenParityTable \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testScrollViewportPolicyUsesParityTableConstants \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSynthesizedGesturePoliciesMatchCommandContracts \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testFreshCoordinateTapContainsUnavailableTextInputProbe \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbeIssueScopeIsThreadBound \
|
||||
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testTextInputProbePreservesEnclosingRunnerWait \
|
||||
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
import XCTest
|
||||
|
||||
// The scroll viewport rule the runner shares with the TS runtime (#2500).
|
||||
//
|
||||
// RULE: a directional scroll centres its swipe, so a focused field puts the swipe's lower endpoint
|
||||
// under the keyboard — the gesture lands on keys, the surface never moves, and the edge loop reads a
|
||||
// stuck container (#2499) rather than a refusal. Clipping the viewport to the band above the
|
||||
// keyboard BEFORE the gesture planner runs keeps the swipe in what is visible, and when that band is
|
||||
// too thin to hold one the rule REFUSES instead of handing back the full frame: a keyboard-struck
|
||||
// swipe and a tiny clipped swipe both read as "stuck", so failing open is what hides the failure.
|
||||
//
|
||||
// The pure rule below is geometry on purpose — no XCUIApplication — so its exact decision is proven
|
||||
// against the golden table shared with its TS twin, `clipScrollViewportAboveKeyboard` in
|
||||
// packages/contracts/src/scroll-gesture.ts, asserted in that file's test beside this one. The table
|
||||
// carries only frames representable in both languages: `CGRect` standardizes a negative extent into a
|
||||
// positive height at a moved origin, so a negative `height` is tested on the TS side alone.
|
||||
//
|
||||
// The `extension RunnerTests` below is the one impure caller: it reads the runner's own live keyboard
|
||||
// frame, because a frame threaded from the daemon would predate the keyboard.
|
||||
|
||||
/** What an on-screen keyboard leaves of a scroll viewport. */
|
||||
enum RunnerScrollKeyboardClip: Equatable {
|
||||
/** No keyboard, or one that does not own this surface: swipe the whole viewport. */
|
||||
case unobstructed
|
||||
/** The viewport trimmed above the keyboard. Report the reduced reference height honestly. */
|
||||
case avoided(frame: CGRect, keyboardMinY: Double)
|
||||
/** Too little surface left to swipe. The caller refuses; it never swipes under the keys. */
|
||||
case occluded(keyboardMinY: Double, visibleHeight: Double)
|
||||
}
|
||||
|
||||
/** Where one directional scroll may place its swipe, once the keyboard has taken its share. */
|
||||
enum RunnerScrollViewport {
|
||||
/**
|
||||
* The band to plan the swipe inside, the frame to rotate its coordinates against, and the keyboard
|
||||
* top when the band was clipped for one. The two frames are separate on purpose: a clip shortens
|
||||
* only the band, while `nativeSynthesizedPoint` derives a `landscapeRight` native x from the
|
||||
* frame's HEIGHT, so rotating inside the band moves the dispatched path sideways off the planned
|
||||
* one.
|
||||
*/
|
||||
case swipe(planFrame: CGRect, coordinateFrame: CGRect, keyboardMinY: Double?)
|
||||
/** Nothing to swipe. The caller answers `occlusionRunnerCode` and performs no gesture. */
|
||||
case occluded(keyboardMinY: Double, visibleHeight: Double)
|
||||
}
|
||||
|
||||
/// The gesture one directional scroll dispatches, built from a resolved viewport in one place so the
|
||||
/// band the plan was made inside and the frame its coordinates rotate against cannot be swapped.
|
||||
struct ScrollGestureDispatch {
|
||||
let plan: RunnerScrollGesturePlan
|
||||
let planFrame: CGRect
|
||||
let coordinateFrame: CGRect
|
||||
let keyboardMinY: Double?
|
||||
}
|
||||
|
||||
/** What a resolved viewport turns into for the command: a gesture, or the reason there is none. */
|
||||
enum ScrollGestureOutcome {
|
||||
case gesture(ScrollGestureDispatch)
|
||||
case unusableFrame
|
||||
case unusablePlan
|
||||
case occluded(keyboardMinY: Double, visibleHeight: Double)
|
||||
}
|
||||
|
||||
extension ScrollGestureDispatch {
|
||||
/// Reports the gesture against the band its plan ran inside, beside the keyboard evidence. The
|
||||
/// synthesis frame stays the full viewport so the coordinates rotate correctly, which leaves the
|
||||
/// payload measured against an axis the caller never planned on: `pixels` are a fraction of the
|
||||
/// band, so the band is what `referenceWidth` and `referenceHeight` have to name.
|
||||
func attachingEvidence(to response: Response) -> Response {
|
||||
guard response.ok else { return response }
|
||||
var payload = response.data ?? DataPayload()
|
||||
payload.referenceWidth = Double(planFrame.width)
|
||||
payload.referenceHeight = Double(planFrame.height)
|
||||
guard let keyboardMinY else {
|
||||
return Response(ok: response.ok, data: payload, error: response.error)
|
||||
}
|
||||
payload.keyboardAvoided = true
|
||||
payload.keyboardMinY = keyboardMinY
|
||||
return Response(ok: response.ok, data: payload, error: response.error)
|
||||
}
|
||||
}
|
||||
|
||||
extension RunnerScrollViewport {
|
||||
/// Plans the swipe inside the band the keyboard left and keeps the viewport as the coordinate basis,
|
||||
/// so a clip shortens the travel without moving the gesture's lane.
|
||||
func gestureDispatch(
|
||||
direction: RunnerScrollDirection,
|
||||
amount: Double?,
|
||||
pixels: Double?
|
||||
) -> ScrollGestureOutcome {
|
||||
switch self {
|
||||
case .occluded(let keyboardMinY, let visibleHeight):
|
||||
return .occluded(keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
|
||||
case .swipe(let planFrame, let coordinateFrame, let keyboardMinY):
|
||||
guard planFrame.width > 0, planFrame.height > 0 else {
|
||||
return .unusableFrame
|
||||
}
|
||||
guard let plan = runnerScrollGesturePlan(
|
||||
direction: direction,
|
||||
amount: amount,
|
||||
pixels: pixels,
|
||||
referenceWidth: planFrame.width,
|
||||
referenceHeight: planFrame.height
|
||||
) else {
|
||||
return .unusablePlan
|
||||
}
|
||||
return .gesture(
|
||||
ScrollGestureDispatch(
|
||||
plan: plan,
|
||||
planFrame: planFrame,
|
||||
coordinateFrame: coordinateFrame,
|
||||
keyboardMinY: keyboardMinY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ScrollViewportPolicy {
|
||||
/** Below this fraction of the viewport, the clipped band cannot hold a reliable swipe. */
|
||||
static let minVisibleFraction: Double = 0.15
|
||||
/**
|
||||
* A fixed allowance kept above the keyboard's top edge, in points. `keyboard.frame` reports the
|
||||
* key plane, not the input accessory or composer bar riding above it, so a swipe ending exactly
|
||||
* at the reported edge can still land on a bar.
|
||||
*/
|
||||
static let accessoryAllowance: Double = 12
|
||||
|
||||
/// The runner's own wire vocabulary, not a shared policy constant: the host keeps it
|
||||
/// `COMMAND_FAILED` and reads it back from `details.runnerErrorCode`.
|
||||
static let occlusionRunnerCode = "SCROLL_KEYBOARD_OCCLUDES_SURFACE"
|
||||
|
||||
/// Clips a scroll viewport to the band above an occluding keyboard, failing open on a frame the
|
||||
/// runner cannot measure: a missing keyboard query is not evidence that the surface is blocked.
|
||||
static func clip(viewport: CGRect, keyboard: CGRect) -> RunnerScrollKeyboardClip {
|
||||
guard isUsable(viewport), isUsable(keyboard) else {
|
||||
return .unobstructed
|
||||
}
|
||||
// A vertical swipe runs along the viewport's centre line, which is the only part of the width
|
||||
// the keyboard has to reach to be struck: a 320pt keyboard centred in an 834pt viewport is 38%
|
||||
// of the width and sits exactly in the path.
|
||||
let swipeCenterX = viewport.minX + viewport.width / 2
|
||||
if swipeCenterX < keyboard.minX || swipeCenterX >= keyboard.maxX {
|
||||
return .unobstructed
|
||||
}
|
||||
let keyboardMinY = keyboard.minY
|
||||
if keyboardMinY >= viewport.maxY || keyboard.maxY <= viewport.minY {
|
||||
return .unobstructed
|
||||
}
|
||||
let visibleHeight = max(0, keyboardMinY - accessoryAllowance - viewport.minY)
|
||||
if visibleHeight < minVisibleFraction * viewport.height {
|
||||
return .occluded(keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
|
||||
}
|
||||
return .avoided(
|
||||
frame: CGRect(
|
||||
x: viewport.minX,
|
||||
y: viewport.minY,
|
||||
width: viewport.width,
|
||||
height: visibleHeight
|
||||
),
|
||||
keyboardMinY: keyboardMinY
|
||||
)
|
||||
}
|
||||
|
||||
/// Splits a clip verdict into the two frames a dispatch needs. The gesture planner runs inside the
|
||||
/// clipped band; the coordinate rotation keeps the frame the viewport was resolved against, because
|
||||
/// the rotation basis is a property of the screen, not of what the keyboard left free.
|
||||
static func frames(referenceFrame: CGRect, clip: RunnerScrollKeyboardClip) -> RunnerScrollViewport {
|
||||
switch clip {
|
||||
case .unobstructed:
|
||||
return .swipe(planFrame: referenceFrame, coordinateFrame: referenceFrame, keyboardMinY: nil)
|
||||
case .avoided(let frame, let keyboardMinY):
|
||||
return .swipe(planFrame: frame, coordinateFrame: referenceFrame, keyboardMinY: keyboardMinY)
|
||||
case .occluded(let keyboardMinY, let visibleHeight):
|
||||
return .occluded(keyboardMinY: keyboardMinY, visibleHeight: visibleHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isUsable(_ rect: CGRect) -> Bool {
|
||||
return [rect.minX, rect.minY, rect.width, rect.height].allSatisfy(\.isFinite)
|
||||
&& rect.width > 0 && rect.height > 0
|
||||
}
|
||||
}
|
||||
|
||||
extension RunnerTests {
|
||||
/// Resolves the frame one directional scroll places its swipe in, and what the keyboard leaves of
|
||||
/// it. Never dismisses: a dismiss drops focus, breaks a `type`/`scroll`/`type` loop, and mutates
|
||||
/// state session-action provenance does not record, so `keyboard dismiss` stays an explicit
|
||||
/// command and this path only ever reduces the space it swipes in.
|
||||
func resolvedScrollViewport(
|
||||
app: XCUIApplication,
|
||||
context: SynthesizedCoordinateContext
|
||||
) -> RunnerScrollViewport {
|
||||
#if os(iOS)
|
||||
// Every scroll reports its decision, including the two ways it avoids reading the keyboard at
|
||||
// all: a policy that forbids the probe, and a probe that finds no keyboard.
|
||||
guard context.allowsKeyboardProbe else {
|
||||
logScrollViewport(decision: "probeSkipped", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
|
||||
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: .unobstructed)
|
||||
}
|
||||
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
||||
logScrollViewport(decision: "noKeyboard", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
|
||||
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: .unobstructed)
|
||||
}
|
||||
let clip = ScrollViewportPolicy.clip(viewport: context.referenceFrame, keyboard: keyboardFrame)
|
||||
switch clip {
|
||||
case .unobstructed:
|
||||
logScrollViewport(decision: "unobstructed", keyboardMinY: nil, swipeHeight: context.referenceFrame.height, context: context)
|
||||
case .avoided(let frame, let keyboardMinY):
|
||||
logScrollViewport(
|
||||
decision: "avoided",
|
||||
keyboardMinY: keyboardMinY,
|
||||
swipeHeight: frame.height,
|
||||
context: context
|
||||
)
|
||||
case .occluded(let keyboardMinY, let visibleHeight):
|
||||
logScrollViewport(
|
||||
decision: "occluded",
|
||||
keyboardMinY: keyboardMinY,
|
||||
swipeHeight: visibleHeight,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
return ScrollViewportPolicy.frames(referenceFrame: context.referenceFrame, clip: clip)
|
||||
#else
|
||||
let fallbackFrame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
||||
return ScrollViewportPolicy.frames(referenceFrame: fallbackFrame, clip: .unobstructed)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The #2500 diagnostic for a scroll that reports no travel: whether the swipe was clipped, and
|
||||
/// whether the keyboard probe was even permitted. `axHealth` is the first thing to read, because a
|
||||
/// policy that skipped the probe looks exactly like a keyboard that was never found.
|
||||
private func logScrollViewport(
|
||||
decision: String,
|
||||
keyboardMinY: Double?,
|
||||
swipeHeight: Double,
|
||||
context: SynthesizedCoordinateContext
|
||||
) {
|
||||
NSLog(
|
||||
"AGENT_DEVICE_RUNNER_SCROLL_VIEWPORT kind=scroll axHealth=%@ keyboardPolicy=%@ decision=%@ keyboardMinY=%@ swipeHeight=%.1f",
|
||||
context.accessibilityHealth.rawValue,
|
||||
context.keyboardPolicy.rawValue,
|
||||
decision,
|
||||
keyboardMinY.map { String(format: "%.1f", $0) } ?? "none",
|
||||
swipeHeight
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
||||
private struct ScrollViewportPolicyFixture: Decodable {
|
||||
struct Frame: Decodable {
|
||||
let x: Double
|
||||
let y: Double
|
||||
let width: Double
|
||||
let height: Double
|
||||
|
||||
var cgRect: CGRect {
|
||||
CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct Constants: Decodable {
|
||||
let minVisibleFraction: Double
|
||||
let accessoryAllowance: Double
|
||||
}
|
||||
|
||||
struct Expected: Decodable {
|
||||
let kind: String
|
||||
let viewport: Frame?
|
||||
let keyboardMinY: Double?
|
||||
let visibleHeight: Double?
|
||||
}
|
||||
|
||||
struct TestCase: Decodable {
|
||||
let name: String
|
||||
let viewport: Frame
|
||||
let keyboard: Frame
|
||||
let expected: Expected
|
||||
}
|
||||
|
||||
let constants: Constants
|
||||
let cases: [TestCase]
|
||||
}
|
||||
|
||||
extension RunnerTests {
|
||||
/// Golden parity table (#2500): every case in contracts/fixtures/scroll-keyboard-policy.json must
|
||||
/// agree with the vitest twin. Add cases there, never fork the rule.
|
||||
func testScrollViewportKeyboardClipMatchesGoldenParityTable() throws {
|
||||
let fixture = try loadScrollViewportPolicyFixture()
|
||||
XCTAssertFalse(fixture.cases.isEmpty, "parity table must not be empty")
|
||||
for testCase in fixture.cases {
|
||||
let clip = ScrollViewportPolicy.clip(
|
||||
viewport: testCase.viewport.cgRect,
|
||||
keyboard: testCase.keyboard.cgRect
|
||||
)
|
||||
switch testCase.expected.kind {
|
||||
case "unobstructed":
|
||||
XCTAssertEqual(clip, .unobstructed, testCase.name)
|
||||
case "avoided":
|
||||
let expectedFrame = try XCTUnwrap(testCase.expected.viewport, testCase.name).cgRect
|
||||
let expectedMinY = try XCTUnwrap(testCase.expected.keyboardMinY, testCase.name)
|
||||
XCTAssertEqual(
|
||||
clip,
|
||||
.avoided(frame: expectedFrame, keyboardMinY: expectedMinY),
|
||||
testCase.name
|
||||
)
|
||||
case "occluded":
|
||||
let expectedMinY = try XCTUnwrap(testCase.expected.keyboardMinY, testCase.name)
|
||||
let expectedVisibleHeight = try XCTUnwrap(testCase.expected.visibleHeight, testCase.name)
|
||||
XCTAssertEqual(
|
||||
clip,
|
||||
.occluded(keyboardMinY: expectedMinY, visibleHeight: expectedVisibleHeight),
|
||||
testCase.name
|
||||
)
|
||||
default:
|
||||
XCTFail("unknown expected kind `\(testCase.expected.kind)` in \(testCase.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The thresholds are the table's, not this file's. The refusal reason and the runner code are
|
||||
/// each one side's own vocabulary: the reason is what the host publishes, the code is what this
|
||||
/// runner answers with, and neither is a shared clip constant.
|
||||
func testScrollViewportPolicyUsesParityTableConstants() throws {
|
||||
let constants = try loadScrollViewportPolicyFixture().constants
|
||||
XCTAssertEqual(constants.minVisibleFraction, ScrollViewportPolicy.minVisibleFraction)
|
||||
XCTAssertEqual(constants.accessoryAllowance, ScrollViewportPolicy.accessoryAllowance)
|
||||
}
|
||||
|
||||
/// A clipped landscape band shortens the frame, and `nativeSynthesizedPoint` derives a
|
||||
/// `landscapeRight` native x from the frame's HEIGHT. Rotating inside the band therefore moves the
|
||||
/// dispatched path sideways by exactly what the keyboard took, off the lane the plan was built for,
|
||||
/// so the plan band and the coordinate basis stay separate values through dispatch (#2500).
|
||||
func testScrollViewportDispatchKeepsTheUnclippedFrameAsItsCoordinateRotationBasis() throws {
|
||||
let viewport = CGRect(x: 0, y: 0, width: 1210, height: 834)
|
||||
let keyboard = CGRect(x: 0, y: 588, width: 1210, height: 246)
|
||||
let clip = ScrollViewportPolicy.clip(viewport: viewport, keyboard: keyboard)
|
||||
guard case .avoided(let band, let keyboardMinY) = clip else {
|
||||
return XCTFail("expected a landscape keyboard to be avoided, got \(clip)")
|
||||
}
|
||||
XCTAssertEqual(band.height, 576)
|
||||
|
||||
guard case .gesture(let gesture) = ScrollViewportPolicy.frames(
|
||||
referenceFrame: viewport,
|
||||
clip: clip
|
||||
).gestureDispatch(direction: .up, amount: nil, pixels: nil) else {
|
||||
return XCTFail("expected a gesture inside the clipped band")
|
||||
}
|
||||
XCTAssertEqual(gesture.planFrame, band)
|
||||
XCTAssertEqual(gesture.keyboardMinY, keyboardMinY)
|
||||
XCTAssertEqual(gesture.coordinateFrame, viewport, "the rotation basis must survive the clip")
|
||||
XCTAssertLessThanOrEqual(
|
||||
max(gesture.plan.y1, gesture.plan.y2),
|
||||
keyboard.minY - ScrollViewportPolicy.accessoryAllowance,
|
||||
"a landscape swipe must stay clear of the keys"
|
||||
)
|
||||
|
||||
let reported = gesture.attachingEvidence(
|
||||
to: Response(
|
||||
ok: true,
|
||||
data: DataPayload(referenceWidth: viewport.width, referenceHeight: viewport.height),
|
||||
error: nil
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
reported.data?.referenceHeight,
|
||||
band.height,
|
||||
"the payload names the band the plan ran inside, not the synthesis frame"
|
||||
)
|
||||
XCTAssertEqual(reported.data?.referenceWidth, viewport.width)
|
||||
XCTAssertEqual(reported.data?.keyboardMinY, keyboardMinY)
|
||||
XCTAssertEqual(reported.data?.keyboardAvoided, true)
|
||||
|
||||
let orientedStartY = gesture.planFrame.minY + gesture.plan.y1
|
||||
let dispatched = nativeSynthesizedPoint(
|
||||
orientedX: gesture.planFrame.minX + gesture.plan.x1,
|
||||
orientedY: orientedStartY,
|
||||
in: gesture.coordinateFrame,
|
||||
interfaceOrientation: RunnerInterfaceOrientation.landscapeRight
|
||||
)
|
||||
let clippedBasis = nativeSynthesizedPoint(
|
||||
orientedX: gesture.planFrame.minX + gesture.plan.x1,
|
||||
orientedY: orientedStartY,
|
||||
in: gesture.planFrame,
|
||||
interfaceOrientation: RunnerInterfaceOrientation.landscapeRight
|
||||
)
|
||||
XCTAssertEqual(
|
||||
dispatched.x - clippedBasis.x,
|
||||
viewport.height - band.height,
|
||||
accuracy: 0.001,
|
||||
"rotating inside the clipped band would shift native x by what the keyboard took"
|
||||
)
|
||||
}
|
||||
|
||||
private func loadScrollViewportPolicyFixture() throws -> ScrollViewportPolicyFixture {
|
||||
let fixtureURL = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // AgentDeviceRunnerUITests
|
||||
.deletingLastPathComponent() // AgentDeviceRunner
|
||||
.deletingLastPathComponent() // runner
|
||||
.deletingLastPathComponent() // apple
|
||||
.deletingLastPathComponent() // repo root
|
||||
.appendingPathComponent("contracts")
|
||||
.appendingPathComponent("fixtures")
|
||||
.appendingPathComponent("scroll-keyboard-policy.json")
|
||||
return try JSONDecoder().decode(
|
||||
ScrollViewportPolicyFixture.self,
|
||||
from: Data(contentsOf: fixtureURL)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+49
-24
@@ -1953,21 +1953,25 @@ extension RunnerTests {
|
||||
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
|
||||
)
|
||||
}
|
||||
let frame = scrollReferenceFrame(app: activeApp, context: scrollContext)
|
||||
guard frame.width > 0, frame.height > 0 else {
|
||||
let viewport = resolvedScrollViewport(app: activeApp, context: scrollContext)
|
||||
let defaults = runnerDragCommandDefaults(command)
|
||||
switch viewport.gestureDispatch(
|
||||
direction: direction,
|
||||
amount: defaults.scrollAmount,
|
||||
pixels: command.pixels
|
||||
) {
|
||||
case .occluded(let occlusionKeyboardMinY, let visibleHeight):
|
||||
return scrollKeyboardOccludedResponse(
|
||||
direction: direction.rawValue,
|
||||
keyboardMinY: occlusionKeyboardMinY,
|
||||
visibleHeight: visibleHeight
|
||||
)
|
||||
case .unusableFrame:
|
||||
return Response(
|
||||
ok: false,
|
||||
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
|
||||
)
|
||||
}
|
||||
let defaults = runnerDragCommandDefaults(command)
|
||||
guard let plan = runnerScrollGesturePlan(
|
||||
direction: direction,
|
||||
amount: defaults.scrollAmount,
|
||||
pixels: command.pixels,
|
||||
referenceWidth: frame.width,
|
||||
referenceHeight: frame.height
|
||||
) else {
|
||||
case .unusablePlan:
|
||||
return Response(
|
||||
ok: false,
|
||||
error: ErrorPayload(
|
||||
@@ -1975,21 +1979,24 @@ extension RunnerTests {
|
||||
message: "scroll could not compute a gesture plan"
|
||||
)
|
||||
)
|
||||
}
|
||||
case .gesture(let gesture):
|
||||
guard scrollDurationIsValid(command.durationMs) else {
|
||||
return invalidScrollDurationResponse(commandName: "scroll")
|
||||
}
|
||||
return executeScrollDragGesture(
|
||||
return gesture.attachingEvidence(
|
||||
to: executeScrollDragGesture(
|
||||
activeApp: activeApp,
|
||||
x: frame.minX + plan.x1,
|
||||
y: frame.minY + plan.y1,
|
||||
x2: frame.minX + plan.x2,
|
||||
y2: frame.minY + plan.y2,
|
||||
x: gesture.planFrame.minX + gesture.plan.x1,
|
||||
y: gesture.planFrame.minY + gesture.plan.y1,
|
||||
x2: gesture.planFrame.minX + gesture.plan.x2,
|
||||
y2: gesture.planFrame.minY + gesture.plan.y2,
|
||||
durationMs: defaults.durationMs,
|
||||
message: "scrolled",
|
||||
context: scrollContext.withReferenceFrame(frame),
|
||||
context: scrollContext.withReferenceFrame(gesture.coordinateFrame),
|
||||
releaseBehavior: command.scrollReleaseBehavior
|
||||
)
|
||||
)
|
||||
}
|
||||
case .desktopScroll:
|
||||
guard let rawDirection = command.direction,
|
||||
let direction = RunnerScrollDirection(rawValue: rawDirection)
|
||||
@@ -2564,12 +2571,30 @@ extension RunnerTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func scrollReferenceFrame(app: XCUIApplication, context: SynthesizedCoordinateContext) -> CGRect {
|
||||
#if os(iOS)
|
||||
return synthesizedFrameAvoidingKeyboardWhenAllowed(app: app, context: context)
|
||||
#else
|
||||
return resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
||||
#endif
|
||||
/// Adds the #2500 avoidance evidence to a scroll response. Only the frame resolver knows whether
|
||||
/// it trimmed the swipe for a keyboard, and only `scroll` has this evidence to carry, so it is
|
||||
/// attached where the frame was resolved rather than threaded through every gesture response.
|
||||
/// The refusal a keyboard forces. It performs no gesture: swiping into the keys would leave the
|
||||
/// surface where it was, which the daemon's no-progress fingerprint reads as a stuck container
|
||||
/// (#2499) and an agent reads as a broken scroll. The TS owner maps the code to the
|
||||
/// `scroll_keyboard_occludes_surface` reason and the "dismiss the keyboard" hint.
|
||||
private func scrollKeyboardOccludedResponse(
|
||||
direction: String,
|
||||
keyboardMinY: Double,
|
||||
visibleHeight: Double
|
||||
) -> Response {
|
||||
return Response(
|
||||
ok: false,
|
||||
error: ErrorPayload(
|
||||
code: ScrollViewportPolicy.occlusionRunnerCode,
|
||||
message: String(
|
||||
format:
|
||||
"scroll %@ refused: the keyboard leaves %.0fpt of visible surface above it, too little to swipe",
|
||||
direction,
|
||||
visibleHeight
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func dragCommandName(message: String) -> String {
|
||||
|
||||
+1
-13
@@ -11,7 +11,7 @@ private struct RunnerUnsupportedOperationError: LocalizedError {
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private enum RunnerInterfaceOrientation {
|
||||
enum RunnerInterfaceOrientation {
|
||||
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
||||
static let unknown = 0
|
||||
#endif
|
||||
@@ -909,18 +909,6 @@ extension RunnerTests {
|
||||
return CGRect(x: 0, y: 0, width: width, height: height)
|
||||
}
|
||||
|
||||
func synthesizedFrameAvoidingKeyboardWhenAllowed(
|
||||
app: XCUIApplication,
|
||||
context: SynthesizedCoordinateContext
|
||||
) -> CGRect {
|
||||
#if os(iOS)
|
||||
guard context.allowsKeyboardProbe else { return context.referenceFrame }
|
||||
return frameAvoidingKeyboard(app: app, frame: context.referenceFrame)
|
||||
#else
|
||||
return context.referenceFrame
|
||||
#endif
|
||||
}
|
||||
|
||||
func keyboardAvoidingSynthesizedDragPoints(
|
||||
app: XCUIApplication,
|
||||
x: Double,
|
||||
|
||||
@@ -268,6 +268,10 @@ struct DataPayload: Codable {
|
||||
var gestureFallback: String?
|
||||
var gestureFallbackMessage: String?
|
||||
var gestureFallbackHint: String?
|
||||
// Scroll keyboard avoidance evidence (#2500): the swipe was clipped to the band above an
|
||||
// on-screen keyboard, and where that band ended. `referenceHeight` already names the clipped axis.
|
||||
var keyboardAvoided: Bool?
|
||||
var keyboardMinY: Double?
|
||||
var maestroNonHittableCoordinateFallbackUsed: Bool?
|
||||
var textEntryRoute: String?
|
||||
var runnerFatal: Bool?
|
||||
|
||||
+11
-3
@@ -89,8 +89,14 @@ func synthesizedGesturePolicy(_ kind: SynthesizedGesturePolicyKind) -> Synthesiz
|
||||
fallbackPolicy: .xctestCoordinateAllowed
|
||||
)
|
||||
case .scroll:
|
||||
// Scroll places a viewport-center-symmetric swipe, so it cannot tell a keyboard-struck swipe
|
||||
// from a scroll that reached the edge without reading the live keyboard frame (#2500). The
|
||||
// probe is not free — `visibleKeyboardFrame` resolves `app.keyboards.firstMatch` with a live AX
|
||||
// fetch — but skipping it on `.unknown` left the first scroll of a session swiping under the
|
||||
// keys, which is the failure this command exists to avoid. `.unavailable` still skips it: there
|
||||
// the fetch is known not to answer, and `ScrollViewportPolicy` fails open on a missing frame.
|
||||
return SynthesizedGesturePolicy(
|
||||
keyboardPolicy: .whenAccessibilityHealthy,
|
||||
keyboardPolicy: .requiredWhenAvailable,
|
||||
fallbackPolicy: .privateSynthesisRequired
|
||||
)
|
||||
case .synthesizedDrag:
|
||||
@@ -182,7 +188,9 @@ extension RunnerTests {
|
||||
)
|
||||
}
|
||||
|
||||
func testSynthesizedKeyboardPolicyKeepsUnknownDragProbeButNotUnknownScrollProbe() {
|
||||
/// Keyboard-policy semantics only. Which command gets which policy is the table below; a probe
|
||||
/// that is merely permitted still costs a live AX fetch, so the two questions stay separate.
|
||||
func testSynthesizedKeyboardPolicyAllowsProbeOnlyWhenAccessibilityPermitsIt() {
|
||||
XCTAssertFalse(
|
||||
SynthesizedKeyboardPolicy.whenAccessibilityHealthy
|
||||
.allowsProbe(accessibilityHealth: .unknown)
|
||||
@@ -208,7 +216,7 @@ extension RunnerTests {
|
||||
XCTAssertEqual(
|
||||
synthesizedGesturePolicy(.scroll),
|
||||
SynthesizedGesturePolicy(
|
||||
keyboardPolicy: .whenAccessibilityHealthy,
|
||||
keyboardPolicy: .requiredWhenAvailable,
|
||||
fallbackPolicy: .privateSynthesisRequired
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user