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..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,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, @@ -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() { 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/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..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,129 +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: 760, - x2: 200, - y2: 40, - referenceWidth: 400, - referenceHeight: 800, - amount: 2, - pixels: 720, - }); -}); - -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: 760, - x2: 200, - y2: 40, - referenceWidth: 400, - referenceHeight: 800, - amount: undefined, - pixels: 720, - }); -}); - -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, - }); +// 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); + } }); -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, 285); - assert.equal(plan.x2, 15); - assert.equal(plan.y1, 300); - assert.equal(plan.y2, 300); - assert.equal(plan.pixels, 270); +// 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/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..7098da7a47 100644 --- a/test/ci/android-workflow-evidence.json +++ b/test/ci/android-workflow-evidence.json @@ -1,6 +1,7 @@ { + "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", - "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