Skip to content

Commit f843dc2

Browse files
authored
fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) (#1820)
* 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.
1 parent 2b6d04a commit f843dc2

13 files changed

Lines changed: 213 additions & 226 deletions

File tree

.github/workflows/android.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ jobs:
7575
pnpm gate build
7676
pnpm clean:daemon
7777
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=smoke AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="${{ steps.fixture-app.outputs.apk-path }}" AGENT_DEVICE_FIXTURE_APP_ID="${{ steps.fixture-app.outputs.app-id }}" node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator.test.ts
78-
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
78+
node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
7979
8080
- name: Upload Android artifacts
8181
if: always()

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift

Lines changed: 76 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import XCTest
33
// Swift port of buildScrollGesturePlan from packages/contracts/src/scroll-gesture.ts.
44
//
55
// This is a deliberate two-place invariant: the daemon keeps the TS implementation (for Android,
6-
// recording, and reported-pixels), and the runner places the gesture with this Swift copy. The
7-
// parity test vectors at the bottom of this file mirror
8-
// packages/contracts/src/scroll-gesture.test.ts —
9-
// if you change the math in either language, update the other and both vector sets.
6+
// recording, and reported-pixels), and the runner places the gesture with this Swift copy. Both
7+
// ports are asserted against the same table, contracts/fixtures/scroll-gesture.json (gated
8+
// XCTest at the bottom of this file, vitest twin packages/contracts/src/scroll-gesture.test.ts)
9+
// if you change the math in either language, update the other and the table.
1010
//
1111
// All inputs here are positive (reference dims, travel, center), so Swift's `.rounded()`
1212
// (half away from zero) matches JS `Math.round` (half up) on every value computed below.
@@ -20,7 +20,10 @@ struct RunnerScrollGesturePlan {
2020
}
2121

2222
private let runnerDefaultScrollAmount = 0.6
23-
private let runnerDefaultEdgePaddingFraction = 0.05
23+
// Both constants are pinned by contracts/fixtures/scroll-gesture.json (`constants`). Scroll gestures
24+
// stay out of the outer 10% of each axis so a saturated scroll never touches down inside the status
25+
// bar / Dynamic Island band (#1781 A1).
26+
private let runnerDefaultEdgePaddingFraction = 0.1
2427

2528
func runnerScrollGesturePlan(
2629
direction: String,
@@ -64,116 +67,88 @@ func runnerScrollGesturePlan(
6467
}
6568

6669
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
67-
extension RunnerTests {
68-
// Cross-language parity vectors mirroring packages/contracts/src/scroll-gesture.test.ts. Keep these
69-
// in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift.
70-
71-
func testRunnerScrollGesturePlanMapsRelativeAmount() throws {
72-
let plan = try XCTUnwrap(
73-
runnerScrollGesturePlan(
74-
direction: "down",
75-
amount: 0.5,
76-
pixels: nil,
77-
referenceWidth: 400,
78-
referenceHeight: 800
79-
)
80-
)
81-
XCTAssertEqual(plan.x1, 200)
82-
XCTAssertEqual(plan.y1, 600)
83-
XCTAssertEqual(plan.x2, 200)
84-
XCTAssertEqual(plan.y2, 200)
85-
XCTAssertEqual(plan.travelPixels, 400)
70+
private struct ScrollGestureFixture: Decodable {
71+
struct Constants: Decodable {
72+
let defaultScrollAmount: Double
73+
let defaultEdgePaddingFraction: Double
8674
}
87-
88-
func testRunnerScrollGesturePlanPixelsDown() throws {
89-
// 300x600, down, pixels 120 -> (150,360)->(150,240), travel 120.
90-
let plan = try XCTUnwrap(
91-
runnerScrollGesturePlan(
92-
direction: "down",
93-
amount: nil,
94-
pixels: 120,
95-
referenceWidth: 300,
96-
referenceHeight: 600
97-
)
98-
)
99-
XCTAssertEqual(plan.x1, 150)
100-
XCTAssertEqual(plan.y1, 360)
101-
XCTAssertEqual(plan.x2, 150)
102-
XCTAssertEqual(plan.y2, 240)
103-
XCTAssertEqual(plan.travelPixels, 120)
75+
struct Expected: Decodable {
76+
let x1: Double
77+
let y1: Double
78+
let x2: Double
79+
let y2: Double
80+
let pixels: Double
81+
}
82+
struct Case: Decodable {
83+
let name: String
84+
let direction: String
85+
let amount: Double?
86+
let pixels: Double?
87+
let referenceWidth: Double
88+
let referenceHeight: Double
89+
let expected: Expected
10490
}
10591

106-
func testRunnerScrollGesturePlanClampsAmountAboveOne() throws {
107-
// 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40).
108-
let plan = try XCTUnwrap(
109-
runnerScrollGesturePlan(
110-
direction: "down",
111-
amount: 2,
112-
pixels: nil,
113-
referenceWidth: 400,
114-
referenceHeight: 800
115-
)
116-
)
117-
XCTAssertEqual(plan.x1, 200)
118-
XCTAssertEqual(plan.y1, 760)
119-
XCTAssertEqual(plan.x2, 200)
120-
XCTAssertEqual(plan.y2, 40)
121-
XCTAssertEqual(plan.travelPixels, 720)
92+
let constants: Constants
93+
let cases: [Case]
94+
}
95+
96+
extension RunnerTests {
97+
// Cross-language parity table: every case in contracts/fixtures/scroll-gesture.json must agree
98+
// with the vitest twin (packages/contracts/src/scroll-gesture.test.ts). Add vectors there,
99+
// never fork the math.
100+
private func loadScrollGestureFixture() throws -> ScrollGestureFixture {
101+
let fixtureURL = URL(fileURLWithPath: #filePath)
102+
.deletingLastPathComponent() // AgentDeviceRunnerUITests
103+
.deletingLastPathComponent() // AgentDeviceRunner
104+
.deletingLastPathComponent() // runner
105+
.deletingLastPathComponent() // apple
106+
.deletingLastPathComponent() // repo root
107+
.appendingPathComponent("contracts")
108+
.appendingPathComponent("fixtures")
109+
.appendingPathComponent("scroll-gesture.json")
110+
return try JSONDecoder().decode(ScrollGestureFixture.self, from: Data(contentsOf: fixtureURL))
122111
}
123112

124-
func testRunnerScrollGesturePlanClampsExplicitPixelsVertically() throws {
125-
// 400x800, down, pixels 1000 clamps travel to the safe band (720): (200,760)->(200,40).
126-
let plan = try XCTUnwrap(
127-
runnerScrollGesturePlan(
128-
direction: "down",
129-
amount: nil,
130-
pixels: 1000,
131-
referenceWidth: 400,
132-
referenceHeight: 800
113+
func testRunnerScrollGesturePlanMatchesParityTable() throws {
114+
let fixture = try loadScrollGestureFixture()
115+
XCTAssertFalse(fixture.cases.isEmpty, "parity table must not be empty")
116+
for testCase in fixture.cases {
117+
let plan = try XCTUnwrap(
118+
runnerScrollGesturePlan(
119+
direction: testCase.direction,
120+
amount: testCase.amount,
121+
pixels: testCase.pixels,
122+
referenceWidth: testCase.referenceWidth,
123+
referenceHeight: testCase.referenceHeight
124+
),
125+
testCase.name
133126
)
134-
)
135-
XCTAssertEqual(plan.x1, 200)
136-
XCTAssertEqual(plan.y1, 760)
137-
XCTAssertEqual(plan.x2, 200)
138-
XCTAssertEqual(plan.y2, 40)
139-
XCTAssertEqual(plan.travelPixels, 720)
127+
XCTAssertEqual(plan.x1, testCase.expected.x1, testCase.name)
128+
XCTAssertEqual(plan.y1, testCase.expected.y1, testCase.name)
129+
XCTAssertEqual(plan.x2, testCase.expected.x2, testCase.name)
130+
XCTAssertEqual(plan.y2, testCase.expected.y2, testCase.name)
131+
XCTAssertEqual(plan.travelPixels, testCase.expected.pixels, testCase.name)
132+
}
140133
}
141134

142-
func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
143-
// 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
144-
// ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
145-
let plan = try XCTUnwrap(
135+
// The planner constants are private on both sides; the table pins them behaviourally on a
136+
// 1000px axis where every rounding step is exact.
137+
func testRunnerScrollGesturePlanUsesParityTableConstants() throws {
138+
let constants = try loadScrollGestureFixture().constants
139+
let defaulted = try XCTUnwrap(
146140
runnerScrollGesturePlan(
147-
direction: "down",
148-
amount: nil,
149-
pixels: 10,
150-
referenceWidth: 2,
151-
referenceHeight: 2
141+
direction: "down", amount: nil, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
152142
)
153143
)
154-
XCTAssertEqual(plan.x1, 1)
155-
XCTAssertEqual(plan.y1, 2)
156-
XCTAssertEqual(plan.x2, 1)
157-
XCTAssertEqual(plan.y2, 0)
158-
XCTAssertEqual(plan.travelPixels, 1)
159-
}
160-
161-
func testRunnerScrollGesturePlanClampsToSafeBand() throws {
162-
// 300x600, right, pixels 500 clamps travel to the safe band (270).
163-
let plan = try XCTUnwrap(
144+
XCTAssertEqual(defaulted.travelPixels, 1000 * constants.defaultScrollAmount)
145+
let saturated = try XCTUnwrap(
164146
runnerScrollGesturePlan(
165-
direction: "right",
166-
amount: nil,
167-
pixels: 500,
168-
referenceWidth: 300,
169-
referenceHeight: 600
147+
direction: "down", amount: 10, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
170148
)
171149
)
172-
XCTAssertEqual(plan.x1, 285)
173-
XCTAssertEqual(plan.x2, 15)
174-
XCTAssertEqual(plan.y1, 300)
175-
XCTAssertEqual(plan.y2, 300)
176-
XCTAssertEqual(plan.travelPixels, 270)
150+
XCTAssertEqual(
151+
saturated.travelPixels, 1000 - 2 * 1000 * constants.defaultEdgePaddingFraction)
177152
}
178153

179154
func testRunnerScrollGesturePlanRejectsUnknownDirection() {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"constants": {
3+
"defaultScrollAmount": 0.6,
4+
"defaultEdgePaddingFraction": 0.1
5+
},
6+
"cases": [
7+
{
8+
"name": "relative amount maps to viewport travel: 400x800 down 0.5 -> (200,600)->(200,200), travel 400",
9+
"direction": "down",
10+
"amount": 0.5,
11+
"referenceWidth": 400,
12+
"referenceHeight": 800,
13+
"expected": { "x1": 200, "y1": 600, "x2": 200, "y2": 200, "pixels": 400 }
14+
},
15+
{
16+
"name": "explicit pixels below the safe band cap: 300x600 down 120px -> (150,360)->(150,240)",
17+
"direction": "down",
18+
"pixels": 120,
19+
"referenceWidth": 300,
20+
"referenceHeight": 600,
21+
"expected": { "x1": 150, "y1": 360, "x2": 150, "y2": 240, "pixels": 120 }
22+
},
23+
{
24+
"name": "amount above 1 clamps to the safe band: 400x800 down 2 -> requested 1600 clamps to 640, (200,720)->(200,80)",
25+
"direction": "down",
26+
"amount": 2,
27+
"referenceWidth": 400,
28+
"referenceHeight": 800,
29+
"expected": { "x1": 200, "y1": 720, "x2": 200, "y2": 80, "pixels": 640 }
30+
},
31+
{
32+
"name": "explicit pixels clamp to the vertical safe band: 400x800 down 1000px -> 640, (200,720)->(200,80)",
33+
"direction": "down",
34+
"pixels": 1000,
35+
"referenceWidth": 400,
36+
"referenceHeight": 800,
37+
"expected": { "x1": 200, "y1": 720, "x2": 200, "y2": 80, "pixels": 640 }
38+
},
39+
{
40+
"name": "tiny frame engages every max(1, ...) floor and the .5 rounding both ports must agree on: 2x2 down 10px -> (1,2)->(1,0), travel 1",
41+
"direction": "down",
42+
"pixels": 10,
43+
"referenceWidth": 2,
44+
"referenceHeight": 2,
45+
"expected": { "x1": 1, "y1": 2, "x2": 1, "y2": 0, "pixels": 1 }
46+
},
47+
{
48+
"name": "explicit pixels clamp to the horizontal safe band: 300x600 right 500px -> 240, (270,300)->(30,300)",
49+
"direction": "right",
50+
"pixels": 500,
51+
"referenceWidth": 300,
52+
"referenceHeight": 600,
53+
"expected": { "x1": 270, "y1": 300, "x2": 30, "y2": 300, "pixels": 240 }
54+
},
55+
{
56+
"name": "#1781 A1: a saturated scroll up on a Pixel 7 (1080x2400) touches down at y=240, clear of its 136px cutout status bar; the 5% band started at y=120 and pulled the notification shade",
57+
"direction": "up",
58+
"amount": 3,
59+
"referenceWidth": 1080,
60+
"referenceHeight": 2400,
61+
"expected": { "x1": 540, "y1": 240, "x2": 540, "y2": 2160, "pixels": 1920 }
62+
}
63+
]
64+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@
189189
"test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/nightly/concurrency-torture.test.ts",
190190
"test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
191191
"test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
192-
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android",
192+
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator",
193193
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
194194
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux"
195195
},

0 commit comments

Comments
 (0)