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