├── .azure-template └── bootstrap.yml ├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── README.md ├── assets └── GesturesPlugin.jpg ├── azure-pipelines.yml ├── package-lock.json ├── package.json ├── src ├── element.js ├── gestures │ ├── doubleTap.js │ ├── dragAndDrop.js │ ├── longPress.js │ └── swipe.js ├── index.js ├── logger.js ├── plugin.js └── sessionInfo.js ├── test └── e2e │ └── android.spec.js └── tsconfig.json /.azure-template/bootstrap.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - task: NodeTool@0 3 | inputs: 4 | versionSpec: '$(NODE_VERSION)' 5 | - script: | 6 | npm config delete prefix 7 | npm config set prefix $NVM_DIR/versions/node/`node --version` 8 | node --version 9 | npm install -g appium 10 | npm install -g wait-on 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/* 2 | /lib/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier', 7 | ], 8 | env: { 9 | node: true, 10 | es6: true, 11 | mocha: true, 12 | }, 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaVersion: 2016, 16 | sourceType: 'module', 17 | babelOptions: { 18 | plugins: ['@babel/plugin-proposal-class-properties'], 19 | }, 20 | }, 21 | plugins: ['prettier', '@typescript-eslint'], 22 | rules: { 23 | 'prettier/prettier': ['error', { singleQuote: true }], 24 | quotes: ['error', 'single'], 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/no-empty-function': 'off', 27 | }, 28 | settings: { 29 | 'import/resolver': { 30 | node: { 31 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 32 | }, 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '*.*.*' 8 | pull_request: 9 | types: 10 | - labeled 11 | 12 | jobs: 13 | release: 14 | if: github.event.action != 'labeled' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Post bumpr status comment 19 | uses: haya14busa/action-bumpr@v1 20 | 21 | # Get tag name. 22 | - id: tag 23 | uses: haya14busa/action-cond@v1 24 | with: 25 | cond: "${{ startsWith(github.ref, 'refs/tags/') }}" 26 | if_true: ${{ github.ref }} 27 | if_false: ${{ steps.bumpr.outputs.next_version }} 28 | 29 | - name: Release 30 | uses: justincy/github-action-npm-release@2.0.2 31 | id: release 32 | - uses: actions/setup-node@v3 33 | if: steps.release.outputs.released == 'true' 34 | with: 35 | registry-url: 'https://registry.npmjs.org' 36 | node-version: 16 37 | - name: Publish 38 | if: steps.release.outputs.released == 'true' 39 | run: echo "//registry.npmjs.org/:_authToken=${{secrets.npm_token}}" > .npmrc && npm ci && npm publish 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.npm_token }} 42 | 43 | release-check: 44 | if: github.event.action == 'labeled' 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Post bumpr status comment 49 | uses: haya14busa/action-bumpr@v1 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | lib/ 4 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/* 2 | test/* 3 | .eslintrc 4 | .vscode 5 | .github 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | AppiumGestures 4 |
5 |
6 |
7 |

8 | 9 | # appium-gestures-plugin [![npm version](https://badge.fury.io/js/appium-gestures-plugin.svg)](https://badge.fury.io/js/appium-gestures-plugin) 10 | 11 | This is an Appium plugin designed to perform basic gestures using W3C Actions. 12 | 13 | ## Prerequisite 14 | 15 | Appium version 2.0 16 | 17 | ## Installation - Server 18 | 19 | Install the plugin using Appium's plugin CLI, either as a named plugin or via NPM: 20 | 21 | ```shell 22 | appium plugin install --source=npm appium-gestures-plugin 23 | ``` 24 | 25 | ## Activation 26 | 27 | The plugin will not be active unless turned on when invoking the Appium server: 28 | 29 | ```shell 30 | appium --use-plugins=gestures 31 | ``` 32 | 33 | # Usage 34 | 35 | Sample app used to demonstrate below gesture is available [here](https://github.com/webdriverio/native-demo-app/releases) 36 | 37 | # Swipe Left 38 | 39 | ```java 40 | RemoteWebElement carousel = (RemoteWebElement) wait.until(presenceOfElementLocated(AppiumBy.accessibilityId("Carousel"))); 41 | 42 | driver.executeScript("gesture: swipe", Map.of("elementId", carousel.getId(), "percentage", 50, "direction", "left")); 43 | ``` 44 | 45 | # Swipe Right 46 | 47 | ```java 48 | RemoteWebElement carousel = (RemoteWebElement) wait.until(presenceOfElementLocated(AppiumBy.accessibilityId("Carousel"))); 49 | 50 | driver.executeScript("gesture: swipe", Map.of("elementId", carousel.getId(), "percentage", 50, "direction", "right")); 51 | ``` 52 | 53 | # Swipe Up 54 | 55 | ```java 56 | RemoteWebElement scrollView = (RemoteWebElement) wait.until(presenceOfElementLocated(AppiumBy.accessibilityId("Swipe-screen"))); 57 | 58 | driver.executeScript("gesture: swipe", Map.of("elementId", scrollView.getId(), 59 | "percentage", 50, 60 | "direction", "up")); 61 | ``` 62 | 63 | # Swipe Down 64 | 65 | ```java 66 | RemoteWebElement scrollView = (RemoteWebElement) wait.until(presenceOfElementLocated(AppiumBy.accessibilityId("Swipe-screen"))); 67 | 68 | driver.executeScript("gesture: swipe", Map.of("elementId", scrollView.getId(), 69 | "percentage", 50, 70 | "direction", "down")); 71 | ``` 72 | 73 | # scrollElementIntoView 74 | 75 | **JAVA** 76 | ```java 77 | RemoteWebElement scrollView = (RemoteWebElement) wait.until(presenceOfElementLocated(AppiumBy.accessibilityId("Swipe-screen"))); 78 | 79 | driver.executeScript("gesture: scrollElementIntoView", Map.of("scrollableView", scrollView.getId(), 80 | "strategy", "accessibility id", 81 | "selector", "WebdriverIO logo", 82 | "percentage", 50, 83 | "direction", "up", 84 | "maxCount", 3)); 85 | 86 | ``` 87 | **PYTHON** 88 | ```python 89 | list_view = driver.find_element(by=AppiumBy.ID, value='android:id/list') 90 | driver.execute_script('gesture: scrollElementIntoView', 91 | {'scrollableView': list_view.id, 'strategy': 'accessibility id', 'selector': 'Picker', 92 | 'percentage': 50, 'direction': 'up', 'maxCount': 3}) 93 | ``` 94 | 95 | Sample app used to demonstrate below gesture is available [here](https://github.com/AppiumTestDistribution/appium-demo/blob/main/VodQA.apk) 96 | 97 | # Drag and Drop 98 | 99 | **JAVA** 100 | ```java 101 | RemoteWebElement source = (RemoteWebElement) wait.until(elementToBeClickable(AppiumBy.accessibilityId("dragMe"))); 102 | RemoteWebElement destination = (RemoteWebElement) wait.until(elementToBeClickable(AppiumBy.accessibilityId("dropzone"))); 103 | 104 | driver.executeScript("gesture: dragAndDrop", Map.of("sourceId", source.getId(), "destinationId", destination.getId())); 105 | ``` 106 | **PYTHON** 107 | ```python 108 | el1 = driver.find_element(by=AppiumBy.ID, value='io.appium.android.apis:id/drag_dot_1') 109 | el2 = driver.find_element(by=AppiumBy.ID, value='io.appium.android.apis:id/drag_dot_2') 110 | 111 | driver.execute_script('gesture: dragAndDrop', { 112 | 'sourceId': el1.id, 113 | 'destinationId': el2.id, 114 | }) 115 | ``` 116 | 117 | # Double Tap 118 | 119 | ```java 120 | RemoteWebElement doubleTapMe = (RemoteWebElement) driver.findElement(AppiumBy.accessibilityId("doubleTapMe")); 121 | 122 | driver.executeScript("gesture: doubleTap", Map.of("elementId", doubleTapMe.getId())); 123 | ``` 124 | 125 | # Long Press 126 | 127 | Pressure has to be between 0 and 1. 128 | 129 | ```java 130 | RemoteWebElement longPress = (RemoteWebElement) driver.findElement(AppiumBy.accessibilityId("longpress")); 131 | 132 | driver.executeScript("gesture: longPress", Map.of("elementId", longPress.getId(), "pressure", 0.5, "duration", 800)); 133 | 134 | ``` 135 | 136 | # WDIO 137 | 138 | ```js 139 | await driver.execute('gesture: dragAndDrop', { sourceId, destinationId }); 140 | ``` 141 | 142 | ## Supported 143 | 144 | - Swipe Left, right, up and down 145 | - scrollElementIntoView 146 | - Drag and Drop 147 | - Double Tap 148 | - Long Press 149 | 150 | ### TODO 151 | 152 | - zoom 153 | - multi finger swipe 154 | -------------------------------------------------------------------------------- /assets/GesturesPlugin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-gestures-plugin/ffe7b9668ce70c28e597b94ce6ca32a193f27868/assets/GesturesPlugin.jpg -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Gradle 2 | # Build your Java project and run tests with Gradle using a Gradle wrapper script. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/java 5 | trigger: 6 | - main 7 | 8 | variables: 9 | ANDROID_EMU_NAME: test 10 | ANDROID_EMU_ABI: x86 11 | ANDROID_EMU_TARGET: android-28 12 | ANDROID_EMU_TAG: default 13 | XCODE_VERSION: 14.2 14 | IOS_PLATFORM_VERSION: 16.2 15 | IOS_DEVICE_NAME: iPhone 12 16 | NODE_VERSION: 18.x 17 | JDK_VERSION: 1.8 18 | 19 | jobs: 20 | - job: Android_E2E_Tests 21 | pool: 22 | vmImage: 'macOS-latest' 23 | steps: 24 | - template: .azure-template/bootstrap.yml 25 | - script: $NVM_DIR/versions/node/`node --version`/bin/appium driver install uiautomator2 26 | displayName: Install UIA2 driver 27 | - script: | 28 | echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --install 'system-images;$(ANDROID_EMU_TARGET);$(ANDROID_EMU_TAG);$(ANDROID_EMU_ABI)' 29 | echo "no" | $ANDROID_HOME/tools/bin/avdmanager create avd -n "$(ANDROID_EMU_NAME)" -k 'system-images;$(ANDROID_EMU_TARGET);$(ANDROID_EMU_TAG);$(ANDROID_EMU_ABI)' --force 30 | echo $ANDROID_HOME/emulator/emulator -list-avds 31 | 32 | echo "Starting emulator" 33 | nohup $ANDROID_HOME/emulator/emulator -avd "$(ANDROID_EMU_NAME)" -no-snapshot -delay-adb > /dev/null 2>&1 & 34 | $ANDROID_HOME/platform-tools/adb wait-for-device 35 | $ANDROID_HOME/platform-tools/adb devices -l 36 | echo "Emulator started" 37 | displayName: Emulator configuration 38 | - script: | 39 | npm ci 40 | $NVM_DIR/versions/node/$(node --version)/bin/appium plugin install --source=local . 41 | nohup $NVM_DIR/versions/node/$(node --version)/bin/appium server -ka 800 --use-plugins=gestures -pa /wd/hub & 42 | $NVM_DIR/versions/node/$(node --version)/bin/wait-on http://127.0.0.1:4723/wd/hub/status 43 | PLATFORM=android npm run test-e2e 44 | displayName: Android E2E Test 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appium-gestures-plugin", 3 | "version": "4.0.1", 4 | "description": "This is an Appium plugin designed to perform gestures using W3C Actions.", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "build": "npx tsc", 8 | "prepublish": "npx tsc", 9 | "lint": "eslint '**/*.js' --fix", 10 | "test-e2e": "mocha --require ts-node/register -p test/e2e/android.spec.js --timeout 260000", 11 | "prettier": "prettier '**/*.js' --write --single-quote", 12 | "appium-home": "rm -rf /tmp/some-temp-dir && export APPIUM_HOME=/tmp/some-temp-dir", 13 | "install-plugin": "npm run build && appium plugin install --source=local $(pwd)", 14 | "clear-cache": "rm -rf $HOME/.cache/appium-gestures-plugin", 15 | "install-driver": "export APPIUM_HOME=/tmp/some-temp-dir && appium driver install uiautomator2", 16 | "install-wait-plugin": "export APPIUM_HOME=/tmp/some-temp-dir && appium plugin install --source=npm appium-wait-plugin", 17 | "reinstall-plugin": "export APPIUM_HOME=/tmp/some-temp-dir && npm run appium-home && (appium plugin uninstall gestures || exit 0) && npm run install-plugin", 18 | "run-server": "export APPIUM_HOME=/tmp/some-temp-dir && appium server -ka 800 --use-plugins=gestures -pa /wd/hub " 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/AppiumTestDistribution/appium-gestures-plugin.git" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Saikrishna", 27 | "email": "saikrishna321@yahoo.com" 28 | }, 29 | { 30 | "name": "Srinivasan Sekar", 31 | "email": "srinivasan.sekar1990@gmail.com" 32 | } 33 | ], 34 | "license": "ISC", 35 | "bugs": { 36 | "url": "https://github.com/AppiumTestDistribution/appium-gestures-plugin/issues" 37 | }, 38 | "homepage": "https://github.com/AppiumTestDistribution/appium-gestures-plugin#readme", 39 | "devDependencies": { 40 | "@babel/core": "^7.3.4", 41 | "@babel/plugin-proposal-class-properties": "^7.13.0", 42 | "@types/chai": "^4.2.16", 43 | "@types/lodash": "^4.14.170", 44 | "@types/mocha": "^8.2.2", 45 | "@types/node": "^15.6.1", 46 | "babel-eslint": "^10.0.1", 47 | "chai": "^4.1.0", 48 | "eslint": "^8.42.0", 49 | "eslint-config-prettier": "^8.1.0", 50 | "eslint-plugin-import": "^2.8.0", 51 | "eslint-plugin-prettier": "^3.0.1", 52 | "husky": "^6.0.0", 53 | "lint-staged": "^11.0.0", 54 | "mocha": "^10.2.0", 55 | "prettier": "^2.0.5", 56 | "sinon": "^15.1.0", 57 | "ts-node": "^10.0.0", 58 | "typescript": "^5.0.4", 59 | "webdriverio": "8.27.0" 60 | }, 61 | "dependencies": { 62 | "@appium/base-plugin": "^2.2.26" 63 | }, 64 | "peerDependencies": { 65 | "appium": "^2.3.0" 66 | }, 67 | "appium": { 68 | "pluginName": "gestures", 69 | "mainClass": "GesturesPlugin" 70 | }, 71 | "engines": { 72 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0", 73 | "npm": ">=8" 74 | }, 75 | "lint-staged": { 76 | "src/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 77 | "npm run prettier", 78 | "git add" 79 | ] 80 | }, 81 | "husky": { 82 | "hooks": { 83 | "pre-commit": "lint-staged", 84 | "pre-push": "npm run test" 85 | } 86 | }, 87 | "files": [ 88 | "lib" 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /src/element.js: -------------------------------------------------------------------------------- 1 | export function getCenter(value) { 2 | return { 3 | x: value.x + value.width / 2, 4 | y: value.y + value.height / 2, 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /src/gestures/doubleTap.js: -------------------------------------------------------------------------------- 1 | import * as Element from '../element'; 2 | 3 | export default async function doubleTap(elementId, driver) { 4 | { 5 | const value = await driver.getElementRect(elementId); 6 | const { x, y } = await Element.getCenter(value); 7 | 8 | const androidPauseAction = { 9 | duration: 0, 10 | type: 'pause', 11 | }; 12 | 13 | const actionsData = [ 14 | { 15 | id: 'finger1', 16 | type: 'pointer', 17 | parameters: { pointerType: 'touch' }, 18 | actions: [ 19 | { duration: 0, x, y, type: 'pointerMove', origin: 'viewport' }, 20 | { button: 0, type: 'pointerDown' }, 21 | { duration: 200, type: 'pause' }, 22 | { button: 0, type: 'pointerUp' }, 23 | { duration: 40, type: 'pause' }, 24 | { button: 0, type: 'pointerDown' }, 25 | { duration: 200, type: 'pause' }, 26 | { button: 0, type: 'pointerUp' }, 27 | ], 28 | }, 29 | ]; 30 | 31 | if (driver.caps.automationName !== 'XCuiTest') { 32 | actionsData[0].actions.unshift(androidPauseAction); 33 | } 34 | 35 | await driver.performActions(actionsData); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/gestures/dragAndDrop.js: -------------------------------------------------------------------------------- 1 | import * as Element from '../element'; 2 | import log from '../logger'; 3 | 4 | export default async function dragAndDrop(sourceId, destinationId, driver) { 5 | const [source, destination] = await Promise.all([ 6 | driver.getElementRect(sourceId), 7 | driver.getElementRect(destinationId), 8 | ]); 9 | 10 | const [{ x: sourceX, y: sourceY }, { x: destinationX, y: destinationY }] = await Promise.all([ 11 | Element.getCenter(source), 12 | Element.getCenter(destination), 13 | ]); 14 | 15 | const androidPauseAction = { 16 | duration: 0, 17 | type: 'pause', 18 | }; 19 | 20 | const actionsData = [ 21 | { 22 | id: 'finger', 23 | type: 'pointer', 24 | parameters: { pointerType: 'touch' }, 25 | actions: [ 26 | { duration: 0, x: sourceX, y: sourceY, type: 'pointerMove', origin: 'viewport' }, 27 | { button: 1, type: 'pointerDown' }, 28 | { duration: 600, type: 'pause' }, 29 | { 30 | duration: 600, 31 | x: destinationX, 32 | y: destinationY, 33 | type: 'pointerMove', 34 | origin: 'viewport', 35 | }, 36 | { button: 1, type: 'pointerUp' }, 37 | ], 38 | }, 39 | ]; 40 | 41 | if (driver.caps.automationName === 'XCuiTest') { 42 | await driver.performActions(actionsData); 43 | } else { 44 | log.info('Drag and Drop for android'); 45 | const androidActions = actionsData; 46 | androidActions[0].actions.unshift(androidPauseAction); 47 | await driver.performActions(actionsData); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/gestures/longPress.js: -------------------------------------------------------------------------------- 1 | import * as Element from '../element'; 2 | import log from '../logger'; 3 | 4 | export default async function longPress(elementId, pressure, duration, driver) { 5 | const elementRect = await driver.getElementRect(elementId); 6 | log.info( 7 | `Performing a long press on element ${elementId} with pressure ${pressure}% and duration ${duration}ms` 8 | ); 9 | const { x, y } = Element.getCenter(elementRect); 10 | 11 | const actionsData = { 12 | id: 'finger', 13 | type: 'pointer', 14 | parameters: { pointerType: 'touch' }, 15 | actions: [ 16 | { duration: 0, x, y, type: 'pointerMove', origin: 'viewport' }, 17 | { button: 1, pressure, type: 'pointerDown' }, 18 | { duration, type: 'pause' }, 19 | { button: 1, type: 'pointerUp' }, 20 | ], 21 | }; 22 | 23 | if (driver.caps.automationName !== 'XCuiTest') { 24 | actionsData.actions.unshift({ duration: 0, type: 'pause' }); 25 | } 26 | 27 | await driver.performActions([actionsData]); 28 | } 29 | -------------------------------------------------------------------------------- /src/gestures/swipe.js: -------------------------------------------------------------------------------- 1 | import * as Element from '../element'; 2 | import log from '../logger'; 3 | 4 | const MAGIC_NUMBER = 49; 5 | 6 | export async function swipe(elementId, percentage, direction, driver) { 7 | const swipeActions = []; 8 | const value = await driver.getElementRect(elementId); 9 | log.info(`Swiping ${direction} at ${percentage}% of the element ${elementId}`); 10 | const pointer = getDirectionActions(direction, value, percentage); 11 | const actionsData = getActionsData(elementId, pointer, driver); 12 | swipeActions.push(actionsData); 13 | await driver.performActions(swipeActions); 14 | } 15 | 16 | export async function scrollElementIntoView(config) { 17 | const { 18 | scrollableView, 19 | strategy, 20 | selector, 21 | percentage, 22 | direction, 23 | maxCount = 5, 24 | driver, 25 | } = config; 26 | 27 | for ( 28 | let count = 0; 29 | count < maxCount && !(await isElementFound(driver, strategy, selector)); 30 | count++ 31 | ) { 32 | log.info('Swiping now...'); 33 | await swipe(scrollableView, percentage, direction, driver); 34 | } 35 | } 36 | 37 | function getDirectionActions(direction, value, percentage) { 38 | const { x, y } = Element.getCenter(value); 39 | const directionActions = { 40 | left: { 41 | sourceX: x + (value.width * MAGIC_NUMBER) / 100, 42 | sourceY: y, 43 | destinationX: (x + (value.width * MAGIC_NUMBER) / 100) * (percentage / 100), 44 | destinationY: y, 45 | }, 46 | right: { 47 | sourceX: (x + (value.width * MAGIC_NUMBER) / 100) * (percentage / 100), 48 | sourceY: y, 49 | destinationX: x + (value.width * MAGIC_NUMBER) / 100, 50 | destinationY: y, 51 | }, 52 | up: { 53 | sourceX: x, 54 | sourceY: y + (value.height * MAGIC_NUMBER) / 100, 55 | destinationX: x, 56 | destinationY: y - percentage / 100, 57 | }, 58 | down: { 59 | sourceX: x, 60 | sourceY: y - percentage / 100, 61 | destinationX: x, 62 | destinationY: y + (value.height * MAGIC_NUMBER) / 100, 63 | }, 64 | }; 65 | return directionActions[direction]; 66 | } 67 | 68 | function getActionsData(elementId, pointer, driver) { 69 | const actionsData = { 70 | id: `${elementId}`, 71 | type: 'pointer', 72 | parameters: { pointerType: 'touch' }, 73 | actions: [ 74 | { 75 | duration: 0, 76 | x: pointer.sourceX, 77 | y: pointer.sourceY, 78 | type: 'pointerMove', 79 | origin: 'viewport', 80 | }, 81 | { button: 1, type: 'pointerDown' }, 82 | { duration: 600, type: 'pause' }, 83 | { 84 | duration: 600, 85 | x: pointer.destinationX, 86 | y: pointer.destinationY, 87 | type: 'pointerMove', 88 | origin: 'viewport', 89 | }, 90 | { button: 1, type: 'pointerUp' }, 91 | ], 92 | }; 93 | 94 | if (driver.caps.automationName !== 'XCuiTest') { 95 | actionsData.actions.unshift({ duration: 0, type: 'pause' }); 96 | } 97 | 98 | return actionsData; 99 | } 100 | 101 | async function isElementFound(driver, strategy, selector) { 102 | try { 103 | log.info(`Checking if ${strategy} element '${selector}' is present`); 104 | await driver.findElement(strategy, selector); 105 | log.info(`Element '${selector}' is found`); 106 | return true; 107 | } catch (e) { 108 | log.info(`Element '${selector}' is not found`); 109 | return false; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import GesturesPlugin from './plugin'; 2 | export default GesturesPlugin; 3 | export { GesturesPlugin }; 4 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | import { logger } from '@appium/support'; 2 | const log = logger.getLogger('gestures'); 3 | module.exports = log; 4 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import BasePlugin from '@appium/base-plugin'; 2 | import dragAndDrop from './gestures/dragAndDrop'; 3 | import { swipe, scrollElementIntoView } from './gestures/swipe'; 4 | import doubleTap from './gestures/doubleTap'; 5 | import longPress from './gestures/longPress'; 6 | 7 | export default class GesturesPlugin extends BasePlugin { 8 | static executeMethodMap = { 9 | 'gesture: dragAndDrop': { 10 | command: 'dragAndDrop', 11 | params: { required: ['sourceId', 'destinationId'] }, 12 | }, 13 | 'gesture: swipe': { 14 | command: 'swipe', 15 | params: { 16 | required: ['elementId', 'percentage', 'direction'], 17 | }, 18 | }, 19 | 'gesture: scrollElementIntoView': { 20 | command: 'scrollElementIntoView', 21 | params: { 22 | required: ['scrollableView', 'strategy', 'selector', 'percentage', 'direction', 'maxCount'], 23 | }, 24 | }, 25 | 'gesture: doubleTap': { 26 | command: 'doubleTap', 27 | params: { required: ['elementId'] }, 28 | }, 29 | 'gesture: longPress': { 30 | command: 'longPress', 31 | params: { required: ['elementId', 'pressure', 'duration'] }, 32 | }, 33 | }; 34 | 35 | constructor(pluginName) { 36 | super(pluginName); 37 | } 38 | 39 | async execute(next, driver, script, args) { 40 | return await this.executeMethod(next, driver, script, args); 41 | } 42 | 43 | async swipe(next, driver, elementId, percentage, direction) { 44 | await swipe(elementId, percentage, direction, driver); 45 | } 46 | 47 | async scrollElementIntoView( 48 | next, 49 | driver, 50 | scrollableView, 51 | strategy, 52 | selector, 53 | percentage, 54 | direction, 55 | maxCount 56 | ) { 57 | await scrollElementIntoView({ 58 | scrollableView, 59 | strategy, 60 | selector, 61 | percentage, 62 | direction, 63 | maxCount, 64 | driver, 65 | }); 66 | } 67 | 68 | async dragAndDrop(next, driver, sourceId, destinationId) { 69 | await dragAndDrop(sourceId, destinationId, driver); 70 | } 71 | 72 | async doubleTap(next, driver, elementId) { 73 | await doubleTap(elementId, driver); 74 | } 75 | 76 | async longPress(next, driver, elementId, pressure, duration) { 77 | await longPress(elementId, pressure, duration, driver); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/sessionInfo.js: -------------------------------------------------------------------------------- 1 | export default function sessionInfo(driver) { 2 | const automationName = driver.caps.automationName; 3 | 4 | if (automationName === 'XCuiTest') { 5 | const baseUrl = `${driver.wda.wdaBaseUrl}:${driver.wda.wdaRemotePort}`; 6 | const jwProxySessionId = driver.wda.jwproxy.sessionId; 7 | return { 8 | baseUrl, 9 | jwProxySessionId, 10 | automationName, 11 | driverUrl: `${baseUrl}/session/${jwProxySessionId}`, 12 | }; 13 | } else { 14 | const baseUrl = `http://${driver.uiautomator2.host}:${driver.uiautomator2.systemPort}`; 15 | const jwProxySessionId = driver.uiautomator2.jwproxy.sessionId; 16 | return { 17 | baseUrl, 18 | jwProxySessionId, 19 | automationName, 20 | driverUrl: `${baseUrl}/session/${jwProxySessionId}`, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test/e2e/android.spec.js: -------------------------------------------------------------------------------- 1 | import { remote } from 'webdriverio'; 2 | 3 | const APPIUM_HOST = '127.0.0.1'; 4 | const APPIUM_PORT = 4723; 5 | const WDIO_PARAMS = { 6 | connectionRetryCount: 0, 7 | hostname: APPIUM_HOST, 8 | port: APPIUM_PORT, 9 | path: '/wd/hub/', 10 | logLevel: 'info', 11 | waitforTimeout: 10000, 12 | mochaOpts: { 13 | timeout: 20000, 14 | }, 15 | }; 16 | const capabilities = { 17 | platformName: 'Android', 18 | 'appium:automationName': 'UIAutomator2', 19 | 'appium:deviceName': 'emulator-5555', 20 | 'appium:app': 21 | 'https://github.com/AppiumTestDistribution/appium-demo/blob/main/VodQA.apk?raw=true', 22 | }; 23 | let driver; 24 | describe('Plugin Test', () => { 25 | beforeEach(async () => { 26 | driver = await remote({ ...WDIO_PARAMS, capabilities }); 27 | }); 28 | 29 | it('Horizontal swipe test', async () => { 30 | await driver.$('~login').click(); 31 | await driver.$('~slider1').click(); 32 | const slider = await driver.$('~slider'); 33 | await slider.waitForDisplayed({ timeout: 10000 }); 34 | await driver.executeScript('gesture: swipe', [ 35 | { 36 | elementId: slider.elementId, 37 | percentage: 50, 38 | direction: 'right', 39 | }, 40 | ]); 41 | }); 42 | 43 | it('Drag & Drop test', async () => { 44 | await driver.$('~login').click(); 45 | await driver.$('~dragAndDrop').click(); 46 | const dragMe = await driver.$('~dragMe'); 47 | await dragMe.waitForDisplayed({ timeout: 10000 }); 48 | const dropzone = await driver.$('~dropzone'); 49 | await dropzone.waitForDisplayed({ timeout: 10000 }); 50 | await driver.executeScript('gesture: dragAndDrop', [ 51 | { 52 | sourceId: dragMe.elementId, 53 | destinationId: dropzone.elementId, 54 | }, 55 | ]); 56 | }); 57 | 58 | afterEach(async () => await driver.deleteSession()); 59 | }); 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | "incremental": true /* Enable incremental compilation */, 7 | "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | "allowJs": true /* Allow javascript files to be compiled. */, 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./lib" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [ 49 | // "./typings", 50 | // "./node_modules/@types" 51 | // ], 52 | "types": ["node"] /* Type declaration files to be included in compilation. */, 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */, 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["./src/*", "src/index.js"], 73 | "exclude": ["lib", "node_modules", "test", "typings"] 74 | } 75 | --------------------------------------------------------------------------------