├── .npmignore ├── src ├── typings │ ├── portscanner │ │ └── index.d.ts │ └── appium-ios-device │ │ └── index.d.ts ├── platform.ts ├── logger.ts ├── types.ts ├── desiredCaps.ts ├── android.ts ├── macOS.ts ├── iOS.ts ├── session.ts ├── commands │ └── element.ts ├── iProxy.ts ├── utils.ts └── driver.ts ├── .github ├── FUNDING.yml └── workflows │ ├── pr-title.yml │ ├── test.yml │ ├── publish.yml │ ├── doc.yml │ ├── appium3_ios.yml │ └── appium3._android.yml ├── logos ├── lt.png ├── sauce-labs.png └── flutter-driver.png ├── test ├── qr.png ├── SecondImage.png ├── unit │ ├── utils.specs.ts │ └── element.specs.ts └── specs │ └── test.e2e.js ├── FlutterLogo.jpg ├── flutter-finder ├── wdio-flutter-by-service │ ├── src │ │ ├── types.ts │ │ ├── demo.ts │ │ ├── service.ts │ │ ├── methods.ts │ │ ├── utils.ts │ │ └── index.ts │ ├── test_remove │ │ ├── qr.png │ │ ├── SecondImage.png │ │ └── specs │ │ │ └── test.e2e.js │ ├── tsconfig.json │ ├── package.json │ └── wdio.conf.ts ├── .DS_Store ├── .github │ └── workflows │ │ ├── publish.yml │ │ └── main.yml ├── README.md └── LICENSE ├── .prettierrc ├── .mocharc.js ├── .prettierignore ├── tsconfig.test.json ├── docs └── commands.md ├── android.conf.ts ├── tsconfig.json ├── ios.conf.ts ├── LICENSE ├── .releaserc ├── .gitignore ├── package.json ├── finder.ts ├── README.md ├── CHANGELOG.md └── wdio.conf.ts /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | src 3 | -------------------------------------------------------------------------------- /src/typings/portscanner/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'portscanner'; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: appium-flutter-integration-driver 2 | -------------------------------------------------------------------------------- /src/typings/appium-ios-device/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'appium-ios-device'; 2 | -------------------------------------------------------------------------------- /logos/lt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/logos/lt.png -------------------------------------------------------------------------------- /test/qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/test/qr.png -------------------------------------------------------------------------------- /FlutterLogo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/FlutterLogo.jpg -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = { 2 | IOS: 'ios', 3 | ANDROID: 'android', 4 | MAC: 'mac', 5 | } as const; 6 | -------------------------------------------------------------------------------- /logos/sauce-labs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/logos/sauce-labs.png -------------------------------------------------------------------------------- /test/SecondImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/test/SecondImage.png -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/types.ts: -------------------------------------------------------------------------------- 1 | export type LocatorConfig = { 2 | name: string; 3 | stategy: string; 4 | }; 5 | -------------------------------------------------------------------------------- /flutter-finder/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/flutter-finder/.DS_Store -------------------------------------------------------------------------------- /logos/flutter-driver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/logos/flutter-driver.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 3, 4 | "semi": true, 5 | "trailingComma": "all", 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extension: ['ts'], 3 | spec: 'test/**/element.specs.ts', 4 | require: 'ts-node/register', 5 | timeout: 20000, 6 | }; 7 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/demo.ts: -------------------------------------------------------------------------------- 1 | import * as methods from './methods.js'; 2 | 3 | for (let method in methods) { 4 | console.log(method); 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { logger } from 'appium/support'; 2 | import { AppiumLogger } from '@appium/types'; 3 | 4 | export const log: AppiumLogger = logger.getLogger(`FlutterIntegrationDriver`); 5 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/test_remove/qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/flutter-finder/wdio-flutter-by-service/test_remove/qr.png -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type PortForwardCallback = ( 2 | udid: string, 3 | systemPort: number, 4 | devicePort: number, 5 | ) => any; 6 | export type PortReleaseCallback = (udid: string, systemPort: number) => any; 7 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/test_remove/SecondImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppiumTestDistribution/appium-flutter-integration-driver/HEAD/flutter-finder/wdio-flutter-by-service/test_remove/SecondImage.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | README.md 3 | docs/commands.md 4 | finder.ts 5 | flutter-by/wdio-flutter-by-service/package.json 6 | flutter-by/wdio-flutter-by-service/package-lock.json 7 | *.conf.ts 8 | CHANGELOG.md 9 | test/*.png 10 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "skipLibCheck": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "noImplicitAny": false, 9 | "strict": false, 10 | "resolveJsonModule": true 11 | }, 12 | "include": ["test/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | on: 3 | pull_request: 4 | 5 | 6 | jobs: 7 | lint: 8 | name: https://www.conventionalcommits.org 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: beemojs/conventional-pr-action@v3 12 | with: 13 | config-preset: angular 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | name: Tests 5 | 6 | jobs: 7 | UnitTest: 8 | runs-on: macos-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 22 14 | - run: npm install --no-package-lock 15 | name: Install dependencies 16 | - run: npm run build 17 | name: Build 18 | - run: npm run test 19 | name: Unit Test 20 | -------------------------------------------------------------------------------- /src/desiredCaps.ts: -------------------------------------------------------------------------------- 1 | export const desiredCapConstraints = { 2 | avd: { 3 | isString: true, 4 | }, 5 | automationName: { 6 | isString: true, 7 | presence: true, 8 | }, 9 | platformName: { 10 | inclusionCaseInsensitive: ['iOS', 'Android', 'Mac'], 11 | isString: true, 12 | presence: true, 13 | }, 14 | udid: { 15 | isString: true, 16 | }, 17 | launchTimeout: { 18 | isNumber: true, 19 | }, 20 | flutterServerLaunchTimeout: { 21 | isNumber: true, 22 | }, 23 | flutterSystemPort: { 24 | isNumber: true, 25 | }, 26 | address: { 27 | isString: true, 28 | }, 29 | } as const; 30 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Flutter Commands 2 | 3 | ## mobile: launchApp 4 | 5 | Launches the app with given app with appId(Android) or bundleId iOS. Will throw error when the app is not installed. 6 | 7 | ### Arguments 8 | 9 | Name | Type | Required | Description | Example 10 | --- | --- | --- | --- | --- 11 | appId | string | yes | app identifier(Android) or bundle identifier(iOS) of the app to be launched | com.mycompany.app 12 | arguments | string|array | no | One or more command line arguments for the app. If the app is already running then this argument is ignored. | ['-s', '-m'] 13 | environment | dict | no | Environment variables mapping for the app. If the app is already running then this argument is ignored. | {'var': 'value'} 14 | -------------------------------------------------------------------------------- /android.conf.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { config as baseConfig } from './wdio.conf.ts'; 3 | import { join } from 'node:path'; 4 | 5 | export const config: WebdriverIO.Config = { 6 | ...baseConfig, 7 | capabilities: [ 8 | { 9 | // capabilities for local Appium web tests on an Android Emulator 10 | platformName: 'Android', 11 | 'appium:automationName': 'FlutterIntegration', 12 | 'appium:orientation': 'PORTRAIT', 13 | 'appium:app': process.env.APP_PATH, 14 | 'appium:newCommandTimeout': 240, 15 | 'appium:flutterServerLaunchTimeout': 30000, 16 | 'appium:flutterEnableMockCamera': true, 17 | 'appium:adbExecTimeout': 80000 18 | }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: lts/* 16 | check-latest: true 17 | - run: npm install --no-package-lock 18 | name: Install dependencies 19 | - run: npm run build 20 | name: Build 21 | - run: npx semantic-release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.npm_token }} 25 | name: Release 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@appium/tsconfig/tsconfig.json", 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "target": "es2020", 7 | "module": "NodeNext", 8 | "removeComments": true, 9 | "noImplicitAny": true, 10 | "strictPropertyInitialization": true, 11 | "strictNullChecks": true, 12 | "outDir": "build", 13 | "types": [ 14 | "node", 15 | "@wdio/globals/types", 16 | "@wdio/mocha-framework", 17 | "expect-webdriverio", 18 | "@wdio/types" 19 | ], 20 | "checkJs": true, 21 | "moduleResolution": "Node16", 22 | "resolvePackageJsonExports": true 23 | }, 24 | "include": ["src", "typings/*.d.ts", "finder.ts", "./wdio"] 25 | } 26 | -------------------------------------------------------------------------------- /ios.conf.ts: -------------------------------------------------------------------------------- 1 | //@ts-nocheck 2 | import { config as baseConfig } from './wdio.conf.ts'; 3 | 4 | export const config: WebdriverIO.Config = { 5 | ...baseConfig, 6 | capabilities: [ 7 | { 8 | // capabilities for local Appium web tests on an Android Emulator 9 | platformName: 'iOS', 10 | 'appium:automationName': 'FlutterIntegration', 11 | 'appium:orientation': 'PORTRAIT', 12 | 'appium:udid': process.env.UDID, 13 | 'appium:app': process.env.APP_PATH, 14 | 'appium:newCommandTimeout': 240, 15 | 'appium:usePreinstalledWDA': true, 16 | 'appium:showIOSLog': true, 17 | 'appium:wdaLocalPort': 8456, 18 | 'appium:flutterServerLaunchTimeout': 25000, 19 | 'appium:flutterEnableMockCamera': true 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /flutter-finder/.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm install --no-package-lock 20 | - run: npm run build 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 24 | -------------------------------------------------------------------------------- /flutter-finder/README.md: -------------------------------------------------------------------------------- 1 | # Appium Flutter Integration Driver - Flutter Finders 2 | 3 | This repository contains the Flutter Finders for the Appium Flutter Integration Driver. 4 | 5 | ## Overview 6 | 7 | The Flutter Finders are used to locate elements in a Flutter application. They are an essential part of testing Flutter applications using the Appium Flutter Integration Driver. 8 | 9 | ## Getting Started 10 | 11 | To use these Flutter Finders, follow the instructions in the [Appium Flutter Integration Driver documentation](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver). 12 | 13 | ## Contributing 14 | 15 | Contributions are welcome! 16 | 17 | ## Released Versions 18 | 19 | | Version | Client | 20 | |-------------|--------| 21 | | 1.0.9 | WDIO | 22 | | In Progress | JAVA | 23 | | Not planned | Ruby | 24 | | Not planned | Python | 25 | 26 | ## License 27 | 28 | This project is licensed under the MIT License. 29 | -------------------------------------------------------------------------------- /src/android.ts: -------------------------------------------------------------------------------- 1 | import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; 2 | import type { InitialOpts } from '@appium/types'; 3 | import type { AppiumFlutterDriver } from './driver'; 4 | import type ADB from 'appium-adb'; 5 | 6 | export async function startAndroidSession( 7 | this: AppiumFlutterDriver, 8 | ...args: any[] 9 | ): Promise { 10 | this.log.info(`Starting an Android proxy session`); 11 | const androiddriver = new AndroidUiautomator2Driver(); 12 | //@ts-ignore Args are ok 13 | await androiddriver.createSession(...args); 14 | return androiddriver; 15 | } 16 | 17 | export async function androidPortForward( 18 | adb: ADB, 19 | systemPort: number, 20 | devicePort: number, 21 | ) { 22 | await adb.forwardPort(systemPort!, devicePort); 23 | } 24 | 25 | export async function androidRemovePortForward(adb: ADB, systemPort: number) { 26 | await adb.removePortForward(systemPort); 27 | } 28 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "target": "es2022", 5 | "moduleResolution": "node16", 6 | 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | 12 | "outDir": "./build", 13 | "allowJs": true, 14 | "declaration": true, 15 | "declarationMap": true, 16 | "resolveJsonModule": true, 17 | "removeComments": false, 18 | "strictFunctionTypes": false, 19 | "experimentalDecorators": true, 20 | "lib": ["es2023", "dom", "es2021"], 21 | "types": ["node"] 22 | }, 23 | "include": ["/src/**/*.ts"], 24 | "exclude": [ 25 | "node_modules", 26 | "**/node_modules/*", 27 | "__mocks__", 28 | "packages/**/node_modules", 29 | "**/*.spec.ts", 30 | "**/*.conf.ts", 31 | "coverage", 32 | "examples", 33 | "/*.js", 34 | "test_remove/**/*", 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/macOS.ts: -------------------------------------------------------------------------------- 1 | import type { AppiumFlutterDriver } from './driver'; 2 | // @ts-ignore 3 | import { Mac2Driver } from 'appium-mac2-driver'; 4 | import type { InitialOpts } from '@appium/types'; 5 | import { DEVICE_CONNECTIONS_FACTORY } from './iProxy'; 6 | 7 | export async function startMacOsSession( 8 | this: AppiumFlutterDriver, 9 | ...args: any[] 10 | ): Promise { 11 | this.log.info(`Starting an MacOs proxy session`); 12 | const macOsDriver = new Mac2Driver({} as InitialOpts); 13 | await macOsDriver.createSession(...args); 14 | return macOsDriver; 15 | } 16 | 17 | export async function macOsPortForward( 18 | udid: string, 19 | systemPort: number, 20 | devicePort: number, 21 | ) { 22 | await DEVICE_CONNECTIONS_FACTORY.requestConnection(udid, systemPort, { 23 | usePortForwarding: true, 24 | devicePort: devicePort, 25 | }); 26 | } 27 | 28 | export function macOsRemovePortForward(udid: string, systemPort: number) { 29 | DEVICE_CONNECTIONS_FACTORY.releaseConnection(udid, systemPort); 30 | } 31 | -------------------------------------------------------------------------------- /src/iOS.ts: -------------------------------------------------------------------------------- 1 | import type { AppiumFlutterDriver } from './driver'; 2 | // @ts-ignore 3 | import { XCUITestDriver } from 'appium-xcuitest-driver'; 4 | import { DEVICE_CONNECTIONS_FACTORY } from './iProxy'; 5 | import { XCUITestDriverOpts } from 'appium-xcuitest-driver/build/lib/driver'; 6 | 7 | export async function startIOSSession( 8 | this: AppiumFlutterDriver, 9 | ...args: any[] 10 | ): Promise { 11 | this.log.info(`Starting an IOS proxy session`); 12 | const iosdriver = new XCUITestDriver({} as XCUITestDriverOpts); 13 | await iosdriver.createSession.apply(iosdriver, args); 14 | return iosdriver; 15 | } 16 | 17 | export async function iosPortForward( 18 | udid: string, 19 | systemPort: number, 20 | devicePort: number, 21 | ) { 22 | await DEVICE_CONNECTIONS_FACTORY.requestConnection(udid, systemPort, { 23 | usePortForwarding: true, 24 | devicePort: devicePort, 25 | }); 26 | } 27 | 28 | export function iosRemovePortForward(udid: string, systemPort: number) { 29 | DEVICE_CONNECTIONS_FACTORY.releaseConnection(udid, systemPort); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 AppiumTestDistribution 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flutter-finder/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 AppiumTestDistribution 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wdio-flutter-by-service", 3 | "version": "1.3.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "rimraf build && tsc -b" 7 | }, 8 | "exports": { 9 | ".": [ 10 | { 11 | "types": "./build/index.d.ts", 12 | "import": "./build/index.js" 13 | } 14 | ], 15 | "./package.json": "./package.json" 16 | }, 17 | "type": "module", 18 | "keywords": [], 19 | "author": "", 20 | "types": "./build/index.d.ts", 21 | "license": "MIT License", 22 | "dependencies": { 23 | "@wdio/globals": "9.17.0", 24 | "@wdio/logger": "^9.18.0", 25 | "@wdio/utils": "^9.19.2", 26 | "lodash": "^4.17.21", 27 | "webdriverio": "9.19.2" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^20.14.10", 31 | "@types/webdriverio": "^5.0.0", 32 | "@wdio/appium-service": "9.19.2", 33 | "@wdio/cli": "9.19.2", 34 | "@wdio/local-runner": "^9.19.2", 35 | "@wdio/mocha-framework": "^9.19.2", 36 | "@wdio/spec-reporter": "^9.19.2", 37 | "@wdio/types": "9.19.2", 38 | "mocha": "^10.4.0", 39 | "rimraf": "^5.0.7", 40 | "ts-node": "^10.9.2", 41 | "typescript": "^5.4.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@semantic-release/commit-analyzer", { 4 | "preset": "angular", 5 | "releaseRules": [ 6 | {"type": "chore", "release": "patch"} 7 | ] 8 | }], 9 | ["@semantic-release/release-notes-generator", { 10 | "preset": "conventionalcommits", 11 | "presetConfig": { 12 | "types": [ 13 | {"type": "feat", "section": "Features"}, 14 | {"type": "fix", "section": "Bug Fixes"}, 15 | {"type": "perf", "section": "Performance Improvements"}, 16 | {"type": "revert", "section": "Reverts"}, 17 | {"type": "chore", "section": "Miscellaneous Chores"}, 18 | {"type": "refactor", "section": "Code Refactoring"}, 19 | {"type": "docs", "section": "Documentation", "hidden": true}, 20 | {"type": "style", "section": "Styles", "hidden": true}, 21 | {"type": "test", "section": "Tests", "hidden": true}, 22 | {"type": "build", "section": "Build System", "hidden": true}, 23 | {"type": "ci", "section": "Continuous Integration", "hidden": true} 24 | ] 25 | } 26 | }], 27 | ["@semantic-release/changelog", { 28 | "changelogFile": "CHANGELOG.md" 29 | }], 30 | "@semantic-release/npm", 31 | ["@semantic-release/git", { 32 | "assets": ["docs", "package.json", "CHANGELOG.md"], 33 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 34 | }], 35 | "@semantic-release/github" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/service.ts: -------------------------------------------------------------------------------- 1 | import { LocatorConfig } from './types.js'; 2 | import { registerCustomMethod, registerLocators } from './utils.js'; 3 | import * as methods from './methods.js'; 4 | 5 | export class FlutterIntegrationDriverService { 6 | private locatorConfig: LocatorConfig[] = [ 7 | { 8 | name: 'flutterByValueKey', 9 | stategy: '-flutter key', 10 | }, 11 | { 12 | name: 'flutterBySemanticsLabel', 13 | stategy: '-flutter semantics label', 14 | }, 15 | { 16 | name: 'flutterByToolTip', 17 | stategy: '-flutter tooltip', 18 | }, 19 | { 20 | name: 'flutterByText', 21 | stategy: '-flutter text', 22 | }, 23 | { 24 | name: 'flutterByTextContaining', 25 | stategy: '-flutter text containing', 26 | }, 27 | { 28 | name: 'flutterByType', 29 | stategy: '-flutter type', 30 | }, 31 | { 32 | name: 'flutterByDescendant', 33 | stategy: '-flutter descendant', 34 | }, 35 | { 36 | name: 'flutterByAncestor', 37 | stategy: '-flutter ancestor', 38 | }, 39 | ]; 40 | /** 41 | * this browser object is passed in here for the first time 42 | */ 43 | async before(config: any, capabilities: any, browser: WebdriverIO.Browser) { 44 | registerLocators(this.locatorConfig); 45 | 46 | for (let method in methods) { 47 | registerCustomMethod(method, (methods as any)[method], { 48 | attachToBrowser: true, 49 | attachToElement: false, 50 | }); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Update README with latest flutter server version 4 | 5 | # Controls when the workflow will run 6 | on: 7 | repository_dispatch: 8 | types: [server-update] 9 | 10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 11 | jobs: 12 | # This workflow contains a single job called "build" 13 | build: 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v4 21 | 22 | - name: Update README with latest commit id and push to repo 23 | run: | 24 | echo "SERVER_VERSION:" ${{ github.event.client_payload.version }} 25 | git config --global user.name "GitHub Actions" 26 | git config --global user.email "actions@github.com" 27 | git status 28 | BRANCH_NAME=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') 29 | git pull --rebase origin main 30 | git checkout -b run-test-upstream-${{ github.event.client_payload.version }} 31 | git pull --rebase origin main 32 | sed -i "s/appium_flutter_server: [0-9]*\.[0-9]*\.[0-9]*/appium_flutter_server: ${{ github.event.client_payload.version }}/" README.md 33 | git add README.md 34 | git commit -m "Update README with latest commit ID - ${{ github.event.client_payload.version }}" 35 | git push -u origin run-test-upstream-${{ github.event.client_payload.version }} 36 | gh pr create --title "docs: Update server version in ReadMe" --body "Test driver with latest server version" 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import type { AppiumFlutterDriver } from './driver'; 2 | import _ from 'lodash'; 3 | import { PLATFORM } from './platform'; 4 | import { startAndroidSession } from './android'; 5 | import { startIOSSession } from './iOS'; 6 | import { startMacOsSession } from './macOS'; 7 | import type { DefaultCreateSessionResult } from '@appium/types'; 8 | 9 | export async function createSession( 10 | this: AppiumFlutterDriver, 11 | sessionId: string, 12 | caps: any, 13 | ...args: any[] 14 | ): Promise> { 15 | try { 16 | switch (_.toLower(caps.platformName)) { 17 | case PLATFORM.IOS: 18 | this.proxydriver = await startIOSSession.bind(this)(...args); 19 | this.proxydriver.relaxedSecurityEnabled = 20 | this.relaxedSecurityEnabled; 21 | this.proxydriver.denyInsecure = this.denyInsecure; 22 | this.proxydriver.allowInsecure = this.allowInsecure; 23 | 24 | break; 25 | case PLATFORM.ANDROID: 26 | this.proxydriver = await startAndroidSession.bind(this)(...args); 27 | this.proxydriver.relaxedSecurityEnabled = 28 | this.relaxedSecurityEnabled; 29 | this.proxydriver.denyInsecure = this.denyInsecure; 30 | this.proxydriver.allowInsecure = this.allowInsecure; 31 | break; 32 | case PLATFORM.MAC: 33 | this.proxydriver = await startMacOsSession.bind(this)(...args); 34 | this.proxydriver.relaxedSecurityEnabled = 35 | this.relaxedSecurityEnabled; 36 | this.proxydriver.denyInsecure = this.denyInsecure; 37 | this.proxydriver.allowInsecure = this.allowInsecure; 38 | break; 39 | default: 40 | this.log.errorWithException( 41 | `Unsupported platformName: ${caps.platformName}. ` + 42 | `Only the following platforms are supported: ${_.keys(PLATFORM)}`, 43 | ); 44 | } 45 | 46 | return [sessionId, this.opts]; 47 | } catch (e) { 48 | await this.deleteSession(); 49 | throw e; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/appium3_ios.yml: -------------------------------------------------------------------------------- 1 | name: iOS WDIO Tests with Appium 3 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | CI: true 8 | SHOW_XCODE_LOG: true 9 | PREBUILT_WDA_PATH: ${{ github.workspace }}/wda/WebDriverAgentRunner-Runner.app 10 | 11 | jobs: 12 | wdio_ios: 13 | runs-on: macos-26 14 | name: WDIO iOS (Xcode 16.4) 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install Node.js 22.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22.x' 22 | 23 | - name: Select Xcode 16.4 24 | uses: maxim-lobanov/setup-xcode@v1 25 | with: 26 | xcode-version: '16.4' 27 | 28 | - name: List Installed Simulators 29 | run: xcrun simctl list devices available 30 | - name: Install jq 31 | run: brew install jq 32 | 33 | - run: | 34 | version=$(grep 'appium_flutter_server:' README.md | awk '{print $2}') 35 | ios_app="https://github.com/AppiumTestDistribution/appium-flutter-server/releases/download/$version/ios.zip" 36 | echo "Downloading from: $ios_app" 37 | curl -LO "$ios_app" 38 | echo "APP_PATH=$(pwd)/ios.zip" >> $GITHUB_ENV 39 | name: Download sample iOS app 40 | 41 | - name: Start iOS Simulator UI 42 | run: open -Fn "$(xcode-select --print-path)/Applications/Simulator.app" 43 | 44 | - name: Boot simulator 45 | id: prepareSimulator 46 | uses: futureware-tech/simulator-action@v4 47 | with: 48 | model: 'iPhone 16' 49 | os_version: '18.5' 50 | shutdown_after_job: false 51 | wait_for_boot: true 52 | 53 | - run: | 54 | target_sim_id=$(xcrun simctl list devices available | grep -A 10 "Booted" | cut -d "(" -f2 | cut -d ")" -f1 | head -n 1) 55 | echo "Target sim id: $target_sim_id" 56 | echo "udid=$target_sim_id" >> $GITHUB_ENV 57 | 58 | - run: | 59 | npm install -g appium 60 | npm install --no-package-lock 61 | npm run build-flutter-by-service 62 | npm run build 63 | name: Install Appium and deps 64 | 65 | - run: | 66 | appium driver list 67 | appium driver doctor xcuitest 68 | appium driver run xcuitest download-wda-sim --platform=ios --outdir=$(dirname "$PREBUILT_WDA_PATH") 69 | echo "WDA path: $PREBUILT_WDA_PATH" 70 | echo "WDA_PATH=$PREBUILT_WDA_PATH" >> $GITHUB_ENV 71 | name: Build WDA with XCUITest driver 72 | 73 | - run: | 74 | echo "UDID: $udid" 75 | echo "WDA path: $PREBUILT_WDA_PATH" 76 | xcrun simctl install $udid $PREBUILT_WDA_PATH 77 | xcrun simctl launch $udid "com.facebook.WebDriverAgentRunner.xctrunner" 78 | name: Install and launch WDA on Simulator 79 | 80 | - run: | 81 | mkdir -p appium-logs 82 | UDID=$udid APP_PATH=$APP_PATH npm run wdio-ios | tee appium-logs/logs.txt 83 | name: Run WDIO iOS 84 | 85 | 86 | - name: Upload logs 87 | if: ${{ always() }} 88 | uses: actions/upload-artifact@v4 89 | with: 90 | name: appium-logs 91 | path: appium-logs 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | .idea 5 | src/.DS_Store 6 | .DS_Store 7 | appium-server 8 | build 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | flutter-finder/wdio-flutter-by-service/node_modules 15 | flutter-finder/wdio-flutter-by-service/package-lock.json 16 | flutter-finder/wdio-flutter-by-service/build 17 | flutter-finder/wdio-flutter-by-service/test 18 | flutter-finder/wdio-flutter-by-service/appium-logs 19 | package-lock.json 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # Snowpack dependency directory (https://snowpack.dev/) 56 | web_modules/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional stylelint cache 68 | .stylelintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variable files 86 | .env 87 | .env.development.local 88 | .env.test.local 89 | .env.production.local 90 | .env.local 91 | 92 | # parcel-bundler cache (https://parceljs.org/) 93 | .cache 94 | .parcel-cache 95 | 96 | # Next.js build output 97 | .next 98 | out 99 | 100 | # Nuxt.js build / generate output 101 | .nuxt 102 | dist 103 | 104 | # Gatsby files 105 | .cache/ 106 | # Comment in the public line in if your project uses Gatsby and not Next.js 107 | # https://nextjs.org/blog/next-9-1#public-directory-support 108 | # public 109 | 110 | # vuepress build output 111 | .vuepress/dist 112 | 113 | # vuepress v2.x temp and cache directory 114 | .temp 115 | .cache 116 | 117 | # Docusaurus cache and generated files 118 | .docusaurus 119 | 120 | # Serverless directories 121 | .serverless/ 122 | 123 | # FuseBox cache 124 | .fusebox/ 125 | 126 | # DynamoDB Local files 127 | .dynamodb/ 128 | 129 | # TernJS port file 130 | .tern-port 131 | 132 | # Stores VSCode versions used for testing VSCode extensions 133 | .vscode-test 134 | 135 | # yarn v2 136 | .yarn/cache 137 | .yarn/unplugged 138 | .yarn/build-state.yml 139 | .yarn/install-state.gz 140 | .pnp.* 141 | .history 142 | .npmrc 143 | appium-logs 144 | 145 | flutter-by/java/.gradle 146 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/methods.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '@wdio/globals'; 2 | import { w3cElementToWdioElement } from './utils.js'; 3 | import * as fs from "node:fs"; 4 | 5 | export type WaitForOption = { 6 | element?: WebdriverIO.Element; 7 | locator?: Flutter.Locator; 8 | timeout?: number; 9 | } 10 | 11 | export async function flutterWaitForVisible( 12 | this: WebdriverIO.Browser, 13 | options: WaitForOption, 14 | ) { 15 | return await browser.executeScript('flutter: waitForVisible', [options]); 16 | } 17 | 18 | export async function flutterWaitForAbsent( 19 | this: WebdriverIO.Browser, 20 | options: WaitForOption, 21 | ) { 22 | return await browser.executeScript('flutter: waitForAbsent', [options]); 23 | } 24 | 25 | export async function flutterDoubleClick( 26 | this: WebdriverIO.Browser, 27 | options: { 28 | element?: WebdriverIO.Element; 29 | offset?: Flutter.Point; 30 | }, 31 | ) { 32 | const { element, offset } = options; 33 | return await browser.executeScript('flutter: doubleClick', [ 34 | { origin: element, offset: offset }, 35 | ]); 36 | } 37 | 38 | export async function flutterLongPress( 39 | this: WebdriverIO.Browser, 40 | options: { 41 | element: WebdriverIO.Element; 42 | offset?: Flutter.Point; 43 | }, 44 | ) { 45 | const { element, offset } = options; 46 | return await browser.executeScript('flutter: longPress', [ 47 | { origin: element, offset: offset }, 48 | ]); 49 | } 50 | 51 | 52 | export async function flutterScrollTillVisible( 53 | this: WebdriverIO.Browser, 54 | options: { 55 | finder: WebdriverIO.Element | Flutter.Locator; 56 | scrollView?: WebdriverIO.Element; 57 | scrollDirection?: 'up' | 'right' | 'down' | 'left'; 58 | delta?: number; 59 | maxScrolls?: number; 60 | settleBetweenScrollsTimeout?: number; 61 | dragDuration?: number; 62 | }, 63 | ): Promise { 64 | // Convert the finder to the proper format for the server 65 | let finderForServer; 66 | if (options.finder && typeof options.finder === 'object' && 'using' in options.finder) { 67 | // It's a locator object (like from flutterByDescendant) 68 | finderForServer = options.finder; 69 | } else { 70 | // It's an element, extract the locator 71 | finderForServer = options.finder; 72 | } 73 | 74 | const serverOptions = { 75 | ...options, 76 | finder: finderForServer, 77 | }; 78 | 79 | const response = await browser.executeScript('flutter: scrollTillVisible', [ 80 | serverOptions, 81 | ]); 82 | return await w3cElementToWdioElement(this, response); 83 | } 84 | 85 | export async function flutterDragAndDrop(this: WebdriverIO.Browser, options: { 86 | source: WebdriverIO.Element; 87 | target: WebdriverIO.Element; 88 | }) { 89 | return await browser.executeScript('flutter: dragAndDrop', [options]); 90 | } 91 | 92 | export async function flutterInjectImage(this: WebdriverIO.Browser, filePath: string) { 93 | const base64Image = await convertFileToBase64(filePath); 94 | return await browser.executeScript('flutter: injectImage', [{ base64Image }]); 95 | } 96 | 97 | export async function flutterActivateInjectedImage(this: WebdriverIO.Browser, options: { 98 | imageId: String; 99 | }) { 100 | return await browser.executeScript('flutter: activateInjectedImage', [options]); 101 | } 102 | 103 | async function convertFileToBase64(filePath: string): Promise { 104 | return new Promise((resolve, reject) => { 105 | fs.readFile(filePath, { encoding: 'base64' }, (err, data) => { 106 | if (err) reject(err); 107 | else resolve(data); 108 | }); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appium-flutter-integration-driver", 3 | "description": "Appium driver for automating flutter apps using flutter integration SDK", 4 | "keywords": [ 5 | "appium", 6 | "flutter" 7 | ], 8 | "version": "2.0.3", 9 | "author": "", 10 | "license": "MIT License", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/AppiumTestDistribution/appium-flutter-integration-driver" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues" 17 | }, 18 | "main": "./build/src/driver.js", 19 | "bin": {}, 20 | "directories": { 21 | "src": "src" 22 | }, 23 | "files": [ 24 | "build", 25 | "src" 26 | ], 27 | "appium": { 28 | "driverName": "flutter-integration", 29 | "automationName": "FlutterIntegration", 30 | "platformNames": [ 31 | "Android", 32 | "iOS", 33 | "Mac" 34 | ], 35 | "mainClass": "AppiumFlutterDriver", 36 | "flutterServerVersion": ">=0.0.18 <1.0.0" 37 | }, 38 | "scripts": { 39 | "format": "prettier --write .", 40 | "clean": "rimraf package-lock.json && rimraf node_modules && npm install", 41 | "build": "rimraf build && tsc", 42 | "watch": "tsc --watch", 43 | "prettier": "prettier 'src/**/*.ts' 'test/**/*.*' --write --single-quote", 44 | "prettier-check": "prettier 'src/**/*.ts' 'test/**/*.*' --check", 45 | "appium-home": "rm -rf /tmp/some-temp-dir && export APPIUM_HOME=/tmp/some-temp-dir", 46 | "test": "NODE_OPTIONS='--loader ts-node/esm' mocha --require ts-node/register test/**/*.ts", 47 | "run-server": "appium server -ka 800 -pa /wd/hub --allow-insecure=adb_shell", 48 | "install-driver": "npm run build && appium driver install --source=local $(pwd)", 49 | "reinstall-driver": "(appium driver uninstall flutter-integration || exit 0) && npm run install-driver", 50 | "wdio-android": "wdio run ./android.conf.ts", 51 | "wdio-ios": "wdio run ./ios.conf.ts", 52 | "build-flutter-by-service": "cd ./flutter-finder/wdio-flutter-by-service && npm install --no-package-lock && npm run build" 53 | }, 54 | "devDependencies": { 55 | "@appium/types": "^0.x", 56 | "@semantic-release/changelog": "^6.0.3", 57 | "@semantic-release/commit-analyzer": "^13.0.0", 58 | "@semantic-release/git": "^10.0.1", 59 | "@semantic-release/npm": "^12.0.1", 60 | "@semantic-release/release-notes-generator": "^14.0.1", 61 | "@testing-library/webdriverio": "^3.2.1", 62 | "@types/bluebird": "^3.5.42", 63 | "@types/chai": "^4.3.16", 64 | "@types/lodash": "^4.17.16", 65 | "@types/mocha": "^10.0.7", 66 | "@types/semver": "^7.7.0", 67 | "@types/sinon": "^17.0.3", 68 | "@wdio/appium-service": "^9.19.2", 69 | "@wdio/cli": "^9.19.2", 70 | "@wdio/globals": "^9.17.0", 71 | "@wdio/local-runner": "^9.19.2", 72 | "@wdio/mocha-framework": "^9.19.2", 73 | "@wdio/spec-reporter": "^9.19.2", 74 | "@wdio/types": "9.19.2", 75 | "@wdio/utils": "^9.19.2", 76 | "chai": "^5.1.1", 77 | "chai-as-promised": "^8.0.0", 78 | "conventional-changelog-conventionalcommits": "^8.0.0", 79 | "eslint-config-prettier": "^9.1.0", 80 | "eslint-plugin-prettier": "^5.1.3", 81 | "jest": "^29.7.0", 82 | "mocha": "^10.6.0", 83 | "prettier": "^3.3.2", 84 | "rimraf": "^5.0.7", 85 | "semantic-release": "^24.0.0", 86 | "sinon": "^18.0.0", 87 | "ts-mocha": "^10.0.0", 88 | "ts-node": "^10.9.2", 89 | "typescript": "^5.5.3", 90 | "wdio-flutter-by-service": "file:./flutter-finder/wdio-flutter-by-service", 91 | "webdriverio": "9.19.2" 92 | }, 93 | "peerDependencies": { 94 | "appium": "^3.0.0" 95 | }, 96 | "dependencies": { 97 | "@appium/base-driver": "^10.0.0", 98 | "appium-adb": "^13.0.0", 99 | "appium-ios-device": "^3.0.0", 100 | "appium-uiautomator2-driver": "^5.0.0", 101 | "appium-xcuitest-driver": "^10.0.0", 102 | "appium-mac2-driver": "^3.0.0", 103 | "async-retry": "^1.3.3", 104 | "asyncbox": "^3.0.0", 105 | "bluebird": "^3.7.2", 106 | "lodash": "^4.17.21", 107 | "portscanner": "^2.2.0", 108 | "semver": "^7.6.2" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /finder.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { command } from 'webdriver'; 3 | import path from 'path'; 4 | 5 | export async function registerCommands() { 6 | const utils = await import( 7 | path.join( 8 | require.resolve('webdriverio').replace('cjs/index.js', ''), 9 | 'utils', 10 | 'getElementObject.js', 11 | ) 12 | ); 13 | 14 | function handler(multi: boolean = false) { 15 | return async function (value: string) { 16 | let findElement; 17 | 18 | let args = ['key', value]; 19 | let suffix = multi ? 'elements' : 'element'; 20 | if (this['elementId']) { 21 | args = [this['elementId'], 'key', value]; 22 | findElement = command( 23 | 'POST', 24 | `/session/:sessionId/element/:elementId/${suffix}`, 25 | { 26 | command: 'flutterFinderByKey', 27 | description: 'a new WebDriver command', 28 | ref: 'https://vendor.com/commands/#myNewCommand', 29 | variables: [ 30 | { 31 | name: 'elementId', 32 | type: 'string', 33 | description: 'a valid parameter', 34 | required: true, 35 | }, 36 | ], 37 | parameters: [ 38 | { 39 | name: 'using', 40 | type: 'string', 41 | description: 'a valid parameter', 42 | required: true, 43 | }, 44 | { 45 | name: 'value', 46 | type: 'string', 47 | description: 'a valid parameter', 48 | required: true, 49 | }, 50 | ], 51 | returns: { 52 | type: 'object', 53 | name: 'element', 54 | description: 55 | "A JSON representation of an element object, e.g. `{ 'element-6066-11e4-a52e-4f735466cecf': 'ELEMENT_1' }`.", 56 | }, 57 | }, 58 | ); 59 | } else { 60 | findElement = command('POST', `/session/:sessionId/${suffix}`, { 61 | command: 'flutterFinderByKey', 62 | description: 'a new WebDriver command', 63 | ref: 'https://vendor.com/commands/#myNewCommand', 64 | variables: [], 65 | parameters: [ 66 | { 67 | name: 'using', 68 | type: 'string', 69 | description: 'a valid parameter', 70 | required: true, 71 | }, 72 | { 73 | name: 'value', 74 | type: 'string', 75 | description: 'a valid parameter', 76 | required: true, 77 | }, 78 | { 79 | name: 'context', 80 | type: 'string', 81 | description: 'a valid parameter', 82 | required: false, 83 | }, 84 | ], 85 | returns: { 86 | type: 'object', 87 | name: 'element', 88 | description: 89 | "A JSON representation of an element object, e.g. `{ 'element-6066-11e4-a52e-4f735466cecf': 'ELEMENT_1' }`.", 90 | }, 91 | }); 92 | } 93 | 94 | const response = await findElement.call(browser, ...args); 95 | console.log(utils.getElement); 96 | try { 97 | if (multi) { 98 | return response.map((element: any) => 99 | utils.getElement.call(this, null, element), 100 | ); 101 | } else { 102 | return utils.getElement.call(this, null, response); 103 | } 104 | } catch (e) { 105 | console.log(e); 106 | } 107 | }; 108 | } 109 | browser.addCommand('flutterFinderByKey$', handler()); 110 | browser.addCommand('flutterFinderByKey$', handler(), true); 111 | 112 | browser.addCommand('flutterFinderByKey$$', handler(true)); 113 | browser.addCommand('flutterFinderByKey$$', handler(true), true); 114 | 115 | /** 116 | * 117 | * 1. Element visible 118 | * 2. Element not visible 119 | * 3. element enable/disabled 120 | * 4. Element count 121 | * 6. 122 | * 123 | */ 124 | } 125 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { LocatorConfig } from './types.js'; 2 | import { command } from 'webdriver'; 3 | import { browser } from '@wdio/globals'; 4 | import path from 'path'; 5 | import { createRequire } from 'module'; 6 | import { pathToFileURL } from 'url'; 7 | import fs from 'fs'; 8 | 9 | const require = createRequire(import.meta.url); 10 | let getElement: any; 11 | 12 | const constructElementObject = async function () { 13 | if(!getElement) { 14 | const wdioPath = require.resolve('webdriverio'); 15 | const targetPath = wdioPath.replace(".cjs", "js") 16 | const fileUrl = targetPath.replace("indexjs", "index.js"); 17 | 18 | let fileContent = fs.readFileSync(fileUrl, "utf8"); 19 | const exportRegex = /export\s*{([^}]*)}/; 20 | 21 | const exportBlock = fileContent.match(exportRegex); 22 | if(exportBlock && !/\bgetElement\b/.test(exportBlock[1])) { 23 | fileContent = fileContent.replace(exportRegex, (match, group) => { 24 | return `export {${group.trim().endsWith(",") ? group.trim() : group.trim() + ","} getElement };`; 25 | }); 26 | 27 | fs.writeFileSync(fileUrl, fileContent, "utf8"); 28 | } 29 | 30 | getElement = (await import(fileUrl)).getElement; 31 | } 32 | 33 | return getElement; 34 | }; 35 | 36 | const flutterElementFinder = function ( 37 | finderName: string, 38 | strategy: string, 39 | isMultipleFind: boolean = false, 40 | ) { 41 | return async function ( 42 | this: WebdriverIO.Browser | WebdriverIO.Element, 43 | selector: any, 44 | ) { 45 | const suffix = isMultipleFind ? 'elements' : 'element'; 46 | const elementId = (this as WebdriverIO.Element)['elementId']; 47 | const endpoint = !elementId 48 | ? `/session/:sessionId/${suffix}` 49 | : `/session/:sessionId/element/:elementId/${suffix}`; 50 | if (typeof selector !== 'string') { 51 | selector = JSON.stringify(selector); 52 | } 53 | const args = [elementId, strategy, selector].filter(Boolean); 54 | 55 | const variables = elementId 56 | ? [ 57 | { 58 | name: 'elementId', 59 | type: 'string', 60 | description: 'a valid parameter', 61 | required: true, 62 | }, 63 | ] 64 | : []; 65 | 66 | const parameters = [ 67 | { 68 | name: 'using', 69 | type: 'string', 70 | description: 'a valid parameter', 71 | required: true, 72 | }, 73 | { 74 | name: 'value', 75 | type: 'string', 76 | description: 'a valid parameter', 77 | required: true, 78 | }, 79 | ]; 80 | 81 | const findElement = command('POST', endpoint, { 82 | command: finderName, 83 | variables, 84 | parameters, 85 | ref: '', 86 | }); 87 | 88 | const response: any = await findElement.call(browser as any, ...args); 89 | if (response && response.error) { 90 | throw new Error(response.message); 91 | } 92 | 93 | if (isMultipleFind) { 94 | return await Promise.all( 95 | response.map((element: any) => w3cElementToWdioElement(this, element)), 96 | ); 97 | } else { 98 | return await w3cElementToWdioElement(this, response); 99 | } 100 | }; 101 | }; 102 | 103 | export async function w3cElementToWdioElement(context: any, response: any) { 104 | const getElement = await constructElementObject(); 105 | return getElement.call(context, null, response); 106 | } 107 | 108 | export function registerLocators(locatorConfig: Array) { 109 | for (let config of locatorConfig) { 110 | const methodName = config.name; 111 | const $ = flutterElementFinder(methodName, config.stategy, false); 112 | const $$ = flutterElementFinder(methodName, config.stategy, true); 113 | registerCustomMethod(`${methodName}$`, $, { 114 | attachToBrowser: true, 115 | attachToElement: true, 116 | }); 117 | registerCustomMethod(`${methodName}$$`, $$, { 118 | attachToBrowser: true, 119 | attachToElement: true, 120 | }); 121 | registerCustomMethod( 122 | `${methodName}`, 123 | (value: any) => { 124 | // For complex finders (descendant, ancestor), use 'selector' property 125 | // For simple finders, use 'value' property 126 | if (config.name === 'flutterByDescendant' || config.name === 'flutterByAncestor') { 127 | return { 128 | using: config.stategy, 129 | selector: typeof value !== 'string' ? value : JSON.parse(value), 130 | }; 131 | } else { 132 | return { 133 | using: config.stategy, 134 | value: typeof value !== 'string' ? JSON.stringify(value) : value, 135 | }; 136 | } 137 | }, 138 | { 139 | attachToBrowser: true, 140 | attachToElement: false, 141 | }, 142 | ); 143 | } 144 | } 145 | 146 | export function registerCustomMethod( 147 | methodName: string, 148 | handler: any, 149 | attach: { attachToBrowser: boolean; attachToElement: boolean }, 150 | ) { 151 | if (attach.attachToBrowser) { 152 | browser.addCommand(methodName, handler); 153 | } 154 | 155 | if (attach.attachToElement) { 156 | browser.addCommand(methodName, handler, true); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /test/unit/utils.specs.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import * as utils from '../../src/utils'; 3 | import { AppiumFlutterDriver } from '../../src/driver'; 4 | import { JWProxy } from 'appium/driver'; 5 | import sinon from 'sinon'; 6 | 7 | describe('attachAppLaunchArguments', function () { 8 | let driver; 9 | let chai, expect; 10 | let sandbox; 11 | 12 | beforeEach(async function () { 13 | chai = await import('chai'); 14 | const chaiAsPromised = await import('chai-as-promised'); 15 | 16 | chai.should(); 17 | chai.use(chaiAsPromised.default); 18 | 19 | expect = chai.expect; 20 | sandbox = sinon.createSandbox(); 21 | driver = new AppiumFlutterDriver(); 22 | driver.log; 23 | const debugStrub = sandbox.stub(driver._log, 'info'); 24 | sandbox.stub(Object.getPrototypeOf(driver), 'log').get(() => { 25 | return { info: sandbox.spy() }; 26 | }); 27 | }); 28 | 29 | afterEach(function () { 30 | sandbox.restore(); 31 | }); 32 | 33 | it('should attach flutter server port to processArguments for iOS platform', function () { 34 | const parsedCaps = { platformName: 'iOS', flutterSystemPort: '12345' }; 35 | const caps = [{}, { alwaysMatch: {}, firstMatch: [{}] }]; 36 | 37 | utils.attachAppLaunchArguments.call(driver, parsedCaps, ...caps); 38 | 39 | const expectedArgs = ['--flutter-server-port=12345']; 40 | expect(caps[1].alwaysMatch['appium:processArguments'].args).to.deep.equal( 41 | expectedArgs, 42 | ); 43 | }); 44 | 45 | it('should not modify caps if no W3C caps are passed', function () { 46 | const parsedCaps = { platformName: 'iOS', flutterSystemPort: '12345' }; 47 | const caps = [{}]; // No W3C caps 48 | 49 | utils.attachAppLaunchArguments.call(driver, parsedCaps, ...caps); 50 | 51 | expect(caps[0]).to.deep.equal({}); 52 | }); 53 | 54 | it('should not modify caps if platform is not iOS', function () { 55 | const parsedCaps = { 56 | platformName: 'Android', 57 | flutterSystemPort: '12345', 58 | }; 59 | const caps = [{}, { alwaysMatch: {}, firstMatch: [{}] }]; 60 | 61 | utils.attachAppLaunchArguments.call(driver, parsedCaps, ...caps); 62 | 63 | expect(caps[1].firstMatch[0]).to.not.have.property( 64 | 'appium:processArguments', 65 | ); 66 | }); 67 | }); 68 | 69 | describe('Utils Test', function () { 70 | let chai, expect; 71 | let sandbox: sinon.SinonSandbox; 72 | let driver: AppiumFlutterDriver; 73 | let proxy: JWProxy; 74 | before(async function () { 75 | chai = await import('chai'); 76 | const chaiAsPromised = await import('chai-as-promised'); 77 | 78 | chai.should(); 79 | chai.use(chaiAsPromised.default); 80 | 81 | expect = chai.expect; 82 | sandbox = sinon.createSandbox(); 83 | driver = new AppiumFlutterDriver(); 84 | proxy = new JWProxy({ server: '127.0.0.1', port: 4723 }); 85 | driver.proxy = function () {}; 86 | // Mocking proxydriver and its wda property 87 | driver.proxydriver = { 88 | wda: { 89 | jwproxy: { 90 | // Mock any methods of jwproxy that you need for your tests 91 | }, 92 | }, 93 | }; 94 | }); 95 | afterEach(function () { 96 | sandbox.restore(); 97 | }); 98 | it('should return the proxy for valid strategies', async function () { 99 | sandbox.stub(driver, 'proxy').value(proxy); 100 | const result = await utils.getProxyDriver.call(driver, 'key'); 101 | expect(result).to.equal(proxy); 102 | }); 103 | 104 | it('should return true for valid W3C capabilities', function () { 105 | const caps = { 106 | alwaysMatch: { browserName: 'chrome' }, 107 | firstMatch: [{}], 108 | }; 109 | expect(utils.isW3cCaps(caps)).to.be.true; 110 | }); 111 | 112 | it('should return false for non-object values', function () { 113 | expect(utils.isW3cCaps(null)).to.be.false; 114 | expect(utils.isW3cCaps(undefined)).to.be.false; 115 | expect(utils.isW3cCaps(42)).to.be.false; 116 | expect(utils.isW3cCaps('string')).to.be.false; 117 | }); 118 | 119 | it('should return false for empty objects', function () { 120 | expect(utils.isW3cCaps({})).to.be.false; 121 | }); 122 | 123 | it('should return false for objects missing both alwaysMatch and firstMatch', function () { 124 | const caps = { browserName: 'chrome' }; 125 | expect(utils.isW3cCaps(caps)).to.be.false; 126 | }); 127 | 128 | it('should return true for objects with valid alwaysMatch and empty firstMatch', function () { 129 | const caps = { 130 | alwaysMatch: { platformName: 'iOS' }, 131 | firstMatch: [{}], 132 | }; 133 | expect(utils.isW3cCaps(caps)).to.be.true; 134 | }); 135 | 136 | it('should return true for objects with valid firstMatch and no alwaysMatch', function () { 137 | const caps = { 138 | firstMatch: [{ platformName: 'Android' }], 139 | }; 140 | expect(utils.isW3cCaps(caps)).to.be.true; 141 | }); 142 | 143 | it('should return false for objects with invalid firstMatch structure', function () { 144 | const caps = { 145 | firstMatch: 'invalid', 146 | }; 147 | expect(utils.isW3cCaps(caps)).to.be.false; 148 | }); 149 | 150 | it('should return false for objects with invalid alwaysMatch structure', function () { 151 | const caps = { 152 | alwaysMatch: 'invalid', 153 | }; 154 | expect(utils.isW3cCaps(caps)).to.be.false; 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/src/index.ts: -------------------------------------------------------------------------------- 1 | import { FlutterIntegrationDriverService } from './service.js'; 2 | import type { ChainablePromiseElement } from 'webdriverio'; 3 | export default FlutterIntegrationDriverService; 4 | 5 | declare global { 6 | namespace WebdriverIO { 7 | interface Browser { 8 | flutterByDescendant(options: { 9 | of: WebdriverIO.Element; 10 | matching: WebdriverIO.Element; 11 | }): Promise; 12 | flutterByDescendant$(options: { 13 | of: WebdriverIO.Element; 14 | matching: WebdriverIO.Element; 15 | }): ChainablePromiseElement; 16 | flutterByDescendant$$(options: { 17 | of: WebdriverIO.Element; 18 | matching: WebdriverIO.Element; 19 | }): ChainablePromiseElement[]; 20 | flutterByAncestor(options: { 21 | of: WebdriverIO.Element; 22 | matching: WebdriverIO.Element; 23 | }): Promise; 24 | flutterByAncestor$(options: { 25 | of: WebdriverIO.Element; 26 | matching: WebdriverIO.Element; 27 | }): ChainablePromiseElement; 28 | flutterByAncestor$$(options: { 29 | of: WebdriverIO.Element; 30 | matching: WebdriverIO.Element; 31 | }): ChainablePromiseElement[]; 32 | flutterByValueKey(value: string): Promise; 33 | flutterByValueKey$( 34 | value: string, 35 | ): ChainablePromiseElement; 36 | flutterByValueKey$$( 37 | value: string, 38 | ): ChainablePromiseElement[]; 39 | flutterBySemanticsLabel(label: string): Promise; 40 | flutterBySemanticsLabel$( 41 | label: string, 42 | ): ChainablePromiseElement; 43 | flutterBySemanticsLabel$$( 44 | label: string, 45 | ): ChainablePromiseElement[]; 46 | flutterByText(text: string): Promise; 47 | flutterByText$( 48 | text: string, 49 | ): ChainablePromiseElement; 50 | flutterByType(text: string): Promise; 51 | flutterByType$( 52 | text: string, 53 | ): ChainablePromiseElement; 54 | flutterByType$$( 55 | text: string, 56 | ): ChainablePromiseElement[]; 57 | flutterByText$$( 58 | text: string, 59 | ): ChainablePromiseElement[]; 60 | flutterWaitForVisible(options: { 61 | element: WebdriverIO.Element; 62 | timeout?: number; 63 | }): Promise; 64 | flutterDoubleClick(options: { 65 | element: WebdriverIO.Element; 66 | offset?: { x: number; y: number }; 67 | }): Promise; 68 | flutterLongPress(options: { 69 | element: WebdriverIO.Element; 70 | offset?: { x: number; y: number }; 71 | }): Promise; 72 | flutterWaitForAbsent(options: { 73 | element: WebdriverIO.Element; 74 | timeout?: number; 75 | }): Promise; 76 | 77 | flutterScrollTillVisible(options: { 78 | finder: WebdriverIO.Element; 79 | scrollView?: WebdriverIO.Element; 80 | scrollDirection?: 'up' | 'right' | 'down' | 'left'; 81 | delta?: number; 82 | maxScrolls?: number; 83 | settleBetweenScrollsTimeout?: number; 84 | dragDuration?: number; 85 | }): ChainablePromiseElement | null; 86 | 87 | flutterDragAndDrop(options: { 88 | source: WebdriverIO.Element; 89 | target: WebdriverIO.Element; 90 | }): Promise; 91 | flutterInjectImage(filePath: string): Promise; 92 | flutterActivateInjectedImage(options: { 93 | imageId: string; 94 | }): Promise; 95 | } 96 | interface Element { 97 | flutterByValueKey(value: string): Promise; 98 | flutterByValueKey$(value: string): WebdriverIO.Element; 99 | flutterByValueKey$$(value: string): WebdriverIO.Element[]; 100 | flutterBySemanticsLabel(label: string): Promise; 101 | flutterBySemanticsLabel$(label: string): WebdriverIO.Element; 102 | flutterBySemanticsLabel$$(label: string): WebdriverIO.Element[]; 103 | flutterByText(text: string): Promise; 104 | flutterByText$(text: string): WebdriverIO.Element; 105 | flutterByText$$(text: string): WebdriverIO.Element[]; 106 | flutterByType(text: string): Promise; 107 | flutterByType$( 108 | text: string, 109 | ): ChainablePromiseElement; 110 | flutterByType$$( 111 | text: string, 112 | ): ChainablePromiseElement[]; 113 | flutterByDescendant(options: { 114 | of: WebdriverIO.Element; 115 | matching: WebdriverIO.Element; 116 | }): Promise; 117 | flutterByDescendant$(options: { 118 | of: WebdriverIO.Element; 119 | matching: WebdriverIO.Element; 120 | }): ChainablePromiseElement; 121 | flutterByDescendant$$(options: { 122 | of: WebdriverIO.Element; 123 | matching: WebdriverIO.Element; 124 | }): ChainablePromiseElement[]; 125 | flutterByAncestor(options: { 126 | of: WebdriverIO.Element; 127 | matching: WebdriverIO.Element; 128 | }): Promise; 129 | flutterByAncestor$(options: { 130 | of: WebdriverIO.Element; 131 | matching: WebdriverIO.Element; 132 | }): ChainablePromiseElement; 133 | flutterByAncestor$$(options: { 134 | of: WebdriverIO.Element; 135 | matching: WebdriverIO.Element; 136 | }): ChainablePromiseElement[]; 137 | } 138 | } 139 | 140 | namespace Flutter { 141 | // @ts-ignore 142 | type Locator = { 143 | using: string; 144 | value?: string; 145 | selector?: any; 146 | }; 147 | 148 | // @ts-ignore 149 | type Point = { 150 | x: number; 151 | y: number; 152 | }; 153 | } 154 | } 155 | 156 | export { FlutterIntegrationDriverService }; 157 | -------------------------------------------------------------------------------- /.github/workflows/appium3._android.yml: -------------------------------------------------------------------------------- 1 | name: Appium Flutter Integration Driver with Appium 3 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | CI: true 8 | 9 | jobs: 10 | Android_E2E_WDIO: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | api-level: [35] 15 | target: [google_apis] 16 | steps: 17 | - name: Check out my other private repo 18 | uses: actions/checkout@master 19 | - name: Set up JDK 17 20 | uses: actions/setup-java@v3 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | - name: Setup Android SDK 25 | uses: android-actions/setup-android@v2.0.10 26 | 27 | - name: 'List files' 28 | run: | 29 | version=$(grep 'appium_flutter_server:' README.md | awk '{print $2}') 30 | android_app="https://github.com/AppiumTestDistribution/appium-flutter-server/releases/download/$version/app-debug.apk" 31 | echo "$android_app" 32 | curl -LO $android_app 33 | ls ${{ github.workspace }} 34 | echo "APP_PATH=${{ github.workspace }}/app-debug.apk" >> $GITHUB_ENV 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: 22 38 | - name: Enable KVM group perms 39 | run: | 40 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 41 | sudo udevadm control --reload-rules 42 | sudo udevadm trigger --name-match=kvm 43 | - name: Linting 44 | run: | 45 | npm run build-flutter-by-service 46 | npm install --no-package-lock 47 | npm run prettier-check 48 | - name: Build Driver 49 | run: | 50 | npm run build 51 | - name: Install Drivers 52 | run: | 53 | npm install -g appium 54 | appium driver list 55 | - name: run tests 56 | uses: reactivecircus/android-emulator-runner@v2 57 | with: 58 | api-level: ${{ matrix.api-level }} 59 | target: ${{ matrix.target }} 60 | arch: x86_64 61 | profile: Nexus 6 62 | script: | 63 | echo ${{ env }} 64 | adb devices 65 | appium driver list 66 | mkdir ${{ github.workspace }}/appium-logs 67 | adb logcat > ${{ github.workspace }}/appium-logs/flutter.txt & 68 | echo $android_app 69 | APP_PATH=${{ env.APP_PATH }} npm run wdio-android 70 | # appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && 71 | - name: upload appium logs 72 | if: always() 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: appium-logs 76 | path: ${{ github.workspace }}/appium-logs 77 | # Android_E2E_JAVA: 78 | # runs-on: ubuntu-latest 79 | # strategy: 80 | # matrix: 81 | # api-level: [ 29 ] 82 | # target: [ google_apis ] 83 | # steps: 84 | # - name: Check out my other private repo 85 | # uses: actions/checkout@master 86 | # - name: Set up JDK 17 87 | # uses: actions/setup-java@v3 88 | # with: 89 | # java-version: '17' 90 | # distribution: 'adopt' 91 | # - name: Setup Android SDK 92 | # uses: android-actions/setup-android@v2.0.10 93 | 94 | # - name: 'List files' 95 | # run: | 96 | # release_info=$(curl -s https://api.github.com/repos/AppiumTestDistribution/appium-flutter-server/releases/latest) 97 | # asset_urls=$(echo "$release_info" | grep "browser_download_url" | cut -d '"' -f 4) 98 | # android_app=$(echo "$asset_urls" | head -n 1) 99 | # echo "$android_app" 100 | # ios_app=$(echo "$asset_urls" | tail -n 1) 101 | # echo "$ios_app" 102 | # curl -LO $android_app 103 | # ls ${{ github.workspace }} 104 | # echo "APP_PATH=${{ github.workspace }}/app-debug.apk" >> $GITHUB_ENV 105 | # - uses: actions/setup-node@v4 106 | # with: 107 | # node-version: 20 108 | # - name: Enable KVM group perms 109 | # run: | 110 | # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 111 | # sudo udevadm control --reload-rules 112 | # sudo udevadm trigger --name-match=kvm 113 | # - name: Linting 114 | # run: | 115 | # npm install --no-package-lock 116 | # npm run prettier-check 117 | # - name: Build Driver 118 | # run: | 119 | # npm run build 120 | # - name: Install Drivers 121 | # run: | 122 | # npm install -g appium@2.19.0 123 | # appium driver list 124 | # - name: run tests 125 | # uses: reactivecircus/android-emulator-runner@v2 126 | # with: 127 | # api-level: ${{ matrix.api-level }} 128 | # target: ${{ matrix.target }} 129 | # arch: x86_64 130 | # profile: Nexus 6 131 | # script: | 132 | # echo ${{ env }} 133 | # adb devices 134 | # node --version 135 | # appium driver list 136 | # mkdir ${{ github.workspace }}/appium-logs 137 | # adb logcat > ${{ github.workspace }}/appium-logs/flutter.txt & 138 | # echo $android_app 139 | # ls 140 | # APP_PATH=${{ env.APP_PATH }} Platform=android ./gradlew clean test 141 | # # appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && 142 | # - name: upload appium logs 143 | # if: always() 144 | # uses: actions/upload-artifact@v4 145 | # with: 146 | # name: appium-logs-java 147 | # path: ${{ github.workspace }}/finder/flutter-by/java/appium.log 148 | -------------------------------------------------------------------------------- /src/commands/element.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { getProxyDriver, FLUTTER_LOCATORS } from '../utils'; 3 | import { JWProxy } from 'appium/driver'; 4 | import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; 5 | // @ts-ignore 6 | import { XCUITestDriver } from 'appium-xcuitest-driver'; 7 | // @ts-ignore 8 | import { Mac2Driver } from 'appium-mac2-driver'; 9 | import { W3C_ELEMENT_KEY } from 'appium/driver'; 10 | import type { AppiumFlutterDriver } from '../driver'; 11 | 12 | export const ELEMENT_CACHE = new Map(); 13 | 14 | export function constructFindElementPayload( 15 | strategy: string, 16 | selector: string, 17 | context?: any, 18 | proxyDriver?: XCUITestDriver | AndroidUiautomator2Driver | Mac2Driver, 19 | ) { 20 | if (!strategy || !selector) { 21 | return undefined; 22 | } 23 | const isFlutterLocator = 24 | strategy.startsWith('-flutter') || FLUTTER_LOCATORS.includes(strategy); 25 | 26 | let parsedSelector = selector; 27 | if ( 28 | ['-flutter descendant', '-flutter ancestor'].includes(strategy) && 29 | _.isString(selector) 30 | ) { 31 | parsedSelector = JSON.parse(selector); 32 | } 33 | 34 | // If user is looking for Native IOS/Mac locator 35 | if ( 36 | !isFlutterLocator && 37 | proxyDriver && 38 | (proxyDriver instanceof XCUITestDriver || 39 | proxyDriver instanceof Mac2Driver) 40 | ) { 41 | return { using: strategy, value: parsedSelector, context }; 42 | } else { 43 | return { strategy, selector: parsedSelector, context }; 44 | } 45 | } 46 | 47 | export async function findElOrEls( 48 | this: AppiumFlutterDriver, 49 | strategy: string, 50 | selector: string, 51 | mult: boolean, 52 | context: string, 53 | ): Promise { 54 | const driver = await getProxyDriver.bind(this)(strategy); 55 | let elementBody; 56 | 57 | elementBody = constructFindElementPayload( 58 | strategy, 59 | selector, 60 | context, 61 | this.proxydriver, 62 | ); 63 | if (mult) { 64 | const response = await driver.command('/elements', 'POST', elementBody); 65 | response.forEach((element: any) => { 66 | ELEMENT_CACHE.set(element.ELEMENT || element[W3C_ELEMENT_KEY], driver); 67 | }); 68 | return response; 69 | } else { 70 | const element = await driver.command('/element', 'POST', elementBody); 71 | ELEMENT_CACHE.set(element.ELEMENT || element[W3C_ELEMENT_KEY], driver); 72 | return element; 73 | } 74 | } 75 | 76 | export async function click(this: AppiumFlutterDriver, element: string) { 77 | const driver = ELEMENT_CACHE.get(element); 78 | 79 | if (this.proxydriver instanceof Mac2Driver) { 80 | this.log.debug('Mac2Driver detected, using non-blocking click'); 81 | 82 | try { 83 | // Working around Mac2Driver issues which is blocking click request when clicking on Flutter elements opens native dialog 84 | // For Flutter elements, we just verify the element is in our cache 85 | if (!ELEMENT_CACHE.has(element)) { 86 | throw new Error('Element not found in cache'); 87 | } 88 | 89 | // Element exists, send click command 90 | driver 91 | .command(`/element/${element}/click`, 'POST', { 92 | element, 93 | }) 94 | .catch((err: Error) => { 95 | // Log error but don't block 96 | this.log.debug( 97 | `Click command sent (non-blocking). Any error: ${err.message}`, 98 | ); 99 | }); 100 | 101 | // Return success since element check passed 102 | return true; 103 | } catch (err) { 104 | // Element check failed - this is a legitimate error we should report 105 | this.log.error('Element validation failed before click:', err); 106 | throw new Error(`Element validation failed: ${err.message}`); 107 | } 108 | } else { 109 | // For other drivers, proceed with normal click behavior 110 | return await driver.command(`/element/${element}/click`, 'POST', { 111 | element, 112 | }); 113 | } 114 | } 115 | 116 | export async function getText(this: AppiumFlutterDriver, elementId: string) { 117 | const driver = ELEMENT_CACHE.get(elementId); 118 | return String(await driver.command(`/element/${elementId}/text`, 'GET', {})); 119 | } 120 | 121 | export async function getElementRect( 122 | this: AppiumFlutterDriver, 123 | elementId: string, 124 | ) { 125 | const driver = ELEMENT_CACHE.get(elementId); 126 | return await driver.command(`/element/${elementId}/rect`, 'GET', {}); 127 | } 128 | 129 | export async function elementEnabled( 130 | this: AppiumFlutterDriver, 131 | elementId: string, 132 | ) { 133 | return toBool(await this.getAttribute('enabled', elementId)); 134 | } 135 | 136 | export async function getAttribute( 137 | this: AppiumFlutterDriver, 138 | attribute: string, 139 | elementId: string, 140 | ) { 141 | const driver = ELEMENT_CACHE.get(elementId); 142 | return await driver.command( 143 | `/element/${elementId}/attribute/${attribute}`, 144 | 'GET', 145 | {}, 146 | ); 147 | } 148 | 149 | export async function setValue( 150 | this: AppiumFlutterDriver, 151 | text: string, 152 | elementId: string, 153 | ) { 154 | const driver = ELEMENT_CACHE.get(elementId); 155 | return await driver.command(`/element/${elementId}/value`, 'POST', { 156 | text: text && _.isArray(text) ? text.join('') : text, // text should always be a string 157 | value: text && _.isString(text) ? [...text] : text, // value should always be a array of char 158 | }); 159 | } 160 | 161 | export async function clear(this: AppiumFlutterDriver, elementId: string) { 162 | const driver = ELEMENT_CACHE.get(elementId); 163 | return await driver.command(`/element/${elementId}/clear`, 'POST', { 164 | elementId, 165 | }); 166 | } 167 | 168 | export async function elementDisplayed( 169 | this: AppiumFlutterDriver, 170 | elementId: string, 171 | ) { 172 | return await this.getAttribute('displayed', elementId); 173 | } 174 | 175 | function toBool(s: string | boolean) { 176 | return _.isString(s) ? s.toLowerCase() === 'true' : !!s; 177 | } 178 | -------------------------------------------------------------------------------- /flutter-finder/.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: Appium Flutter Integration Driver 5 | jobs: 6 | Java_Test_Android: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | api-level: [29] 11 | target: [google_apis] 12 | steps: 13 | - name: Check out my other private repo 14 | uses: actions/checkout@master 15 | - name: Set up JDK 17 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: '17' 19 | distribution: 'adopt' 20 | - name: Setup Android SDK 21 | uses: android-actions/setup-android@v2.0.10 22 | 23 | - name: 'List files' 24 | run: | 25 | release_info=$(curl -s https://api.github.com/repos/AppiumTestDistribution/appium-flutter-server/releases/latest) 26 | asset_urls=$(echo "$release_info" | grep "browser_download_url" | cut -d '"' -f 4) 27 | android_app=$(echo "$asset_urls" | head -n 1) 28 | echo "$android_app" 29 | ios_app=$(echo "$asset_urls" | tail -n 1) 30 | echo "$ios_app" 31 | curl -LO $android_app 32 | ls ${{ github.workspace }} 33 | echo "APP_PATH=${{ github.workspace }}/app-debug.apk" >> $GITHUB_ENV 34 | - uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | - name: Enable KVM group perms 38 | run: | 39 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 40 | sudo udevadm control --reload-rules 41 | sudo udevadm trigger --name-match=kvm 42 | - name: Setup gradle 43 | uses: gradle/actions/setup-gradle@v3 44 | - name: run tests 45 | uses: reactivecircus/android-emulator-runner@v2 46 | with: 47 | api-level: ${{ matrix.api-level }} 48 | target: ${{ matrix.target }} 49 | arch: x86_64 50 | profile: Nexus 6 51 | working-directory: ${{ github.workspace }}/flutter-by/java 52 | script: | 53 | echo ${{ env }} 54 | adb devices 55 | node --version 56 | mkdir ${{ github.workspace }}/appium-logs 57 | npm install -g wait-on 58 | npm install -g appium@2.19.0 59 | appium driver install uiautomator2@4.0.0 60 | appium driver install --source npm appium-flutter-integration-driver 61 | appium driver list 62 | appium plugin list 63 | adb logcat > ${{ github.workspace }}/appium-logs/flutter.txt & 64 | Platform=android APP_PATH=${{ env.APP_PATH }} ./gradlew clean build 65 | # appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && 66 | - name: upload appium logs 67 | if: always() 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: appium-logs 71 | path: ${{ github.workspace }}/flutter-by/java/build/reports 72 | WDIO_Test_Android: 73 | runs-on: ubuntu-latest 74 | strategy: 75 | matrix: 76 | api-level: [ 29 ] 77 | target: [ google_apis ] 78 | steps: 79 | - name: Check out my other private repo 80 | uses: actions/checkout@master 81 | - name: Set up JDK 17 82 | uses: actions/setup-java@v3 83 | with: 84 | java-version: '17' 85 | distribution: 'adopt' 86 | - name: Setup Android SDK 87 | uses: android-actions/setup-android@v2.0.10 88 | 89 | - name: 'List files' 90 | run: | 91 | release_info=$(curl -s https://api.github.com/repos/AppiumTestDistribution/appium-flutter-server/releases/latest) 92 | asset_urls=$(echo "$release_info" | grep "browser_download_url" | cut -d '"' -f 4) 93 | android_app=$(echo "$asset_urls" | head -n 1) 94 | echo "$android_app" 95 | ios_app=$(echo "$asset_urls" | tail -n 1) 96 | echo "$ios_app" 97 | curl -LO $android_app 98 | ls ${{ github.workspace }} 99 | echo "APP_PATH=${{ github.workspace }}/app-debug.apk" >> $GITHUB_ENV 100 | - uses: actions/setup-node@v4 101 | with: 102 | node-version: 20 103 | - name: Enable KVM group perms 104 | run: | 105 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 106 | sudo udevadm control --reload-rules 107 | sudo udevadm trigger --name-match=kvm 108 | - name: run tests 109 | uses: reactivecircus/android-emulator-runner@v2 110 | with: 111 | api-level: ${{ matrix.api-level }} 112 | target: ${{ matrix.target }} 113 | arch: x86_64 114 | profile: Nexus 6 115 | working-directory: ${{ github.workspace }}/flutter-by/wdio-flutter-by-service 116 | script: | 117 | echo ${{ env }} 118 | adb devices 119 | node --version 120 | mkdir ${{ github.workspace }}/appium-logs 121 | npm install 122 | npm run build 123 | npm install -g wait-on 124 | npm install -g appium@2.19.0 125 | appium driver install uiautomator2@4.0.0 126 | appium driver install --source npm appium-flutter-integration-driver 127 | appium driver list 128 | appium plugin list 129 | adb logcat > ${{ github.workspace }}/appium-logs/flutter.txt & 130 | APP_PATH=${{ env.APP_PATH }} npm run wdio-android 131 | # appium server -pa=/wd/hub & wait-on http://127.0.0.1:4723/wd/hub/status && 132 | - name: upload appium logs 133 | if: always() 134 | uses: actions/upload-artifact@v4 135 | with: 136 | name: appium-logs 137 | path: ${{ github.workspace }}/flutter-by/java/build/reports 138 | -------------------------------------------------------------------------------- /test/unit/element.specs.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import sinon from 'sinon'; 3 | import * as utils from '../../src/utils'; 4 | import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; 5 | // @ts-ignore 6 | import { XCUITestDriver } from 'appium-xcuitest-driver'; 7 | // @ts-ignore 8 | import { Mac2Driver } from 'appium-mac2-driver'; 9 | import { W3C_ELEMENT_KEY } from 'appium/driver'; 10 | import { 11 | ELEMENT_CACHE, 12 | clear, 13 | click, 14 | elementDisplayed, 15 | elementEnabled, 16 | findElOrEls, 17 | getAttribute, 18 | getElementRect, 19 | getText, 20 | setValue, 21 | } from '../../src/commands/element'; 22 | 23 | describe('Element Interaction Functions', () => { 24 | let sandbox, chai, expect; 25 | let mockDriver; 26 | let mockAppiumFlutterDriver; 27 | 28 | before(async function () { 29 | chai = await import('chai'); 30 | const chaiAsPromised = await import('chai-as-promised'); 31 | 32 | chai.should(); 33 | chai.use(chaiAsPromised.default); 34 | 35 | expect = chai.expect; 36 | }); 37 | 38 | beforeEach(() => { 39 | sandbox = sinon.createSandbox(); 40 | mockDriver = { 41 | command: sandbox.stub().resolves({}) as any, 42 | }; 43 | mockAppiumFlutterDriver = { 44 | proxydriver: {}, 45 | }; 46 | sandbox.stub(utils, 'getProxyDriver').resolves(mockDriver); 47 | }); 48 | 49 | afterEach(() => { 50 | sandbox.restore(); 51 | ELEMENT_CACHE.clear(); 52 | }); 53 | 54 | describe('findElOrEls', () => { 55 | it('should find a single element correctly', async () => { 56 | const element = { ELEMENT: 'elem1' }; 57 | mockDriver.command.resolves(element as any); 58 | mockDriver.constructor = { name: 'NotJWProxy' }; 59 | mockAppiumFlutterDriver.proxydriver = { 60 | constructor: { name: 'NotAndroidUiautomator2Driver' }, 61 | }; 62 | 63 | const result = await findElOrEls.call( 64 | mockAppiumFlutterDriver, 65 | 'strategy', 66 | 'selector', 67 | false, 68 | 'context', 69 | ); 70 | 71 | expect(result).to.deep.equal(element); 72 | expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); 73 | // Since proxydriver is not Mac2Driver, XCUITestDriver, or AndroidUiautomator2Driver 74 | expect( 75 | mockDriver.command.calledWith('/element', 'POST', { 76 | strategy: 'strategy', 77 | selector: 'selector', 78 | context: 'context', 79 | }), 80 | ).to.be.true; 81 | }); 82 | 83 | it('should find multiple elements correctly', async () => { 84 | const elements = [{ ELEMENT: 'elem1' }, { ELEMENT: 'elem2' }]; 85 | mockDriver.command.resolves(elements); 86 | mockDriver.constructor = { name: 'NotJWProxy' }; 87 | mockAppiumFlutterDriver.proxydriver = { 88 | constructor: { name: 'NotAndroidUiautomator2Driver' }, 89 | }; 90 | 91 | const result = await findElOrEls.call( 92 | mockAppiumFlutterDriver, 93 | 'strategy', 94 | 'selector', 95 | true, 96 | 'context', 97 | ); 98 | 99 | expect(result).to.deep.equal(elements); 100 | expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); 101 | expect(ELEMENT_CACHE.get('elem2')).to.equal(mockDriver); 102 | expect( 103 | mockDriver.command.calledWith('/elements', 'POST', { 104 | strategy: 'strategy', 105 | selector: 'selector', 106 | context: 'context', 107 | }), 108 | ).to.be.true; 109 | }); 110 | 111 | it('should handle W3C element key', async () => { 112 | const element = { [W3C_ELEMENT_KEY]: 'elem1' }; 113 | mockDriver.command.resolves(element); 114 | 115 | await findElOrEls.call( 116 | mockAppiumFlutterDriver, 117 | 'strategy', 118 | 'selector', 119 | false, 120 | 'context', 121 | ); 122 | 123 | expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); 124 | }); 125 | 126 | it('should use different element body for AndroidUiautomator2Driver', async () => { 127 | mockAppiumFlutterDriver.proxydriver = new AndroidUiautomator2Driver(); 128 | 129 | await findElOrEls.call( 130 | mockAppiumFlutterDriver, 131 | 'strategy', 132 | 'selector', 133 | false, 134 | 'context', 135 | ); 136 | 137 | expect( 138 | mockDriver.command.calledWith('/element', 'POST', { 139 | strategy: 'strategy', 140 | selector: 'selector', 141 | context: 'context', 142 | }), 143 | ).to.be.true; 144 | }); 145 | 146 | it('should use different element body for XCUITestDriver', async () => { 147 | mockAppiumFlutterDriver.proxydriver = new XCUITestDriver(); 148 | 149 | await findElOrEls.call( 150 | mockAppiumFlutterDriver, 151 | 'strategy', 152 | 'selector', 153 | false, 154 | 'context', 155 | ); 156 | 157 | expect( 158 | mockDriver.command.calledWith('/element', 'POST', { 159 | using: 'strategy', 160 | value: 'selector', 161 | context: 'context', 162 | }), 163 | ).to.be.true; 164 | }); 165 | 166 | it('should use different element body for Mac2Driver', async () => { 167 | mockAppiumFlutterDriver.proxydriver = new Mac2Driver(); 168 | 169 | await findElOrEls.call( 170 | mockAppiumFlutterDriver, 171 | 'strategy', 172 | 'selector', 173 | false, 174 | 'context', 175 | ); 176 | expect( 177 | mockDriver.command.calledWith('/element', 'POST', { 178 | using: 'strategy', 179 | value: 'selector', 180 | context: 'context', 181 | }), 182 | ).to.be.true; 183 | }); 184 | }); 185 | 186 | describe('click', () => { 187 | it('should find a single element correctly', async () => { 188 | const element = { ELEMENT: 'elem1' }; 189 | mockDriver.command.resolves(element as any); 190 | mockDriver.constructor = { name: 'NotJWProxy' }; 191 | mockAppiumFlutterDriver.proxydriver = { 192 | constructor: { name: 'NotAndroidUiautomator2Driver' }, 193 | }; 194 | 195 | const result = await findElOrEls.call( 196 | mockAppiumFlutterDriver, 197 | 'strategy', 198 | 'selector', 199 | false, 200 | 'context', 201 | ); 202 | 203 | expect(result).to.deep.equal(element); 204 | expect(ELEMENT_CACHE.get('elem1')).to.equal(mockDriver); 205 | expect( 206 | mockDriver.command.calledWith('/element', 'POST', { 207 | strategy: 'strategy', 208 | selector: 'selector', 209 | context: 'context', 210 | }), 211 | ).to.be.true; 212 | }); 213 | }); 214 | 215 | describe('getText', () => { 216 | it('should get text from an element correctly', async () => { 217 | const elementId = 'elem1'; 218 | ELEMENT_CACHE.set(elementId, mockDriver); 219 | mockDriver.command.resolves('Some text'); 220 | 221 | const result = await getText.call(mockAppiumFlutterDriver, elementId); 222 | 223 | expect(result).to.equal('Some text'); 224 | expect( 225 | mockDriver.command.calledWith( 226 | `/element/${elementId}/text`, 227 | 'GET', 228 | {}, 229 | ), 230 | ).to.be.true; 231 | }); 232 | }); 233 | 234 | describe('getRect', () => { 235 | it('should get rect from an element correctly', async () => { 236 | const elementId = 'elem1'; 237 | ELEMENT_CACHE.set(elementId, mockDriver); 238 | mockDriver.command.resolves( 239 | '{"x": 10, "y": 20, "width": 100, "height": 50}', 240 | ); 241 | 242 | const result = await getElementRect.call( 243 | mockAppiumFlutterDriver, 244 | elementId, 245 | ); 246 | 247 | expect(result).to.equal( 248 | '{"x": 10, "y": 20, "width": 100, "height": 50}', 249 | ); 250 | expect( 251 | mockDriver.command.calledWith( 252 | `/element/${elementId}/rect`, 253 | 'GET', 254 | {}, 255 | ), 256 | ).to.be.true; 257 | }); 258 | }); 259 | 260 | describe('getAttribute', () => { 261 | it('should get an attribute from an element correctly', async () => { 262 | const elementId = 'elem1'; 263 | const attribute = 'someAttribute'; 264 | ELEMENT_CACHE.set(elementId, mockDriver); 265 | mockDriver.command.resolves('attributeValue'); 266 | 267 | const result = await getAttribute.call( 268 | mockAppiumFlutterDriver, 269 | attribute, 270 | elementId, 271 | ); 272 | 273 | expect(result).to.equal('attributeValue'); 274 | expect( 275 | mockDriver.command.calledWith( 276 | `/element/${elementId}/attribute/${attribute}`, 277 | 'GET', 278 | {}, 279 | ), 280 | ).to.be.true; 281 | }); 282 | }); 283 | 284 | describe('setValue', () => { 285 | it('should set a value for an element correctly', async () => { 286 | const elementId = 'elem1'; 287 | const text = 'Some text'; 288 | ELEMENT_CACHE.set(elementId, mockDriver); 289 | 290 | await setValue.call(mockAppiumFlutterDriver, text, elementId); 291 | 292 | expect( 293 | mockDriver.command.calledWith( 294 | `/element/${elementId}/value`, 295 | 'POST', 296 | { text, value: [...text] }, 297 | ), 298 | ).to.be.true; 299 | }); 300 | }); 301 | 302 | describe('clear', () => { 303 | it('should clear an element correctly', async () => { 304 | const elementId = 'elem1'; 305 | ELEMENT_CACHE.set(elementId, mockDriver); 306 | 307 | await clear.call(mockAppiumFlutterDriver, elementId); 308 | 309 | expect( 310 | mockDriver.command.calledWith( 311 | `/element/${elementId}/clear`, 312 | 'POST', 313 | { elementId }, 314 | ), 315 | ).to.be.true; 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/iProxy.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import net from 'net'; 3 | import B from 'bluebird'; 4 | import { logger, util, timing } from 'appium/support'; 5 | import { utilities } from 'appium-ios-device'; 6 | import { checkPortStatus } from 'portscanner'; 7 | import { waitForCondition } from 'asyncbox'; 8 | import type { AppiumLogger } from '@appium/types'; 9 | 10 | const LOCALHOST = '127.0.0.1'; 11 | 12 | class iProxy { 13 | private readonly localport: number; 14 | private deviceport: number; 15 | private udid: any; 16 | private localServer: any; 17 | private log: AppiumLogger; 18 | private onBeforeProcessExit: any; 19 | constructor(udid: any, localport: any, deviceport: any) { 20 | this.localport = parseInt(localport, 10); 21 | this.deviceport = parseInt(deviceport, 10); 22 | this.udid = udid; 23 | this.localServer = null; 24 | this.log = logger.getLogger( 25 | `iProxy@${udid.substring(0, 8)}:${this.localport}`, 26 | ); 27 | } 28 | 29 | async start() { 30 | if (this.localServer) { 31 | return; 32 | } 33 | 34 | this.localServer = net.createServer(async (localSocket) => { 35 | let remoteSocket: any; 36 | try { 37 | // We can only connect to the remote socket after the local socket connection succeeds 38 | remoteSocket = await utilities.connectPort( 39 | this.udid, 40 | this.deviceport, 41 | ); 42 | } catch (e: any) { 43 | this.log.debug(e.message); 44 | localSocket.destroy(); 45 | return; 46 | } 47 | 48 | const destroyCommChannel = () => { 49 | remoteSocket.unpipe(localSocket); 50 | localSocket.unpipe(remoteSocket); 51 | }; 52 | remoteSocket.once('close', () => { 53 | destroyCommChannel(); 54 | localSocket.destroy(); 55 | }); 56 | // not all remote socket errors are critical for the user 57 | remoteSocket.on('error', (e: any) => this.log.debug(e)); 58 | localSocket.once('end', destroyCommChannel); 59 | localSocket.once('close', () => { 60 | destroyCommChannel(); 61 | remoteSocket.destroy(); 62 | }); 63 | localSocket.on('error', (e) => this.log.warn(e.message)); 64 | localSocket.pipe(remoteSocket); 65 | remoteSocket.pipe(localSocket); 66 | }); 67 | const listeningPromise = new B((resolve: any, reject: any) => { 68 | /** @type {net.Server} */ this.localServer.once('listening', resolve); 69 | /** @type {net.Server} */ this.localServer.once('error', reject); 70 | }); 71 | this.localServer.listen(this.localport); 72 | try { 73 | await listeningPromise; 74 | } catch (e) { 75 | this.localServer = null; 76 | throw e; 77 | } 78 | this.localServer.on('error', (e: any) => this.log.warn(e.message)); 79 | this.localServer.once('close', (e: any) => { 80 | if (e) { 81 | this.log.info( 82 | `The connection has been closed with error ${e.message}`, 83 | ); 84 | } else { 85 | this.log.info('The connection has been closed'); 86 | } 87 | this.localServer = null; 88 | }); 89 | 90 | this.onBeforeProcessExit = this._closeLocalServer.bind(this); 91 | // Make sure we free up the socket on process exit 92 | process.on('beforeExit', this.onBeforeProcessExit); 93 | } 94 | 95 | _closeLocalServer() { 96 | if (!this.localServer) { 97 | return; 98 | } 99 | 100 | this.log.debug('Closing the connection'); 101 | this.localServer.close(); 102 | this.localServer = null; 103 | } 104 | 105 | stop() { 106 | if (this.onBeforeProcessExit) { 107 | process.off('beforeExit', this.onBeforeProcessExit); 108 | this.onBeforeProcessExit = null; 109 | } 110 | 111 | this._closeLocalServer(); 112 | } 113 | } 114 | 115 | const log = logger.getLogger('DevCon Factory Device Farm'); 116 | const PORT_CLOSE_TIMEOUT = 15 * 1000; // 15 seconds 117 | const SPLITTER = ':'; 118 | 119 | class DeviceConnectionsFactory { 120 | private _connectionsMapping: any; 121 | constructor() { 122 | this._connectionsMapping = {}; 123 | } 124 | 125 | _udidAsToken(udid: any) { 126 | return `${util.hasValue(udid) ? udid : ''}${SPLITTER}`; 127 | } 128 | 129 | _portAsToken(port: any) { 130 | return `${SPLITTER}${util.hasValue(port) ? port : ''}`; 131 | } 132 | 133 | _toKey(udid: string, port: number) { 134 | return `${util.hasValue(udid) ? udid : ''}${SPLITTER}${util.hasValue(port) ? port : ''}`; 135 | } 136 | 137 | _releaseProxiedConnections(connectionKeys: any) { 138 | const keys = connectionKeys.filter((k: any) => 139 | _.has(this._connectionsMapping[k], 'iproxy'), 140 | ); 141 | for (const key of keys) { 142 | log.info(`Releasing the listener for '${key}'`); 143 | try { 144 | this._connectionsMapping[key].iproxy.stop(); 145 | } catch (e) { 146 | log.debug(e); 147 | } 148 | } 149 | return keys; 150 | } 151 | 152 | listConnections(udid: string | null, port: number, strict = false) { 153 | if (!udid && !port) { 154 | return []; 155 | } 156 | 157 | // `this._connectionMapping` keys have format `udid:port` 158 | // the `strict` argument enforces to match keys having both `udid` and `port` 159 | // if they are defined 160 | // while in non-strict mode keys having any of these are going to be matched 161 | return _.keys(this._connectionsMapping).filter((key) => 162 | strict && udid && port 163 | ? key === this._toKey(udid, port) 164 | : (udid && key.startsWith(this._udidAsToken(udid))) || 165 | (port && key.endsWith(this._portAsToken(port))), 166 | ); 167 | } 168 | 169 | async requestConnection(udid: any, port: any, options: any) { 170 | if (!udid || !port) { 171 | log.warn('Did not know how to request the connection:'); 172 | if (!udid) { 173 | log.warn('- Device UDID is unset'); 174 | } 175 | if (!port) { 176 | log.warn('- The local port number is unset'); 177 | } 178 | return; 179 | } 180 | 181 | const { usePortForwarding, devicePort } = options; 182 | 183 | log.info( 184 | `Requesting connection for device ${udid} on local port ${port}` + 185 | (devicePort ? `, device port ${devicePort}` : ''), 186 | ); 187 | log.debug( 188 | `Cached connections count: ${_.size(this._connectionsMapping)}`, 189 | ); 190 | const connectionsOnPort = this.listConnections(null, port); 191 | if (!_.isEmpty(connectionsOnPort)) { 192 | log.info( 193 | `Found cached connections on port #${port}: ${JSON.stringify(connectionsOnPort)}`, 194 | ); 195 | } 196 | 197 | if (usePortForwarding) { 198 | let isPortBusy = (await checkPortStatus(port, LOCALHOST)) === 'open'; 199 | if (isPortBusy) { 200 | log.warn( 201 | `Port #${port} is busy. Did you quit the previous driver session(s) properly?`, 202 | ); 203 | if (!_.isEmpty(connectionsOnPort)) { 204 | log.info('Trying to release the port'); 205 | for (const key of this._releaseProxiedConnections( 206 | connectionsOnPort, 207 | )) { 208 | delete this._connectionsMapping[key]; 209 | } 210 | const timer = new timing.Timer().start(); 211 | try { 212 | await waitForCondition( 213 | async () => { 214 | try { 215 | if ( 216 | (await checkPortStatus(port, LOCALHOST)) !== 217 | 'open' 218 | ) { 219 | log.info( 220 | `Port #${port} has been successfully released after ` + 221 | `${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, 222 | ); 223 | isPortBusy = false; 224 | return true; 225 | } 226 | } catch (ign) { 227 | /* empty */ 228 | } 229 | return false; 230 | }, 231 | { 232 | waitMs: PORT_CLOSE_TIMEOUT, 233 | intervalMs: 300, 234 | }, 235 | ); 236 | } catch (ign) { 237 | log.warn( 238 | `Did not know how to release port #${port} in ` + 239 | `${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, 240 | ); 241 | } 242 | } 243 | } 244 | 245 | if (isPortBusy) { 246 | throw new Error( 247 | `The port #${port} is occupied by an other process. ` + 248 | 'You can either quit that process or select another free port.', 249 | ); 250 | } 251 | } 252 | const currentKey = this._toKey(udid, port); 253 | if (usePortForwarding) { 254 | const iproxy = new iProxy(udid, port, devicePort); 255 | try { 256 | await iproxy.start(); 257 | this._connectionsMapping[currentKey] = { iproxy }; 258 | } catch (e) { 259 | try { 260 | iproxy.stop(); 261 | } catch (e1) { 262 | log.debug(e1); 263 | } 264 | throw e; 265 | } 266 | } else { 267 | this._connectionsMapping[currentKey] = {}; 268 | } 269 | log.info(`Successfully requested the connection for ${currentKey}`); 270 | } 271 | 272 | releaseConnection(udid: string, port: number) { 273 | if (!udid && !port) { 274 | log.warn( 275 | 'Neither device UDID nor local port is set. ' + 276 | 'Did not know how to release the connection', 277 | ); 278 | return; 279 | } 280 | log.info( 281 | `Releasing connections for ${udid || 'any'} device on ${port || 'any'} port number`, 282 | ); 283 | 284 | const keys = this.listConnections(udid, port, true); 285 | if (_.isEmpty(keys)) { 286 | log.info('No cached connections have been found'); 287 | return; 288 | } 289 | log.info(`Found cached connections to release: ${JSON.stringify(keys)}`); 290 | this._releaseProxiedConnections(keys); 291 | for (const key of keys) { 292 | delete this._connectionsMapping[key]; 293 | } 294 | log.debug( 295 | `Cached connections count: ${_.size(this._connectionsMapping)}`, 296 | ); 297 | } 298 | } 299 | 300 | const DEVICE_CONNECTIONS_FACTORY = new DeviceConnectionsFactory(); 301 | 302 | export { DEVICE_CONNECTIONS_FACTORY, DeviceConnectionsFactory }; 303 | export default DEVICE_CONNECTIONS_FACTORY; 304 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Flutter-Appium 4 |
5 |
6 |
7 |

8 | Appium Flutter Integration Driver is a test automation tool for Flutter apps on multiple platforms/OSes. It is part of the Appium mobile test automation tool maintained by the community. Feel free to create PRs to fix issues or improve this driver. 9 | 10 | ## Native Flutter Integration Driver vs Appium Flutter Integration Driver 11 | 12 | | Use Cases | Native Flutter Driver | Appium Flutter Integration Driver | 13 | | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------- | --------------------------------- | 14 | | Writing tests in languages other than Dart | ❌ | ✔️ | 15 | | Running integration tests for Flutter apps with embedded webview or native view, or existing native apps with embedded Flutter view | ❌ | ✔️ | 16 | | Running tests on multiple devices simultaneously | ❌ | ✔️ | 17 | | Running integration tests on device farms that offer Appium support | ❌ | ✔️ | 18 | | App interactions beyond Flutter’s contextuality (e.g., sending an OTP from a message application) | ❌ | ✔️ | 19 | 20 | ## Differences from Appium Flutter Driver 21 | 22 | The current Appium Flutter Driver is built on top of the `flutter_test` SDK, which is deprecated. The potential deprecation ([Expand deprecation policy to package:flutter_driver](https://github.com/flutter/flutter/issues/139249)) means this driver may not work with future Flutter updates. It also does not handle all cases, such as permission dialog handling. 23 | 24 | ## Why Use Appium Flutter Integration Driver? 25 | 26 | This driver is built using [Flutter Integration Test](https://docs.flutter.dev/cookbook/testing/integration/introduction). 27 | 28 | :star: **Strong Typing & Fluent APIs:** Ensures robust and easy-to-use interfaces. 29 | 30 | :star: **Element Handling**: Automatically waits for elements to attach to the DOM before interacting. 31 | 32 | :star: **Seamless Context Switching**: No need to switch between contexts, such as Flutter and native; the driver handles it effortlessly. 33 | 34 | :star: **Auto Wait for Render Cycles**: Automatically waits for frame render cycles, including animations and screen transitions. 35 | 36 | :star: **Simplified Powerful Gestures**: Supports powerful yet simplified gestures like LongPress, ScrollToElement, DragAndDrop, and DoubleClick. 37 | 38 | :star: **Element Chaining**: Allows chaining of elements, enabling you to find child elements under a specific parent easily. 39 | 40 | 41 | ## Install the Flutter Integration Driver 42 | 43 | ```bash 44 | appium driver install --source npm appium-flutter-integration-driver 45 | ``` 46 | ## Prepare the app with Flutter Integration Server 47 | 48 | 1. In your Flutter app's `pubspec.yaml`, add the following dependencies: 49 | 50 | Get the latest version from `https://pub.dev/packages/appium_flutter_server/install` 51 | 52 | ```yaml 53 | dev_dependencies: 54 | appium_flutter_server: 0.0.33 55 | ``` 56 | 57 | 2. Create a directory called `integration_test` in the root of your Flutter project. 58 | 3. Create a file called `appium_test.dart` in the `integration_test` directory. 59 | 4. Add the following code to the `appium_test.dart` file: 60 | 61 | ```dart 62 | import 'package:appium_flutter_server/appium_flutter_server.dart'; 63 | import 'package:appium_testing_app/main.dart'; 64 | 65 | void main() { 66 | initializeTest(app: const MyApp()); 67 | } 68 | ``` 69 | If you are in need to configure certain prerequists before the testing app is loaded, you can try the following code: 70 | ```dart 71 | import 'package:appium_testing_app/main.dart'; as app; 72 | void main() { 73 | initializeTest( 74 | callback: (WidgetTester tester) async { 75 | // Perform any prerequisite steps or initialize any dependencies required by the app 76 | // and make sure to pump the app widget using below statement. 77 | await tester.pumpWidget(const app.MyApp()); 78 | }, 79 | ); 80 | } 81 | ``` 82 | 83 | 5. Build the Android app: 84 | 85 | ```bash 86 | ./gradlew app:assembleDebug -Ptarget=`pwd`/../integration_test/appium_test.dart 87 | ``` 88 | 89 | 6. Build the iOS app: 90 | For Simulator - Debug mode 91 | ```bash 92 | flutter build ios integration_test/appium_test.dart --simulator 93 | ``` 94 | For Real Device - Release mode 95 | ```bash 96 | flutter build ipa --release integration_test/appium_test.dart 97 | ``` 98 | 99 | 7. Build the MacOS app: 100 | ```bash 101 | flutter build macos --release integration_test/appium_test.dart 102 | ``` 103 | 104 | Bingo! You are ready to run your tests using Appium Flutter Integration Driver. 105 | 106 | Check if your Flutter app is running on the device or emulator. 107 | 108 | For Android 109 | ``` 110 | 1. Run adb command `adb logcat | grep flutter` to check if the Flutter app is running. 111 | 2. Open the application in the device or emulator manually. 112 | 3. Verify the logs in the console. 113 | ``` 114 | ``` 115 | 06-17 17:02:13.246 32697 32743 I flutter : The Dart VM service is listening on http://127.0.0.1:33339/E2REX61NaiI=/ 116 | 06-17 17:02:13.584 32697 32735 I flutter : 00:00 +0: appium flutter server 117 | 06-17 17:02:14.814 32697 32735 I flutter : shelfRun HTTP service running on port 9000 118 | 06-17 17:02:14.814 32697 32735 I flutter : [APPIUM FLUTTER] Appium flutter server is listening on port 9000 119 | 06-17 17:02:14.866 32697 32735 I flutter : [APPIUM FLUTTER] New Request [GET] http://127.0.0.1:10000/status 120 | 06-17 17:02:14.869 32697 32735 I flutter : [APPIUM FLUTTER] response {message: Flutter driver is ready to accept new connections, appInfo: {appName: appium_testing_app, buildNumber: 1, packageName: com.example.appium_testing_app, version: 1.0.0, buildSignature: F2C7CEC8F907AB830B7802C2178515D1FD4BEBA154E981FB61FFC8EC9A8F8195}} 121 | ``` 122 | 123 | For iOS 124 | Simulator: 125 | 126 | ```xcrun simctl spawn booted log stream | grep flutter``` 127 | 128 | Real Device: Check xcode device logs. 129 | 130 | 2. Open the application in the device or emulator manually. 131 | ``` 132 | 06-17 17:02:13.246 32697 32743 I flutter : The Dart VM service is listening on http://127.0.0.1:33339/E2REX61NaiI=/ 133 | 06-17 17:02:13.584 32697 32735 I flutter : 00:00 +0: appium flutter server 134 | 06-17 17:02:14.814 32697 32735 I flutter : shelfRun HTTP service running on port 9000 135 | 06-17 17:02:14.814 32697 32735 I flutter : [APPIUM FLUTTER] Appium flutter server is listening on port 9000 136 | 06-17 17:02:14.866 32697 32735 I flutter : [APPIUM FLUTTER] New Request [GET] http://127.0.0.1:10000/status 137 | 06-17 138 | ``` 139 | 140 | ## [Getting Started](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/wiki/Get-Started) 141 | ## [How to Inspect elements?](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/wiki/Flutter-Inspector) 142 | 143 | ## Appium Flutter Integration Driver vs. Appium UiAutomator2/XCUITest Driver 144 | 145 | - The driver manages the application under test and the device under test via Appium UiAutomator2/XCUITest drivers. 146 | - Newer Flutter versions expose their accessibility labels to the system's accessibility features. This means some Flutter elements can be found and interacted with using `accessibility_id` in the vanilla Appium UiAutomator2/XCUITest drivers, although some elements require interaction over the Dart VM. 147 | - Using native driver command will directly hit the Appium UiAutomator2/XCUITest driver. 148 | 149 | For more details, refer to the documentation for each driver: 150 | 151 | - [Appium UiAutomator2 Driver](https://github.com/appium/appium-uiautomator2-driver) 152 | - [Appium XCUITest Driver](https://appium.github.io/appium-xcuitest-driver/latest) 153 | 154 | ## Capabilities for Appium Flutter Integration Driver 155 | 156 | | Capability | Description | Required | 157 | |-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| 158 | | appium:flutterServerLaunchTimeout | Time in ms to wait for flutter server to be pingable. Default is 5000ms | No | 159 | | appium:flutterSystemPort | The number of the port on the host machine used for the Flutter server. By default the first free port from 10000..11000 range is selected. It is recommended to set this value if you are running parallel tests on the same machine. | No | 160 | | appium:flutterEnableMockCamera | Mock camera image. This works if the AUT uses [image_picker](https://pub.dev/packages/image_picker). Make sure the server is started with `--allow-insecure=adb_shell` for android | No | 161 | | appium:flutterElementWaitTimeout | Time in ms to wait for element to be in viewport, Default is 5000ms | No | 162 | | appium:flutterScrollMaxIteration | Max Iteration of scroll as an Integer value, Default value is 15 | No | 163 | | appium:flutterScrollDelta | The Scroll Delta as a double value, Default value is 64 | No | 164 | 165 | 166 | 🚨 **Important Notice for iOS Testing** 167 | 168 | ⚠️ Testing on real iOS devices for `semanticsLabel` may not work due to an issue raised with Flutter. For updates and more information, please refer to [GitHub issue #151238](https://github.com/flutter/flutter/issues/151238). 169 | 170 | ## Acknowledgements 💚 171 | 172 |

173 | LambdaTest 174 |

175 | 176 | We would also like to thank LambdaTest for their support in integrating the Appium Flutter Driver with their Real Device Cloud. For more information on running Appium Flutter Integration tests on LambdaTest, please refer to their [documentation](https://www.lambdatest.com/support/docs/appium-flutter-integration/). 177 | 178 |

179 | Sauce Labs 180 |

181 | 182 | We would like to extend our heartfelt thanks to Sauce Labs for integrating the Appium Flutter Driver with their Real Device Cloud. Their assistance has been invaluable in enabling us to deliver robust and reliable testing solutions for Flutter applications. If you want to try the Flutter Integration Driver on Sauce Labs, check their [docs](https://docs.saucelabs.com/mobile-apps/automated-testing/appium/appium-flutter-integration-driver/). Thank you, Sauce Labs, for your continuous support. 183 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; 2 | // @ts-ignore 3 | import { XCUITestDriver } from 'appium-xcuitest-driver'; 4 | // @ts-ignore 5 | import { Mac2Driver } from 'appium-mac2-driver'; 6 | import { findAPortNotInUse } from 'portscanner'; 7 | import { waitForCondition } from 'asyncbox'; 8 | import { JWProxy } from '@appium/base-driver'; 9 | import type { PortForwardCallback, PortReleaseCallback } from './types'; 10 | import { type AppiumFlutterDriver } from './driver'; 11 | import _ from 'lodash'; 12 | import type { StringRecord } from '@appium/types'; 13 | import { node } from 'appium/support'; 14 | import path from 'node:path'; 15 | import fs from 'node:fs'; 16 | import semver from 'semver'; 17 | 18 | const DEVICE_PORT_RANGE = [9000, 9020]; 19 | const SYSTEM_PORT_RANGE = [10000, 11000]; 20 | const { 21 | appium: { flutterServerVersion: FLUTTER_SERVER_VERSION_REQ }, 22 | version: PACKAGE_VERSION, 23 | } = readManifest(); 24 | export const FLUTTER_LOCATORS = [ 25 | 'key', 26 | 'semantics label', 27 | 'text', 28 | 'type', 29 | 'text containing', 30 | 'descendant', 31 | 'ancestor', 32 | ]; 33 | export async function getProxyDriver( 34 | this: AppiumFlutterDriver, 35 | strategy: string, 36 | ): Promise { 37 | if (strategy.startsWith('-flutter') || FLUTTER_LOCATORS.includes(strategy)) { 38 | this.log.debug( 39 | `getProxyDriver: using flutter driver, strategy: ${strategy}`, 40 | ); 41 | return this.proxy; 42 | } else if (this.proxydriver instanceof AndroidUiautomator2Driver) { 43 | this.log.debug( 44 | 'getProxyDriver: using AndroidUiautomator2Driver driver for Android', 45 | ); 46 | // @ts-ignore Proxy instance is OK 47 | return this.proxydriver.uiautomator2.jwproxy; 48 | } else if (this.proxydriver instanceof XCUITestDriver) { 49 | this.log.debug('getProxyDriver: using XCUITestDriver driver for iOS'); 50 | // @ts-ignore Proxy instance is OK 51 | return this.proxydriver.wda.jwproxy; 52 | } else if (this.proxydriver instanceof Mac2Driver) { 53 | this.log.debug('getProxyDriver: using Mac2Driver driver for mac'); 54 | // @ts-ignore Proxy instance is OK 55 | return this.proxydriver.wda.proxy; 56 | } else { 57 | throw new Error( 58 | `proxydriver is unknown type (${typeof this.proxydriver})`, 59 | ); 60 | } 61 | } 62 | 63 | export function isFlutterDriverCommand(command: string) { 64 | return ( 65 | [ 66 | 'createSession', 67 | 'deleteSession', 68 | 'getSession', 69 | 'getSessions', 70 | 'findElement', 71 | 'findElements', 72 | 'findElementFromElement', 73 | 'findElementsFromElement', 74 | 'click', 75 | 'getText', 76 | 'setValue', 77 | 'keys', 78 | 'getName', 79 | 'clear', 80 | 'elementSelected', 81 | 'elementEnabled', 82 | 'getAttribute', 83 | 'elementDisplayed', 84 | 'execute', 85 | 'getElementRect', 86 | 'getSize', 87 | ].indexOf(command) >= 0 88 | ); 89 | } 90 | 91 | export async function getFreePort(): Promise { 92 | const [start, end] = SYSTEM_PORT_RANGE; 93 | return await findAPortNotInUse(start, end); 94 | } 95 | 96 | export async function waitForFlutterServerToBeActive( 97 | this: AppiumFlutterDriver, 98 | proxy: JWProxy, 99 | packageName: string, 100 | flutterPort: number, 101 | ): Promise { 102 | await waitForCondition( 103 | async () => { 104 | let response: unknown; 105 | try { 106 | response = await proxy.command('/status', 'GET'); 107 | } catch (err: any) { 108 | this.log.info( 109 | `FlutterServer not reachable on port ${flutterPort}, Retrying..`, 110 | ); 111 | return false; 112 | } 113 | return validateServerStatus.bind(this)(response, packageName); 114 | }, 115 | { 116 | waitMs: this.opts.flutterServerLaunchTimeout ?? 5000, 117 | intervalMs: 150, 118 | }, 119 | ); 120 | } 121 | 122 | export async function waitForFlutterServer( 123 | this: AppiumFlutterDriver, 124 | port: number, 125 | packageName: string, 126 | ) { 127 | const proxy = new JWProxy({ 128 | server: '127.0.0.1', 129 | port: port, 130 | base: '', 131 | reqBasePath: '', 132 | }); 133 | await waitForFlutterServerToBeActive.bind(this)(proxy, packageName, port); 134 | } 135 | 136 | export async function fetchFlutterServerPort( 137 | this: AppiumFlutterDriver, 138 | { 139 | udid, 140 | systemPort, 141 | portForwardCallback, 142 | portReleaseCallback, 143 | packageName, 144 | isIosSimulator, 145 | }: { 146 | udid: string; 147 | systemPort?: number | null; 148 | portForwardCallback?: PortForwardCallback; 149 | portReleaseCallback?: PortReleaseCallback; 150 | packageName: string; 151 | isIosSimulator: boolean; 152 | }, 153 | ): Promise { 154 | const [startPort, endPort] = DEVICE_PORT_RANGE as [number, number]; 155 | let devicePort = startPort; 156 | let forwardedPort = systemPort; 157 | 158 | if (isIosSimulator && (systemPort || devicePort)) { 159 | try { 160 | this.log.info( 161 | `Checking if flutter server is running on port ${systemPort || devicePort} for simulator with id ${udid}`, 162 | ); 163 | await waitForFlutterServer.bind(this)( 164 | (systemPort || devicePort)!, 165 | packageName, 166 | ); 167 | this.log.info( 168 | `Flutter server is successfully running on port ${systemPort || devicePort}`, 169 | ); 170 | return (systemPort || devicePort)!; 171 | } catch (e) { 172 | return null; 173 | } 174 | } 175 | 176 | while (devicePort <= endPort) { 177 | /** 178 | * For ios simulators, we dont need a dedicated system port and no port forwarding is required 179 | * We need to use the same port range used by flutter server to check if the server is running 180 | */ 181 | if (isIosSimulator) { 182 | forwardedPort = devicePort; 183 | } 184 | 185 | if (portForwardCallback) { 186 | await portForwardCallback(udid, forwardedPort!, devicePort); 187 | } 188 | try { 189 | this.log.info( 190 | `Checking if flutter server is running on port ${devicePort}`, 191 | ); 192 | await waitForFlutterServer.bind(this)(forwardedPort!, packageName); 193 | this.log.info( 194 | `Flutter server is successfully running on port ${devicePort}`, 195 | ); 196 | return forwardedPort!; 197 | } catch (e) { 198 | if (portReleaseCallback) { 199 | await portReleaseCallback(udid, forwardedPort!); 200 | } 201 | if (portForwardCallback) { 202 | await portForwardCallback(udid, systemPort!, devicePort); 203 | } 204 | try { 205 | this.log.info( 206 | `Checking if flutter server is running on port ${devicePort}`, 207 | ); 208 | await waitForFlutterServer.bind(this)(forwardedPort!, packageName); 209 | this.log.info( 210 | `Flutter server is successfully running on port ${devicePort}`, 211 | ); 212 | return forwardedPort!; 213 | } catch (e) { 214 | if (portReleaseCallback) { 215 | await portReleaseCallback(udid, systemPort!); 216 | } 217 | } 218 | devicePort++; 219 | } 220 | } 221 | return null; 222 | } 223 | 224 | export function isW3cCaps(caps: any) { 225 | if (!_.isPlainObject(caps)) { 226 | return false; 227 | } 228 | 229 | const isFirstMatchValid = () => 230 | _.isArray(caps.firstMatch) && 231 | !_.isEmpty(caps.firstMatch) && 232 | _.every(caps.firstMatch, _.isPlainObject); 233 | const isAlwaysMatchValid = () => _.isPlainObject(caps.alwaysMatch); 234 | if (_.has(caps, 'firstMatch') && _.has(caps, 'alwaysMatch')) { 235 | return isFirstMatchValid() && isAlwaysMatchValid(); 236 | } 237 | if (_.has(caps, 'firstMatch')) { 238 | return isFirstMatchValid(); 239 | } 240 | if (_.has(caps, 'alwaysMatch')) { 241 | return isAlwaysMatchValid(); 242 | } 243 | return false; 244 | } 245 | 246 | export function attachAppLaunchArguments( 247 | this: AppiumFlutterDriver, 248 | parsedCaps: any, 249 | ...caps: any 250 | ) { 251 | const w3cCaps = [...caps].find(isW3cCaps); 252 | // If no W3C caps are passed, session creation will eventually fail. So its not required to update the caps 253 | if (!w3cCaps) { 254 | return; 255 | } 256 | const platformName: string | undefined = parsedCaps['platformName']; 257 | const systemPort: string | undefined = parsedCaps['flutterSystemPort']; 258 | 259 | if (platformName && systemPort && platformName.toLowerCase() == 'ios') { 260 | w3cCaps.firstMatch ??= []; 261 | w3cCaps.alwaysMatch ??= {}; 262 | const firstMatch = w3cCaps.firstMatch.find( 263 | (caps: Record) => caps['appium:processArguments'], 264 | ); 265 | const capsToUpdate = firstMatch ?? w3cCaps.alwaysMatch; 266 | capsToUpdate['appium:processArguments'] = _.assign( 267 | { args: [] }, 268 | capsToUpdate['appium:processArguments'], 269 | ); 270 | 271 | capsToUpdate['appium:processArguments'].args = _.flatten([ 272 | capsToUpdate['appium:processArguments'].args, 273 | `--flutter-server-port=${systemPort}`, 274 | ]); 275 | 276 | this.log.info( 277 | `iOS platform detected and flutterSystemPort capability is present. 278 | So attaching processArguments: ${JSON.stringify(capsToUpdate['appium:processArguments'])}`, 279 | ); 280 | } 281 | } 282 | 283 | function validateServerStatus( 284 | this: AppiumFlutterDriver, 285 | status: unknown, 286 | packageName: string, 287 | ): boolean { 288 | const compatibilityMessage = 289 | `Please check the driver readme to ensure the compatibility ` + 290 | `between the server module integrated into the application under test ` + 291 | `and the current driver version ${PACKAGE_VERSION}.`; 292 | const formattedStatus = _.truncate(JSON.stringify(status), { length: 200 }); 293 | const logAndThrow = (errMsg: string) => { 294 | this.log.info(errMsg); 295 | throw new Error(errMsg); 296 | }; 297 | if (!_.isPlainObject(status)) { 298 | logAndThrow( 299 | `The server response ${formattedStatus} ` + 300 | `is not a valid object. ${compatibilityMessage}`, 301 | ); 302 | } 303 | const statusMap = status as StringRecord; 304 | if (!statusMap.appInfo || !statusMap.appInfo?.packageName) { 305 | logAndThrow( 306 | `The server response ${formattedStatus} ` + 307 | `does not contain a package name. ${compatibilityMessage}`, 308 | ); 309 | } 310 | if (statusMap.appInfo.packageName !== packageName) { 311 | logAndThrow( 312 | `The server response ` + 313 | `contains an unexpected package name (${statusMap.appInfo.packageName} != ${packageName}). ` + 314 | `Does this server belong to another app?`, 315 | ); 316 | } 317 | if (!statusMap.serverVersion) { 318 | logAndThrow( 319 | `The server response ${formattedStatus} ` + 320 | `does not contain a valid server version. ${compatibilityMessage}`, 321 | ); 322 | } 323 | if (!semver.satisfies(statusMap.serverVersion, FLUTTER_SERVER_VERSION_REQ)) { 324 | logAndThrow( 325 | `The server version ${statusMap.serverVersion} does not satisfy the driver ` + 326 | `version requirement '${FLUTTER_SERVER_VERSION_REQ}'. ` + 327 | compatibilityMessage, 328 | ); 329 | } 330 | return true; 331 | } 332 | 333 | function readManifest(): StringRecord { 334 | return JSON.parse( 335 | fs.readFileSync( 336 | path.join( 337 | node.getModuleRootSync( 338 | 'appium-flutter-integration-driver', 339 | __filename, 340 | )!, 341 | 'package.json', 342 | ), 343 | { encoding: 'utf8' }, 344 | ), 345 | ); 346 | } 347 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.3](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v2.0.2...v2.0.3) (2025-10-17) 2 | 3 | ### Miscellaneous Chores 4 | 5 | * fix typos ([b0f302b](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/b0f302bf2ad8f35370847d64d626883dc1b9183c)) 6 | 7 | ## [2.0.2](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v2.0.1...v2.0.2) (2025-10-09) 8 | 9 | ### Bug Fixes 10 | 11 | * safe parse element locators when used in flutter commands ([#152](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/152)) ([bae30b8](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/bae30b820c0639552f3c834a69ab8728d28b3857)) 12 | 13 | ## [2.0.1](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v2.0.0...v2.0.1) (2025-10-09) 14 | 15 | ### Bug Fixes 16 | 17 | * locator parsing ([#151](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/151)) ([acc617c](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/acc617c281b08019f7aeb20ce3d1d2ef2cf22482)) 18 | 19 | ## [2.0.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.5.1...v2.0.0) (2025-09-19) 20 | 21 | ### ⚠ BREAKING CHANGES 22 | 23 | * - Following changes are added 24 | - Updated all dependencies to point to appium3 25 | - This version will work only with appium 3 and above 26 | 27 | ### Features 28 | 29 | * support for appium3 ([#146](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/146)) ([c2fcab9](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/c2fcab9ee83c5d463de75bf089b75b9530d04bfc)) 30 | 31 | ## [1.5.1](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.5.0...v1.5.1) (2025-09-19) 32 | 33 | ### Reverts 34 | 35 | * Revert "feat: Appium 3 support ([#142](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/142))" ([#145](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/145)) ([fbb22db](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/fbb22dbb5a7ca17f2813b22e64517c4a406e42e6)) 36 | 37 | ## [1.5.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.4.1...v1.5.0) (2025-09-19) 38 | 39 | ### Features 40 | 41 | * Appium 3 support ([#142](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/142)) ([2979048](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/297904836572161b2d552a87261441a360b6d9db)) 42 | 43 | ## [1.4.1](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.4.0...v1.4.1) (2025-09-17) 44 | 45 | ### Bug Fixes 46 | 47 | * Added support for descendant within scroll ([#141](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/141)) ([fbae4c3](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/fbae4c3bcfbda27f86fe476a83a91d76292b4853)) 48 | 49 | ## [1.4.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.3.1...v1.4.0) (2025-09-16) 50 | 51 | ### Features 52 | 53 | * Support descendant ([#140](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/140)) ([cd16886](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/cd16886f015e18e3570ab5fa729b7ab989725307)), closes [#126](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/126) 54 | 55 | ## [1.3.1](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.3.0...v1.3.1) (2025-09-12) 56 | 57 | ### Bug Fixes 58 | 59 | * Add support to get elementRect ([#134](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/134)) ([4b1f451](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/4b1f451f2bc181850be30db0b6b4092a6ad2851f)) 60 | 61 | ## [1.3.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.2.0...v1.3.0) (2025-08-18) 62 | 63 | ### Features 64 | 65 | * enable webview context handling and command proxying in driver ([#131](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/131)) ([628bf35](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/628bf35bb1c5efe8bcd18e2a70d9ebf5e65662fb)) 66 | 67 | ## [1.2.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.1.4...v1.2.0) (2025-05-01) 68 | 69 | ### Features 70 | 71 | * add command to fetch render tree by widget type ([#119](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/119)) ([d439028](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/d439028228aa5481c30163cb95184f389566772b)) 72 | 73 | ## [1.1.4](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.1.3...v1.1.4) (2025-04-02) 74 | 75 | ### Bug Fixes 76 | 77 | * update driver versions and fix jwproxy base path ([#114](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/114)) ([05e9767](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/05e9767b59fb430e4f3b9e58afbf632bd207c908)) 78 | 79 | ### Miscellaneous Chores 80 | 81 | * fix build issues ([#115](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/115)) ([32e5152](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/32e515252fe99731459f6915cd612e3958a8f660)) 82 | 83 | ## [1.1.3](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.1.2...v1.1.3) (2024-07-26) 84 | 85 | ### Miscellaneous Chores 86 | 87 | * update server version in readme without template ([#81](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/81)) ([37ac332](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/37ac3322ec23de3dde59ef93bc787a3900e834f9)) 88 | 89 | ## [1.1.2](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.1.1...v1.1.2) (2024-07-25) 90 | 91 | ### Bug Fixes 92 | 93 | * backward compatability for setValue command ([#79](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/79)) ([aff491d](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/aff491d1964dcaeed123f0e5b2736a069773f782)) 94 | 95 | ## [1.1.1](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.1.0...v1.1.1) (2024-07-23) 96 | 97 | ### Bug Fixes 98 | 99 | * update readMe and also add locator argument for doubleClick and longpress ([#76](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/76)) ([904d875](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/904d875f63647560fe019fe5c937792b927583ca)) 100 | 101 | ## [1.1.0](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.7...v1.1.0) (2024-07-19) 102 | 103 | ### Features 104 | 105 | * mock camera image ([#71](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/71)) ([c0818e9](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/c0818e996a85cbfbf5b0c82c98ec8aa1aeb8f68a)) 106 | 107 | ## [1.0.7](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.6...v1.0.7) (2024-07-17) 108 | 109 | ### Bug Fixes 110 | 111 | * Add support for locator with -flutter prefix ([#64](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/64)) ([42cf914](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/42cf91463d48ee6cad0dec542a79c95e98eee8fa)) 112 | 113 | ## [1.0.6](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.5...v1.0.6) (2024-07-11) 114 | 115 | ### Bug Fixes 116 | 117 | * run ios simulator tests on default port ([#51](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/51)) ([526b65b](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/526b65b7320d334dc82cf67c4785b3b6f6151f06)) 118 | 119 | ## [1.0.5](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.4...v1.0.5) (2024-07-11) 120 | 121 | ### Bug Fixes 122 | 123 | * Mute irrelevant typescript complains ([#48](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/48)) ([c41316a](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/c41316af74dbdb6637697fcaa6392bb0b086d4b0)) 124 | * use lts for semantic release ([#49](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/49)) ([9b47d50](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/9b47d50bcffed0ca1b536fcb3ca07b399cbc1bd3)) 125 | * Various type declarations and imports ([#47](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/47)) ([fbe4e46](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/fbe4e461368aca22fc354f41aa7df10e239cc41b)) 126 | 127 | ### Miscellaneous Chores 128 | 129 | * add build step for publish ([#45](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/45)) ([bfed790](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/bfed790828bea1003b657f13a67cec17b1b81795)) 130 | * fix node version ([#46](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/46)) ([b812327](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/b812327d8f63f010efd5f7b11ba011cb3f2cc9e8)) 131 | 132 | ## [1.0.4](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.3...v1.0.4) (2024-07-10) 133 | 134 | ### Miscellaneous Chores 135 | 136 | * Reduce the appium peerdependency to 2.5 ([#43](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/43)) ([f3e20e2](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/f3e20e276833e684537fdbd205dd4781ffc2cbd8)) 137 | 138 | ## [1.0.3](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/compare/v1.0.2...v1.0.3) (2024-07-10) 139 | 140 | ### Miscellaneous Chores 141 | 142 | * **release:** 1.0.0 [skip ci] ([8af6ce8](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/8af6ce81879ba1c425ab5fbd33047722cf72dac2)), closes [#19](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/19) [#40](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/40) [#41](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/41) [#33](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/33) 143 | * setup semantic release ([#41](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/41)) ([10a00cb](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/10a00cb311090f2da90290e41d1089c90c4328b8)) 144 | * **test:** Updated test with byType ([#42](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/42)) ([a8b52c2](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/a8b52c25a7f4daa7dca6b3efbd998a4f9bfccdd7)) 145 | 146 | ## 1.0.0 (2024-07-10) 147 | 148 | ### Bug Fixes 149 | 150 | * Respect adb settings provided to the driver ([#19](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/19)) ([e3a0148](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/e3a0148c107a77aea776df3b729f3190cb83a5b4)) 151 | 152 | ### Miscellaneous Chores 153 | 154 | * Add server status validation ([#40](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/40)) ([20299df](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/20299df550ee0ba5180250ab58db759fc1d30c43)) 155 | * setup semantic release ([#41](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/41)) ([10a00cb](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/10a00cb311090f2da90290e41d1089c90c4328b8)) 156 | * Use logger attached to the driver instance where possible ([#33](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/issues/33)) ([639aef8](https://github.com/AppiumTestDistribution/appium-flutter-integration-driver/commit/639aef80009b878c4b9fb8c706ae0e6e14334fd9)) 157 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/test_remove/specs/test.e2e.js: -------------------------------------------------------------------------------- 1 | import { browser, expect } from '@wdio/globals'; 2 | import path from "path"; 3 | 4 | async function performLogin(userName = 'admin', password = '1234') { 5 | await browser.takeScreenshot(); 6 | const att = await browser.flutterByValueKey$('username_text_field'); 7 | console.log(await att.getAttribute('all')); 8 | await browser.flutterByValueKey$('username_text_field').clearValue(); 9 | await browser.flutterByValueKey$('username_text_field').addValue(userName); 10 | 11 | await browser.flutterByValueKey$('password_text_field').clearValue(); 12 | await browser.flutterByValueKey$('password').addValue(password); 13 | await browser.flutterByValueKey$('LoginButton').click(); 14 | } 15 | 16 | async function openScreen(screenTitle) { 17 | const screenListElement = await browser.flutterScrollTillVisible({ 18 | finder: await browser.flutterByText(screenTitle), 19 | }); 20 | await screenListElement.click(); 21 | } 22 | 23 | function itForAndroidOnly(description, fn) { 24 | if (browser.isIOS) { 25 | it.skip(description, fn); 26 | } else { 27 | it(description, fn); 28 | } 29 | } 30 | 31 | describe('Image mocking', async() => { 32 | afterEach(async () => { 33 | await handleAppManagement(); 34 | }); 35 | 36 | it('Inject Image', async() => { 37 | const firstImageToMock = path.resolve('test/qr.png'); 38 | const secondImageToMock = path.resolve('test/SecondImage.png'); 39 | await performLogin(); 40 | await openScreen('Image Picker'); 41 | const firstInjectedImage = await browser.flutterInjectImage(firstImageToMock); 42 | await browser.flutterByValueKey$('capture_image').click(); 43 | await browser.flutterByText$('PICK').click(); 44 | expect(await browser.flutterByText$('Success!').isDisplayed()).toBe(true); 45 | await browser.flutterInjectImage(secondImageToMock); 46 | await browser.flutterByValueKey$('capture_image').click(); 47 | await browser.flutterByText$('PICK').click(); 48 | expect(await browser.flutterByText$('SecondInjectedImage').isDisplayed()).toBe(true); 49 | await browser.flutterActivateInjectedImage({ imageId: firstInjectedImage }); 50 | await browser.flutterByValueKey$('capture_image').click(); 51 | await browser.flutterByText$('PICK').click(); 52 | expect(await browser.flutterByText$('Success!').isDisplayed()).toBe(true); 53 | }) 54 | 55 | 56 | }) 57 | 58 | async function handleAppManagement() { 59 | const appID = browser.isIOS 60 | ? 'com.example.appiumTestingApp' 61 | : 'com.example.appium_testing_app'; 62 | if (await browser.isAppInstalled(appID)) { 63 | await browser.removeApp(appID); 64 | } 65 | await browser.installApp(process.env.APP_PATH); 66 | await browser.pause(2000); 67 | if (await browser.isAppInstalled(appID)) { 68 | console.log('App is installed'); 69 | await browser.execute('flutter: launchApp', { 70 | appId: appID, 71 | arguments: ['--dummy-arguments'], 72 | environment: {}, 73 | }); 74 | } 75 | } 76 | 77 | describe('My Login application', () => { 78 | afterEach(async () => { 79 | await handleAppManagement(); 80 | }); 81 | 82 | it('GetText test', async () => { 83 | const userNameField = await browser.flutterByValueKey$('username_text_field'); 84 | const passwordField = await browser.flutterByValueKey$('password_text_field'); 85 | expect(await userNameField.getText()).toEqual("admin"); 86 | expect(await passwordField.getText()).toEqual("1234"); 87 | 88 | await userNameField.clearValue(); 89 | await userNameField.addValue("admin123"); 90 | await passwordField.clearValue(); 91 | await passwordField.addValue("password123"); 92 | 93 | //TextEdit field 94 | expect(await userNameField.getText()).toEqual("admin123"); 95 | expect(await passwordField.getText()).toEqual("password123"); 96 | }); 97 | 98 | it('Create Session with Flutter Integration Driver', async () => { 99 | await performLogin(); 100 | await openScreen('Double Tap'); 101 | const element = await browser 102 | .flutterByValueKey$('double_tap_button') 103 | .flutterByText$('Double Tap'); 104 | expect(await element.getText()).toEqual('Double Tap'); 105 | await browser.flutterDoubleClick({ 106 | element: element, 107 | }); 108 | let popUpText = await browser.flutterByText$('Double Tap Successful'); 109 | expect(await popUpText.getText()).toEqual('Double Tap Successful'); 110 | await browser.flutterByText$('Ok').click(); 111 | await browser.flutterDoubleClick({ 112 | element, 113 | offset: { 114 | x: 10, 115 | y: 0, 116 | }, 117 | }); 118 | popUpText = await browser.flutterByText$('Double Tap Successful'); 119 | expect(await popUpText.getText()).toEqual('Double Tap Successful'); 120 | }); 121 | 122 | it('Wait Test', async () => { 123 | await performLogin(); 124 | await openScreen('Lazy Loading'); 125 | expect(await browser.flutterByValueKey$('message_field').getText()).toEqual('Hello world'); 126 | await browser.flutterByValueKey$('toggle_button').click(); 127 | await browser 128 | .flutterWaitForAbsent({ 129 | locator: await browser.flutterByValueKey('message_field'), 130 | timeout: 10 } 131 | ); 132 | expect( 133 | ( 134 | await browser.flutterByValueKey$$('message_field') 135 | ).length, 136 | ).toEqual(0); 137 | await browser.flutterByValueKey$('toggle_button').click(); 138 | await browser.flutterWaitForVisible({ 139 | locator: await browser.flutterByValueKey('message_field'), 140 | timeout: 10 141 | }); 142 | expect(await browser.flutterByValueKey$('message_field').getText()).toEqual('Hello world'); 143 | }); 144 | 145 | it('Scroll Test', async () => { 146 | await performLogin(); 147 | await openScreen('Vertical Swiping'); 148 | const javaElement = await browser.flutterScrollTillVisible({ 149 | finder: await browser.flutterByText('Java'), 150 | }); 151 | expect(await javaElement.getAttribute('displayed')).toBe(true); 152 | 153 | const protractorElement = await browser.flutterScrollTillVisible({ 154 | finder: await browser.flutterByText('Protractor'), 155 | }); 156 | expect(await javaElement.getAttribute('displayed')).toBe(false); 157 | expect(await protractorElement.getAttribute('displayed')).toBe(true); 158 | 159 | await browser.flutterScrollTillVisible({ 160 | finder: await browser.flutterByText('Java'), 161 | scrollDirection: 'up', 162 | }); 163 | expect(await protractorElement.getAttribute('displayed')).toBe(false); 164 | expect(await javaElement.getAttribute('displayed')).toBe(true); 165 | }); 166 | 167 | it('Long Press', async () => { 168 | await performLogin(); 169 | await openScreen('Long Press'); 170 | const longPressElement = 171 | await browser.flutterByValueKey$('long_press_button'); 172 | await browser.flutterLongPress({ element: longPressElement }); 173 | const popUpText = await browser 174 | .flutterByText$('It was a long press') 175 | .isDisplayed(); 176 | expect(popUpText).toBe(true); 177 | }); 178 | 179 | it('Should be able perform action when frame is rendering', async () => { 180 | await performLogin(); 181 | await openScreen('Loader Screen'); 182 | await browser.flutterByValueKey$('loader_login_button').click(); 183 | expect(await browser.flutterByText$('Button pressed').isDisplayed()).toBe( 184 | true, 185 | ); 186 | }); 187 | 188 | it('Properties Test', async () => { 189 | await performLogin(); 190 | await openScreen('UI Elements'); 191 | const prop2 = await browser.flutterBySemanticsLabel$( 192 | 'disabled_text_field', 193 | ); 194 | const disableTextFieldState = await prop2.getAttribute('flags'); 195 | expect(disableTextFieldState).toEqual( 196 | '[isTextField, hasEnabledState, isReadOnly]', 197 | ); 198 | 199 | const prop4 = await browser.flutterBySemanticsLabel$('switch_button'); 200 | await prop4.getAttribute('flags'); 201 | expect(await prop4.getAttribute('flags')).toEqual( 202 | '[hasEnabledState, isEnabled, hasToggledState, isFocusable]', 203 | ); 204 | await prop4.click(); 205 | await prop4.getAttribute('flags'); 206 | expect(await prop4.getAttribute('flags')).toEqual( 207 | '[hasEnabledState, isEnabled, hasToggledState, isToggled, isFocusable]', 208 | ); 209 | const prop5 = await browser.flutterBySemanticsLabel$('switch_button'); 210 | await prop5.getAttribute('all'); // Will return all attributes attached to the element 211 | // { 212 | // owner: 'SemanticsOwner#fd8a3', 213 | // isMergedIntoParent: 'false', 214 | // mergeAllDescendantsIntoThisNode: 'false', 215 | // rect: 'Rect.fromLTRB(0.0, 0.0, 60.0, 48.0)', 216 | // tags: 'null', 217 | // actions: '[tap]', 218 | // customActions: '[]', 219 | // flags: '[hasEnabledState, isEnabled, hasToggledState, isToggled, isFocusable]', 220 | // isInvisible: 'false', 221 | // isHidden: 'false', 222 | // identifier: 'null', 223 | // label: 'switch_button', 224 | // value: 'null', 225 | // increasedValue: 'null', 226 | // decreasedValue: 'null', 227 | // hint: 'null', 228 | // tooltip: 'null', 229 | // textDirection: 'null', 230 | // sortKey: 'null', 231 | // platformViewId: 'null', 232 | // maxValueLength: 'null', 233 | // currentValueLength: 'null', 234 | // scrollChildren: 'null', 235 | // scrollIndex: 'null', 236 | // scrollExtentMin: 'null', 237 | // scrollPosition: 'null', 238 | // scrollExtentMax: 'null', 239 | // indexInParent: 'null', 240 | // elevation: '0.0', 241 | // thickness: '0.0', 242 | // container: 'true', 243 | // properties: 'SemanticsProperties(label: "switch_button")', 244 | // checked: 'null', 245 | // mixed: 'null', 246 | // expanded: 'null', 247 | // selected: 'null', 248 | // attributedLabel: 'null', 249 | // attributedValue: 'null', 250 | // attributedIncreasedValue: 'null', 251 | // attributedDecreasedValue: 'null', 252 | // attributedHint: 'null', 253 | // hintOverrides: 'null' 254 | // } 255 | }); 256 | 257 | it.skip('Invalid Driver', async () => { 258 | await browser 259 | .flutterBySemanticsLabel$('username_text_field') 260 | .clearValue(); 261 | await browser 262 | .flutterBySemanticsLabel$('username_text_field') 263 | .addValue('admin1'); 264 | 265 | await browser 266 | .flutterBySemanticsLabel$('password_text_field') 267 | .clearValue(); 268 | await browser.flutterByValueKey$('password').addValue('12345'); 269 | await browser.flutterBySemanticsLabel$('login_button').click(); 270 | 271 | await browser.flutterByText$('Ok').click(); 272 | }); 273 | 274 | it('Drag and Drop', async () => { 275 | await performLogin(); 276 | await openScreen('Drag & Drop'); 277 | const dragElement = await browser.flutterByValueKey$('drag_me'); 278 | const dropElement = await browser.flutterByValueKey$('drop_zone'); 279 | await browser.flutterDragAndDrop({ 280 | source: dragElement, 281 | target: dropElement, 282 | }); 283 | const dropped = await browser 284 | .flutterByText$('The box is dropped') 285 | .getText(); 286 | expect(dropped).toEqual('The box is dropped'); 287 | }); 288 | 289 | it('Descendant Test', async () => { 290 | await performLogin(); 291 | await openScreen('Nested Scroll'); 292 | const childElement = await browser.flutterByDescendant$({ 293 | of: await browser.flutterByValueKey('parent_card_1'), 294 | matching: await browser.flutterByText('Child 2'), 295 | }); 296 | expect(await childElement.getText()).toEqual('Child 2'); 297 | }); 298 | 299 | it.only('Ancestor Test', async () => { 300 | await performLogin(); 301 | await openScreen('Nested Scroll'); 302 | const parentElement = await browser.flutterByAncestor$({ 303 | of: await browser.flutterByText('Child 2'), 304 | matching: await browser.flutterByValueKey('parent_card_4'), 305 | }); 306 | expect(await parentElement.isDisplayed()).toBe(true); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /wdio.conf.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import type { Options } from '@wdio/types'; 3 | import { join } from 'node:path'; 4 | const appiumServerPath = join( 5 | process.cwd(), 6 | 'node_modules', 7 | 'appium', 8 | 'index.js', 9 | ); 10 | console.log(appiumServerPath); 11 | console.log(join(process.cwd(), 'appium-log.txt')); 12 | export const config: Options.Testrunner = { 13 | // 14 | // ==================== 15 | // Runner Configuration 16 | // ==================== 17 | // WebdriverIO supports running e2e tests as well as unit and component tests. 18 | runner: 'local', 19 | hostname: '127.0.0.1', 20 | port: 4723, 21 | path: '/wd/hub', 22 | autoCompileOpts: { 23 | autoCompile: true, 24 | tsNodeOpts: { 25 | project: 'tsconfig.json', 26 | transpileOnly: true, 27 | }, 28 | }, 29 | // 30 | // ================== 31 | // Specify Test Files 32 | // ================== 33 | // Define which test specs should run. The pattern is relative to the directory 34 | // of the configuration file being run. 35 | // 36 | // The specs are defined as an array of spec files (optionally using wildcards 37 | // that will be expanded). The test for each spec file will be run in a separate 38 | // worker process. In order to have a group of spec files run in the same worker 39 | // process simply enclose them in an array within the specs array. 40 | // 41 | // The path of the spec files will be resolved relative from the directory of 42 | // of the config file unless it's absolute. 43 | // 44 | specs: ['./test/specs/**/*.js'], 45 | // Patterns to exclude. 46 | exclude: [ 47 | // 'path/to/excluded/files' 48 | ], 49 | // 50 | // ============ 51 | // Capabilities 52 | // ============ 53 | // Define your capabilities here. WebdriverIO can run multiple capabilities at the same 54 | // time. Depending on the number of capabilities, WebdriverIO launches several test 55 | // sessions. Within your capabilities you can overwrite the spec and exclude options in 56 | // order to group specific specs to a specific capability. 57 | // 58 | // First, you can define how many instances should be started at the same time. Let's 59 | // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have 60 | // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec 61 | // files and you set maxInstances to 10, all spec files will get tested at the same time 62 | // and 30 processes will get spawned. The property handles how many capabilities 63 | // from the same test should run tests. 64 | // 65 | maxInstances: 1, 66 | // 67 | // If you have trouble getting all important capabilities together, check out the 68 | // Sauce Labs platform configurator - a great tool to configure your capabilities: 69 | // https://saucelabs.com/platform/platform-configurator 70 | // 71 | capabilities: [], 72 | 73 | // 74 | // =================== 75 | // Test Configurations 76 | // =================== 77 | // Define all options that are relevant for the WebdriverIO instance here 78 | // 79 | // Level of logging verbosity: trace | debug | info | warn | error | silent 80 | logLevel: 'debug', 81 | // 82 | // Set specific log levels per logger 83 | // loggers: 84 | // - webdriver, webdriverio 85 | // - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service 86 | // - @wdio/mocha-framework, @wdio/jasmine-framework 87 | // - @wdio/local-runner 88 | // - @wdio/sumologic-reporter 89 | // - @wdio/cli, @wdio/config, @wdio/utils 90 | // Level of logging verbosity: trace | debug | info | warn | error | silent 91 | // logLevels: { 92 | // webdriver: 'info', 93 | // '@wdio/appium-service': 'info' 94 | // }, 95 | // 96 | // If you only want to run your tests until a specific amount of tests have failed use 97 | // bail (default is 0 - don't bail, run all tests). 98 | bail: 1, 99 | // 100 | // Set a base URL in order to shorten url command calls. If your `url` parameter starts 101 | // with `/`, the base url gets prepended, not including the path portion of your baseUrl. 102 | // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url 103 | // gets prepended directly. 104 | // baseUrl: 'http://localhost:8080', 105 | // 106 | // Default timeout for all waitFor* commands. 107 | // waitforTimeout: 10000, 108 | // 109 | // Default timeout in milliseconds for request 110 | // if browser driver or grid doesn't send response 111 | // connectionRetryTimeout: 120000, 112 | // 113 | // Default request retries count 114 | connectionRetryCount: 0, 115 | // 116 | // Test runner services 117 | // Services take over a specific job you don't want to take care of. They enhance 118 | // your test setup with almost no effort. Unlike plugins, they don't add new 119 | // commands. Instead, they hook themselves up into the test process. 120 | services: [ 121 | ['flutter-by', {}], 122 | [ 123 | 'appium', 124 | { 125 | args: { 126 | basePath: '/wd/hub', 127 | port: 4723, 128 | log: join(process.cwd(), 'appium-logs', 'logs.txt'), 129 | allowInsecure: '*:chromedriver_autodownload,*:adb_shell', 130 | }, 131 | }, 132 | ], 133 | ], 134 | // 135 | // Framework you want to run your specs with. 136 | // The following are supported: Mocha, Jasmine, and Cucumber 137 | // see also: https://webdriver.io/docs/frameworks 138 | // 139 | // Make sure you have the wdio adapter package for the specific framework installed 140 | // before running any tests. 141 | framework: 'mocha', 142 | 143 | // 144 | // The number of times to retry the entire specfile when it fails as a whole 145 | specFileRetries: 0, 146 | // 147 | // Delay in seconds between the spec file retry attempts 148 | // specFileRetriesDelay: 0, 149 | // 150 | // Whether or not retried spec files should be retried immediately or deferred to the end of the queue 151 | // specFileRetriesDeferred: false, 152 | // 153 | // Test reporter for stdout. 154 | // The only one supported by default is 'dot' 155 | // see also: https://webdriver.io/docs/dot-reporter 156 | reporters: ['spec'], 157 | 158 | // Options to be passed to Mocha. 159 | // See the full list at http://mochajs.org/ 160 | mochaOpts: { 161 | ui: 'bdd', 162 | timeout: 60000, 163 | }, 164 | 165 | // 166 | // ===== 167 | // Hooks 168 | // ===== 169 | // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance 170 | // it and to build services around it. You can either apply a single function or an array of 171 | // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got 172 | // resolved to continue. 173 | /** 174 | * Gets executed once before all workers get launched. 175 | * @param {object} config wdio configuration object 176 | * @param {Array.} capabilities list of capabilities details 177 | */ 178 | // onPrepare: function (config, capabilities) { 179 | // }, 180 | /** 181 | * Gets executed before a worker process is spawned and can be used to initialize specific service 182 | * for that worker as well as modify runtime environments in an async fashion. 183 | * @param {string} cid capability id (e.g 0-0) 184 | * @param {object} caps object containing capabilities for session that will be spawn in the worker 185 | * @param {object} specs specs to be run in the worker process 186 | * @param {object} args object that will be merged with the main configuration once worker is initialized 187 | * @param {object} execArgv list of string arguments passed to the worker process 188 | */ 189 | // onWorkerStart: function (cid, caps, specs, args, execArgv) { 190 | // }, 191 | /** 192 | * Gets executed just after a worker process has exited. 193 | * @param {string} cid capability id (e.g 0-0) 194 | * @param {number} exitCode 0 - success, 1 - fail 195 | * @param {object} specs specs to be run in the worker process 196 | * @param {number} retries number of retries used 197 | */ 198 | // onWorkerEnd: function (cid, exitCode, specs, retries) { 199 | // }, 200 | /** 201 | * Gets executed just before initialising the webdriver session and test framework. It allows you 202 | * to manipulate configurations depending on the capability or spec. 203 | * @param {object} config wdio configuration object 204 | * @param {Array.} capabilities list of capabilities details 205 | * @param {Array.} specs List of spec file paths that are to be run 206 | * @param {string} cid worker id (e.g. 0-0) 207 | */ 208 | // beforeSession: function (config, capabilities, specs, cid) { 209 | // }, 210 | /** 211 | * Gets executed before test execution begins. At this point you can access to all global 212 | * variables like `browser`. It is the perfect place to define custom commands. 213 | * @param {Array.} capabilities list of capabilities details 214 | * @param {Array.} specs List of spec file paths that are to be run 215 | * @param {object} browser instance of created browser/device session 216 | */ 217 | before: async function (capabilities, specs) { 218 | // await registerCommands(); 219 | }, 220 | /** 221 | * Runs before a WebdriverIO command gets executed. 222 | * @param {string} commandName hook command name 223 | * @param {Array} args arguments that command would receive 224 | */ 225 | // beforeCommand: function (commandName, args) { 226 | // }, 227 | /** 228 | * Hook that gets executed before the suite starts 229 | * @param {object} suite suite details 230 | */ 231 | // beforeSuite: function (suite) { 232 | // }, 233 | /** 234 | * Function to be executed before a test (in Mocha/Jasmine) starts. 235 | */ 236 | // beforeTest: function (test, context) { 237 | // }, 238 | /** 239 | * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling 240 | * beforeEach in Mocha) 241 | */ 242 | // beforeHook: function (test, context, hookName) { 243 | // }, 244 | /** 245 | * Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling 246 | * afterEach in Mocha) 247 | */ 248 | // afterHook: function (test, context, { error, result, duration, passed, retries }, hookName) { 249 | // }, 250 | /** 251 | * Function to be executed after a test (in Mocha/Jasmine only) 252 | * @param {object} test test object 253 | * @param {object} context scope object the test was executed with 254 | * @param {Error} result.error error object in case the test fails, otherwise `undefined` 255 | * @param {*} result.result return object of test function 256 | * @param {number} result.duration duration of test 257 | * @param {boolean} result.passed true if test has passed, otherwise false 258 | * @param {object} result.retries information about spec related retries, e.g. `{ attempts: 0, limit: 0 }` 259 | */ 260 | // afterTest: function(test, context, { error, result, duration, passed, retries }) { 261 | // }, 262 | 263 | /** 264 | * Hook that gets executed after the suite has ended 265 | * @param {object} suite suite details 266 | */ 267 | // afterSuite: function (suite) { 268 | // }, 269 | /** 270 | * Runs after a WebdriverIO command gets executed 271 | * @param {string} commandName hook command name 272 | * @param {Array} args arguments that command would receive 273 | * @param {number} result 0 - command success, 1 - command error 274 | * @param {object} error error object if any 275 | */ 276 | // afterCommand: function (commandName, args, result, error) { 277 | // }, 278 | /** 279 | * Gets executed after all tests are done. You still have access to all global variables from 280 | * the test. 281 | * @param {number} result 0 - test pass, 1 - test fail 282 | * @param {Array.} capabilities list of capabilities details 283 | * @param {Array.} specs List of spec file paths that ran 284 | */ 285 | // after: function (result, capabilities, specs) { 286 | // }, 287 | /** 288 | * Gets executed right after terminating the webdriver session. 289 | * @param {object} config wdio configuration object 290 | * @param {Array.} capabilities list of capabilities details 291 | * @param {Array.} specs List of spec file paths that ran 292 | */ 293 | // afterSession: function (config, capabilities, specs) { 294 | // }, 295 | /** 296 | * Gets executed after all workers got shut down and the process is about to exit. An error 297 | * thrown in the onComplete hook will result in the test run failing. 298 | * @param {object} exitCode 0 - success, 1 - fail 299 | * @param {object} config wdio configuration object 300 | * @param {Array.} capabilities list of capabilities details 301 | * @param {} results object containing test results 302 | */ 303 | // onComplete: function(exitCode, config, capabilities, results) { 304 | // }, 305 | /** 306 | * Gets executed when a refresh happens. 307 | * @param {string} oldSessionId session ID of the old session 308 | * @param {string} newSessionId session ID of the new session 309 | */ 310 | // onReload: function(oldSessionId, newSessionId) { 311 | // } 312 | /** 313 | * Hook that gets executed before a WebdriverIO assertion happens. 314 | * @param {object} params information about the assertion to be executed 315 | */ 316 | // beforeAssertion: function(params) { 317 | // } 318 | /** 319 | * Hook that gets executed after a WebdriverIO assertion happened. 320 | * @param {object} params information about the assertion that was executed, including its results 321 | */ 322 | // afterAssertion: function(params) { 323 | // } 324 | }; 325 | -------------------------------------------------------------------------------- /flutter-finder/wdio-flutter-by-service/wdio.conf.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import type { Options } from '@wdio/types'; 3 | import { join } from 'node:path'; 4 | const appiumServerPath = join( 5 | process.cwd(), 6 | 'node_modules', 7 | 'appium', 8 | 'index.js', 9 | ); 10 | console.log(appiumServerPath); 11 | console.log(join(process.cwd(), 'appium-log.txt')); 12 | export const config: Options.Testrunner = { 13 | // 14 | // ==================== 15 | // Runner Configuration 16 | // ==================== 17 | // WebdriverIO supports running e2e tests as well as unit and component tests. 18 | runner: 'local', 19 | hostname: '127.0.0.1', 20 | port: 4723, 21 | path: '/wd/hub', 22 | autoCompileOpts: { 23 | autoCompile: true, 24 | tsNodeOpts: { 25 | project: 'tsconfig.json', 26 | transpileOnly: true, 27 | }, 28 | }, 29 | // 30 | // ================== 31 | // Specify Test Files 32 | // ================== 33 | // Define which test specs should run. The pattern is relative to the directory 34 | // of the configuration file being run. 35 | // 36 | // The specs are defined as an array of spec files (optionally using wildcards 37 | // that will be expanded). The test for each spec file will be run in a separate 38 | // worker process. In order to have a group of spec files run in the same worker 39 | // process simply enclose them in an array within the specs array. 40 | // 41 | // The path of the spec files will be resolved relative from the directory of 42 | // of the config file unless it's absolute. 43 | // 44 | specs: ['./test/specs/**/*.js'], 45 | // Patterns to exclude. 46 | exclude: [ 47 | // 'path/to/excluded/files' 48 | ], 49 | // 50 | // ============ 51 | // Capabilities 52 | // ============ 53 | // Define your capabilities here. WebdriverIO can run multiple capabilities at the same 54 | // time. Depending on the number of capabilities, WebdriverIO launches several test 55 | // sessions. Within your capabilities you can overwrite the spec and exclude options in 56 | // order to group specific specs to a specific capability. 57 | // 58 | // First, you can define how many instances should be started at the same time. Let's 59 | // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have 60 | // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec 61 | // files and you set maxInstances to 10, all spec files will get tested at the same time 62 | // and 30 processes will get spawned. The property handles how many capabilities 63 | // from the same test should run tests. 64 | // 65 | maxInstances: 1, 66 | // 67 | // If you have trouble getting all important capabilities together, check out the 68 | // Sauce Labs platform configurator - a great tool to configure your capabilities: 69 | // https://saucelabs.com/platform/platform-configurator 70 | // 71 | capabilities: [], 72 | 73 | // 74 | // =================== 75 | // Test Configurations 76 | // =================== 77 | // Define all options that are relevant for the WebdriverIO instance here 78 | // 79 | // Level of logging verbosity: trace | debug | info | warn | error | silent 80 | logLevel: 'debug', 81 | // 82 | // Set specific log levels per logger 83 | // loggers: 84 | // - webdriver, webdriverio 85 | // - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service 86 | // - @wdio/mocha-framework, @wdio/jasmine-framework 87 | // - @wdio/local-runner 88 | // - @wdio/sumologic-reporter 89 | // - @wdio/cli, @wdio/config, @wdio/utils 90 | // Level of logging verbosity: trace | debug | info | warn | error | silent 91 | // logLevels: { 92 | // webdriver: 'info', 93 | // '@wdio/appium-service': 'info' 94 | // }, 95 | // 96 | // If you only want to run your tests until a specific amount of tests have failed use 97 | // bail (default is 0 - don't bail, run all tests). 98 | bail: 1, 99 | // 100 | // Set a base URL in order to shorten url command calls. If your `url` parameter starts 101 | // with `/`, the base url gets prepended, not including the path portion of your baseUrl. 102 | // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url 103 | // gets prepended directly. 104 | // baseUrl: 'http://localhost:8080', 105 | // 106 | // Default timeout for all waitFor* commands. 107 | // waitforTimeout: 10000, 108 | // 109 | // Default timeout in milliseconds for request 110 | // if browser driver or grid doesn't send response 111 | // connectionRetryTimeout: 120000, 112 | // 113 | // Default request retries count 114 | connectionRetryCount: 0, 115 | // 116 | // Test runner services 117 | // Services take over a specific job you don't want to take care of. They enhance 118 | // your test setup with almost no effort. Unlike plugins, they don't add new 119 | // commands. Instead, they hook themselves up into the test process. 120 | services: [ 121 | [`${join( 122 | process.cwd(), 123 | 'build', 124 | 'index.js', 125 | )}`, {}], 126 | [ 127 | 'appium', 128 | { 129 | command : 'appium', 130 | args: { 131 | basePath: '/wd/hub', 132 | port: 4723, 133 | log: join(process.cwd(), 'appium-logs', 'logs.txt'), 134 | allowInsecure: 'adb_shell' 135 | }, 136 | }, 137 | ], 138 | ], 139 | // 140 | // Framework you want to run your specs with. 141 | // The following are supported: Mocha, Jasmine, and Cucumber 142 | // see also: https://webdriver.io/docs/frameworks 143 | // 144 | // Make sure you have the wdio adapter package for the specific framework installed 145 | // before running any tests. 146 | framework: 'mocha', 147 | 148 | // 149 | // The number of times to retry the entire specfile when it fails as a whole 150 | // specFileRetries: 1, 151 | // 152 | // Delay in seconds between the spec file retry attempts 153 | // specFileRetriesDelay: 0, 154 | // 155 | // Whether or not retried spec files should be retried immediately or deferred to the end of the queue 156 | // specFileRetriesDeferred: false, 157 | // 158 | // Test reporter for stdout. 159 | // The only one supported by default is 'dot' 160 | // see also: https://webdriver.io/docs/dot-reporter 161 | reporters: ['spec'], 162 | 163 | // Options to be passed to Mocha. 164 | // See the full list at http://mochajs.org/ 165 | mochaOpts: { 166 | ui: 'bdd', 167 | timeout: 60000, 168 | }, 169 | 170 | // 171 | // ===== 172 | // Hooks 173 | // ===== 174 | // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance 175 | // it and to build services around it. You can either apply a single function or an array of 176 | // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got 177 | // resolved to continue. 178 | /** 179 | * Gets executed once before all workers get launched. 180 | * @param {object} config wdio configuration object 181 | * @param {Array.} capabilities list of capabilities details 182 | */ 183 | // onPrepare: function (config, capabilities) { 184 | // }, 185 | /** 186 | * Gets executed before a worker process is spawned and can be used to initialize specific service 187 | * for that worker as well as modify runtime environments in an async fashion. 188 | * @param {string} cid capability id (e.g 0-0) 189 | * @param {object} caps object containing capabilities for session that will be spawn in the worker 190 | * @param {object} specs specs to be run in the worker process 191 | * @param {object} args object that will be merged with the main configuration once worker is initialized 192 | * @param {object} execArgv list of string arguments passed to the worker process 193 | */ 194 | // onWorkerStart: function (cid, caps, specs, args, execArgv) { 195 | // }, 196 | /** 197 | * Gets executed just after a worker process has exited. 198 | * @param {string} cid capability id (e.g 0-0) 199 | * @param {number} exitCode 0 - success, 1 - fail 200 | * @param {object} specs specs to be run in the worker process 201 | * @param {number} retries number of retries used 202 | */ 203 | // onWorkerEnd: function (cid, exitCode, specs, retries) { 204 | // }, 205 | /** 206 | * Gets executed just before initialising the webdriver session and test framework. It allows you 207 | * to manipulate configurations depending on the capability or spec. 208 | * @param {object} config wdio configuration object 209 | * @param {Array.} capabilities list of capabilities details 210 | * @param {Array.} specs List of spec file paths that are to be run 211 | * @param {string} cid worker id (e.g. 0-0) 212 | */ 213 | // beforeSession: function (config, capabilities, specs, cid) { 214 | // }, 215 | /** 216 | * Gets executed before test execution begins. At this point you can access to all global 217 | * variables like `browser`. It is the perfect place to define custom commands. 218 | * @param {Array.} capabilities list of capabilities details 219 | * @param {Array.} specs List of spec file paths that are to be run 220 | * @param {object} browser instance of created browser/device session 221 | */ 222 | before: async function (capabilities, specs) { 223 | // await registerCommands(); 224 | }, 225 | /** 226 | * Runs before a WebdriverIO command gets executed. 227 | * @param {string} commandName hook command name 228 | * @param {Array} args arguments that command would receive 229 | */ 230 | // beforeCommand: function (commandName, args) { 231 | // }, 232 | /** 233 | * Hook that gets executed before the suite starts 234 | * @param {object} suite suite details 235 | */ 236 | // beforeSuite: function (suite) { 237 | // }, 238 | /** 239 | * Function to be executed before a test (in Mocha/Jasmine) starts. 240 | */ 241 | // beforeTest: function (test, context) { 242 | // }, 243 | /** 244 | * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling 245 | * beforeEach in Mocha) 246 | */ 247 | // beforeHook: function (test, context, hookName) { 248 | // }, 249 | /** 250 | * Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling 251 | * afterEach in Mocha) 252 | */ 253 | // afterHook: function (test, context, { error, result, duration, passed, retries }, hookName) { 254 | // }, 255 | /** 256 | * Function to be executed after a test (in Mocha/Jasmine only) 257 | * @param {object} test test object 258 | * @param {object} context scope object the test was executed with 259 | * @param {Error} result.error error object in case the test fails, otherwise `undefined` 260 | * @param {*} result.result return object of test function 261 | * @param {number} result.duration duration of test 262 | * @param {boolean} result.passed true if test has passed, otherwise false 263 | * @param {object} result.retries information about spec related retries, e.g. `{ attempts: 0, limit: 0 }` 264 | */ 265 | // afterTest: function(test, context, { error, result, duration, passed, retries }) { 266 | // }, 267 | 268 | /** 269 | * Hook that gets executed after the suite has ended 270 | * @param {object} suite suite details 271 | */ 272 | // afterSuite: function (suite) { 273 | // }, 274 | /** 275 | * Runs after a WebdriverIO command gets executed 276 | * @param {string} commandName hook command name 277 | * @param {Array} args arguments that command would receive 278 | * @param {number} result 0 - command success, 1 - command error 279 | * @param {object} error error object if any 280 | */ 281 | // afterCommand: function (commandName, args, result, error) { 282 | // }, 283 | /** 284 | * Gets executed after all tests are done. You still have access to all global variables from 285 | * the test. 286 | * @param {number} result 0 - test pass, 1 - test fail 287 | * @param {Array.} capabilities list of capabilities details 288 | * @param {Array.} specs List of spec file paths that ran 289 | */ 290 | // after: function (result, capabilities, specs) { 291 | // }, 292 | /** 293 | * Gets executed right after terminating the webdriver session. 294 | * @param {object} config wdio configuration object 295 | * @param {Array.} capabilities list of capabilities details 296 | * @param {Array.} specs List of spec file paths that ran 297 | */ 298 | // afterSession: function (config, capabilities, specs) { 299 | // }, 300 | /** 301 | * Gets executed after all workers got shut down and the process is about to exit. An error 302 | * thrown in the onComplete hook will result in the test run failing. 303 | * @param {object} exitCode 0 - success, 1 - fail 304 | * @param {object} config wdio configuration object 305 | * @param {Array.} capabilities list of capabilities details 306 | * @param {} results object containing test results 307 | */ 308 | // onComplete: function(exitCode, config, capabilities, results) { 309 | // }, 310 | /** 311 | * Gets executed when a refresh happens. 312 | * @param {string} oldSessionId session ID of the old session 313 | * @param {string} newSessionId session ID of the new session 314 | */ 315 | // onReload: function(oldSessionId, newSessionId) { 316 | // } 317 | /** 318 | * Hook that gets executed before a WebdriverIO assertion happens. 319 | * @param {object} params information about the assertion to be executed 320 | */ 321 | // beforeAssertion: function(params) { 322 | // } 323 | /** 324 | * Hook that gets executed after a WebdriverIO assertion happened. 325 | * @param {object} params information about the assertion that was executed, including its results 326 | */ 327 | // afterAssertion: function(params) { 328 | // } 329 | }; 330 | -------------------------------------------------------------------------------- /test/specs/test.e2e.js: -------------------------------------------------------------------------------- 1 | import { browser, expect } from '@wdio/globals'; 2 | import path from 'path'; 3 | 4 | async function performLogin(userName = 'admin', password = '1234') { 5 | await browser.takeScreenshot(); 6 | const att = await browser.flutterByValueKey$('username_text_field'); 7 | console.log(await att.getAttribute('all')); 8 | await browser.flutterByValueKey$('username_text_field').clearValue(); 9 | await browser.flutterByValueKey$('username_text_field').addValue(userName); 10 | 11 | await browser.flutterByValueKey$('password_text_field').clearValue(); 12 | await browser.flutterByValueKey$('password').addValue(password); 13 | await browser.flutterByValueKey$('LoginButton').click(); 14 | } 15 | 16 | async function handleAppManagement() { 17 | const currentContext = await browser.getContext(); 18 | if (currentContext !== 'NATIVE_APP') { 19 | await browser.switchContext('NATIVE_APP'); 20 | } 21 | 22 | const appID = browser.isIOS 23 | ? 'com.example.appiumTestingApp' 24 | : 'com.example.appium_testing_app'; 25 | if (await browser.isAppInstalled(appID)) { 26 | await browser.removeApp(appID); 27 | } 28 | await browser.installApp(process.env.APP_PATH); 29 | await browser.pause(2000); 30 | if (await browser.isAppInstalled(appID)) { 31 | console.log('App is installed'); 32 | await browser.execute('flutter: launchApp', { 33 | appId: appID, 34 | arguments: ['--dummy-arguments'], 35 | environment: {}, 36 | }); 37 | } 38 | } 39 | 40 | function itForAndroidOnly(description, fn) { 41 | if (browser.isIOS) { 42 | it.skip(description, fn); 43 | } else { 44 | it(description, fn); 45 | } 46 | } 47 | 48 | async function openScreen(screenTitle) { 49 | const screenListElement = await browser.flutterScrollTillVisible({ 50 | finder: await browser.flutterByText(screenTitle), 51 | }); 52 | await screenListElement.click(); 53 | } 54 | 55 | async function switchToWebview(timeout = 20000) { 56 | const webviewContext = await browser.waitUntil( 57 | async () => { 58 | const contexts = await browser.getContexts(); 59 | return contexts.find((ctx) => ctx.includes('WEBVIEW')); 60 | }, 61 | { 62 | timeout, 63 | timeoutMsg: `WEBVIEW context not found within ${timeout / 1000}s`, 64 | }, 65 | ); 66 | 67 | await browser.switchContext(webviewContext); 68 | return webviewContext; 69 | } 70 | 71 | describe('My Login application', () => { 72 | afterEach(async () => { 73 | await handleAppManagement(); 74 | }); 75 | 76 | it('Create Session with Flutter Integration Driver', async () => { 77 | await performLogin(); 78 | await openScreen('Double Tap'); 79 | const element = await browser 80 | .flutterByValueKey$('double_tap_button') 81 | .flutterByText$('Double Tap'); 82 | expect(await element.getText()).toEqual('Double Tap'); 83 | const size = await element.getSize(); 84 | expect(parseInt(size.width)).toBeGreaterThan(0); 85 | expect(parseInt(size.height)).toBeGreaterThan(0); 86 | await browser.flutterDoubleClick({ 87 | element: element, 88 | }); 89 | let popUpText = await browser.flutterByText$('Double Tap Successful'); 90 | expect(await popUpText.getText()).toEqual('Double Tap Successful'); 91 | await browser.flutterByText$('Ok').click(); 92 | await browser.flutterDoubleClick({ 93 | element, 94 | offset: { 95 | x: 10, 96 | y: 0, 97 | }, 98 | }); 99 | popUpText = await browser.flutterByText$('Double Tap Successful'); 100 | expect(await popUpText.getText()).toEqual('Double Tap Successful'); 101 | }); 102 | 103 | it('Wait Test', async () => { 104 | await performLogin(); 105 | await openScreen('Lazy Loading'); 106 | const message = await browser.flutterByValueKey$('message_field'); 107 | expect(await message.getText()).toEqual('Hello world'); 108 | await browser.flutterByValueKey$('toggle_button').click(); 109 | await browser.flutterWaitForAbsent({ element: message, timeout: 10 }); 110 | expect( 111 | await ( 112 | await browser.flutterByValueKey$$('message_field') 113 | ).length, 114 | ).toEqual(0); 115 | await browser.flutterByValueKey$('toggle_button').click(); 116 | await browser.flutterWaitForVisible({ element: message, timeout: 10 }); 117 | expect(await message.getText()).toEqual('Hello world'); 118 | }); 119 | 120 | it('Descendant Test', async () => { 121 | await performLogin(); 122 | await openScreen('Nested Scroll'); 123 | const childElement = await browser.flutterByDescendant$({ 124 | of: await browser.flutterByValueKey('parent_card_1'), 125 | matching: await browser.flutterByText('Child 2'), 126 | }); 127 | expect(await childElement.getText()).toEqual('Child 2'); 128 | }); 129 | 130 | it('Scroll until visible with Descendant', async () => { 131 | await performLogin(); 132 | await openScreen('Nested Scroll'); 133 | const childElement = await browser.flutterScrollTillVisible({ 134 | finder: await browser.flutterByDescendant({ 135 | of: await browser.flutterByValueKey('parent_card_4'), 136 | matching: await browser.flutterByText('Child 2'), 137 | }), 138 | delta: 100, 139 | scrollDirection: 'down', 140 | }); 141 | expect(await childElement.getText()).toEqual('Child 2'); 142 | }); 143 | it('Ancestor Test', async () => { 144 | await performLogin(); 145 | await openScreen('Nested Scroll'); 146 | const parentElement = await browser.flutterByAncestor$({ 147 | of: await browser.flutterByText('Child 2'), 148 | matching: await browser.flutterByValueKey('parent_card_1'), 149 | }); 150 | expect(await parentElement.getAttribute('displayed')).toBe(true); 151 | }); 152 | it('Scroll Test', async () => { 153 | await performLogin(); 154 | await openScreen('Vertical Swiping'); 155 | const javaElement = await browser.flutterScrollTillVisible({ 156 | finder: await browser.flutterByText('Java'), 157 | }); 158 | expect(await javaElement.getAttribute('displayed')).toBe(true); 159 | 160 | const protractorElement = await browser.flutterScrollTillVisible({ 161 | finder: await browser.flutterByText('Protractor'), 162 | }); 163 | expect(await javaElement.getAttribute('displayed')).toBe(false); 164 | expect(await protractorElement.getAttribute('displayed')).toBe(true); 165 | 166 | await browser.flutterScrollTillVisible({ 167 | finder: await browser.flutterByText('Java'), 168 | scrollDirection: 'up', 169 | }); 170 | expect(await protractorElement.getAttribute('displayed')).toBe(false); 171 | expect(await javaElement.getAttribute('displayed')).toBe(true); 172 | }); 173 | 174 | it('Long Press', async () => { 175 | await performLogin(); 176 | await openScreen('Long Press'); 177 | const longPressElement = 178 | await browser.flutterByValueKey$('long_press_button'); 179 | await browser.flutterLongPress({ element: longPressElement }); 180 | const popUpText = await browser 181 | .flutterByText$('It was a long press') 182 | .isDisplayed(); 183 | expect(popUpText).toBe(true); 184 | }); 185 | 186 | it('Should be able perform action when frame is rendering', async () => { 187 | await performLogin(); 188 | await openScreen('Loader Screen'); 189 | await browser.flutterByValueKey$('loader_login_button').click(); 190 | expect(await browser.flutterByText$('Button pressed').isDisplayed()).toBe( 191 | true, 192 | ); 193 | }); 194 | 195 | it('Properties Test', async () => { 196 | await performLogin(); 197 | await openScreen('UI Elements'); 198 | const prop2 = await browser.flutterBySemanticsLabel$( 199 | 'disabled_text_field', 200 | ); 201 | const disableTextFieldState = await prop2.getAttribute('flags'); 202 | expect(disableTextFieldState).toEqual( 203 | '[isTextField, hasEnabledState, isReadOnly]', 204 | ); 205 | 206 | const prop4 = await browser.flutterBySemanticsLabel$('switch_button'); 207 | await prop4.getAttribute('flags'); 208 | expect(await prop4.getAttribute('flags')).toEqual( 209 | '[hasEnabledState, isEnabled, hasToggledState, isFocusable]', 210 | ); 211 | await prop4.click(); 212 | await prop4.getAttribute('flags'); 213 | expect(await prop4.getAttribute('flags')).toEqual( 214 | '[hasEnabledState, isEnabled, hasToggledState, isToggled, isFocusable]', 215 | ); 216 | const prop5 = await browser.flutterBySemanticsLabel$('switch_button'); 217 | await prop5.getAttribute('all'); // Will return all attributes attached to the element 218 | // { 219 | // owner: 'SemanticsOwner#fd8a3', 220 | // isMergedIntoParent: 'false', 221 | // mergeAllDescendantsIntoThisNode: 'false', 222 | // rect: 'Rect.fromLTRB(0.0, 0.0, 60.0, 48.0)', 223 | // tags: 'null', 224 | // actions: '[tap]', 225 | // customActions: '[]', 226 | // flags: '[hasEnabledState, isEnabled, hasToggledState, isToggled, isFocusable]', 227 | // isInvisible: 'false', 228 | // isHidden: 'false', 229 | // identifier: 'null', 230 | // label: 'switch_button', 231 | // value: 'null', 232 | // increasedValue: 'null', 233 | // decreasedValue: 'null', 234 | // hint: 'null', 235 | // tooltip: 'null', 236 | // textDirection: 'null', 237 | // sortKey: 'null', 238 | // platformViewId: 'null', 239 | // maxValueLength: 'null', 240 | // currentValueLength: 'null', 241 | // scrollChildren: 'null', 242 | // scrollIndex: 'null', 243 | // scrollExtentMin: 'null', 244 | // scrollPosition: 'null', 245 | // scrollExtentMax: 'null', 246 | // indexInParent: 'null', 247 | // elevation: '0.0', 248 | // thickness: '0.0', 249 | // container: 'true', 250 | // properties: 'SemanticsProperties(label: "switch_button")', 251 | // checked: 'null', 252 | // mixed: 'null', 253 | // expanded: 'null', 254 | // selected: 'null', 255 | // attributedLabel: 'null', 256 | // attributedValue: 'null', 257 | // attributedIncreasedValue: 'null', 258 | // attributedDecreasedValue: 'null', 259 | // attributedHint: 'null', 260 | // hintOverrides: 'null' 261 | // } 262 | }); 263 | 264 | it.skip('Invalid Driver', async () => { 265 | await browser 266 | .flutterBySemanticsLabel$('username_text_field') 267 | .clearValue(); 268 | await browser 269 | .flutterBySemanticsLabel$('username_text_field') 270 | .addValue('admin1'); 271 | 272 | await browser 273 | .flutterBySemanticsLabel$('password_text_field') 274 | .clearValue(); 275 | await browser.flutterByValueKey$('password').addValue('12345'); 276 | await browser.flutterBySemanticsLabel$('login_button').click(); 277 | 278 | await browser.flutterByText$('Ok').click(); 279 | }); 280 | 281 | it('Drag and Drop', async () => { 282 | await performLogin(); 283 | await openScreen('Drag & Drop'); 284 | const dragElement = await browser.flutterByValueKey$('drag_me'); 285 | const dropElement = await browser.flutterByValueKey$('drop_zone'); 286 | await browser.flutterDragAndDrop({ 287 | source: dragElement, 288 | target: dropElement, 289 | }); 290 | const dropped = await browser 291 | .flutterByText$('The box is dropped') 292 | .getText(); 293 | expect(dropped).toEqual('The box is dropped'); 294 | }); 295 | 296 | //TODO: Wbview is not inspectable on iOS demo app, need to fix it. 297 | itForAndroidOnly( 298 | 'should switch to webview context and validate the page title', 299 | async () => { 300 | await performLogin(); 301 | await openScreen('Web View'); 302 | await switchToWebview(); 303 | 304 | await browser.waitUntil( 305 | async () => (await browser.getTitle()) === 'Hacker News', 306 | { 307 | timeout: 10000, 308 | timeoutMsg: 'Expected Hacker News title not found', 309 | }, 310 | ); 311 | 312 | const title = await browser.getTitle(); 313 | expect(title).toEqual( 314 | 'Hacker News', 315 | 'Webview title did not match expected', 316 | ); 317 | }, 318 | ); 319 | 320 | itForAndroidOnly( 321 | 'should execute native commands correctly while in Webview context', 322 | async () => { 323 | await performLogin(); 324 | await openScreen('Web View'); 325 | await switchToWebview(); 326 | 327 | // Verify no-proxy native commands still operate while in webview context 328 | const currentContext = await browser.getContext(); 329 | expect(currentContext).toContain('WEBVIEW'); 330 | 331 | const contexts = await browser.getContexts(); 332 | expect(Array.isArray(contexts)).toBe(true); 333 | expect(contexts.length).toBeGreaterThan(0); 334 | 335 | const windowHandle = await browser.getWindowHandle(); 336 | expect(typeof windowHandle).toBe('string'); 337 | 338 | const pageSource = await browser.getPageSource(); 339 | expect(typeof pageSource).toBe('string'); 340 | }, 341 | ); 342 | 343 | itForAndroidOnly( 344 | 'should switch back and forth between native and Webview contexts', 345 | async () => { 346 | await performLogin(); 347 | await openScreen('Web View'); 348 | 349 | await switchToWebview(); 350 | expect(await browser.getContext()).toContain('WEBVIEW'); 351 | 352 | await browser.switchContext('NATIVE_APP'); 353 | expect(await browser.getContext()).toBe('NATIVE_APP'); 354 | 355 | await switchToWebview(); 356 | expect(await browser.getContext()).toContain('WEBVIEW'); 357 | }, 358 | ); 359 | }); 360 | 361 | describe('Image mocking', async () => { 362 | afterEach(async () => { 363 | await handleAppManagement(); 364 | }); 365 | 366 | it('Inject Image', async () => { 367 | const firstImageToMock = path.resolve('test/qr.png'); 368 | const secondImageToMock = path.resolve('test/SecondImage.png'); 369 | await performLogin(); 370 | await openScreen('Image Picker'); 371 | const firstInjectedImage = 372 | await browser.flutterInjectImage(firstImageToMock); 373 | await browser.flutterByValueKey$('capture_image').click(); 374 | await browser.flutterByText$('PICK').click(); 375 | expect(await browser.flutterByText$('Success!').isDisplayed()).toBe(true); 376 | await browser.flutterInjectImage(secondImageToMock); 377 | await browser.flutterByValueKey$('capture_image').click(); 378 | await browser.flutterByText$('PICK').click(); 379 | expect( 380 | await browser.flutterByText$('SecondInjectedImage').isDisplayed(), 381 | ).toBe(true); 382 | await browser.flutterActivateInjectedImage({ 383 | imageId: firstInjectedImage, 384 | }); 385 | await browser.flutterByValueKey$('capture_image').click(); 386 | await browser.flutterByText$('PICK').click(); 387 | expect(await browser.flutterByText$('Success!').isDisplayed()).toBe(true); 388 | }); 389 | }); 390 | -------------------------------------------------------------------------------- /src/driver.ts: -------------------------------------------------------------------------------- 1 | import { desiredCapConstraints } from './desiredCaps'; 2 | import { JWProxy, BaseDriver } from '@appium/base-driver'; 3 | import type { 4 | DefaultCreateSessionResult, 5 | DriverData, 6 | W3CDriverCaps, 7 | DriverCaps, 8 | } from '@appium/types'; 9 | type FlutterDriverConstraints = typeof desiredCapConstraints; 10 | // @ts-ignore 11 | import { XCUITestDriver } from 'appium-xcuitest-driver'; 12 | import { AndroidUiautomator2Driver } from 'appium-uiautomator2-driver'; 13 | // @ts-ignore 14 | import { Mac2Driver } from 'appium-mac2-driver'; 15 | import { createSession as createSessionMixin } from './session'; 16 | import { 17 | findElOrEls, 18 | click, 19 | getText, 20 | elementDisplayed, 21 | getAttribute, 22 | elementEnabled, 23 | setValue, 24 | clear, 25 | ELEMENT_CACHE, 26 | getElementRect, 27 | constructFindElementPayload, 28 | } from './commands/element'; 29 | import { 30 | attachAppLaunchArguments, 31 | fetchFlutterServerPort, 32 | FLUTTER_LOCATORS, 33 | getFreePort, 34 | isFlutterDriverCommand, 35 | waitForFlutterServerToBeActive, 36 | } from './utils'; 37 | import { logger, util } from 'appium/support'; 38 | import { androidPortForward, androidRemovePortForward } from './android'; 39 | import { iosPortForward, iosRemovePortForward } from './iOS'; 40 | import type { PortForwardCallback, PortReleaseCallback } from './types'; 41 | import _ from 'lodash'; 42 | 43 | import type { RouteMatcher } from '@appium/types'; 44 | 45 | const WEBVIEW_NO_PROXY = [ 46 | [`GET`, new RegExp(`^/session/[^/]+/appium`)], 47 | [`GET`, new RegExp(`^/session/[^/]+/context`)], 48 | [`GET`, new RegExp(`^/session/[^/]+/element/[^/]+/rect`)], 49 | [`GET`, new RegExp(`^/session/[^/]+/log/types$`)], 50 | [`GET`, new RegExp(`^/session/[^/]+/orientation`)], 51 | [`POST`, new RegExp(`^/session/[^/]+/appium`)], 52 | [`POST`, new RegExp(`^/session/[^/]+/context`)], 53 | [`POST`, new RegExp(`^/session/[^/]+/log$`)], 54 | [`POST`, new RegExp(`^/session/[^/]+/orientation`)], 55 | [`POST`, new RegExp(`^/session/[^/]+/touch/multi/perform`)], 56 | [`POST`, new RegExp(`^/session/[^/]+/touch/perform`)], 57 | ] as import('@appium/types').RouteMatcher[]; 58 | 59 | export class AppiumFlutterDriver extends BaseDriver { 60 | // @ts-ignore 61 | public proxydriver: XCUITestDriver | AndroidUiautomator2Driver | Mac2Driver; 62 | public flutterPort: number | null | undefined; 63 | private internalCaps: DriverCaps | undefined; 64 | public proxy: JWProxy | undefined; 65 | private proxyWebViewActive: boolean = false; 66 | public readonly NATIVE_CONTEXT_NAME: string = `NATIVE_APP`; 67 | public currentContext: string = this.NATIVE_CONTEXT_NAME; 68 | click = click; 69 | findElOrEls = findElOrEls; 70 | getText = getText; 71 | getAttribute = getAttribute; 72 | getElementRect = getElementRect; 73 | elementDisplayed = elementDisplayed; 74 | elementEnabled = elementEnabled; 75 | setValue = setValue; 76 | clear = clear; 77 | constructor(args: any, shouldValidateCaps: boolean) { 78 | super(args, shouldValidateCaps); 79 | this.desiredCapConstraints = desiredCapConstraints; 80 | this.locatorStrategies = [ 81 | 'xpath', 82 | 'css selector', 83 | 'id', 84 | 'name', 85 | 'class name', 86 | '-android uiautomator', 87 | 'accessibility id', 88 | '-ios predicate string', 89 | '-ios class chain', 90 | ...FLUTTER_LOCATORS, //to support backward compatibility 91 | ...FLUTTER_LOCATORS.map((locator) => `-flutter ${locator}`), 92 | '-flutter descendant', 93 | '-flutter ancestor', 94 | ]; 95 | } 96 | 97 | static executeMethodMap = { 98 | 'flutter: doubleClick': { 99 | command: 'doubleClick', 100 | params: { 101 | required: [], 102 | optional: ['origin', 'offset', 'locator'], 103 | }, 104 | }, 105 | 'flutter: waitForVisible': { 106 | command: 'waitForElementToBeVisible', 107 | params: { 108 | required: [], 109 | optional: ['element', 'locator', 'timeout'], 110 | }, 111 | }, 112 | 'flutter: waitForAbsent': { 113 | command: 'waitForElementToBeGone', 114 | params: { 115 | required: [], 116 | optional: ['element', 'locator', 'timeout'], 117 | }, 118 | }, 119 | 'flutter: scrollTillVisible': { 120 | command: 'scrollTillVisible', 121 | params: { 122 | required: [], 123 | optional: [ 124 | 'finder', 125 | 'scrollView', 126 | 'delta', 127 | 'maxScrolls', 128 | 'settleBetweenScrollsTimeout', 129 | 'dragDuration', 130 | 'scrollDirection', 131 | ], 132 | }, 133 | }, 134 | 'flutter: longPress': { 135 | command: 'longPress', 136 | params: { 137 | required: [], 138 | optional: ['origin', 'offset', 'locator'], 139 | }, 140 | }, 141 | 'flutter: dragAndDrop': { 142 | command: 'dragAndDrop', 143 | params: { 144 | required: ['source', 'target'], 145 | }, 146 | }, 147 | 'flutter: launchApp': { 148 | command: 'mobilelaunchApp', 149 | params: { 150 | required: ['appId'], 151 | optional: ['arguments', 'environment'], 152 | }, 153 | }, 154 | 'flutter: injectImage': { 155 | command: 'injectImage', 156 | params: { 157 | required: ['base64Image'], 158 | }, 159 | }, 160 | 'flutter: activateInjectedImage': { 161 | command: 'activateInjectedImage', 162 | params: { 163 | required: ['imageId'], 164 | }, 165 | }, 166 | 'flutter: renderTree': { 167 | command: 'renderTree', 168 | params: { 169 | required: [], 170 | optional: ['widgetType', 'text', 'key'], 171 | }, 172 | }, 173 | }; 174 | 175 | async doubleClick(origin: any, offset: any, locator: any) { 176 | return this.proxy?.command( 177 | `/session/:sessionId/appium/gestures/double_click`, 178 | 'POST', 179 | { 180 | origin, 181 | offset, 182 | locator, 183 | }, 184 | ); 185 | //console.log('DoubleTap', value, JSON.parse(JSON.stringify(value)).elementId); 186 | } 187 | 188 | async injectImage(base64Image: string) { 189 | async function grantPermissions(permission: string) { 190 | await this.proxydriver.execute('mobile: changePermissions', { 191 | permissions: [permission], 192 | action: 'allow', 193 | target: 'appops', 194 | }); 195 | } 196 | 197 | if (this.proxydriver instanceof AndroidUiautomator2Driver) { 198 | // @ts-ignore 199 | if (this.proxydriver.uiautomator2.adb._apiLevel < 33) { 200 | await grantPermissions.call(this, 'WRITE_EXTERNAL_STORAGE'); 201 | await grantPermissions.call(this, 'READ_EXTERNAL_STORAGE'); 202 | } else { 203 | await grantPermissions.call(this, 'MANAGE_EXTERNAL_STORAGE'); 204 | } 205 | } 206 | return this.proxy?.command(`/session/:sessionId/inject_image`, 'POST', { 207 | base64Image, 208 | }); 209 | } 210 | 211 | async activateInjectedImage(imageId: string) { 212 | return this.proxy?.command( 213 | `/session/:sessionId/activate_inject_image`, 214 | 'POST', 215 | { 216 | imageId, 217 | }, 218 | ); 219 | } 220 | 221 | async executeCommand(command: any, ...args: any) { 222 | if ( 223 | this.currentContext === this.NATIVE_CONTEXT_NAME && 224 | isFlutterDriverCommand(command) 225 | ) { 226 | this.log.debug( 227 | `executeCommand: command ${command} is flutter command using flutter driver`, 228 | ); 229 | return await super.executeCommand(command, ...args); 230 | } else { 231 | this.log.info( 232 | `Executing the command: ${command} with args: ${args} and flutterCommand ${isFlutterDriverCommand(command)}`, 233 | ); 234 | } 235 | 236 | this.handleContextSwitch(command, args); 237 | logger.default.info( 238 | `Executing the proxy command: ${command} with args: ${args}`, 239 | ); 240 | return await this.proxydriver.executeCommand(command as string, ...args); 241 | } 242 | 243 | private handleContextSwitch(command: string, args: any[]): void { 244 | if (command === 'setContext') { 245 | const isWebviewContext = 246 | typeof args[0] === 'string' && args[0].includes('WEBVIEW'); 247 | if (typeof args[0] === 'string' && args[0].length > 0) { 248 | this.currentContext = args[0]; 249 | } else { 250 | logger.default.warn( 251 | `Attempted to set context to invalid value: ${args[0]}. Keeping current context: ${this.currentContext}`, 252 | ); 253 | } 254 | 255 | if (isWebviewContext) { 256 | this.proxyWebViewActive = true; 257 | } else { 258 | this.proxyWebViewActive = false; 259 | } 260 | } 261 | } 262 | 263 | public getProxyAvoidList(): RouteMatcher[] { 264 | return WEBVIEW_NO_PROXY; 265 | } 266 | 267 | public async createSession( 268 | ...args: any[] 269 | ): Promise> { 270 | const [sessionId, caps] = await super.createSession( 271 | ...(JSON.parse(JSON.stringify(args)) as [ 272 | W3CDriverCaps, 273 | W3CDriverCaps, 274 | W3CDriverCaps, 275 | DriverData[], 276 | ]), 277 | ); 278 | 279 | this.internalCaps = caps; 280 | /** 281 | * To support parallel execution in iOS simulators 282 | * flutterServerPort need to be passed as launch argument using appium:processArguments 283 | * Refer: https://appium.github.io/appium-xcuitest-driver/latest/reference/capabilities/ 284 | */ 285 | attachAppLaunchArguments.bind(this)(caps, ...args); 286 | 287 | let sessionCreated = await createSessionMixin.bind(this)( 288 | sessionId, 289 | caps, 290 | ...JSON.parse(JSON.stringify(args)), 291 | ); 292 | const packageName = 293 | this.proxydriver instanceof AndroidUiautomator2Driver 294 | ? this.proxydriver.opts.appPackage! 295 | : this.proxydriver.opts.bundleId!; 296 | 297 | const isIosSimulator = 298 | this.proxydriver instanceof XCUITestDriver && 299 | !this.proxydriver.isRealDevice(); 300 | 301 | const portcallbacks: { 302 | portForwardCallback?: PortForwardCallback; 303 | portReleaseCallback?: PortReleaseCallback; 304 | } = {}; 305 | if (this.proxydriver instanceof AndroidUiautomator2Driver) { 306 | portcallbacks.portForwardCallback = async ( 307 | _: string, 308 | systemPort: number, 309 | devicePort: number, 310 | ) => 311 | await androidPortForward( 312 | // @ts-ignore ADB instance is ok 313 | (this.proxydriver as AndroidUiautomator2Driver).adb, 314 | systemPort, 315 | devicePort, 316 | ); 317 | portcallbacks.portReleaseCallback = async ( 318 | _: string, 319 | systemPort: number, 320 | ) => 321 | await androidRemovePortForward( 322 | // @ts-ignore ADB instance is ok 323 | (this.proxydriver as AndroidUiautomator2Driver).adb, 324 | systemPort, 325 | ); 326 | } else if (!isIosSimulator) { 327 | portcallbacks.portForwardCallback = iosPortForward; 328 | portcallbacks.portReleaseCallback = iosRemovePortForward; 329 | } 330 | 331 | const systemPort = 332 | this.internalCaps.flutterSystemPort || 333 | (isIosSimulator ? null : await getFreePort()); 334 | const udid = this.proxydriver.opts.udid!; 335 | 336 | this.flutterPort = await fetchFlutterServerPort.bind(this)({ 337 | udid, 338 | packageName, 339 | ...portcallbacks, 340 | systemPort, 341 | isIosSimulator, 342 | }); 343 | 344 | if (!this.flutterPort) { 345 | throw new Error( 346 | `Flutter server is not started. ` + 347 | `Please make sure the application under test is configured properly.Please refer ` + 348 | `https://github.com/AppiumTestDistribution/appium-flutter-integration-driver?tab=readme-ov-file#how-to-use-appium-flutter-integration-driver.`, 349 | ); 350 | } 351 | // @ts-ignore 352 | this.proxy = new JWProxy({ 353 | server: this.internalCaps.address || '127.0.0.1', 354 | port: this.flutterPort, 355 | }); 356 | 357 | await this.proxy.command('/session', 'POST', { capabilities: caps }); 358 | return sessionCreated; 359 | } 360 | async waitForElementToBeGone(element: any, locator: any, timeout: number) { 361 | return this.proxy?.command( 362 | `/session/:sessionId/element/wait/absent`, 363 | 'POST', 364 | { 365 | element, 366 | locator: constructFindElementPayload( 367 | locator?.using, 368 | locator?.value, 369 | ), 370 | timeout, 371 | }, 372 | ); 373 | } 374 | 375 | async waitForElementToBeVisible( 376 | element: any, 377 | locator: any, 378 | timeout: number, 379 | ) { 380 | return this.proxy?.command( 381 | `/session/:sessionId/element/wait/visible`, 382 | 'POST', 383 | { 384 | element, 385 | locator: constructFindElementPayload( 386 | locator?.using, 387 | locator?.value, 388 | ), 389 | timeout, 390 | }, 391 | ); 392 | } 393 | 394 | async longPress(origin: any, offset: any, locator: any) { 395 | return this.proxy?.command( 396 | `/session/:sessionId/appium/gestures/long_press`, 397 | 'POST', 398 | { 399 | origin, 400 | offset, 401 | locator: constructFindElementPayload( 402 | locator?.using, 403 | locator?.value, 404 | ), 405 | }, 406 | ); 407 | } 408 | 409 | async dragAndDrop(source: any, target: any) { 410 | return this.proxy?.command( 411 | `/session/:sessionId/appium/gestures/drag_drop`, 412 | 'POST', 413 | { 414 | source, 415 | target, 416 | }, 417 | ); 418 | } 419 | 420 | async scrollTillVisible( 421 | finder: any, 422 | scrollView: any, 423 | delta: any, 424 | maxScrolls: any, 425 | settleBetweenScrollsTimeout: any, 426 | dragDuration: any, 427 | scrollDirection: string, 428 | ) { 429 | const element: any = await this.proxy?.command( 430 | `/session/:sessionId/appium/gestures/scroll_till_visible`, 431 | 'POST', 432 | { 433 | finder: constructFindElementPayload( 434 | finder.using || finder.strategy, 435 | finder.value || finder.selector, 436 | ), 437 | scrollView, 438 | delta, 439 | maxScrolls, 440 | settleBetweenScrollsTimeout, 441 | dragDuration, 442 | scrollDirection, 443 | }, 444 | ); 445 | if (element.ELEMENT || element[util.W3C_WEB_ELEMENT_IDENTIFIER]) { 446 | ELEMENT_CACHE.set( 447 | element.ELEMENT || element[util.W3C_WEB_ELEMENT_IDENTIFIER], 448 | this.proxy, 449 | ); 450 | } 451 | return element; 452 | } 453 | 454 | async execute(script: any, args: any) { 455 | if (script.startsWith('flutter:')) { 456 | return await this.executeMethod(script, args); 457 | } 458 | // @ts-ignore 459 | return await this.proxydriver.execute(script, args); 460 | } 461 | 462 | public proxyActive(): boolean { 463 | // In WebView context, all request should go to each driver 464 | // so that they can handle http request properly. 465 | // On iOS, WebView context is handled by XCUITest driver while Android is by chromedriver. 466 | // It means XCUITest driver should keep the XCUITest driver as a proxy, 467 | // while UIAutomator2 driver should proxy to chromedriver instead of UIA2 proxy. 468 | return ( 469 | this.proxyWebViewActive && 470 | !(this.proxydriver instanceof XCUITestDriver) 471 | ); 472 | } 473 | 474 | public canProxy(): boolean { 475 | return this.proxyWebViewActive; 476 | } 477 | 478 | async deleteSession() { 479 | if ( 480 | this.proxydriver instanceof AndroidUiautomator2Driver && 481 | this.flutterPort 482 | ) { 483 | // @ts-ignore 484 | await this.proxydriver.adb.removePortForward(this.flutterPort); 485 | } 486 | await this.proxydriver?.deleteSession(this.sessionId); 487 | await super.deleteSession(); 488 | } 489 | 490 | async mobilelaunchApp(appId: string, args: string[], environment: any) { 491 | let activateAppResponse; 492 | this.currentContext = this.NATIVE_CONTEXT_NAME; 493 | this.proxyWebViewActive = false; 494 | const launchArgs = _.assign( 495 | { arguments: [] as string[] }, 496 | { arguments: args, environment }, 497 | ); 498 | 499 | // Add port parameter to launch argument and only supported for iOS 500 | if (this.proxydriver instanceof XCUITestDriver) { 501 | launchArgs.arguments = _.flatten([ 502 | launchArgs.arguments, 503 | `--flutter-server-port=${this.internalCaps?.flutterSystemPort || this.flutterPort}`, 504 | ]); 505 | this.log.info( 506 | 'Attaching launch arguments to XCUITestDriver ' + 507 | JSON.stringify(launchArgs), 508 | ); 509 | activateAppResponse = await this.proxydriver.execute( 510 | 'mobile: launchApp', 511 | [{ bundleId: appId, ...launchArgs }], 512 | ); 513 | } else { 514 | //@ts-ignore this.proxydriver will be an instance of AndroidUiautomator2Driver 515 | activateAppResponse = await this.proxydriver.execute( 516 | 'mobile: activateApp', 517 | [{ appId }], 518 | ); 519 | } 520 | 521 | await waitForFlutterServerToBeActive.bind(this)( 522 | this.proxy, 523 | appId, 524 | this.flutterPort, 525 | ); 526 | await this.proxy?.command('/session', 'POST', { 527 | capabilities: Object.assign( 528 | {}, 529 | this.proxydriver.originalCaps?.alwaysMatch, 530 | this.proxydriver.originalCaps?.firstMatch[0], 531 | ), 532 | }); 533 | return activateAppResponse; 534 | } 535 | 536 | async renderTree(widgetType?: string, text?: string, key?: string) { 537 | const body: Record = {}; 538 | 539 | if (widgetType !== undefined) { 540 | body['widgetType'] = widgetType; 541 | } 542 | if (text !== undefined) { 543 | body['text'] = text; 544 | } 545 | if (key !== undefined) { 546 | body['key'] = key; 547 | } 548 | 549 | const url = `/session/${this.sessionId}/element/render_tree`; 550 | return this.proxy?.command(url, 'POST', body); 551 | } 552 | } 553 | --------------------------------------------------------------------------------