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
48 changes: 47 additions & 1 deletion src/compat/maestro/__tests__/runtime-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { pointForMaestroTapOnTarget } from '../runtime-geometry.ts';
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from '../runtime-geometry.ts';

test('pointForMaestroTapOnTarget biases large scroll-area text containers toward the visible label', () => {
const point = pointForMaestroTapOnTarget(
Expand Down Expand Up @@ -38,3 +38,49 @@ test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', ()

expect(point).toEqual({ x: 675, y: 2164 });
});

test('swipeCoordinatesFromTarget preserves Maestro target-relative swipe distance', () => {
const swipe = swipeCoordinatesFromTarget(
{
node: {
index: 12,
ref: 'e12',
type: 'Cell',
label: 'Card',
rect: { x: 100, y: 200, width: 100, height: 80 },
},
rect: { x: 100, y: 200, width: 100, height: 80 },
frame: { referenceWidth: 402, referenceHeight: 874 },
},
'right',
);

expect(swipe).toEqual({
ok: true,
start: { x: 150, y: 240 },
end: { x: 300, y: 240 },
});
});

test('swipeCoordinatesFromTarget clamps swipe endpoints to the viewport margin', () => {
const swipe = swipeCoordinatesFromTarget(
{
node: {
index: 12,
ref: 'e12',
type: 'Cell',
label: 'Card',
rect: { x: 340, y: 200, width: 100, height: 80 },
},
rect: { x: 340, y: 200, width: 100, height: 80 },
frame: { referenceWidth: 402, referenceHeight: 874 },
},
'right',
);

expect(swipe).toEqual({
ok: true,
start: { x: 390, y: 240 },
end: { x: 394, y: 240 },
});
});
81 changes: 80 additions & 1 deletion src/compat/maestro/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect, test } from 'vitest';
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
import type { SnapshotState } from '../../../utils/snapshot.ts';
import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts';
import {
invokeMaestroSwipeScreen,
invokeMaestroTapOn,
invokeMaestroTapPointPercent,
} from '../runtime-interactions.ts';

test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => {
const selector =
Expand Down Expand Up @@ -59,6 +63,81 @@ test('invokeMaestroSwipeScreen uses an Android content-lane directional swipe',
expect(swipes).toEqual([['864', '1521', '216', '1521', '300']]);
});

test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async () => {
const swipes: string[][] = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
token: 'test',
session: 'article',
flags: { platform: 'ios' },
},
positionals: ['percent', '50', '75', '50', '35', '300'],
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
if (req.command === 'snapshot') {
return { ok: true, data: fullScreenSnapshot(400, 800) };
}
if (req.command === 'swipe') {
swipes.push(req.positionals ?? []);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

expect(response.ok).toBe(true);
expect(swipes).toEqual([['200', '600', '200', '280', '300']]);
});

test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => {
const swipes: string[][] = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
token: 'test',
session: 'pager',
flags: { platform: 'android' },
},
positionals: ['percent', '90', '50', '10', '50', '300'],
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
if (req.command === 'snapshot') {
return { ok: true, data: fullScreenSnapshot(390, 600) };
}
if (req.command === 'swipe') {
swipes.push(req.positionals ?? []);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

expect(response.ok).toBe(true);
expect(swipes).toEqual([['351', '390', '39', '390', '300']]);
});

