From 8ece823c35077a97258000f8742e445f82ba0348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 12:40:22 +0200 Subject: [PATCH 1/2] fix(scroll): keep saturated scroll gestures out of the status bar; gate Android replays from android/emulator (#1781 A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- .github/workflows/android.yml | 2 +- .../RunnerTests+ScrollGesture.swift | 45 +++++++++++++------ package.json | 2 +- packages/contracts/src/scroll-gesture.test.ts | 34 ++++++++++---- packages/contracts/src/scroll-gesture.ts | 6 ++- test/ci/android-workflow-evidence.json | 2 +- .../android/{ => emulator}/01-settings.ad | 0 .../{ => emulator}/02-deep-navigation.ad | 0 .../{ => emulator}/03-scroll-discovery.ad | 0 .../{ => emulator}/04-text-input-keyboard.ad | 0 .../{ => emulator}/05-app-lifecycle.ad | 0 .../{ => emulator}/06-swipe-gestures.ad | 0 12 files changed, 65 insertions(+), 26 deletions(-) rename test/integration/replays/android/{ => emulator}/01-settings.ad (100%) rename test/integration/replays/android/{ => emulator}/02-deep-navigation.ad (100%) rename test/integration/replays/android/{ => emulator}/03-scroll-discovery.ad (100%) rename test/integration/replays/android/{ => emulator}/04-text-input-keyboard.ad (100%) rename test/integration/replays/android/{ => emulator}/05-app-lifecycle.ad (100%) rename test/integration/replays/android/{ => emulator}/06-swipe-gestures.ad (100%) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a62801e06d..32ae294165 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift index 5612a21e8c..ecb12c4e0e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift @@ -20,7 +20,9 @@ struct RunnerScrollGesturePlan { } private let runnerDefaultScrollAmount = 0.6 -private let runnerDefaultEdgePaddingFraction = 0.05 +// Mirrors DEFAULT_EDGE_PADDING_FRACTION: 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, @@ -104,7 +106,7 @@ extension RunnerTests { } func testRunnerScrollGesturePlanClampsAmountAboveOne() throws { - // 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40). + // 400x800, down, amount 2 -> requested 1600 clamps to the safe band (640): (200,720)->(200,80). let plan = try XCTUnwrap( runnerScrollGesturePlan( direction: "down", @@ -115,14 +117,14 @@ extension RunnerTests { ) ) XCTAssertEqual(plan.x1, 200) - XCTAssertEqual(plan.y1, 760) + XCTAssertEqual(plan.y1, 720) XCTAssertEqual(plan.x2, 200) - XCTAssertEqual(plan.y2, 40) - XCTAssertEqual(plan.travelPixels, 720) + XCTAssertEqual(plan.y2, 80) + XCTAssertEqual(plan.travelPixels, 640) } func testRunnerScrollGesturePlanClampsExplicitPixelsVertically() throws { - // 400x800, down, pixels 1000 clamps travel to the safe band (720): (200,760)->(200,40). + // 400x800, down, pixels 1000 clamps travel to the safe band (640): (200,720)->(200,80). let plan = try XCTUnwrap( runnerScrollGesturePlan( direction: "down", @@ -133,10 +135,10 @@ extension RunnerTests { ) ) XCTAssertEqual(plan.x1, 200) - XCTAssertEqual(plan.y1, 760) + XCTAssertEqual(plan.y1, 720) XCTAssertEqual(plan.x2, 200) - XCTAssertEqual(plan.y2, 40) - XCTAssertEqual(plan.travelPixels, 720) + XCTAssertEqual(plan.y2, 80) + XCTAssertEqual(plan.travelPixels, 640) } func testRunnerScrollGesturePlanFloorsTinyFrames() throws { @@ -159,7 +161,7 @@ extension RunnerTests { } func testRunnerScrollGesturePlanClampsToSafeBand() throws { - // 300x600, right, pixels 500 clamps travel to the safe band (270). + // 300x600, right, pixels 500 clamps travel to the safe band (240). let plan = try XCTUnwrap( runnerScrollGesturePlan( direction: "right", @@ -169,11 +171,28 @@ extension RunnerTests { referenceHeight: 600 ) ) - XCTAssertEqual(plan.x1, 285) - XCTAssertEqual(plan.x2, 15) + XCTAssertEqual(plan.x1, 270) + XCTAssertEqual(plan.x2, 30) XCTAssertEqual(plan.y1, 300) XCTAssertEqual(plan.y2, 300) - XCTAssertEqual(plan.travelPixels, 270) + XCTAssertEqual(plan.travelPixels, 240) + } + + func testRunnerScrollGesturePlanKeepsSaturatedScrollUpOutOfStatusBar() throws { + // 1080x2400, up, amount 3 (#1781 A1): the touch-down at y=240 clears a Pixel 7's 136px cutout + // status bar; the 5% band used to start at y=120 and pulled the notification shade down. + let plan = try XCTUnwrap( + runnerScrollGesturePlan( + direction: "up", + amount: 3, + pixels: nil, + referenceWidth: 1080, + referenceHeight: 2400 + ) + ) + XCTAssertEqual(plan.y1, 240) + XCTAssertEqual(plan.y2, 2160) + XCTAssertGreaterThan(plan.y1, 136) } func testRunnerScrollGesturePlanRejectsUnknownDirection() { diff --git a/package.json b/package.json index ed6ae0c3fe..83d6a1855e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/contracts/src/scroll-gesture.test.ts b/packages/contracts/src/scroll-gesture.test.ts index 0c54366630..b2c6bc5694 100644 --- a/packages/contracts/src/scroll-gesture.test.ts +++ b/packages/contracts/src/scroll-gesture.test.ts @@ -100,13 +100,13 @@ test('buildScrollGesturePlan clamps amounts above 1 to the safe gesture band', ( assert.deepEqual(plan, { direction: 'down', x1: 200, - y1: 760, + y1: 720, x2: 200, - y2: 40, + y2: 80, referenceWidth: 400, referenceHeight: 800, amount: 2, - pixels: 720, + pixels: 640, }); }); @@ -121,13 +121,13 @@ test('buildScrollGesturePlan clamps explicit pixel travel to the vertical safe g assert.deepEqual(plan, { direction: 'down', x1: 200, - y1: 760, + y1: 720, x2: 200, - y2: 40, + y2: 80, referenceWidth: 400, referenceHeight: 800, amount: undefined, - pixels: 720, + pixels: 640, }); }); @@ -162,11 +162,27 @@ test('buildScrollGesturePlan clamps pixel travel to the safe gesture band', () = referenceHeight: 600, }); - assert.equal(plan.x1, 285); - assert.equal(plan.x2, 15); + assert.equal(plan.x1, 270); + assert.equal(plan.x2, 30); assert.equal(plan.y1, 300); assert.equal(plan.y2, 300); - assert.equal(plan.pixels, 270); + assert.equal(plan.pixels, 240); +}); + +// #1781 A1: an edge-to-edge app window includes the status bar, so a saturated `scroll up` used +// to start inside it (y=120 on a Pixel 7, whose cutout status bar is 136px tall) and pulled the +// notification shade down instead of scrolling. The band keeps the touch-down below the bar. +test('buildScrollGesturePlan keeps a saturated scroll up out of a Pixel 7 status bar', () => { + const plan = buildScrollGesturePlan({ + direction: 'up', + amount: 3, + referenceWidth: 1080, + referenceHeight: 2400, + }); + + assert.equal(plan.y1, 240); + assert.equal(plan.y2, 2160); + assert.ok(plan.y1 > 136, `touch-down y=${plan.y1} must clear the 136px status bar`); }); test('buildScrollGesturePlan rejects invalid amounts', () => { diff --git a/packages/contracts/src/scroll-gesture.ts b/packages/contracts/src/scroll-gesture.ts index a041e62849..6d09cfac42 100644 --- a/packages/contracts/src/scroll-gesture.ts +++ b/packages/contracts/src/scroll-gesture.ts @@ -84,7 +84,11 @@ export type InPageSwipeGesturePlan = { }; const DEFAULT_SCROLL_AMOUNT = 0.6; -const DEFAULT_EDGE_PADDING_FRACTION = 0.05; +// Scroll gestures never touch the outer 10% of either axis. Modern app windows are edge-to-edge, +// so the viewport includes the system bars: a swipe that starts inside the status bar (5.7% of a +// Pixel 7's height, 6.9% of an iPhone's with a Dynamic Island) pulls the notification shade or +// Notification Center down instead of scrolling. 10% clears both with margin (#1781 A1). +const DEFAULT_EDGE_PADDING_FRACTION = 0.1; // Edge presets stay close to the system gesture boundary without emitting edge coordinates. const SWIPE_PRESET_EDGE_MARGIN_PX = 8; diff --git a/test/ci/android-workflow-evidence.json b/test/ci/android-workflow-evidence.json index 61e84a363e..bad48f735c 100644 --- a/test/ci/android-workflow-evidence.json +++ b/test/ci/android-workflow-evidence.json @@ -2,5 +2,5 @@ "workflow": ".github/workflows/android.yml", "job": "smoke-android", "step": "Run Android smoke checks", - "invocation": "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" + "invocation": "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" } diff --git a/test/integration/replays/android/01-settings.ad b/test/integration/replays/android/emulator/01-settings.ad similarity index 100% rename from test/integration/replays/android/01-settings.ad rename to test/integration/replays/android/emulator/01-settings.ad diff --git a/test/integration/replays/android/02-deep-navigation.ad b/test/integration/replays/android/emulator/02-deep-navigation.ad similarity index 100% rename from test/integration/replays/android/02-deep-navigation.ad rename to test/integration/replays/android/emulator/02-deep-navigation.ad diff --git a/test/integration/replays/android/03-scroll-discovery.ad b/test/integration/replays/android/emulator/03-scroll-discovery.ad similarity index 100% rename from test/integration/replays/android/03-scroll-discovery.ad rename to test/integration/replays/android/emulator/03-scroll-discovery.ad diff --git a/test/integration/replays/android/04-text-input-keyboard.ad b/test/integration/replays/android/emulator/04-text-input-keyboard.ad similarity index 100% rename from test/integration/replays/android/04-text-input-keyboard.ad rename to test/integration/replays/android/emulator/04-text-input-keyboard.ad diff --git a/test/integration/replays/android/05-app-lifecycle.ad b/test/integration/replays/android/emulator/05-app-lifecycle.ad similarity index 100% rename from test/integration/replays/android/05-app-lifecycle.ad rename to test/integration/replays/android/emulator/05-app-lifecycle.ad diff --git a/test/integration/replays/android/06-swipe-gestures.ad b/test/integration/replays/android/emulator/06-swipe-gestures.ad similarity index 100% rename from test/integration/replays/android/06-swipe-gestures.ad rename to test/integration/replays/android/emulator/06-swipe-gestures.ad From 9f4670ba0ce3a23cdb15d76fb06ec2e29247d742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 14:09:41 +0200 Subject: [PATCH 2/2] test(scroll): drive the TS and Swift scroll-plan parity vectors from one table (#1820 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../RunnerTests+ScrollGesture.swift | 192 +++++++---------- contracts/fixtures/scroll-gesture.json | 64 ++++++ packages/contracts/src/scroll-gesture.test.ts | 201 ++++++------------ test/ci/android-workflow-evidence.json | 1 + 4 files changed, 203 insertions(+), 255 deletions(-) create mode 100644 contracts/fixtures/scroll-gesture.json diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift index ecb12c4e0e..523ee1aeb7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift @@ -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. @@ -20,8 +20,9 @@ struct RunnerScrollGesturePlan { } private let runnerDefaultScrollAmount = 0.6 -// Mirrors DEFAULT_EDGE_PADDING_FRACTION: 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). +// 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( @@ -66,133 +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 } - - func testRunnerScrollGesturePlanClampsAmountAboveOne() throws { - // 400x800, down, amount 2 -> requested 1600 clamps to the safe band (640): (200,720)->(200,80). - let plan = try XCTUnwrap( - runnerScrollGesturePlan( - direction: "down", - amount: 2, - pixels: nil, - referenceWidth: 400, - referenceHeight: 800 - ) - ) - XCTAssertEqual(plan.x1, 200) - XCTAssertEqual(plan.y1, 720) - XCTAssertEqual(plan.x2, 200) - XCTAssertEqual(plan.y2, 80) - XCTAssertEqual(plan.travelPixels, 640) + 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 testRunnerScrollGesturePlanClampsExplicitPixelsVertically() throws { - // 400x800, down, pixels 1000 clamps travel to the safe band (640): (200,720)->(200,80). - let plan = try XCTUnwrap( - runnerScrollGesturePlan( - direction: "down", - amount: nil, - pixels: 1000, - referenceWidth: 400, - referenceHeight: 800 - ) - ) - XCTAssertEqual(plan.x1, 200) - XCTAssertEqual(plan.y1, 720) - XCTAssertEqual(plan.x2, 200) - XCTAssertEqual(plan.y2, 80) - XCTAssertEqual(plan.travelPixels, 640) + 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 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( - runnerScrollGesturePlan( - direction: "down", - amount: nil, - pixels: 10, - referenceWidth: 2, - referenceHeight: 2 + 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, 1) - XCTAssertEqual(plan.y1, 2) - XCTAssertEqual(plan.x2, 1) - XCTAssertEqual(plan.y2, 0) - XCTAssertEqual(plan.travelPixels, 1) + 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 testRunnerScrollGesturePlanClampsToSafeBand() throws { - // 300x600, right, pixels 500 clamps travel to the safe band (240). - 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: "right", - amount: nil, - pixels: 500, - referenceWidth: 300, - referenceHeight: 600 + direction: "down", amount: nil, pixels: nil, referenceWidth: 1000, referenceHeight: 1000 ) ) - XCTAssertEqual(plan.x1, 270) - XCTAssertEqual(plan.x2, 30) - XCTAssertEqual(plan.y1, 300) - XCTAssertEqual(plan.y2, 300) - XCTAssertEqual(plan.travelPixels, 240) - } - - func testRunnerScrollGesturePlanKeepsSaturatedScrollUpOutOfStatusBar() throws { - // 1080x2400, up, amount 3 (#1781 A1): the touch-down at y=240 clears a Pixel 7's 136px cutout - // status bar; the 5% band used to start at y=120 and pulled the notification shade down. - let plan = try XCTUnwrap( + XCTAssertEqual(defaulted.travelPixels, 1000 * constants.defaultScrollAmount) + let saturated = try XCTUnwrap( runnerScrollGesturePlan( - direction: "up", - amount: 3, - pixels: nil, - referenceWidth: 1080, - referenceHeight: 2400 + direction: "down", amount: 10, pixels: nil, referenceWidth: 1000, referenceHeight: 1000 ) ) - XCTAssertEqual(plan.y1, 240) - XCTAssertEqual(plan.y2, 2160) - XCTAssertGreaterThan(plan.y1, 136) + XCTAssertEqual( + saturated.travelPixels, 1000 - 2 * 1000 * constants.defaultEdgePaddingFraction) } func testRunnerScrollGesturePlanRejectsUnknownDirection() { diff --git a/contracts/fixtures/scroll-gesture.json b/contracts/fixtures/scroll-gesture.json new file mode 100644 index 0000000000..0030b48560 --- /dev/null +++ b/contracts/fixtures/scroll-gesture.json @@ -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 } + } + ] +} diff --git a/packages/contracts/src/scroll-gesture.test.ts b/packages/contracts/src/scroll-gesture.test.ts index b2c6bc5694..fad1a36e46 100644 --- a/packages/contracts/src/scroll-gesture.test.ts +++ b/packages/contracts/src/scroll-gesture.test.ts @@ -1,5 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { assertScrollGestureInput, @@ -44,145 +46,70 @@ test('buildInPageSwipeGesturePlan truncates percentage coordinates on odd viewpo ); }); -// The buildScrollGesturePlan vectors below are the canonical cross-language parity vectors, -// mirrored by RunnerTests+ScrollGesture.swift (runnerScrollGesturePlan). If you change the scroll -// math, update both this suite and the Swift parity test so the two ports cannot drift silently. -test('buildScrollGesturePlan maps relative amount to viewport travel', () => { - const plan = buildScrollGesturePlan({ - direction: 'down', - amount: 0.5, - referenceWidth: 400, - referenceHeight: 800, - }); - - assert.deepEqual(plan, { - direction: 'down', - x1: 200, - y1: 600, - x2: 200, - y2: 200, - referenceWidth: 400, - referenceHeight: 800, - amount: 0.5, - pixels: 400, - }); -}); - -test('buildScrollGesturePlan maps explicit pixels below the safe band cap', () => { - const plan = buildScrollGesturePlan({ - direction: 'down', - pixels: 120, - referenceWidth: 300, - referenceHeight: 600, - }); - - assert.deepEqual(plan, { - direction: 'down', - x1: 150, - y1: 360, - x2: 150, - y2: 240, - referenceWidth: 300, - referenceHeight: 600, - amount: undefined, - pixels: 120, - }); -}); - -test('buildScrollGesturePlan clamps amounts above 1 to the safe gesture band', () => { - const plan = buildScrollGesturePlan({ - direction: 'down', - amount: 2, - referenceWidth: 400, - referenceHeight: 800, - }); - - assert.deepEqual(plan, { - direction: 'down', - x1: 200, - y1: 720, - x2: 200, - y2: 80, - referenceWidth: 400, - referenceHeight: 800, - amount: 2, - pixels: 640, - }); -}); - -test('buildScrollGesturePlan clamps explicit pixel travel to the vertical safe gesture band', () => { - const plan = buildScrollGesturePlan({ - direction: 'down', - pixels: 1000, - referenceWidth: 400, - referenceHeight: 800, - }); - - assert.deepEqual(plan, { - direction: 'down', - x1: 200, - y1: 720, - x2: 200, - y2: 80, - referenceWidth: 400, - referenceHeight: 800, - amount: undefined, - pixels: 640, - }); -}); - -test('buildScrollGesturePlan floors padding and travel on tiny frames', () => { - // 2x2 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). - const plan = buildScrollGesturePlan({ - direction: 'down', - pixels: 10, - referenceWidth: 2, - referenceHeight: 2, - }); - - assert.deepEqual(plan, { - direction: 'down', - x1: 1, - y1: 2, - x2: 1, - y2: 0, - referenceWidth: 2, - referenceHeight: 2, - amount: undefined, - pixels: 1, - }); -}); - -test('buildScrollGesturePlan clamps pixel travel to the safe gesture band', () => { - const plan = buildScrollGesturePlan({ - direction: 'right', - pixels: 500, - referenceWidth: 300, - referenceHeight: 600, - }); - - assert.equal(plan.x1, 270); - assert.equal(plan.x2, 30); - assert.equal(plan.y1, 300); - assert.equal(plan.y2, 300); - assert.equal(plan.pixels, 240); +// Cross-language parity table: every case in contracts/fixtures/scroll-gesture.json is asserted +// here AND by the Swift port (runnerScrollGesturePlan in RunnerTests+ScrollGesture.swift, gated +// XCTest in the same file). Add vectors to the table, never to one suite — drift on either side +// turns CI red without a simulator. +type ScrollGestureFixture = { + constants: { defaultScrollAmount: number; defaultEdgePaddingFraction: number }; + cases: Array<{ + name: string; + direction: 'up' | 'down' | 'left' | 'right'; + amount?: number; + pixels?: number; + referenceWidth: number; + referenceHeight: number; + expected: { x1: number; y1: number; x2: number; y2: number; pixels: number }; + }>; +}; + +const SCROLL_TABLE_PATH = path.resolve( + import.meta.dirname, + '..', + '..', + '..', + 'contracts', + 'fixtures', + 'scroll-gesture.json', +); + +function readScrollGestureFixture(): ScrollGestureFixture { + return JSON.parse(fs.readFileSync(SCROLL_TABLE_PATH, 'utf8')) as ScrollGestureFixture; +} + +test('buildScrollGesturePlan agrees with every scroll-gesture parity table case', () => { + const { cases } = readScrollGestureFixture(); + assert.ok(cases.length > 0, 'parity table must not be empty'); + assert.equal(new Set(cases.map((c) => c.name)).size, cases.length, 'case names must be unique'); + for (const fixture of cases) { + const plan = buildScrollGesturePlan({ + direction: fixture.direction, + amount: fixture.amount, + pixels: fixture.pixels, + referenceWidth: fixture.referenceWidth, + referenceHeight: fixture.referenceHeight, + }); + assert.deepEqual( + { x1: plan.x1, y1: plan.y1, x2: plan.x2, y2: plan.y2, pixels: plan.pixels }, + fixture.expected, + fixture.name, + ); + assert.equal(plan.direction, fixture.direction, fixture.name); + assert.equal(plan.amount, fixture.amount, fixture.name); + assert.equal(plan.referenceWidth, fixture.referenceWidth, fixture.name); + assert.equal(plan.referenceHeight, fixture.referenceHeight, fixture.name); + } }); -// #1781 A1: an edge-to-edge app window includes the status bar, so a saturated `scroll up` used -// to start inside it (y=120 on a Pixel 7, whose cutout status bar is 136px tall) and pulled the -// notification shade down instead of scrolling. The band keeps the touch-down below the bar. -test('buildScrollGesturePlan keeps a saturated scroll up out of a Pixel 7 status bar', () => { - const plan = buildScrollGesturePlan({ - direction: 'up', - amount: 3, - referenceWidth: 1080, - referenceHeight: 2400, - }); - - assert.equal(plan.y1, 240); - assert.equal(plan.y2, 2160); - assert.ok(plan.y1 > 136, `touch-down y=${plan.y1} must clear the 136px status bar`); +// The two planner constants are private on both sides; the table pins them behaviourally on a +// 1000px axis where every rounding step is exact. +test('buildScrollGesturePlan uses the parity table default amount and edge padding', () => { + const { constants } = readScrollGestureFixture(); + const frame = { referenceWidth: 1000, referenceHeight: 1000 }; + const defaulted = buildScrollGesturePlan({ direction: 'down', ...frame }); + assert.equal(defaulted.pixels, 1000 * constants.defaultScrollAmount); + const saturated = buildScrollGesturePlan({ direction: 'down', amount: 10, ...frame }); + assert.equal(saturated.pixels, 1000 - 2 * 1000 * constants.defaultEdgePaddingFraction); }); test('buildScrollGesturePlan rejects invalid amounts', () => { diff --git a/test/ci/android-workflow-evidence.json b/test/ci/android-workflow-evidence.json index bad48f735c..7098da7a47 100644 --- a/test/ci/android-workflow-evidence.json +++ b/test/ci/android-workflow-evidence.json @@ -1,4 +1,5 @@ { + "purpose": "Pins the exact Android smoke-replay invocation android.yml must carry, verbatim, so the PR lane cannot silently drop or drift the replay step (test/ci/trusted-fixture-artifact.test.mjs asserts this line appears in the named job/step). Update it together with the workflow.", "workflow": ".github/workflows/android.yml", "job": "smoke-android", "step": "Run Android smoke checks",