mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
f843dc2df1
* fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) `pnpm gate replay-android` failed 4/8 whenever it ran after the full-tier Android E2E (replays-nightly run 32107665052, job 95620294899): 05-app-lifecycle, 06-swipe-gestures and both fixture replays diverged under "A system surface covers the app". The E2E was not the cause. Reproduced on a pixel_7 / API 36 AVD with the same cutout geometry CI's `avdmanager --device pixel_7` produces (status bar 136px, not the 63px of a plain 1080x2400 skin): - `03-scroll-discovery.ad` runs `scroll up 3`. The scroll planner clamps travel to the viewport minus a 5% band, so the touch-down landed at y=120 — inside the 136px status bar — and pulled the notification shade instead of scrolling. On API 36 the app window is edge-to-edge, so the reported viewport starts at y=0 and includes that bar. - The shade then covered every replay until `04`'s `back` closed it. Native readdir order on the runner (03, 05, 06, fixture/02, fixture/01, 04, 01, 02) put four files in that window; the last green run (2026-07-30) had 04 right after 03, so the pull was masked. Fix in the product, not the lane: DEFAULT_EDGE_PADDING_FRACTION 0.05 -> 0.1 in the TS scroll planner and its Swift port. Every real Pixel has a cutout (5.7% of a Pixel 7's height) and an iPhone's Dynamic Island status bar is 6.9%, so any saturated `scroll up` opened the shade / Notification Center for real agents too. Parity vectors updated in both suites plus a Pixel 7 regression vector (1080x2400, amount 3 -> touch-down y=240 > 136). Second contamination the same order exposed once the shade was gone: `fixture/02-selector-routes-covered-diagnosis.ad` is a #1715 reproduction recipe that FAILS BY DESIGN at step 9 (covered-target refusal) and leaves the device in landscape, yet the gate enumerated `test/integration/replays/android` recursively. iOS keeps gate replays in `replays/ios/simulator` and fixture recipes in `replays/ios/fixture`; Android now mirrors that: the six Settings replays move to `replays/android/emulator`, `test:replay:android` points there, and `fixture/` stays E2E-owned (`full:fixture-replays` already runs 01 by path). android.yml and the workflow-evidence fixture follow the path; the replay-compat manifest keeps the historical paths it pins at released tags. Verified live (Pixel 7 geometry, API 36, --retries 0): control run at main head in CI order reproduces exactly CI's 4/8; with the fix, `pnpm gate replay-android` 6/6 in both native and CI order, and `03` leaves Settings on screen (scroll up 3 now touches down at y=240). * test(scroll): drive the TS and Swift scroll-plan parity vectors from one table (#1820 review) The two suites hand-mirrored the same vectors and #1820 had to edit both by hand — the drift class the repo already closes for the tap-point rule via contracts/fixtures/tap-point-policy.json. The scroll vectors (plus both planner constants, pinned behaviourally on a 1000px axis) now live in contracts/fixtures/scroll-gesture.json; scroll-gesture.test.ts and RunnerTests+ScrollGesture.swift iterate it. Verified: vitest 10/10; the four XCTests run on an iOS 26.2 simulator with the unit flag on (Executed 4 tests, 0 failures). Also: test/ci/android-workflow-evidence.json says what it guards. Follow-up for content-safe viewport bounds + discovery order: #1821.
197 lines
7.0 KiB
Swift
197 lines
7.0 KiB
Swift
import XCTest
|
|
|
|
// Swift port of buildScrollGesturePlan from packages/contracts/src/scroll-gesture.ts.
|
|
//
|
|
// This is a deliberate two-place invariant: the daemon keeps the TS implementation (for Android,
|
|
// recording, and reported-pixels), and the runner places the gesture with this Swift copy. Both
|
|
// ports are asserted against the same table, contracts/fixtures/scroll-gesture.json (gated
|
|
// XCTest at the bottom of this file, vitest twin packages/contracts/src/scroll-gesture.test.ts) —
|
|
// if you change the math in either language, update the other and the table.
|
|
//
|
|
// All inputs here are positive (reference dims, travel, center), so Swift's `.rounded()`
|
|
// (half away from zero) matches JS `Math.round` (half up) on every value computed below.
|
|
|
|
struct RunnerScrollGesturePlan {
|
|
let x1: Double
|
|
let y1: Double
|
|
let x2: Double
|
|
let y2: Double
|
|
let travelPixels: Double
|
|
}
|
|
|
|
private let runnerDefaultScrollAmount = 0.6
|
|
// Both constants are pinned by contracts/fixtures/scroll-gesture.json (`constants`). Scroll gestures
|
|
// stay out of the outer 10% of each axis so a saturated scroll never touches down inside the status
|
|
// bar / Dynamic Island band (#1781 A1).
|
|
private let runnerDefaultEdgePaddingFraction = 0.1
|
|
|
|
func runnerScrollGesturePlan(
|
|
direction: String,
|
|
amount: Double?,
|
|
pixels: Double?,
|
|
referenceWidth: Double,
|
|
referenceHeight: Double
|
|
) -> RunnerScrollGesturePlan? {
|
|
// Mirror the TS INVALID_ARGS contract: non-positive or non-finite amount/pixels are rejected
|
|
// rather than clamped into a journaled 1px scroll. The daemon validates before sending, so
|
|
// this only triggers for non-daemon wire clients.
|
|
if let amount, !(amount.isFinite && amount > 0) { return nil }
|
|
if let pixels, !(pixels.isFinite && pixels > 0) { return nil }
|
|
let axisLength = (direction == "up" || direction == "down") ? referenceHeight : referenceWidth
|
|
let requestedAmount = amount ?? runnerDefaultScrollAmount
|
|
let requestedPixels: Double =
|
|
pixels.map { max(1, $0.rounded()) } ?? (axisLength * requestedAmount).rounded()
|
|
let edgePadding = max(1, (axisLength * runnerDefaultEdgePaddingFraction).rounded())
|
|
let maxTravelPixels = max(1, axisLength - edgePadding * 2)
|
|
let travelPixels = max(1, min(requestedPixels, maxTravelPixels))
|
|
let halfTravel = (travelPixels / 2).rounded()
|
|
let centerX = (referenceWidth / 2).rounded()
|
|
let centerY = (referenceHeight / 2).rounded()
|
|
|
|
func plan(_ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> RunnerScrollGesturePlan {
|
|
RunnerScrollGesturePlan(x1: x1, y1: y1, x2: x2, y2: y2, travelPixels: travelPixels)
|
|
}
|
|
|
|
switch direction {
|
|
case "up":
|
|
return plan(centerX, centerY - halfTravel, centerX, centerY + halfTravel)
|
|
case "down":
|
|
return plan(centerX, centerY + halfTravel, centerX, centerY - halfTravel)
|
|
case "left":
|
|
return plan(centerX - halfTravel, centerY, centerX + halfTravel, centerY)
|
|
case "right":
|
|
return plan(centerX + halfTravel, centerY, centerX - halfTravel, centerY)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
|
private struct ScrollGestureFixture: Decodable {
|
|
struct Constants: Decodable {
|
|
let defaultScrollAmount: Double
|
|
let defaultEdgePaddingFraction: Double
|
|
}
|
|
struct Expected: Decodable {
|
|
let x1: Double
|
|
let y1: Double
|
|
let x2: Double
|
|
let y2: Double
|
|
let pixels: Double
|
|
}
|
|
struct Case: Decodable {
|
|
let name: String
|
|
let direction: String
|
|
let amount: Double?
|
|
let pixels: Double?
|
|
let referenceWidth: Double
|
|
let referenceHeight: Double
|
|
let expected: Expected
|
|
}
|
|
|
|
let constants: Constants
|
|
let cases: [Case]
|
|
}
|
|
|
|
extension RunnerTests {
|
|
// Cross-language parity table: every case in contracts/fixtures/scroll-gesture.json must agree
|
|
// with the vitest twin (packages/contracts/src/scroll-gesture.test.ts). Add vectors there,
|
|
// never fork the math.
|
|
private func loadScrollGestureFixture() throws -> ScrollGestureFixture {
|
|
let fixtureURL = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent() // AgentDeviceRunnerUITests
|
|
.deletingLastPathComponent() // AgentDeviceRunner
|
|
.deletingLastPathComponent() // runner
|
|
.deletingLastPathComponent() // apple
|
|
.deletingLastPathComponent() // repo root
|
|
.appendingPathComponent("contracts")
|
|
.appendingPathComponent("fixtures")
|
|
.appendingPathComponent("scroll-gesture.json")
|
|
return try JSONDecoder().decode(ScrollGestureFixture.self, from: Data(contentsOf: fixtureURL))
|
|
}
|
|
|
|
func testRunnerScrollGesturePlanMatchesParityTable() throws {
|
|
let fixture = try loadScrollGestureFixture()
|
|
XCTAssertFalse(fixture.cases.isEmpty, "parity table must not be empty")
|
|
for testCase in fixture.cases {
|
|
let plan = try XCTUnwrap(
|
|
runnerScrollGesturePlan(
|
|
direction: testCase.direction,
|
|
amount: testCase.amount,
|
|
pixels: testCase.pixels,
|
|
referenceWidth: testCase.referenceWidth,
|
|
referenceHeight: testCase.referenceHeight
|
|
),
|
|
testCase.name
|
|
)
|
|
XCTAssertEqual(plan.x1, testCase.expected.x1, testCase.name)
|
|
XCTAssertEqual(plan.y1, testCase.expected.y1, testCase.name)
|
|
XCTAssertEqual(plan.x2, testCase.expected.x2, testCase.name)
|
|
XCTAssertEqual(plan.y2, testCase.expected.y2, testCase.name)
|
|
XCTAssertEqual(plan.travelPixels, testCase.expected.pixels, testCase.name)
|
|
}
|
|
}
|
|
|
|
// The planner constants are private on both sides; the table pins them behaviourally on a
|
|
// 1000px axis where every rounding step is exact.
|
|
func testRunnerScrollGesturePlanUsesParityTableConstants() throws {
|
|
let constants = try loadScrollGestureFixture().constants
|
|
let defaulted = try XCTUnwrap(
|
|
runnerScrollGesturePlan(
|
|
direction: "down", amount: nil, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
|
|
)
|
|
)
|
|
XCTAssertEqual(defaulted.travelPixels, 1000 * constants.defaultScrollAmount)
|
|
let saturated = try XCTUnwrap(
|
|
runnerScrollGesturePlan(
|
|
direction: "down", amount: 10, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
saturated.travelPixels, 1000 - 2 * 1000 * constants.defaultEdgePaddingFraction)
|
|
}
|
|
|
|
func testRunnerScrollGesturePlanRejectsUnknownDirection() {
|
|
XCTAssertNil(
|
|
runnerScrollGesturePlan(
|
|
direction: "sideways",
|
|
amount: nil,
|
|
pixels: 100,
|
|
referenceWidth: 300,
|
|
referenceHeight: 600
|
|
)
|
|
)
|
|
}
|
|
|
|
func testRunnerScrollGesturePlanRejectsInvalidAmountAndPixels() {
|
|
XCTAssertNil(
|
|
runnerScrollGesturePlan(
|
|
direction: "down",
|
|
amount: 0,
|
|
pixels: nil,
|
|
referenceWidth: 300,
|
|
referenceHeight: 600
|
|
)
|
|
)
|
|
XCTAssertNil(
|
|
runnerScrollGesturePlan(
|
|
direction: "down",
|
|
amount: nil,
|
|
pixels: -10,
|
|
referenceWidth: 300,
|
|
referenceHeight: 600
|
|
)
|
|
)
|
|
XCTAssertNil(
|
|
runnerScrollGesturePlan(
|
|
direction: "down",
|
|
amount: .infinity,
|
|
pixels: nil,
|
|
referenceWidth: 300,
|
|
referenceHeight: 600
|
|
)
|
|
)
|
|
}
|
|
}
|
|
#endif
|