diff --git a/packages/core/src/device/index.ts b/packages/core/src/device/index.ts
index b8277c3846..acefe6ddb8 100644
--- a/packages/core/src/device/index.ts
+++ b/packages/core/src/device/index.ts
@@ -760,6 +760,8 @@ export const ActionSwipeParamSchema = z.object({
),
distance: z
.number()
+ .finite()
+ .positive()
.optional()
.describe('The distance in pixels to swipe (mutually exclusive with end)'),
end: getMidsceneLocationSchema()
@@ -769,13 +771,18 @@ export const ActionSwipeParamSchema = z.object({
),
duration: z
.number()
+ .finite()
+ .positive()
.default(300)
.describe('Duration of the swipe gesture in milliseconds'),
repeat: z
.number()
+ .finite()
+ .int()
+ .nonnegative()
.optional()
.describe(
- 'The number of times to repeat the swipe gesture. 1 for default, 0 for infinite (e.g. endless swipe until the end of the page)',
+ 'The number of times to repeat the swipe gesture. 1 for default, 0 for continuous mode capped at 10 repeats',
),
});
@@ -798,7 +805,31 @@ export function normalizeMobileSwipeParam(
repeatCount: number;
} {
const { width, height } = screenSize;
- const { start, end } = param;
+ const { start, end, distance, direction } = param;
+
+ if (end !== undefined && distance !== undefined) {
+ throw new Error('end and distance are mutually exclusive');
+ }
+ if (distance !== undefined && (!Number.isFinite(distance) || distance <= 0)) {
+ throw new Error('distance must be a positive finite number');
+ }
+ if (distance !== undefined && direction === undefined) {
+ throw new Error('direction is required when using distance');
+ }
+
+ const duration = param.duration ?? 300;
+ if (!Number.isFinite(duration) || duration <= 0) {
+ throw new Error('duration must be a positive finite number');
+ }
+
+ const repeatCount = param.repeat ?? 1;
+ if (
+ !Number.isFinite(repeatCount) ||
+ !Number.isInteger(repeatCount) ||
+ repeatCount < 0
+ ) {
+ throw new Error('repeat must be a non-negative finite integer');
+ }
const startPoint = start
? { x: start.center[0], y: start.center[1] }
@@ -808,26 +839,18 @@ export function normalizeMobileSwipeParam(
if (end) {
endPoint = { x: end.center[0], y: end.center[1] };
- } else if (param.distance) {
- const direction = param.direction;
- if (!direction) {
- throw new Error('direction is required for swipe gesture');
- }
+ } else if (distance !== undefined) {
endPoint = {
x:
startPoint.x +
(direction === 'right'
- ? param.distance
+ ? distance
: direction === 'left'
- ? -param.distance
+ ? -distance
: 0),
y:
startPoint.y +
- (direction === 'down'
- ? param.distance
- : direction === 'up'
- ? -param.distance
- : 0),
+ (direction === 'down' ? distance : direction === 'up' ? -distance : 0),
};
} else {
throw new Error(
@@ -838,14 +861,12 @@ export function normalizeMobileSwipeParam(
endPoint.x = Math.max(0, Math.min(endPoint.x, width));
endPoint.y = Math.max(0, Math.min(endPoint.y, height));
- const duration = param.duration ?? 300;
-
- let repeatCount = typeof param.repeat === 'number' ? param.repeat : 1;
- if (repeatCount === 0) {
- repeatCount = 10;
- }
-
- return { startPoint, endPoint, duration, repeatCount };
+ return {
+ startPoint,
+ endPoint,
+ duration,
+ repeatCount: repeatCount === 0 ? 10 : repeatCount,
+ };
}
export const defineActionSwipe = (config: {
diff --git a/packages/core/tests/unit-test/device/swipe.test.ts b/packages/core/tests/unit-test/device/swipe.test.ts
new file mode 100644
index 0000000000..7e27d2c7d1
--- /dev/null
+++ b/packages/core/tests/unit-test/device/swipe.test.ts
@@ -0,0 +1,167 @@
+import { ActionSwipeParamSchema, normalizeMobileSwipeParam } from '@/device';
+import type { LocateResultElement } from '@/types';
+import { describe, expect, it } from 'vitest';
+
+const screenSize = { width: 1000, height: 800 };
+
+const locatedAt = (x: number, y: number): LocateResultElement =>
+ ({
+ center: [x, y],
+ }) as LocateResultElement;
+
+describe('ActionSwipeParamSchema', () => {
+ it.each([
+ {
+ name: 'negative distance',
+ param: { distance: -100, direction: 'right' },
+ },
+ {
+ name: 'zero distance',
+ param: { distance: 0, direction: 'right' },
+ },
+ {
+ name: 'negative duration',
+ param: { end: { prompt: 'right edge' }, duration: -1 },
+ },
+ {
+ name: 'non-finite duration',
+ param: {
+ end: { prompt: 'right edge' },
+ duration: Number.POSITIVE_INFINITY,
+ },
+ },
+ {
+ name: 'negative repeat',
+ param: { end: { prompt: 'right edge' }, repeat: -1 },
+ },
+ {
+ name: 'fractional repeat',
+ param: { end: { prompt: 'right edge' }, repeat: 1.5 },
+ },
+ {
+ name: 'non-finite repeat',
+ param: {
+ end: { prompt: 'right edge' },
+ repeat: Number.POSITIVE_INFINITY,
+ },
+ },
+ ])('rejects $name', ({ param }) => {
+ expect(ActionSwipeParamSchema.safeParse(param).success).toBe(false);
+ });
+
+ it('accepts distance with direction and a non-negative integer repeat', () => {
+ expect(
+ ActionSwipeParamSchema.safeParse({
+ distance: 100,
+ direction: 'right',
+ repeat: 0,
+ }).success,
+ ).toBe(true);
+ });
+});
+
+describe('normalizeMobileSwipeParam', () => {
+ it('normalizes a valid relative swipe', () => {
+ expect(
+ normalizeMobileSwipeParam(
+ {
+ start: locatedAt(500, 400),
+ distance: 100,
+ direction: 'right',
+ duration: 200,
+ repeat: 2,
+ },
+ screenSize,
+ ),
+ ).toEqual({
+ startPoint: { x: 500, y: 400 },
+ endPoint: { x: 600, y: 400 },
+ duration: 200,
+ repeatCount: 2,
+ });
+ });
+
+ it.each([
+ {
+ name: 'negative distance',
+ param: { distance: -100, direction: 'right' as const },
+ message: 'distance must be a positive finite number',
+ },
+ {
+ name: 'zero distance',
+ param: { distance: 0, direction: 'right' as const },
+ message: 'distance must be a positive finite number',
+ },
+ {
+ name: 'non-finite distance',
+ param: {
+ distance: Number.POSITIVE_INFINITY,
+ direction: 'right' as const,
+ },
+ message: 'distance must be a positive finite number',
+ },
+ {
+ name: 'missing direction',
+ param: { distance: 100 },
+ message: 'direction is required when using distance',
+ },
+ {
+ name: 'negative duration',
+ param: { end: locatedAt(600, 400), duration: -1 },
+ message: 'duration must be a positive finite number',
+ },
+ {
+ name: 'non-finite duration',
+ param: {
+ end: locatedAt(600, 400),
+ duration: Number.POSITIVE_INFINITY,
+ },
+ message: 'duration must be a positive finite number',
+ },
+ {
+ name: 'negative repeat',
+ param: { end: locatedAt(600, 400), repeat: -1 },
+ message: 'repeat must be a non-negative finite integer',
+ },
+ {
+ name: 'fractional repeat',
+ param: { end: locatedAt(600, 400), repeat: 1.5 },
+ message: 'repeat must be a non-negative finite integer',
+ },
+ {
+ name: 'non-finite repeat',
+ param: {
+ end: locatedAt(600, 400),
+ repeat: Number.POSITIVE_INFINITY,
+ },
+ message: 'repeat must be a non-negative finite integer',
+ },
+ ])('rejects $name', ({ param, message }) => {
+ expect(() => normalizeMobileSwipeParam(param, screenSize)).toThrow(message);
+ });
+
+ it('rejects end and distance together', () => {
+ expect(() =>
+ normalizeMobileSwipeParam(
+ {
+ end: locatedAt(600, 400),
+ distance: 100,
+ direction: 'right',
+ },
+ screenSize,
+ ),
+ ).toThrow('end and distance are mutually exclusive');
+ });
+
+ it('keeps zero repeat as the capped continuous mode', () => {
+ expect(
+ normalizeMobileSwipeParam(
+ {
+ end: locatedAt(600, 400),
+ repeat: 0,
+ },
+ screenSize,
+ ).repeatCount,
+ ).toBe(10);
+ });
+});
diff --git a/packages/core/tests/unit-test/prompt/planning/action-description.test.ts b/packages/core/tests/unit-test/prompt/planning/action-description.test.ts
index 2cb5d85225..a77bbc601c 100644
--- a/packages/core/tests/unit-test/prompt/planning/action-description.test.ts
+++ b/packages/core/tests/unit-test/prompt/planning/action-description.test.ts
@@ -535,7 +535,7 @@ describe('buildActionDescription and serializeActionDescriptions', () => {
repeat:
type: number
optional: true
- description: The number of times to repeat the swipe gesture. 1 for default, 0 for infinite (e.g. endless swipe until the end of the page)
+ description: The number of times to repeat the swipe gesture. 1 for default, 0 for continuous mode capped at 10 repeats
sample: |-
Swipe