|
| 1 | +import { z } from 'zod'; |
| 2 | +import { getDriver, getPlatformName } from '../../session-store.js'; |
| 3 | +import log from '../../logger.js'; |
| 4 | +import { elementUUIDScheme } from '../../schema.js'; |
| 5 | + |
| 6 | +function calculateSwipeCoordinates( |
| 7 | + direction: 'left' | 'right' | 'up' | 'down', |
| 8 | + width: number, |
| 9 | + height: number |
| 10 | +): { startX: number; startY: number; endX: number; endY: number } { |
| 11 | + const centerX = Math.floor(width / 2); |
| 12 | + const centerY = Math.floor(height / 2); |
| 13 | + |
| 14 | + switch (direction) { |
| 15 | + case 'left': |
| 16 | + return { |
| 17 | + startX: Math.floor(width * 0.8), |
| 18 | + startY: centerY, |
| 19 | + endX: Math.floor(width * 0.2), |
| 20 | + endY: centerY, |
| 21 | + }; |
| 22 | + case 'right': |
| 23 | + return { |
| 24 | + startX: Math.floor(width * 0.2), |
| 25 | + startY: centerY, |
| 26 | + endX: Math.floor(width * 0.8), |
| 27 | + endY: centerY, |
| 28 | + }; |
| 29 | + case 'up': |
| 30 | + return { |
| 31 | + startX: centerX, |
| 32 | + startY: Math.floor(height * 0.8), |
| 33 | + endX: centerX, |
| 34 | + endY: Math.floor(height * 0.2), |
| 35 | + }; |
| 36 | + case 'down': |
| 37 | + return { |
| 38 | + startX: centerX, |
| 39 | + startY: Math.floor(height * 0.2), |
| 40 | + endX: centerX, |
| 41 | + endY: Math.floor(height * 0.8), |
| 42 | + }; |
| 43 | + default: |
| 44 | + throw new Error(`Invalid direction: ${direction}`); |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +async function performAndroidSwipe( |
| 49 | + driver: any, |
| 50 | + startX: number, |
| 51 | + startY: number, |
| 52 | + endX: number, |
| 53 | + endY: number, |
| 54 | + duration: number |
| 55 | +): Promise<void> { |
| 56 | + await driver.performActions([ |
| 57 | + { |
| 58 | + type: 'pointer', |
| 59 | + id: 'finger1', |
| 60 | + parameters: { pointerType: 'touch' }, |
| 61 | + actions: [ |
| 62 | + { type: 'pointerMove', duration: 0, x: startX, y: startY }, |
| 63 | + { type: 'pointerDown', button: 0 }, |
| 64 | + { type: 'pause', duration: 250 }, |
| 65 | + { type: 'pointerMove', duration: duration, x: endX, y: endY }, |
| 66 | + { type: 'pointerUp', button: 0 }, |
| 67 | + ], |
| 68 | + }, |
| 69 | + ]); |
| 70 | +} |
| 71 | + |
| 72 | +async function performiOSSwipe( |
| 73 | + driver: any, |
| 74 | + startX: number, |
| 75 | + startY: number, |
| 76 | + endX: number, |
| 77 | + endY: number, |
| 78 | + duration: number |
| 79 | +): Promise<void> { |
| 80 | + try { |
| 81 | + await driver.execute('mobile: dragFromToForDuration', { |
| 82 | + fromX: startX, |
| 83 | + fromY: startY, |
| 84 | + toX: endX, |
| 85 | + toY: endY, |
| 86 | + duration: duration / 1000, |
| 87 | + }); |
| 88 | + log.info('iOS swipe completed using mobile: dragFromToForDuration'); |
| 89 | + } catch (dragError) { |
| 90 | + log.info('mobile: dragFromToForDuration failed, trying performActions'); |
| 91 | + await driver.performActions([ |
| 92 | + { |
| 93 | + type: 'pointer', |
| 94 | + id: 'finger1', |
| 95 | + parameters: { pointerType: 'touch' }, |
| 96 | + actions: [ |
| 97 | + { type: 'pointerMove', duration: 0, x: startX, y: startY }, |
| 98 | + { type: 'pointerDown', button: 0 }, |
| 99 | + { type: 'pause', duration: 200 }, |
| 100 | + { type: 'pointerMove', duration: duration, x: endX, y: endY }, |
| 101 | + { type: 'pause', duration: 50 }, |
| 102 | + { type: 'pointerUp', button: 0 }, |
| 103 | + ], |
| 104 | + }, |
| 105 | + ]); |
| 106 | + log.info('iOS swipe completed using performActions'); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +export default function swipe(server: any): void { |
| 111 | + server.addTool({ |
| 112 | + name: 'appium_swipe', |
| 113 | + description: `Swipe on the current screen in a specified direction or between custom coordinates. |
| 114 | + Supports four directions: left, right, up, down. |
| 115 | + Can also perform custom coordinate-based swipes for precise control. |
| 116 | + This is useful for navigating carousels, switching tabs, dismissing elements, or navigating between screens.`, |
| 117 | + parameters: z.object({ |
| 118 | + direction: z |
| 119 | + .enum(['left', 'right', 'up', 'down']) |
| 120 | + .optional() |
| 121 | + .describe( |
| 122 | + 'Direction to swipe. If provided, coordinates will be calculated automatically based on screen size or, when elementUUID is set, relative to that element. Either direction OR custom coordinates must be provided.' |
| 123 | + ), |
| 124 | + elementUUID: elementUUIDScheme |
| 125 | + .optional() |
| 126 | + .describe( |
| 127 | + 'Optional element to base the swipe on. When provided with direction, the swipe is calculated relative to this element instead of the whole screen.' |
| 128 | + ), |
| 129 | + startX: z |
| 130 | + .number() |
| 131 | + .int() |
| 132 | + .min(0) |
| 133 | + .optional() |
| 134 | + .describe( |
| 135 | + 'Starting X coordinate for custom swipe. Required if direction is not provided.' |
| 136 | + ), |
| 137 | + startY: z |
| 138 | + .number() |
| 139 | + .int() |
| 140 | + .min(0) |
| 141 | + .optional() |
| 142 | + .describe( |
| 143 | + 'Starting Y coordinate for custom swipe. Required if direction is not provided.' |
| 144 | + ), |
| 145 | + endX: z |
| 146 | + .number() |
| 147 | + .int() |
| 148 | + .min(0) |
| 149 | + .optional() |
| 150 | + .describe( |
| 151 | + 'Ending X coordinate for custom swipe. Required if direction is not provided.' |
| 152 | + ), |
| 153 | + endY: z |
| 154 | + .number() |
| 155 | + .int() |
| 156 | + .min(0) |
| 157 | + .optional() |
| 158 | + .describe( |
| 159 | + 'Ending Y coordinate for custom swipe. Required if direction is not provided.' |
| 160 | + ), |
| 161 | + duration: z |
| 162 | + .number() |
| 163 | + .int() |
| 164 | + .min(0) |
| 165 | + .max(5000) |
| 166 | + .default(600) |
| 167 | + .optional() |
| 168 | + .describe( |
| 169 | + 'Duration of the swipe gesture in milliseconds. Default is 600ms. Higher values create slower swipes.' |
| 170 | + ), |
| 171 | + }), |
| 172 | + annotations: { |
| 173 | + readOnlyHint: false, |
| 174 | + openWorldHint: false, |
| 175 | + }, |
| 176 | + execute: async (args: any, context: any): Promise<any> => { |
| 177 | + const driver = getDriver(); |
| 178 | + if (!driver) { |
| 179 | + throw new Error( |
| 180 | + 'No active driver session. Please create a session first.' |
| 181 | + ); |
| 182 | + } |
| 183 | + |
| 184 | + try { |
| 185 | + const platform = getPlatformName(driver); |
| 186 | + let startX: number, startY: number, endX: number, endY: number; |
| 187 | + |
| 188 | + if (args.direction) { |
| 189 | + if (args.elementUUID) { |
| 190 | + const rect = await (driver as any).getElementRect(args.elementUUID); |
| 191 | + const elementCenterX = Math.floor(rect.x + rect.width / 2); |
| 192 | + const elementCenterY = Math.floor(rect.y + rect.height / 2); |
| 193 | + |
| 194 | + switch (args.direction) { |
| 195 | + case 'left': |
| 196 | + startX = Math.floor(rect.x + rect.width * 0.8); |
| 197 | + startY = elementCenterY; |
| 198 | + endX = Math.floor(rect.x + rect.width * 0.2); |
| 199 | + endY = elementCenterY; |
| 200 | + break; |
| 201 | + case 'right': |
| 202 | + startX = Math.floor(rect.x + rect.width * 0.2); |
| 203 | + startY = elementCenterY; |
| 204 | + endX = Math.floor(rect.x + rect.width * 0.8); |
| 205 | + endY = elementCenterY; |
| 206 | + break; |
| 207 | + case 'up': |
| 208 | + startX = elementCenterX; |
| 209 | + startY = Math.floor(rect.y + rect.height * 0.8); |
| 210 | + endX = elementCenterX; |
| 211 | + endY = Math.floor(rect.y + rect.height * 0.2); |
| 212 | + break; |
| 213 | + case 'down': |
| 214 | + startX = elementCenterX; |
| 215 | + startY = Math.floor(rect.y + rect.height * 0.2); |
| 216 | + endX = elementCenterX; |
| 217 | + endY = Math.floor(rect.y + rect.height * 0.8); |
| 218 | + break; |
| 219 | + default: |
| 220 | + throw new Error(`Invalid direction: ${args.direction}`); |
| 221 | + } |
| 222 | + log.info('Calculated element-based swipe coordinates:', { |
| 223 | + elementUUID: args.elementUUID, |
| 224 | + startX, |
| 225 | + startY, |
| 226 | + endX, |
| 227 | + endY, |
| 228 | + }); |
| 229 | + } else { |
| 230 | + const { width, height } = await driver.getWindowSize(); |
| 231 | + log.info('Device screen size:', { width, height }); |
| 232 | + const coords = calculateSwipeCoordinates( |
| 233 | + args.direction, |
| 234 | + width, |
| 235 | + height |
| 236 | + ); |
| 237 | + startX = coords.startX; |
| 238 | + startY = coords.startY; |
| 239 | + endX = coords.endX; |
| 240 | + endY = coords.endY; |
| 241 | + } |
| 242 | + } else if ( |
| 243 | + args.startX !== undefined && |
| 244 | + args.startY !== undefined && |
| 245 | + args.endX !== undefined && |
| 246 | + args.endY !== undefined |
| 247 | + ) { |
| 248 | + startX = args.startX; |
| 249 | + startY = args.startY; |
| 250 | + endX = args.endX; |
| 251 | + endY = args.endY; |
| 252 | + } else { |
| 253 | + throw new Error( |
| 254 | + 'Either direction or all custom coordinates (startX, startY, endX, endY) must be provided.' |
| 255 | + ); |
| 256 | + } |
| 257 | + |
| 258 | + const duration = args.duration || 600; |
| 259 | + |
| 260 | + log.info('Swipe coordinates:', { |
| 261 | + startX, |
| 262 | + startY, |
| 263 | + endX, |
| 264 | + endY, |
| 265 | + duration, |
| 266 | + }); |
| 267 | + |
| 268 | + if (platform === 'Android') { |
| 269 | + if (startX !== endX && Math.abs(startY - endY) < 50) { |
| 270 | + const swipeDuration = Math.min(duration, 400); |
| 271 | + await driver.performActions([ |
| 272 | + { |
| 273 | + type: 'pointer', |
| 274 | + id: 'finger1', |
| 275 | + parameters: { pointerType: 'touch' }, |
| 276 | + actions: [ |
| 277 | + { type: 'pointerMove', duration: 0, x: startX, y: startY }, |
| 278 | + { type: 'pointerDown', button: 0 }, |
| 279 | + { type: 'pause', duration: 200 }, |
| 280 | + { |
| 281 | + type: 'pointerMove', |
| 282 | + duration: swipeDuration, |
| 283 | + x: endX, |
| 284 | + y: endY, |
| 285 | + }, |
| 286 | + { type: 'pause', duration: 50 }, |
| 287 | + { type: 'pointerUp', button: 0 }, |
| 288 | + ], |
| 289 | + }, |
| 290 | + ]); |
| 291 | + log.info('Android horizontal swipe completed'); |
| 292 | + } else { |
| 293 | + await performAndroidSwipe( |
| 294 | + driver, |
| 295 | + startX, |
| 296 | + startY, |
| 297 | + endX, |
| 298 | + endY, |
| 299 | + duration |
| 300 | + ); |
| 301 | + } |
| 302 | + log.info('Android swipe action completed successfully.'); |
| 303 | + } else if (platform === 'iOS') { |
| 304 | + if (args.direction) { |
| 305 | + try { |
| 306 | + await driver.execute('mobile: swipe', { |
| 307 | + direction: args.direction, |
| 308 | + }); |
| 309 | + log.info( |
| 310 | + `iOS swipe completed using mobile: swipe (${args.direction})` |
| 311 | + ); |
| 312 | + } catch (swipeError) { |
| 313 | + log.info('mobile: swipe failed, trying dragFromToForDuration'); |
| 314 | + await performiOSSwipe( |
| 315 | + driver, |
| 316 | + startX, |
| 317 | + startY, |
| 318 | + endX, |
| 319 | + endY, |
| 320 | + duration |
| 321 | + ); |
| 322 | + } |
| 323 | + } else { |
| 324 | + await performiOSSwipe(driver, startX, startY, endX, endY, duration); |
| 325 | + } |
| 326 | + log.info('iOS swipe action completed successfully.'); |
| 327 | + } else { |
| 328 | + throw new Error( |
| 329 | + `Unsupported platform: ${platform}. Only Android and iOS are supported.` |
| 330 | + ); |
| 331 | + } |
| 332 | + |
| 333 | + const directionText = args.direction |
| 334 | + ? ` ${args.direction}` |
| 335 | + : ` from (${startX}, ${startY}) to (${endX}, ${endY})`; |
| 336 | + |
| 337 | + return { |
| 338 | + content: [ |
| 339 | + { |
| 340 | + type: 'text', |
| 341 | + text: `Swiped${directionText} successfully.`, |
| 342 | + }, |
| 343 | + ], |
| 344 | + }; |
| 345 | + } catch (err: any) { |
| 346 | + return { |
| 347 | + content: [ |
| 348 | + { |
| 349 | + type: 'text', |
| 350 | + text: `Failed to perform swipe. Error: ${err.toString()}`, |
| 351 | + }, |
| 352 | + ], |
| 353 | + }; |
| 354 | + } |
| 355 | + }, |
| 356 | + }); |
| 357 | +} |
0 commit comments