test('invokeMaestroTapPointPercent shares percentage point geometry without clamping', async () => {
const clicks: string[][] = [];
const response = await invokeMaestroTapPointPercent({
baseReq: {
token: 'test',
session: 'article',
flags: { platform: 'ios' },
},
positionals: ['125', '-10'],
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
if (req.command === 'snapshot') {
return { ok: true, data: fullScreenSnapshot(400, 800) };
}
if (req.command === 'click') {
clicks.push(req.positionals ?? []);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

expect(response.ok).toBe(true);
expect(clicks).toEqual([['500', '-80']]);
});

function currentBreadcrumbSnapshot(): SnapshotState {
return {
createdAt: Date.now(),
Expand Down
25 changes: 17 additions & 8 deletions src/compat/maestro/runtime-geometry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { clampToRange } from '../../core/scroll-gesture.ts';
import type { Rect, SnapshotNode } from '../../utils/snapshot.ts';
import { interiorCoordinate, pointInsideRect } from '../../utils/rect-center.ts';
import { normalizeType } from '../../utils/snapshot-processing.ts';
Expand Down Expand Up @@ -39,25 +40,37 @@ export function swipeCoordinatesFromTarget(
return {
ok: true,
start: center,
end: { x: center.x, y: clampCoordinate(center.y - verticalDistance, minY, maxY) },
end: {
x: center.x,
y: clampToRange(center.y - verticalDistance, minY, maxY),
},
};
case 'down':
return {
ok: true,
start: center,
end: { x: center.x, y: clampCoordinate(center.y + verticalDistance, minY, maxY) },
end: {
x: center.x,
y: clampToRange(center.y + verticalDistance, minY, maxY),
},
};
case 'left':
return {
ok: true,
start: center,
end: { x: clampCoordinate(center.x - horizontalDistance, minX, maxX), y: center.y },
end: {
x: clampToRange(center.x - horizontalDistance, minX, maxX),
y: center.y,
},
};
case 'right':
return {
ok: true,
start: center,
end: { x: clampCoordinate(center.x + horizontalDistance, minX, maxX), y: center.y },
end: {
x: clampToRange(center.x + horizontalDistance, minX, maxX),
y: center.y,
},
};
default:
return { ok: false, message: 'swipe.label direction must be up, down, left, or right.' };
Expand Down Expand Up @@ -94,10 +107,6 @@ function swipeDistance(frameSize: number | undefined, rectSize: number): number
);
}

function clampCoordinate(value: number, min: number, max: number): number {
return Math.round(Math.min(max, Math.max(min, value)));
}

function shouldBiasMaestroVisibleTextTap(
node: SnapshotNode,
isVisibleTextSelector: boolean,
Expand Down
103 changes: 46 additions & 57 deletions src/compat/maestro/runtime-interactions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
import {
buildSwipeGesturePlan,
clampGesturePoint,
pointFromPercent,
type ScrollDirection,
} from '../../core/scroll-gesture.ts';
import type { ReplayVarScope } from '../../replay/vars.ts';
import { sleep } from '../../utils/timeouts.ts';
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtime-geometry.ts';
Expand Down Expand Up @@ -122,13 +128,11 @@ export async function invokeMaestroTapPointPercent(params: {
);
}

const point = pointFromPercent(frame, xPercent, yPercent);
return await params.invoke({
...params.baseReq,
command: 'click',
positionals: [
String(Math.round((frame.referenceWidth * xPercent) / 100)),
String(Math.round((frame.referenceHeight * yPercent) / 100)),
],
positionals: [String(point.x), String(point.y)],
});
}

Expand Down Expand Up @@ -263,22 +267,12 @@ function resolveDirectionalScreenSwipe(
response: errorResponse('INVALID_ARGS', 'Maestro direction swipe requires a direction.'),
};
}
const point = (xPercent: number, yPercent: number) => percentPoint(frame, xPercent, yPercent, 8);
switch (direction) {
case 'up':
return { ok: true, start: point(50, 80), end: point(50, 20), durationMs };
case 'down':
return { ok: true, start: point(50, 20), end: point(50, 80), durationMs };
case 'left': {
const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 80, 20);
const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
}
case 'right': {
const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 20, 80);
const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
}
case 'left':
case 'right':
return buildMaestroDirectionalScreenSwipe(direction, frame, platform, durationMs);
default:
return {
ok: false,
Expand All @@ -290,13 +284,33 @@ function resolveDirectionalScreenSwipe(
}
}

function androidHorizontalDirectionalSwipeX(
function buildMaestroDirectionalScreenSwipe(
direction: ScrollDirection,
frame: { referenceWidth: number; referenceHeight: number },
platform: string,
startX: number,
endX: number,
): [number, number] {
if (platform !== 'android') return [startX, endX];
return startX < endX ? [20, 80] : [80, 20];
durationMs: string | undefined,
): MaestroScreenSwipeResolution {
const plan = buildSwipeGesturePlan({
direction,
amount: 0.6,
referenceWidth: frame.referenceWidth,
referenceHeight: frame.referenceHeight,
});
const start = clampGesturePoint({ x: plan.x1, y: plan.y1 }, frame, 8);
const end = clampGesturePoint({ x: plan.x2, y: plan.y2 }, frame, 8);

if ((direction === 'left' || direction === 'right') && platform === 'android') {
const contentLaneY = pointFromPercent(frame, 50, 65, { marginPx: 8 }).y;
start.y = contentLaneY;
end.y = contentLaneY;
}

return {
ok: true,
start,
end,
durationMs,
};
}

function resolvePercentScreenSwipe(
Expand All @@ -313,55 +327,30 @@ function resolvePercentScreenSwipe(
};
}
const [x1, y1, x2, y2] = values as [number, number, number, number];
const adjustedY = androidHorizontalContentSwipeY(platform, x1, y1, x2, y2);
const lane = maestroHorizontalContentSwipeLanePercent(platform, x1, y1, x2, y2);
return {
ok: true,
start: percentPoint(frame, x1, adjustedY, 1),
end: percentPoint(frame, x2, adjustedY, 1),
start: pointFromPercent(frame, x1, lane.startY, { marginPx: 1 }),
end: pointFromPercent(frame, x2, lane.endY, { marginPx: 1 }),
durationMs,
};
}

function androidHorizontalContentSwipeY(
function maestroHorizontalContentSwipeLanePercent(
platform: string,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
if (platform !== 'android') return y2;
if (y1 !== y2 || y1 !== 50) return y2;
if (Math.abs(x2 - x1) < 30) return y2;
): { startY: number; endY: number } {
if (platform !== 'android') return { startY: y1, endY: y2 };
if (y1 !== y2 || y1 !== 50) return { startY: y1, endY: y2 };
if (Math.abs(x2 - x1) < 30) return { startY: y1, endY: y2 };
// Maestro's Android driver treats 50% horizontal swipes as content swipes.
// Raw `adb input swipe` at the physical screen midpoint can land above
// horizontally paged content in React Native layouts, so use a lower content
// lane for full-width horizontal Maestro percentage swipes.
return 65;
}

function percentPoint(
frame: { referenceWidth: number; referenceHeight: number },
xPercent: number,
yPercent: number,
marginPx: number,
): { x: number; y: number } {
return {
x: clampPoint(
Math.round((frame.referenceWidth * xPercent) / 100),
marginPx,
frame.referenceWidth,
),
y: clampPoint(
Math.round((frame.referenceHeight * yPercent) / 100),
marginPx,
frame.referenceHeight,
),
};
}

function clampPoint(value: number, marginPx: number, size: number): number {
const max = Math.max(marginPx, size - marginPx);
return Math.min(max, Math.max(marginPx, value));
return { startY: 65, endY: 65 };
}

async function probeMaestroScrollVisibility(
Expand Down
Loading
Loading