Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
pnpm gate build
pnpm clean:daemon
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
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
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

- name: Upload Android artifacts
if: always()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ 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. The
// parity test vectors at the bottom of this file mirror
// packages/contracts/src/scroll-gesture.test.ts —
// if you change the math in either language, update the other and both vector sets.
// 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.
Expand All @@ -20,7 +20,10 @@ struct RunnerScrollGesturePlan {
}

private let runnerDefaultScrollAmount = 0.6
private let runnerDefaultEdgePaddingFraction = 0.05
// 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,
Expand Down Expand Up @@ -64,116 +67,88 @@ func runnerScrollGesturePlan(
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
extension RunnerTests {
// Cross-language parity vectors mirroring packages/contracts/src/scroll-gesture.test.ts. Keep these
// in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift.

func testRunnerScrollGesturePlanMapsRelativeAmount() throws {
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: 0.5,
pixels: nil,
referenceWidth: 400,
referenceHeight: 800
)
)
XCTAssertEqual(plan.x1, 200)
XCTAssertEqual(plan.y1, 600)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 200)
XCTAssertEqual(plan.travelPixels, 400)
private struct ScrollGestureFixture: Decodable {
struct Constants: Decodable {
let defaultScrollAmount: Double
let defaultEdgePaddingFraction: Double
}

func testRunnerScrollGesturePlanPixelsDown() throws {
// 300x600, down, pixels 120 -> (150,360)->(150,240), travel 120.
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: 120,
referenceWidth: 300,
referenceHeight: 600
)
)
XCTAssertEqual(plan.x1, 150)
XCTAssertEqual(plan.y1, 360)
XCTAssertEqual(plan.x2, 150)
XCTAssertEqual(plan.y2, 240)
XCTAssertEqual(plan.travelPixels, 120)
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
}

func testRunnerScrollGesturePlanClampsAmountAboveOne() throws {
// 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40).
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: 2,
pixels: nil,
referenceWidth: 400,
referenceHeight: 800
)
)
XCTAssertEqual(plan.x1, 200)
XCTAssertEqual(plan.y1, 760)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 40)
XCTAssertEqual(plan.travelPixels, 720)
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 testRunnerScrollGesturePlanClampsExplicitPixelsVertically() throws {
// 400x800, down, pixels 1000 clamps travel to the safe band (720): (200,760)->(200,40).
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: 1000,
referenceWidth: 400,
referenceHeight: 800
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, 200)
XCTAssertEqual(plan.y1, 760)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 40)
XCTAssertEqual(plan.travelPixels, 720)
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)
}
}

func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
// 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
// ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
let plan = try XCTUnwrap(
// 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: 10,
referenceWidth: 2,
referenceHeight: 2
direction: "down", amount: nil, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
)
)
XCTAssertEqual(plan.x1, 1)
XCTAssertEqual(plan.y1, 2)
XCTAssertEqual(plan.x2, 1)
XCTAssertEqual(plan.y2, 0)
XCTAssertEqual(plan.travelPixels, 1)
}

func testRunnerScrollGesturePlanClampsToSafeBand() throws {
// 300x600, right, pixels 500 clamps travel to the safe band (270).
let plan = try XCTUnwrap(
XCTAssertEqual(defaulted.travelPixels, 1000 * constants.defaultScrollAmount)
let saturated = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "right",
amount: nil,
pixels: 500,
referenceWidth: 300,
referenceHeight: 600
direction: "down", amount: 10, pixels: nil, referenceWidth: 1000, referenceHeight: 1000
)
)
XCTAssertEqual(plan.x1, 285)
XCTAssertEqual(plan.x2, 15)
XCTAssertEqual(plan.y1, 300)
XCTAssertEqual(plan.y2, 300)
XCTAssertEqual(plan.travelPixels, 270)
XCTAssertEqual(
saturated.travelPixels, 1000 - 2 * 1000 * constants.defaultEdgePaddingFraction)
}

func testRunnerScrollGesturePlanRejectsUnknownDirection() {
Expand Down
64 changes: 64 additions & 0 deletions contracts/fixtures/scroll-gesture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"constants": {
"defaultScrollAmount": 0.6,
"defaultEdgePaddingFraction": 0.1
},
"cases": [
{
"name": "relative amount maps to viewport travel: 400x800 down 0.5 -> (200,600)->(200,200), travel 400",
"direction": "down",
"amount": 0.5,
"referenceWidth": 400,
"referenceHeight": 800,
"expected": { "x1": 200, "y1": 600, "x2": 200, "y2": 200, "pixels": 400 }
},
{
"name": "explicit pixels below the safe band cap: 300x600 down 120px -> (150,360)->(150,240)",
"direction": "down",
"pixels": 120,
"referenceWidth": 300,
"referenceHeight": 600,
"expected": { "x1": 150, "y1": 360, "x2": 150, "y2": 240, "pixels": 120 }
},
{
"name": "amount above 1 clamps to the safe band: 400x800 down 2 -> requested 1600 clamps to 640, (200,720)->(200,80)",
"direction": "down",
"amount": 2,
"referenceWidth": 400,
"referenceHeight": 800,
"expected": { "x1": 200, "y1": 720, "x2": 200, "y2": 80, "pixels": 640 }
},
{
"name": "explicit pixels clamp to the vertical safe band: 400x800 down 1000px -> 640, (200,720)->(200,80)",
"direction": "down",
"pixels": 1000,
"referenceWidth": 400,
"referenceHeight": 800,
"expected": { "x1": 200, "y1": 720, "x2": 200, "y2": 80, "pixels": 640 }
},
{
"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",
"direction": "down",
"pixels": 10,
"referenceWidth": 2,
"referenceHeight": 2,
"expected": { "x1": 1, "y1": 2, "x2": 1, "y2": 0, "pixels": 1 }
},
{
"name": "explicit pixels clamp to the horizontal safe band: 300x600 right 500px -> 240, (270,300)->(30,300)",
"direction": "right",
"pixels": 500,
"referenceWidth": 300,
"referenceHeight": 600,
"expected": { "x1": 270, "y1": 300, "x2": 30, "y2": 300, "pixels": 240 }
},
{
"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",
"direction": "up",
"amount": 3,
"referenceWidth": 1080,
"referenceHeight": 2400,
"expected": { "x1": 540, "y1": 240, "x2": 540, "y2": 2160, "pixels": 1920 }
}
]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
"test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/nightly/concurrency-torture.test.ts",
"test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator",
"test:replay:ios-device": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/device",
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android",
"test:replay:android": "node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator",
"test:replay:macos": "node --experimental-strip-types src/bin.ts test test/integration/replays/macos",
"test:replay:linux": "node --experimental-strip-types src/bin.ts test test/integration/replays/linux"
},
Expand Down
Loading
Loading