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
145 changes: 145 additions & 0 deletions contracts/fixtures/scroll-keyboard-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{
"constants": {
"minVisibleFraction": 0.15,
"accessoryAllowance": 12,
"occlusionReason": "scroll_keyboard_occludes_surface"
},
"cases": [
{
"name": "iPhone portrait keyboard covering the lower 310pt leaves the band above the accessory allowance",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 874 },
"keyboard": { "x": 0, "y": 564, "width": 402, "height": 310 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 552 },
"keyboardMinY": 564
}
},
{
"name": "keyboard flush with the viewport bottom occludes nothing",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 874 },
"keyboard": { "x": 0, "y": 874, "width": 402, "height": 310 },
"expected": { "kind": "unobstructed" }
},
{
"name": "keyboard entirely above the viewport occludes nothing",
"viewport": { "x": 0, "y": 400, "width": 402, "height": 474 },
"keyboard": { "x": 0, "y": 100, "width": 402, "height": 300 },
"expected": { "kind": "unobstructed" }
},
{
"name": "landscape keyboard leaving 28pt of a 402pt axis is refused instead of swiping under the keys",
"viewport": { "x": 0, "y": 0, "width": 874, "height": 402 },
"keyboard": { "x": 0, "y": 40, "width": 874, "height": 362 },
"expected": { "kind": "occluded", "keyboardMinY": 40, "visibleHeight": 28 }
},
{
"name": "keyboard covering the whole viewport is refused with zero visible height",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 874 },
"keyboard": { "x": 0, "y": 0, "width": 402, "height": 874 },
"expected": { "kind": "occluded", "keyboardMinY": 0, "visibleHeight": 0 }
},
{
"name": "keyboard whose top sits above the viewport top is refused with the visible height clamped to zero",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 874 },
"keyboard": { "x": 0, "y": -100, "width": 402, "height": 974 },
"expected": { "kind": "occluded", "keyboardMinY": -100, "visibleHeight": 0 }
},
{
"name": "band exactly at the 15% floor is kept: the refusal boundary is inclusive",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 800 },
"keyboard": { "x": 0, "y": 132, "width": 400, "height": 668 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 120 },
"keyboardMinY": 132
}
},
{
"name": "band one point below the 15% floor is refused",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 800 },
"keyboard": { "x": 0, "y": 131, "width": 400, "height": 669 },
"expected": { "kind": "occluded", "keyboardMinY": 131, "visibleHeight": 119 }
},
{
"name": "keyboard starting exactly on the swipe centre line owns the surface",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 800 },
"keyboard": { "x": 200, "y": 500, "width": 200, "height": 300 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 488 },
"keyboardMinY": 500
}
},
{
"name": "floating keyboard starting one point past the swipe centre line is not struck",
"viewport": { "x": 0, "y": 0, "width": 400, "height": 800 },
"keyboard": { "x": 201, "y": 500, "width": 199, "height": 300 },
"expected": { "kind": "unobstructed" }
},
{
"name": "centred floating keyboard under half the viewport width sits in the swipe's path",
"viewport": { "x": 0, "y": 0, "width": 834, "height": 1112 },
"keyboard": { "x": 257, "y": 780, "width": 320, "height": 332 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 0, "width": 834, "height": 768 },
"keyboardMinY": 780
}
},
{
"name": "keyboard beside a split-screen app leaves that app's viewport swipeable",
"viewport": { "x": 0, "y": 0, "width": 390, "height": 844 },
"keyboard": { "x": 390, "y": 500, "width": 390, "height": 344 },
"expected": { "kind": "unobstructed" }
},
{
"name": "Android absolute-pixel window clips against the window origin, not the display origin",
"viewport": { "x": 0, "y": 120, "width": 1080, "height": 2000 },
"keyboard": { "x": 0, "y": 1600, "width": 1080, "height": 520 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 120, "width": 1080, "height": 1468 },
"keyboardMinY": 1600
}
},
{
"name": "adjustPan window panned up under the IME still clips to its own 188px remainder",
"viewport": { "x": 0, "y": 900, "width": 1080, "height": 900 },
"keyboard": { "x": 0, "y": 1100, "width": 1080, "height": 700 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 900, "width": 1080, "height": 188 },
"keyboardMinY": 1100
}
},
{
"name": "adjustPan window with only 88px of 900px left above the IME is refused",
"viewport": { "x": 0, "y": 900, "width": 1080, "height": 900 },
"keyboard": { "x": 0, "y": 1000, "width": 1080, "height": 800 },
"expected": { "kind": "occluded", "keyboardMinY": 1000, "visibleHeight": 88 }
},
{
"name": "shallow Android keyboard covering under a quarter of a tall window still clips its own top",
"viewport": { "x": 0, "y": 0, "width": 1080, "height": 2200 },
"keyboard": { "x": 0, "y": 1700, "width": 1080, "height": 500 },
"expected": {
"kind": "avoided",
"viewport": { "x": 0, "y": 0, "width": 1080, "height": 1688 },
"keyboardMinY": 1700
}
},
{
"name": "empty keyboard frame fails open: a keyboard the platform cannot measure is not evidence of occlusion",
"viewport": { "x": 0, "y": 0, "width": 402, "height": 874 },
"keyboard": { "x": 0, "y": 0, "width": 0, "height": 0 },
"expected": { "kind": "unobstructed" }
},
{
"name": "empty viewport frame fails open too: resolving the best frame is the caller's job",
"viewport": { "x": 0, "y": 0, "width": 0, "height": 0 },
"keyboard": { "x": 0, "y": 564, "width": 402, "height": 310 },
"expected": { "kind": "unobstructed" }
}
]
}
11 changes: 11 additions & 0 deletions packages/contracts/src/scroll-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,15 @@ export type ScrollCommandResult = {
durationMs?: number;
message?: string;
settle?: SettleObservation;
/**
* Set only when an on-screen keyboard made the owner clip the swipe into the band above it
* (#2500). Absent means the swipe was not clipped, which is not the same claim as `false`: a
* platform that never runs the clip has nothing to report. The platform leaf's `referenceHeight`
* names the shortened axis the reported `pixels` were planned against, and `keyboardMinY` names
* where the keyboard began. A surface the owner refused to swipe at all fails instead, under the
* `scroll_keyboard_occludes_surface` reason.
*/
keyboardAvoided?: true;
/** The keyboard's edge in the same unit as the gesture coordinates, when the swipe was clipped. */
keyboardMinY?: number;
};
117 changes: 117 additions & 0 deletions packages/contracts/src/scroll-gesture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import type { Rect } from '@agent-device/kernel/snapshot';
import {
assertScrollGestureInput,
buildInPageSwipeGesturePlan,
buildScrollGesturePlan,
clampGestureCoordinate,
SCROLL_KEYBOARD_ACCESSORY_ALLOWANCE,
SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION,
SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON,
clipScrollViewportAboveKeyboard,
scrollKeyboardOccludesSurfaceError,
} from './scroll-gesture.ts';
import {
DEFAULT_IOS_SCROLL_AMOUNT,
Expand Down Expand Up @@ -201,3 +207,114 @@ test('clampGestureCoordinate rounds values and clamps them into the safe gesture
test('clampGestureCoordinate returns the lower bound for non-finite coordinates', () => {
assert.equal(clampGestureCoordinate(Number.POSITIVE_INFINITY, 8, 100), 8);
});

// Golden parity table: the SAME JSON is asserted against the Swift twin
// (ScrollViewportPolicy in apple/runner/AgentDeviceRunner/
// AgentDeviceRunnerUITests/RunnerScrollViewportPolicy.swift, gated XCTest in the same file), so
// a clip that drifts between the iOS runner and the Android/TS owner turns CI red on whichever
// side changed, without a simulator.

type ExpectedClip =
| { kind: 'unobstructed' }
| { kind: 'avoided'; viewport: Rect; keyboardMinY: number }
| { kind: 'occluded'; keyboardMinY: number; visibleHeight: number };

type FixtureCase = {
name: string;
viewport: Rect;
keyboard: Rect;
expected: ExpectedClip;
};

type Fixture = {
constants: {
minVisibleFraction: number;
accessoryAllowance: number;
occlusionReason: string;
};
cases: FixtureCase[];
};

const TABLE_PATH = path.resolve(
import.meta.dirname,
'..',
'..',
'..',
'contracts',
'fixtures',
'scroll-keyboard-policy.json',
);

function loadTable(): Fixture {
return JSON.parse(fs.readFileSync(TABLE_PATH, 'utf8')) as Fixture;
}

test('the scroll keyboard clip agrees with every golden parity table case', () => {
const table = loadTable();
assert.ok(table.cases.length > 0, 'parity table must not be empty');
const names = new Set(table.cases.map((fixture) => fixture.name));
assert.equal(names.size, table.cases.length, 'parity table case names must be unique');
for (const fixture of table.cases) {
assert.deepEqual(
clipScrollViewportAboveKeyboard(fixture.viewport, fixture.keyboard),
fixture.expected,
fixture.name,
);
}
});

test('the keyboard clip thresholds and refusal keys belong to the table, not this file', () => {
const { constants } = loadTable();
assert.equal(constants.minVisibleFraction, SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION);
assert.equal(constants.accessoryAllowance, SCROLL_KEYBOARD_ACCESSORY_ALLOWANCE);
assert.equal(constants.occlusionReason, SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON);
});

test('a clipped viewport keeps even a saturated swipe inside the visible band', () => {
// The whole point of clipping before planning: `buildScrollGesturePlan` is untouched and gets the
// clipped axis, so its own edge padding is what holds the gesture above the keyboard.
const viewport: Rect = { x: 0, y: 0, width: 402, height: 874 };
const clip = clipScrollViewportAboveKeyboard(viewport, {
x: 0,
y: 564,
width: 402,
height: 310,
});
assert.equal(clip.kind, 'avoided');
if (clip.kind !== 'avoided') return;
const plan = buildScrollGesturePlan({
direction: 'down',
amount: 10,
referenceWidth: clip.viewport.width,
referenceHeight: clip.viewport.height,
});
assert.ok(Math.max(plan.y1, plan.y2) <= clip.viewport.height);
assert.ok(plan.pixels < clip.viewport.height, 'honored travel must name the clipped axis');
});

test('an unusable keyboard frame fails open instead of refusing every scroll', () => {
const viewport: Rect = { x: 0, y: 0, width: 402, height: 874 };
for (const keyboard of [
{ x: 0, y: Number.NaN, width: 402, height: 310 },
{ x: 0, y: 564, width: Number.POSITIVE_INFINITY, height: 310 },
{ x: 0, y: 564, width: 402, height: -310 },
{ x: 0, y: 564, width: 402, height: 0 },
] satisfies Rect[]) {
assert.equal(clipScrollViewportAboveKeyboard(viewport, keyboard).kind, 'unobstructed');
}
});

test('the occlusion refusal is keyed on its reason, not on its message', () => {
const error = scrollKeyboardOccludesSurfaceError('down', {
keyboardMinY: 564,
visibleHeight: 40,
viewportHeight: 874,
});
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.reason, SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON);
assert.equal(error.details?.keyboardMinY, 564);
assert.equal(error.details?.visibleHeight, 40);
assert.equal(error.details?.viewportHeight, 874);
assert.match(String(error.details?.hint), /keyboard dismiss/);
});
Loading
Loading