├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .prettierignore ├── .travis.yml ├── .vscode ├── debug-ts.js ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts ├── lib │ ├── alert.ts │ ├── appUtils.ts │ ├── constants.ts │ ├── deeplink.ts │ ├── environment.test.ts │ ├── environment.ts │ ├── gestures.ts │ ├── internal │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── select │ │ ├── androidSelector.ts │ │ ├── iosSelector.ts │ │ ├── select.android.test.ts │ │ ├── select.ios.test.ts │ │ ├── select.test.ts │ │ ├── select.ts │ │ ├── selector.test.ts │ │ ├── selector.ts │ │ └── type.ts │ └── utils.ts └── types │ └── wdio-logger.d.ts ├── tsconfig.json └── tsconfig.module.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # https://circleci.com/docs/2.0/language-javascript/ 2 | version: 2 3 | jobs: 4 | 'node-10': 5 | docker: 6 | - image: circleci/node:10 7 | working_directory: ~/typescript-starter 8 | steps: 9 | - checkout 10 | # Download and cache dependencies 11 | - restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "package.json" }} 14 | # fallback to using the latest cache if no exact match is found 15 | - v1-dependencies- 16 | - run: npm install 17 | - save_cache: 18 | paths: 19 | - node_modules 20 | key: v1-dependencies-{{ checksum "package.json" }} 21 | - run: npm test 22 | - run: npm run cov:send 23 | - run: npm run cov:check 24 | 'node-latest': 25 | docker: 26 | - image: circleci/node:latest 27 | working_directory: ~/typescript-starter 28 | steps: 29 | - checkout 30 | - restore_cache: 31 | keys: 32 | - v1-dependencies-{{ checksum "package.json" }} 33 | - v1-dependencies- 34 | - run: npm install 35 | - save_cache: 36 | paths: 37 | - node_modules 38 | key: v1-dependencies-{{ checksum "package.json" }} 39 | - run: npm test 40 | - run: npm run cov:send 41 | - run: npm run cov:check 42 | 43 | workflows: 44 | version: 2 45 | build: 46 | jobs: 47 | - 'node-10' 48 | - 'node-latest' 49 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | 6 | indent_style = space 7 | indent_size = 4 8 | 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [{.babelrc,.eslintrc,.codeclimate.yml,.travis.yml,*.json,.eslintrc.js}] 15 | indent_size = 2 16 | 17 | [*.md] 18 | max_line_length = 0 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martinfrancois/wdio-mobile-utils/a299ae3e6efb7b7061099f6442c6b808cd7bb1c2/.eslintignore -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | tsconfigRootDir: __dirname 6 | }, 7 | plugins: ['@typescript-eslint', 'jest'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:jest/recommended', 11 | 'plugin:@typescript-eslint/eslint-recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'plugin:prettier/recommended' // must be last to override conflicting rules! 14 | ], 15 | rules: {} 16 | }; 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | * **Summary** 8 | 9 | 10 | 11 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | 4 | 5 | * **What is the current behavior?** (You can also link to an open issue here) 6 | 7 | 8 | 9 | * **What is the new behavior (if this is a feature change)?** 10 | 11 | 12 | 13 | * **Other information**: 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | test 4 | src/**.js 5 | .idea/* 6 | 7 | coverage 8 | .nyc_output 9 | *.log 10 | 11 | yarn.lock -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | tsconfig.json 4 | tsconfig.module.json 5 | .travis.yml 6 | .github 7 | .prettierignore 8 | .vscode 9 | build/docs 10 | **/*.test.* 11 | coverage 12 | *.log 13 | .editorconfig 14 | .eslintignore 15 | .eslintrc.js 16 | jest.config.js 17 | *.md 18 | .idea 19 | .circleci 20 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # package.json is formatted by package managers, so we ignore it here 2 | package.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '10' 5 | - '12' 6 | # keep the npm cache to speed up installs 7 | cache: 8 | directories: 9 | - '$HOME/.npm' 10 | after_success: 11 | - npm run cov:send 12 | jobs: 13 | include: 14 | - stage: deploy 15 | node_js: '10' 16 | provider: releases 17 | api_key: $GITHUB_TOKEN 18 | name: Release $TRAVIS_TAG 19 | body: See [CHANGELOG.md](https://github.com/$TRAVIS_REPO_SLUG/blob/master/CHANGELOG.md) 20 | on: 21 | tags: true 22 | -------------------------------------------------------------------------------- /.vscode/debug-ts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const meow = require('meow'); 3 | const path = require('path'); 4 | 5 | const tsFile = getTSFile(); 6 | const jsFile = TS2JS(tsFile); 7 | 8 | replaceCLIArg(tsFile, jsFile); 9 | 10 | // Ava debugger 11 | require('ava/profile'); 12 | 13 | /** 14 | * get ts file path from CLI args 15 | * 16 | * @return string path 17 | */ 18 | function getTSFile() { 19 | const cli = meow(); 20 | return cli.input[0]; 21 | } 22 | 23 | /** 24 | * get associated compiled js file path 25 | * 26 | * @param tsFile path 27 | * @return string path 28 | */ 29 | function TS2JS(tsFile) { 30 | const srcFolder = path.join(__dirname, '..', 'src'); 31 | const distFolder = path.join(__dirname, '..', 'build', 'main'); 32 | 33 | const tsPathObj = path.parse(tsFile); 34 | 35 | return path.format({ 36 | dir: tsPathObj.dir.replace(srcFolder, distFolder), 37 | ext: '.js', 38 | name: tsPathObj.name, 39 | root: tsPathObj.root 40 | }); 41 | } 42 | 43 | /** 44 | * replace a value in CLI args 45 | * 46 | * @param search value to search 47 | * @param replace value to replace 48 | * @return void 49 | */ 50 | function replaceCLIArg(search, replace) { 51 | process.argv[process.argv.indexOf(search)] = replace; 52 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [{ 4 | "type": "node", 5 | "request": "launch", 6 | "name": "Debug Project", 7 | // we test in `build` to make cleanup fast and easy 8 | "cwd": "${workspaceFolder}/build", 9 | // Replace this with your project root. If there are multiple, you can 10 | // automatically run the currently visible file with: "program": ${file}" 11 | "program": "${workspaceFolder}/src/cli/cli.ts", 12 | // "args": ["--no-install"], 13 | "outFiles": ["${workspaceFolder}/build/main/**/*.js"], 14 | "skipFiles": [ 15 | "/**/*.js", 16 | "${workspaceFolder}/node_modules/**/*.js" 17 | ], 18 | "preLaunchTask": "npm: build", 19 | "stopOnEntry": true, 20 | "smartStep": true, 21 | "runtimeArgs": ["--nolazy"], 22 | "env": { 23 | "TYPESCRIPT_STARTER_REPO_URL": "${workspaceFolder}" 24 | }, 25 | "console": "externalTerminal" 26 | }, 27 | { 28 | "type": "node", 29 | "request": "launch", 30 | "name": "Debug Spec", 31 | "program": "${workspaceRoot}/.vscode/debug-ts.js", 32 | "args": ["${file}"], 33 | "skipFiles": ["/**/*.js"], 34 | // Consider using `npm run watch` or `yarn watch` for faster debugging 35 | // "preLaunchTask": "npm: build", 36 | // "smartStep": true, 37 | "runtimeArgs": ["--nolazy"] 38 | }] 39 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | // "typescript.implementationsCodeLens.enabled": true 4 | // "typescript.referencesCodeLens.enabled": true 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [8.0.2](https://github.com/martinfrancois/wdio-mobile-utils/compare/6-v8.0.1...6-v8.0.2) (2020-05-24) 6 | 7 | ### [8.0.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/6-v8.0.0...6-v8.0.1) (2020-05-24) 8 | 9 | ## [8.0.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/6-v7.0.1...6-v8.0.0) (2020-05-24) 10 | 11 | ### ⚠ BREAKING CHANGES 12 | 13 | - **selector:** There is no reason to use the methods `android` and `ios` on `Selector` as part of the public API, which is why they were marked as internal explicitly and renamed to `_android` and `_ios` respectively. 14 | This should make it easier to not use the API in the wrong way. 15 | No migration steps should be necessary for the reasons mentioned above. 16 | If you have previously been using those methods, remember to wrap the `Selector` in either `mobile$` or `mobile$$`. 17 | 18 | Signed-off-by: martinfrancois 19 | 20 | - **selector:** explicitly mark methods `ios` and `android` as internal and private ([a22e116](https://github.com/martinfrancois/wdio-mobile-utils/commit/a22e116c8577c0bbbc4535a6801d616dc6edf861)) 21 | 22 | ### [7.0.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/6-v7.0.0...6-v7.0.1) (2020-05-12) 23 | 24 | ## 7.0.0 (2020-05-11) 25 | 26 | ### ⚠ BREAKING CHANGES 27 | 28 | - **npm:** WebdriverIO v5 was upgraded to WebdriverIO v6. 29 | On the branch `v5`, the library will still be published for WebdriverIO v5. 30 | 31 | Signed-off-by: martinfrancois 32 | 33 | - **utils:** Replace calls to the method by `$('~URL')` 34 | The method was removed as it doesn't really serve a purpose and is too simple to be included in a library. 35 | 36 | Signed-off-by: martinfrancois 37 | 38 | - **utils:** Rename calls to the method `getAutomationTextOfElement` to `getAccessibilityIdOfElement` 39 | 40 | Signed-off-by: martinfrancois 41 | 42 | - **androidSelector:** `AndroidSelector.android(...)` is now `AndroidSelector.of(...)` 43 | 44 | Signed-off-by: martinfrancois 45 | 46 | - **iosSelector:** `IosSelector.ios(...)` is now `IosSelector.of(...)` 47 | 48 | Signed-off-by: martinfrancois 49 | 50 | - **utils:** App-related utility functions `queryAppState`, `isAppState`, `isBrowserAppState`, `openSafari`, `openApp` were moved from `utils.ts` to `appUtils.ts` 51 | 52 | Signed-off-by: martinfrancois 53 | 54 | - **utils:** The old select API (in utils.ts) was replaced by `mobile$(...)` and `mobile$$(...)` methods instead. 55 | 56 | Signed-off-by: martinfrancois 57 | 58 | - **utils:** For consistency, the order of parameters was changed to always include `appId` and `bundleId` last. 59 | 60 | To migrate, change all `openApp` calls to include the `wait` parameter as the first parameter, followed by `appId` and `bundleId` 61 | 62 | - **npm:** Since NodeJS 8 is no longer supported from the 31st of December 2019, NodeJS 10 is required as a minimum. 63 | 64 | ### Features 65 | 66 | - consistently throw an error if the platform is neither Android nor iOS ([7eee09a](https://github.com/martinfrancois/wdio-mobile-utils/commit/7eee09ae1840bff50f3282e06ecc8385f1427437)) 67 | - **alert:** add utility functions ([6d6c93d](https://github.com/martinfrancois/wdio-mobile-utils/commit/6d6c93d0b332895727932a89fbb68a0d30f7c5b8)) 68 | - **alert:** add visibility check for android ([ba129f4](https://github.com/martinfrancois/wdio-mobile-utils/commit/ba129f4563ed545f4fcc8f8e735e76e80c4e792e)) 69 | - **utils:** remove method `getSafariUrl` ([d93ff78](https://github.com/martinfrancois/wdio-mobile-utils/commit/d93ff78a41f181ef435d1d8e361b2633c5026f09)) 70 | - allow timeout to be defined ([0abcb15](https://github.com/martinfrancois/wdio-mobile-utils/commit/0abcb158efeb652592a6d1864b4cca8715cbdf2b)) 71 | - **appium:** add types for appium ([ae1620c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ae1620c73c48b7a84f88693d40cf61463a6e484e)) 72 | - **deeplink:** add deeplink functions ([975a766](https://github.com/martinfrancois/wdio-mobile-utils/commit/975a766974e60b515398b7f2bd5b245d851f72eb)) 73 | - **deeplink:** improve logging output for opening a deeplink in iOS Simulator ([bcf9f06](https://github.com/martinfrancois/wdio-mobile-utils/commit/bcf9f06fe90e8fc99e13738f798b03cb2d98abea)) 74 | - **deeplink:** support iOS devices of any locale when using Appium >=1.17.0 ([b12126f](https://github.com/martinfrancois/wdio-mobile-utils/commit/b12126f98c7e81212312310de7b04f0da16687bd)) 75 | - **environment:** add method to find out if the test is running on an ios simulator ([076c3d1](https://github.com/martinfrancois/wdio-mobile-utils/commit/076c3d1da3927c126006b4ba6dd27228ada2649a)) 76 | - **gestures:** add gestures utility functions ([127c2b3](https://github.com/martinfrancois/wdio-mobile-utils/commit/127c2b35ce13c95324e6ca0f406cc1489401a22a)) 77 | - **internal/utils:** add method to check if argument is of type string ([ffcdd4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ffcdd4ce0abdd01da1c60ae65e3b1c974cfc96cb)) 78 | - **select:** add accessibilityId selectors ([684a7af](https://github.com/martinfrancois/wdio-mobile-utils/commit/684a7afe837e94b393b4d41a3581f4bdf9a20559)) 79 | - **select:** add initial new select API with `enabled` and `disabled` selectors ([e8aaef0](https://github.com/martinfrancois/wdio-mobile-utils/commit/e8aaef03de539a360ff3f92389a0d484a9b55afe)) 80 | - **select:** add select API to use with selectors ([72e818f](https://github.com/martinfrancois/wdio-mobile-utils/commit/72e818f44db4d6c32c3380190d0fc716d8e86923)) 81 | - **select:** add text selectors ([a2dc010](https://github.com/martinfrancois/wdio-mobile-utils/commit/a2dc0108f50ab9b872414f43b4177121bd1e395d)) 82 | - **select:** add type selector ([10efd99](https://github.com/martinfrancois/wdio-mobile-utils/commit/10efd994ba7eb905823d40747e6ea88f8564c4c3)) 83 | - **select:** implement combination selectors (and & or) for iOS ([1b4918d](https://github.com/martinfrancois/wdio-mobile-utils/commit/1b4918d722a42a23c2279ce1d4a491cfe02f9b9b)) 84 | - **selector:** allow either the iOS or Android selector to be null ([108209f](https://github.com/martinfrancois/wdio-mobile-utils/commit/108209f7145dab763c451dbe6e48c7673e28cfeb)) 85 | - **selector:** implement and & or for android (happy path) ([af78f42](https://github.com/martinfrancois/wdio-mobile-utils/commit/af78f428704c809179e3ade22dfe795d50aee92e)) 86 | - **selector:** implement edge cases for or & and combinations on Android ([2a9bd72](https://github.com/martinfrancois/wdio-mobile-utils/commit/2a9bd72d6c3abe7244cb8d41123b355781b8ac7a)) 87 | - **utils:** add utility functions ([b6e79b1](https://github.com/martinfrancois/wdio-mobile-utils/commit/b6e79b11787b2cd0853524671cfda5f38e7fcc93)) 88 | - **utils:** remove old select API in favor of new select API ([4503cb4](https://github.com/martinfrancois/wdio-mobile-utils/commit/4503cb41f261aa0fd26e6ab50c8699d18fede7f9)) 89 | 90 | ### Bug Fixes 91 | 92 | - **deeplink:** compilation error due to renamed import ([ce14b4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ce14b4ca7a5891bc6a19ce45395b651cd094b9ff)) 93 | - **deeplink:** deeplink only finding `XCUIElementTypeTextField` but not `XCUIElementTypeButton` on iPhone Simulator ([27a097b](https://github.com/martinfrancois/wdio-mobile-utils/commit/27a097bef5ba85a05ef88bf7cd09e65a11759ce2)) 94 | - **deeplink:** errors of `bundleId` not being defined ([d532a66](https://github.com/martinfrancois/wdio-mobile-utils/commit/d532a66523046262cc54554363a137b9e9b1fa58)) 95 | - **deeplink:** fix deeplink execution on Android ([0b29cf5](https://github.com/martinfrancois/wdio-mobile-utils/commit/0b29cf51d653e5c64f972644cded903988c5ac7c)) 96 | - **deeplink:** implement backward compatibility layer differently to fix tests on Sauce Labs Virtual Cloud iOS Simulators ([476d7b3](https://github.com/martinfrancois/wdio-mobile-utils/commit/476d7b3532e2d58470899cb13b0313f146818248)) 97 | - **deeplink:** remove context approach for real devices ([9b52082](https://github.com/martinfrancois/wdio-mobile-utils/commit/9b52082892db76e89b0bf7f840ebbfe1748fbcfc)) 98 | - **select:** throw an error only when a selector is really accessed on a platform which is null ([54a0700](https://github.com/martinfrancois/wdio-mobile-utils/commit/54a0700822c77cc406c9d87a5a78c2896a94f616)) 99 | 100 | * **androidSelector:** rename factory method to `of` ([becd0d0](https://github.com/martinfrancois/wdio-mobile-utils/commit/becd0d0f0180e24d38a7abc342960515d68be2b0)) 101 | * **iosSelector:** rename factory method to `of` ([33bfe60](https://github.com/martinfrancois/wdio-mobile-utils/commit/33bfe60887c17645647019e6b154130d0e5a9979)) 102 | * **utils:** change parameter order for `appId` and `bundleId` to be always last ([8e5b7cb](https://github.com/martinfrancois/wdio-mobile-utils/commit/8e5b7cb582ad1e8eee84bb7c9c8c9b642886a2f4)) 103 | * **utils:** move app-related utility functions to appUtils.ts ([ef4eb71](https://github.com/martinfrancois/wdio-mobile-utils/commit/ef4eb712b75254d2bc7015043b793aa158be3d61)) 104 | * **utils:** rename `getAutomationTextOfElement` to `getAccessibilityIdOfElement` ([0cac320](https://github.com/martinfrancois/wdio-mobile-utils/commit/0cac32008ceec913b9dde7024dba2ee91590838e)) 105 | 106 | ### build 107 | 108 | - **npm:** define NodeJS 10 as minimum ([4006f66](https://github.com/martinfrancois/wdio-mobile-utils/commit/4006f66890d57bdd1e0036a53838cbdec21f0b4a)) 109 | - **npm:** update WebdriverIO version to v6 ([b106204](https://github.com/martinfrancois/wdio-mobile-utils/commit/b1062048ad40b41bfffe77fee499309dd5a1f355)) 110 | 111 | ## [6.0.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/5-v5.0.0...5-v6.0.0) (2020-05-11) 112 | 113 | ### ⚠ BREAKING CHANGES 114 | 115 | - **utils:** Replace calls to the method by `$('~URL')` 116 | The method was removed as it doesn't really serve a purpose and is too simple to be included in a library. 117 | 118 | Signed-off-by: martinfrancois 119 | 120 | - **utils:** Rename calls to the method `getAutomationTextOfElement` to `getAccessibilityIdOfElement` 121 | 122 | Signed-off-by: martinfrancois 123 | 124 | ### Features 125 | 126 | - consistently throw an error if the platform is neither Android nor iOS ([7eee09a](https://github.com/martinfrancois/wdio-mobile-utils/commit/7eee09ae1840bff50f3282e06ecc8385f1427437)) 127 | - **utils:** remove method `getSafariUrl` ([d93ff78](https://github.com/martinfrancois/wdio-mobile-utils/commit/d93ff78a41f181ef435d1d8e361b2633c5026f09)) 128 | 129 | * **utils:** rename `getAutomationTextOfElement` to `getAccessibilityIdOfElement` ([0cac320](https://github.com/martinfrancois/wdio-mobile-utils/commit/0cac32008ceec913b9dde7024dba2ee91590838e)) 130 | 131 | ## 5.0.0 (2020-05-11) 132 | 133 | ### ⚠ BREAKING CHANGES 134 | 135 | - **androidSelector:** `AndroidSelector.android(...)` is now `AndroidSelector.of(...)` 136 | 137 | Signed-off-by: martinfrancois 138 | 139 | - **iosSelector:** `IosSelector.ios(...)` is now `IosSelector.of(...)` 140 | 141 | Signed-off-by: martinfrancois 142 | 143 | - **utils:** App-related utility functions `queryAppState`, `isAppState`, `isBrowserAppState`, `openSafari`, `openApp` were moved from `utils.ts` to `appUtils.ts` 144 | 145 | Signed-off-by: martinfrancois 146 | 147 | - **utils:** The old select API (in utils.ts) was replaced by `mobile$(...)` and `mobile$$(...)` methods instead. 148 | 149 | Signed-off-by: martinfrancois 150 | 151 | - **utils:** For consistency, the order of parameters was changed to always include `appId` and `bundleId` last. 152 | 153 | To migrate, change all `openApp` calls to include the `wait` parameter as the first parameter, followed by `appId` and `bundleId` 154 | 155 | - **npm:** Since NodeJS 8 is no longer supported from the 31st of December 2019, NodeJS 10 is required as a minimum. 156 | 157 | ### Features 158 | 159 | - allow timeout to be defined ([0abcb15](https://github.com/martinfrancois/wdio-mobile-utils/commit/0abcb158efeb652592a6d1864b4cca8715cbdf2b)) 160 | - **alert:** add utility functions ([6d6c93d](https://github.com/martinfrancois/wdio-mobile-utils/commit/6d6c93d0b332895727932a89fbb68a0d30f7c5b8)) 161 | - **alert:** add visibility check for android ([ba129f4](https://github.com/martinfrancois/wdio-mobile-utils/commit/ba129f4563ed545f4fcc8f8e735e76e80c4e792e)) 162 | - **appium:** add types for appium ([ae1620c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ae1620c73c48b7a84f88693d40cf61463a6e484e)) 163 | - **deeplink:** add deeplink functions ([975a766](https://github.com/martinfrancois/wdio-mobile-utils/commit/975a766974e60b515398b7f2bd5b245d851f72eb)) 164 | - **deeplink:** improve logging output for opening a deeplink in iOS Simulator ([bcf9f06](https://github.com/martinfrancois/wdio-mobile-utils/commit/bcf9f06fe90e8fc99e13738f798b03cb2d98abea)) 165 | - **deeplink:** support iOS devices of any locale when using Appium >=1.17.0 ([b12126f](https://github.com/martinfrancois/wdio-mobile-utils/commit/b12126f98c7e81212312310de7b04f0da16687bd)) 166 | - **environment:** add method to find out if the test is running on an ios simulator ([076c3d1](https://github.com/martinfrancois/wdio-mobile-utils/commit/076c3d1da3927c126006b4ba6dd27228ada2649a)) 167 | - **gestures:** add gestures utility functions ([127c2b3](https://github.com/martinfrancois/wdio-mobile-utils/commit/127c2b35ce13c95324e6ca0f406cc1489401a22a)) 168 | - **internal/utils:** add method to check if argument is of type string ([ffcdd4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ffcdd4ce0abdd01da1c60ae65e3b1c974cfc96cb)) 169 | - **select:** add accessibilityId selectors ([684a7af](https://github.com/martinfrancois/wdio-mobile-utils/commit/684a7afe837e94b393b4d41a3581f4bdf9a20559)) 170 | - **select:** add initial new select API with `enabled` and `disabled` selectors ([e8aaef0](https://github.com/martinfrancois/wdio-mobile-utils/commit/e8aaef03de539a360ff3f92389a0d484a9b55afe)) 171 | - **select:** add select API to use with selectors ([72e818f](https://github.com/martinfrancois/wdio-mobile-utils/commit/72e818f44db4d6c32c3380190d0fc716d8e86923)) 172 | - **select:** add text selectors ([a2dc010](https://github.com/martinfrancois/wdio-mobile-utils/commit/a2dc0108f50ab9b872414f43b4177121bd1e395d)) 173 | - **select:** add type selector ([10efd99](https://github.com/martinfrancois/wdio-mobile-utils/commit/10efd994ba7eb905823d40747e6ea88f8564c4c3)) 174 | - **select:** implement combination selectors (and & or) for iOS ([1b4918d](https://github.com/martinfrancois/wdio-mobile-utils/commit/1b4918d722a42a23c2279ce1d4a491cfe02f9b9b)) 175 | - **selector:** allow either the iOS or Android selector to be null ([108209f](https://github.com/martinfrancois/wdio-mobile-utils/commit/108209f7145dab763c451dbe6e48c7673e28cfeb)) 176 | - **selector:** implement and & or for android (happy path) ([af78f42](https://github.com/martinfrancois/wdio-mobile-utils/commit/af78f428704c809179e3ade22dfe795d50aee92e)) 177 | - **selector:** implement edge cases for or & and combinations on Android ([2a9bd72](https://github.com/martinfrancois/wdio-mobile-utils/commit/2a9bd72d6c3abe7244cb8d41123b355781b8ac7a)) 178 | - **utils:** add utility functions ([b6e79b1](https://github.com/martinfrancois/wdio-mobile-utils/commit/b6e79b11787b2cd0853524671cfda5f38e7fcc93)) 179 | - **utils:** remove old select API in favor of new select API ([4503cb4](https://github.com/martinfrancois/wdio-mobile-utils/commit/4503cb41f261aa0fd26e6ab50c8699d18fede7f9)) 180 | 181 | ### Bug Fixes 182 | 183 | - **deeplink:** compilation error due to renamed import ([ce14b4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ce14b4ca7a5891bc6a19ce45395b651cd094b9ff)) 184 | - **deeplink:** deeplink only finding `XCUIElementTypeTextField` but not `XCUIElementTypeButton` on iPhone Simulator ([27a097b](https://github.com/martinfrancois/wdio-mobile-utils/commit/27a097bef5ba85a05ef88bf7cd09e65a11759ce2)) 185 | - **deeplink:** errors of `bundleId` not being defined ([d532a66](https://github.com/martinfrancois/wdio-mobile-utils/commit/d532a66523046262cc54554363a137b9e9b1fa58)) 186 | - **deeplink:** implement backward compatibility layer differently to fix tests on Sauce Labs Virtual Cloud iOS Simulators ([476d7b3](https://github.com/martinfrancois/wdio-mobile-utils/commit/476d7b3532e2d58470899cb13b0313f146818248)) 187 | - **deeplink:** remove context approach for real devices ([9b52082](https://github.com/martinfrancois/wdio-mobile-utils/commit/9b52082892db76e89b0bf7f840ebbfe1748fbcfc)) 188 | - **select:** throw an error only when a selector is really accessed on a platform which is null ([54a0700](https://github.com/martinfrancois/wdio-mobile-utils/commit/54a0700822c77cc406c9d87a5a78c2896a94f616)) 189 | 190 | ### build 191 | 192 | - **npm:** define NodeJS 10 as minimum ([4006f66](https://github.com/martinfrancois/wdio-mobile-utils/commit/4006f66890d57bdd1e0036a53838cbdec21f0b4a)) 193 | 194 | * **androidSelector:** rename factory method to `of` ([becd0d0](https://github.com/martinfrancois/wdio-mobile-utils/commit/becd0d0f0180e24d38a7abc342960515d68be2b0)) 195 | * **iosSelector:** rename factory method to `of` ([33bfe60](https://github.com/martinfrancois/wdio-mobile-utils/commit/33bfe60887c17645647019e6b154130d0e5a9979)) 196 | * **utils:** change parameter order for `appId` and `bundleId` to be always last ([8e5b7cb](https://github.com/martinfrancois/wdio-mobile-utils/commit/8e5b7cb582ad1e8eee84bb7c9c8c9b642886a2f4)) 197 | * **utils:** move app-related utility functions to appUtils.ts ([ef4eb71](https://github.com/martinfrancois/wdio-mobile-utils/commit/ef4eb712b75254d2bc7015043b793aa158be3d61)) 198 | 199 | ### [4.0.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/v4.0.0...v4.0.1) (2020-05-11) 200 | 201 | ### Bug Fixes 202 | 203 | - **select:** throw an error only when a selector is really accessed on a platform which is null ([54a0700](https://github.com/martinfrancois/wdio-mobile-utils/commit/54a0700822c77cc406c9d87a5a78c2896a94f616)) 204 | 205 | ## [4.0.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.2.0...v4.0.0) (2020-05-11) 206 | 207 | ### ⚠ BREAKING CHANGES 208 | 209 | - **androidSelector:** `AndroidSelector.android(...)` is now `AndroidSelector.of(...)` 210 | 211 | Signed-off-by: martinfrancois 212 | 213 | - **iosSelector:** `IosSelector.ios(...)` is now `IosSelector.of(...)` 214 | 215 | Signed-off-by: martinfrancois 216 | 217 | - **utils:** App-related utility functions `queryAppState`, `isAppState`, `isBrowserAppState`, `openSafari`, `openApp` were moved from `utils.ts` to `appUtils.ts` 218 | 219 | Signed-off-by: martinfrancois 220 | 221 | - **utils:** The old select API (in utils.ts) was replaced by `mobile$(...)` and `mobile$$(...)` methods instead. 222 | 223 | Signed-off-by: martinfrancois 224 | 225 | ### Features 226 | 227 | - **internal/utils:** add method to check if argument is of type string ([ffcdd4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ffcdd4ce0abdd01da1c60ae65e3b1c974cfc96cb)) 228 | - **select:** add accessibilityId selectors ([684a7af](https://github.com/martinfrancois/wdio-mobile-utils/commit/684a7afe837e94b393b4d41a3581f4bdf9a20559)) 229 | - **select:** add initial new select API with `enabled` and `disabled` selectors ([e8aaef0](https://github.com/martinfrancois/wdio-mobile-utils/commit/e8aaef03de539a360ff3f92389a0d484a9b55afe)) 230 | - **select:** add select API to use with selectors ([72e818f](https://github.com/martinfrancois/wdio-mobile-utils/commit/72e818f44db4d6c32c3380190d0fc716d8e86923)) 231 | - **select:** add text selectors ([a2dc010](https://github.com/martinfrancois/wdio-mobile-utils/commit/a2dc0108f50ab9b872414f43b4177121bd1e395d)) 232 | - **select:** add type selector ([10efd99](https://github.com/martinfrancois/wdio-mobile-utils/commit/10efd994ba7eb905823d40747e6ea88f8564c4c3)) 233 | - **select:** implement combination selectors (and & or) for iOS ([1b4918d](https://github.com/martinfrancois/wdio-mobile-utils/commit/1b4918d722a42a23c2279ce1d4a491cfe02f9b9b)) 234 | - **selector:** allow either the iOS or Android selector to be null ([108209f](https://github.com/martinfrancois/wdio-mobile-utils/commit/108209f7145dab763c451dbe6e48c7673e28cfeb)) 235 | - **selector:** implement and & or for android (happy path) ([af78f42](https://github.com/martinfrancois/wdio-mobile-utils/commit/af78f428704c809179e3ade22dfe795d50aee92e)) 236 | - **selector:** implement edge cases for or & and combinations on Android ([2a9bd72](https://github.com/martinfrancois/wdio-mobile-utils/commit/2a9bd72d6c3abe7244cb8d41123b355781b8ac7a)) 237 | - **utils:** remove old select API in favor of new select API ([4503cb4](https://github.com/martinfrancois/wdio-mobile-utils/commit/4503cb41f261aa0fd26e6ab50c8699d18fede7f9)) 238 | 239 | * **androidSelector:** rename factory method to `of` ([becd0d0](https://github.com/martinfrancois/wdio-mobile-utils/commit/becd0d0f0180e24d38a7abc342960515d68be2b0)) 240 | * **iosSelector:** rename factory method to `of` ([33bfe60](https://github.com/martinfrancois/wdio-mobile-utils/commit/33bfe60887c17645647019e6b154130d0e5a9979)) 241 | * **utils:** move app-related utility functions to appUtils.ts ([ef4eb71](https://github.com/martinfrancois/wdio-mobile-utils/commit/ef4eb712b75254d2bc7015043b793aa158be3d61)) 242 | 243 | ## [3.2.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.1.3...v3.2.0) (2020-05-10) 244 | 245 | ### Features 246 | 247 | - **alert:** add utility functions ([6d6c93d](https://github.com/martinfrancois/wdio-mobile-utils/commit/6d6c93d0b332895727932a89fbb68a0d30f7c5b8)) 248 | - **alert:** add visibility check for android ([ba129f4](https://github.com/martinfrancois/wdio-mobile-utils/commit/ba129f4563ed545f4fcc8f8e735e76e80c4e792e)) 249 | 250 | ### [3.1.3](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.1.2...v3.1.3) (2020-05-10) 251 | 252 | ### [3.1.2](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.1.1...v3.1.2) (2020-05-10) 253 | 254 | ### [3.1.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.1.0...v3.1.1) (2020-05-09) 255 | 256 | ### Bug Fixes 257 | 258 | - **deeplink:** implement backward compatibility layer differently to fix tests on Sauce Labs Virtual Cloud iOS Simulators ([476d7b3](https://github.com/martinfrancois/wdio-mobile-utils/commit/476d7b3532e2d58470899cb13b0313f146818248)) 259 | 260 | ## [3.1.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.0.1...v3.1.0) (2020-05-09) 261 | 262 | ### Features 263 | 264 | - **deeplink:** support iOS devices of any locale when using Appium >=1.17.0 ([b12126f](https://github.com/martinfrancois/wdio-mobile-utils/commit/b12126f98c7e81212312310de7b04f0da16687bd)) 265 | 266 | ### Bug Fixes 267 | 268 | - **deeplink:** deeplink only finding `XCUIElementTypeTextField` but not `XCUIElementTypeButton` on iPhone Simulator ([27a097b](https://github.com/martinfrancois/wdio-mobile-utils/commit/27a097bef5ba85a05ef88bf7cd09e65a11759ce2)) 269 | 270 | ### [3.0.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/v3.0.0...v3.0.1) (2020-05-09) 271 | 272 | ### Bug Fixes 273 | 274 | - **deeplink:** remove context approach for real devices ([9b52082](https://github.com/martinfrancois/wdio-mobile-utils/commit/9b52082892db76e89b0bf7f840ebbfe1748fbcfc)) 275 | 276 | ## [3.0.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.1.2...v3.0.0) (2020-05-09) 277 | 278 | ### ⚠ BREAKING CHANGES 279 | 280 | - **utils:** For consistency, the order of parameters was changed to always include `appId` and `bundleId` last. 281 | 282 | To migrate, change all `openApp` calls to include the `wait` parameter as the first parameter, followed by `appId` and `bundleId` 283 | 284 | ### Features 285 | 286 | - **deeplink:** improve logging output for opening a deeplink in iOS Simulator ([bcf9f06](https://github.com/martinfrancois/wdio-mobile-utils/commit/bcf9f06fe90e8fc99e13738f798b03cb2d98abea)) 287 | 288 | ### Bug Fixes 289 | 290 | - **deeplink:** errors of `bundleId` not being defined ([d532a66](https://github.com/martinfrancois/wdio-mobile-utils/commit/d532a66523046262cc54554363a137b9e9b1fa58)) 291 | 292 | * **utils:** change parameter order for `appId` and `bundleId` to be always last ([8e5b7cb](https://github.com/martinfrancois/wdio-mobile-utils/commit/8e5b7cb582ad1e8eee84bb7c9c8c9b642886a2f4)) 293 | 294 | ### [2.1.2](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.1.1...v2.1.2) (2020-05-09) 295 | 296 | ### Bug Fixes 297 | 298 | - **deeplink:** compilation error due to renamed import ([ce14b4c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ce14b4ca7a5891bc6a19ce45395b651cd094b9ff)) 299 | 300 | ### [2.1.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.1.0...v2.1.1) (2020-05-09) 301 | 302 | ## [2.1.0](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.0.4...v2.1.0) (2020-05-09) 303 | 304 | ### Features 305 | 306 | - **appium:** add types for appium ([ae1620c](https://github.com/martinfrancois/wdio-mobile-utils/commit/ae1620c73c48b7a84f88693d40cf61463a6e484e)) 307 | - **deeplink:** add deeplink functions ([975a766](https://github.com/martinfrancois/wdio-mobile-utils/commit/975a766974e60b515398b7f2bd5b245d851f72eb)) 308 | - **environment:** add method to find out if the test is running on an ios simulator ([076c3d1](https://github.com/martinfrancois/wdio-mobile-utils/commit/076c3d1da3927c126006b4ba6dd27228ada2649a)) 309 | - **utils:** add utility functions ([b6e79b1](https://github.com/martinfrancois/wdio-mobile-utils/commit/b6e79b11787b2cd0853524671cfda5f38e7fcc93)) 310 | 311 | ### [2.0.4](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.0.3...v2.0.4) (2020-03-04) 312 | 313 | ### [2.0.3](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.0.2...v2.0.3) (2020-03-04) 314 | 315 | ### [2.0.2](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.0.1...v2.0.2) (2020-03-04) 316 | 317 | ### [2.0.1](https://github.com/martinfrancois/wdio-mobile-utils/compare/v2.0.0...v2.0.1) (2020-03-04) 318 | 319 | ## 2.0.0 (2020-03-03) 320 | 321 | ### ⚠ BREAKING CHANGES 322 | 323 | - **npm:** Since NodeJS 8 is no longer supported from the 31st of December 2019, NodeJS 10 is required as a minimum. 324 | 325 | ### build 326 | 327 | - **npm:** define NodeJS 10 as minimum ([4006f66](https://github.com/martinfrancois/wdio-mobile-utils/commit/4006f66890d57bdd1e0036a53838cbdec21f0b4a)) 328 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to wdio-mobile-utils 2 | 3 | ## Commit Message Guidelines 4 | 5 | Refer to the [Angular commit message guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) for reference. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 martinfrancois 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

wdio-mobile-utils

2 | 3 |

A cross-platform mobile end-to-end testing library for WebdriverIO.

4 | 5 |

6 | NPM Version 7 | Travis CI Build Status 8 | GitHub license 9 | NPM Downloads 10 | Codecov 11 | Commitizen friendly 12 |

13 | 14 | ## Install 15 | 16 | Releases for WebdriverIO v5 are released on the `v5` branch, while releases for WebdriverIO v6 are releases on the `master` branch. 17 | 18 | Check the [releases here](https://github.com/martinfrancois/wdio-mobile-utils/releases), the releases for WebdriverIO v6 are prefixed with `6-`, while releases for WebdriverIO v5 are prefixed with `5-`. 19 | 20 | ``` 21 | npm install -D wdio-mobile-utils@8.0.2 # WebdriverIO v6 22 | npm install -D wdio-mobile-utils@7.0.3 # WebdriverIO v5 23 | ``` 24 | 25 | ## Table of Contents 26 | 27 | - [TSDoc](#tsdoc) 28 | - [Mobile Selectors](#mobile-selectors) 29 | - [Combining selectors](#combining-selectors) 30 | - [Custom Selectors](#custom-selectors) 31 | - [Usage in Action](#usage-in-action) 32 | 33 | ## TSDoc 34 | 35 | You can find documentation for the individual methods here: 36 | https://martinfrancois.github.io/wdio-mobile-utils/globals.html 37 | 38 | ## Mobile Selectors 39 | 40 | In cases where you cannot use `accessibilityId`s, it is recommended to use `ios predicate` for iOS and `UiSelector` for Android. 41 | wdio-mobile-utils provides an abstraction to build mobile selectors easily which are cross-platform. 42 | This means you can build your selectors using `mobile$` and `mobile$$` and wdio-mobile-utils will automatically convert this into an `ios predicate` for iOS and `UiSelector` for Android for you automatically, depending on which platform the test is running on. 43 | 44 | To select **one element**, use `mobile$`, which is the equivalent to `$` in WebdriverIO. 45 | To select **all elements** use `mobile$$`, which is the equivalent to `$$` in WebdriverIO. 46 | 47 | You can find all of the different `Selector`s you can use in the [TSDoc for Selector](https://martinfrancois.github.io/wdio-mobile-utils/classes/selector.html). 48 | 49 | For example, to select a **button** which works on **both Android and iOS**, we can use the following selector with wdio-mobile-utils: 50 | 51 | ```javascript 52 | mobile$(Selector.type(Type.BUTTON)); 53 | ``` 54 | 55 | Internally, it will convert this into the following `ios predicate` and `UiSelector` selectors, depending on the platform the test is running on: 56 | 57 | ```javascript 58 | // UiSelector 59 | $('android=new UiSelector().className("android.widget.Button")'); 60 | 61 | // ios predicate 62 | $("-ios predicate string:type == 'XCUIElementTypeButton'"); 63 | ``` 64 | 65 | ### Combining selectors 66 | 67 | You can also use multiple selectors together, combining them either with an `AND` (`&&`) or an `OR` (`||`) condition: 68 | 69 | ```javascript 70 | Selector.and(selector1, selector2); // AND (&&) condition 71 | Selector.or(selector1, selector2); // OR (||) condition 72 | ``` 73 | 74 | For example, to select a **button** with the text `Login` which works on **both Android and iOS**, we can use the following selector with wdio-mobile-utils: 75 | 76 | 77 | ```javascript 78 | // compact form 79 | mobile$(Selector.and(Selector.type(Type.BUTTON), Selector.text('Login'))); 80 | 81 | // long form 82 | mobile$( 83 | Selector.and( 84 | Selector.type(Type.BUTTON), 85 | Selector.text('Login') 86 | ) 87 | ); 88 | ``` 89 | 90 | 91 | Internally, it will convert this into the following `ios predicate` and `UiSelector` selectors, depending on the platform the test is running on: 92 | 93 | ```javascript 94 | // UiSelector 95 | $('android=new UiSelector().className("android.widget.Button").text("Login")'); 96 | 97 | // ios predicate 98 | $("-ios predicate string:type == 'XCUIElementTypeButton' && label == 'Login'"); 99 | ``` 100 | 101 | ### Custom Selectors 102 | 103 | If you can't find a selector you're looking for, if it's generic enough to be useful for others, consider contributing with a [PR here](https://github.com/martinfrancois/wdio-mobile-utils/pulls). 104 | 105 | If you need to use a very specific selector or one that may only work on one platform and you still want to make use of the easy fluent API of wdio-mobile-utils, you can use a custom selector. 106 | 107 | For example: 108 | 109 | 110 | ```javascript 111 | mobile$( 112 | Selector.custom( 113 | AndroidSelector.of(ANDROID_UISELECTOR_PROPERTIES.RESOURCE_ID, 'URL'), 114 | IosSelector.of(IOS_PREDICATE_ATTRIBUTES.VALUE, IOS_PREDICATE_COMPARATOR.EQUALS, 'URL') 115 | ) 116 | ); 117 | ``` 118 | 119 | 120 | To create a selector which only works on one platform, set one of the selectors to `null`, like so: 121 | 122 | 123 | ```javascript 124 | mobile$( 125 | Selector.custom( 126 | null, // no selector on Android 127 | IosSelector.of(IOS_PREDICATE_ATTRIBUTES.RECT, IOS_PREDICATE_COMPARATOR.EQUALS, 'URL') 128 | ) 129 | ); 130 | ``` 131 | 132 | 133 | Note that when creating a selector which only works on one platform (for example, only for iOS), if a test is executed on the other platform (for example, Android), it will throw an error. 134 | This also applies in cases where a selector which only works on one platform is combined with a cross-platform selector, which is used on the other platform. 135 | 136 | ## Usage in Action 137 | 138 | Check out the [recording](http://saucecon.com/agenda-2020?agendaPath=session/251027) and the [slides](https://github.com/martinfrancois/saucecon-2020-1-codebase-2-mobile-platforms/blob/master/SauceCon_2020_Online.pdf) of my presentation at SauceCon Online 2020 for detailed information on how to use the library. 139 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/*.test.ts'], 5 | testPathIgnorePatterns: ['/node_modules/'], 6 | coverageDirectory: './coverage/', 7 | collectCoverage: true, 8 | coveragePathIgnorePatterns: ['node_modules/'] 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wdio-mobile-utils", 3 | "version": "8.0.2", 4 | "description": "A cross-platform mobile end-to-end testing library for WebdriverIO.", 5 | "main": "build/main/index.js", 6 | "typings": "build/main/index.d.ts", 7 | "module": "build/module/index.js", 8 | "repository": "https://github.com/martinfrancois/wdio-mobile-utils", 9 | "license": "MIT", 10 | "keywords": [], 11 | "scripts": { 12 | "describe": "npm-scripts-info", 13 | "build": "run-s clean && run-p build:*", 14 | "build:main": "tsc -p tsconfig.json", 15 | "build:module": "tsc -p tsconfig.module.json", 16 | "fix": "run-s fix:*", 17 | "fix:prettier": "prettier \"src/**/*.ts\" --write", 18 | "fix:eslint": "node_modules/.bin/eslint --fix --ext .js,.ts,.tsx src", 19 | "test": "run-s build test:*", 20 | "test:lint": "node_modules/.bin/eslint --ext .js,.ts src && prettier \"src/**/*.ts\" --list-different", 21 | "test:unit": "npx jest", 22 | "watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"", 23 | "cov": "run-s build test:unit && open-cli coverage/lcov-report/index.html", 24 | "cov:send": "node_modules/.bin/codecov", 25 | "doc": "run-s doc:html && open-cli build/docs/index.html", 26 | "doc:html": "typedoc src/ --exclude **/*.test.ts --stripInternal --excludeNotExported --excludePrivate --target ES6 --mode file --out build/docs", 27 | "doc:json": "typedoc src/ --exclude **/*.test.ts --stripInternal --excludeNotExported --excludePrivate --target ES6 --mode file --json build/docs/typedoc.json", 28 | "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", 29 | "version": "standard-version -t 6-v", 30 | "reset": "git clean -dfx && git reset --hard && npm i", 31 | "clean": "trash build test", 32 | "prepare-release": "run-s reset test doc:html version doc:publish", 33 | "publish-release": "git push --follow-tags origin master && npm publish", 34 | "release": "run-s prepare-release publish-release" 35 | }, 36 | "scripts-info": { 37 | "info": "Display information about the package scripts", 38 | "build": "Clean and rebuild the project", 39 | "fix": "Try to automatically fix any linting problems", 40 | "test": "Lint and unit test the project", 41 | "watch": "Watch and rebuild the project on save, then rerun relevant tests", 42 | "cov": "Rebuild, run tests, then create and open the coverage report", 43 | "doc": "Generate HTML API documentation and open it in a browser", 44 | "doc:json": "Generate API documentation in typedoc JSON format", 45 | "version": "Bump package.json version, update CHANGELOG.md, tag release", 46 | "reset": "Delete all untracked files and reset the repo to the last commit", 47 | "prepare-release": "One-step: clean, build, test, publish docs, and prep a release", 48 | "publish-release": "Run after prepare-release to push the release and publish the package on npm", 49 | "release": "One-click release of a new version to NPM" 50 | }, 51 | "engines": { 52 | "node": ">= 10.0.0" 53 | }, 54 | "dependencies": {}, 55 | "devDependencies": { 56 | "@types/jest": "25.2.3", 57 | "@types/node": "10.17.24", 58 | "@typescript-eslint/eslint-plugin": "2.32.0", 59 | "@typescript-eslint/parser": "2.32.0", 60 | "@wdio/logger": "6.0.16", 61 | "@wdio/sync": "6.1.8", 62 | "codecov": "3.7.0", 63 | "cz-conventional-changelog": "3.2.0", 64 | "eslint": "7.1.0", 65 | "eslint-config-prettier": "6.11.0", 66 | "eslint-plugin-jest": "23.13.2", 67 | "eslint-plugin-prettier": "3.1.3", 68 | "gh-pages": "5.0.0", 69 | "husky": "4.2.5", 70 | "jest": "29.7.0", 71 | "npm-run-all": "4.1.5", 72 | "npm-scripts-info": "0.3.9", 73 | "open-cli": "6.0.1", 74 | "prettier": "2.0.5", 75 | "pretty-quick": "2.0.1", 76 | "standard-version": "8.0.0", 77 | "trash-cli": "3.0.0", 78 | "ts-jest": "26.0.0", 79 | "typedoc": "0.17.7", 80 | "typescript": "3.9.3", 81 | "webdriverio": "6.1.12" 82 | }, 83 | "config": { 84 | "commitizen": { 85 | "path": "cz-conventional-changelog" 86 | } 87 | }, 88 | "prettier": { 89 | "singleQuote": true 90 | }, 91 | "husky": { 92 | "hooks": { 93 | "pre-commit": "pretty-quick --staged" 94 | } 95 | }, 96 | "types": "build/main/index.d.ts" 97 | } 98 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/environment'; 2 | export * from './lib/utils'; 3 | export * from './lib/deeplink'; 4 | export * from './lib/alert'; 5 | export * from './lib/appUtils'; 6 | export * from './lib/gestures'; 7 | export * from './lib/select/androidSelector'; 8 | export * from './lib/select/iosSelector'; 9 | export * from './lib/select/selector'; 10 | export * from './lib/select/select'; 11 | export * from './lib/select/type'; 12 | -------------------------------------------------------------------------------- /src/lib/alert.ts: -------------------------------------------------------------------------------- 1 | import { mobile$ } from './select/select'; 2 | import { 3 | ANDROID_UISELECTOR_PROPERTIES, 4 | AndroidSelector, 5 | } from './select/androidSelector'; 6 | import { Selector } from './select/selector'; 7 | import { 8 | IOS_PREDICATE_ATTRIBUTES, 9 | IOS_PREDICATE_COMPARATOR, 10 | IosSelector, 11 | } from './select/iosSelector'; 12 | 13 | /** 14 | * @internal 15 | */ 16 | const SELECTORS = { 17 | ANDROID: { 18 | RESOURCE_ID: 'android:id/alertTitle', 19 | }, 20 | IOS: { 21 | TYPE: 'XCUIElementTypeAlert', 22 | }, 23 | }; 24 | 25 | /** 26 | * @internal 27 | */ 28 | function alertElement(): WebdriverIO.Element { 29 | return mobile$( 30 | Selector.custom( 31 | AndroidSelector.of( 32 | ANDROID_UISELECTOR_PROPERTIES.RESOURCE_ID, 33 | SELECTORS.ANDROID.RESOURCE_ID 34 | ), 35 | IosSelector.of( 36 | IOS_PREDICATE_ATTRIBUTES.TYPE, 37 | IOS_PREDICATE_COMPARATOR.EQUALS, 38 | SELECTORS.IOS.TYPE 39 | ) 40 | ) 41 | ); 42 | } 43 | 44 | /** 45 | * Waits for a native alert to be shown. 46 | * @category Alert 47 | */ 48 | export function waitForAlertDisplayed(): WebdriverIO.Element { 49 | const element = alertElement(); 50 | element.waitForDisplayed(); 51 | return element; 52 | } 53 | 54 | /** 55 | * Returns whether or not a native alert is shown. 56 | * @category Alert 57 | */ 58 | export function isAlertDisplayed(): boolean { 59 | return alertElement().isDisplayed(); 60 | } 61 | 62 | /** 63 | * Accepts the alert in a cross-platform way. 64 | * For example, this would press the "OK" or "Yes" button. 65 | * @category Alert 66 | */ 67 | export function acceptAlert(): void { 68 | if (browser.isAndroid) { 69 | browser.execute('mobile: acceptAlert'); 70 | } else { 71 | // NOTE: using the button name as accessibility identifier does NOT work! 72 | // iOS Gestures are being used here: 73 | // http://appium.io/docs/en/writing-running-appium/ios/ios-xctest-mobile-gestures/ 74 | browser.execute('mobile: alert', { action: 'accept' }); 75 | } 76 | } 77 | 78 | /** 79 | * Dismisses the alert in a cross-platform way. 80 | * For example, this would press the "Close", "Cancel" or "No" button. 81 | * @category Alert 82 | */ 83 | export function dismissAlert(): void { 84 | if (browser.isAndroid) { 85 | browser.execute('mobile: dismissAlert'); 86 | } else { 87 | // NOTE: using the button name as accessibility identifier does NOT work! 88 | // iOS Gestures are being used here: 89 | // http://appium.io/docs/en/writing-running-appium/ios/ios-xctest-mobile-gestures/ 90 | browser.execute('mobile: alert', { action: 'dismiss' }); 91 | } 92 | } 93 | 94 | /** 95 | * Returns the text of an alert. 96 | * @param wait whether to wait for an alert to appear or not 97 | * @returns the text of the alert or {@code null} if no alert is displayed 98 | * @category Alert 99 | */ 100 | export function getAlertText(wait: false): string | null { 101 | if (wait) { 102 | waitForAlertDisplayed(); 103 | } else { 104 | if (!isAlertDisplayed()) { 105 | return null; 106 | } 107 | } 108 | return browser.getAlertText(); 109 | } 110 | -------------------------------------------------------------------------------- /src/lib/appUtils.ts: -------------------------------------------------------------------------------- 1 | // see http://appium.io/docs/en/commands/device/app/app-state/ 2 | import { CHROME_APP_ID, DEFAULT_TIMEOUT, SAFARI_BUNDLE_ID } from './constants'; 3 | import { 4 | assertIdDefined, 5 | Platform, 6 | UNSUPPORTED_PLATFORM_ERROR, 7 | } from './internal/utils'; 8 | 9 | export enum APP_RUNNING_STATE { 10 | NOT_INSTALLED = 0, 11 | NOT_RUNNING = 1, 12 | BACKGROUND_OR_SUSPENDED = 2, 13 | BACKGROUND = 3, 14 | FOREGROUND = 4, 15 | } 16 | 17 | /** 18 | * Returns the state of the app. 19 | * @param {string} appId ID of the app (Android) 20 | * @param {string} bundleId bundle id of the app (iOS) 21 | * @returns {APP_RUNNING_STATE} the current app's running state 22 | * @see http://appium.io/docs/en/commands/device/app/app-state/ 23 | * @category App Utility 24 | */ 25 | export function queryAppState( 26 | appId?: string, 27 | bundleId?: string 28 | ): APP_RUNNING_STATE { 29 | if (browser.isIOS) { 30 | assertIdDefined(bundleId, Platform.IOS); 31 | return browser.execute('mobile: queryAppState', { bundleId: bundleId }); 32 | } else if (browser.isAndroid) { 33 | assertIdDefined(appId, Platform.ANDROID); 34 | return browser.queryAppState(appId); 35 | } 36 | throw new Error(UNSUPPORTED_PLATFORM_ERROR); 37 | } 38 | 39 | /** 40 | * Returns true if the specified app state is met. 41 | * 42 | * @param {boolean} state which state to check for 43 | * @param {boolean} wait If set to true, will wait for the app to be running. 44 | * @param {string} appId ID of the app (Android) 45 | * @param {string} bundleId bundle id of the app (iOS) 46 | * @param {number} timeout how long to wait until the state is met 47 | * @category App Utility 48 | */ 49 | export function isAppState( 50 | state: APP_RUNNING_STATE, 51 | wait = false, 52 | appId?: string, 53 | bundleId?: string, 54 | timeout = DEFAULT_TIMEOUT 55 | ): boolean { 56 | if (wait) { 57 | return browser.waitUntil( 58 | () => { 59 | return queryAppState(appId, bundleId) === state; 60 | }, 61 | { timeout: timeout, timeoutMsg: 'App is not in state: ' + state } 62 | ); 63 | } else { 64 | return queryAppState(appId, bundleId) === state; 65 | } 66 | } 67 | 68 | /** 69 | * Returns true if the specified app state is met for Safari. 70 | * 71 | * @param {boolean} state Which state should be checked for 72 | * @param {boolean} wait If set to true, will wait for the app to be running. 73 | * @category App Utility 74 | */ 75 | export function isBrowserAppState( 76 | state: APP_RUNNING_STATE, 77 | wait = false 78 | ): boolean { 79 | if (browser.isIOS) { 80 | return isAppState(state, wait, undefined, SAFARI_BUNDLE_ID); 81 | } else if (browser.isAndroid) { 82 | return isAppState(state, wait, CHROME_APP_ID, undefined); 83 | } 84 | throw new Error(UNSUPPORTED_PLATFORM_ERROR); 85 | } 86 | 87 | /** 88 | * Opens Safari and waits until it is running. 89 | * @category App Utility 90 | */ 91 | export function openSafari(): void { 92 | browser.waitUntil(() => { 93 | browser.execute('mobile: launchApp', { bundleId: SAFARI_BUNDLE_ID }); 94 | return isBrowserAppState(APP_RUNNING_STATE.FOREGROUND, false); 95 | }); 96 | } 97 | 98 | /** 99 | * Opens the app. 100 | * 101 | * @param {boolean} wait whether or not the start of the app should be awaited 102 | * @param {string} appId ID of the app (Android) 103 | * @param {string} bundleId bundle id of the app (iOS) 104 | * @category App Utility 105 | */ 106 | export function openApp(wait = false, appId?: string, bundleId?: string): void { 107 | browser.waitUntil(() => { 108 | if (browser.isAndroid) { 109 | browser.activateApp(appId); 110 | } else if (browser.isIOS) { 111 | browser.execute('mobile: activateApp', { bundleId: bundleId }); 112 | } else { 113 | throw new Error(UNSUPPORTED_PLATFORM_ERROR); 114 | } 115 | return isAppState(APP_RUNNING_STATE.FOREGROUND, wait, appId, bundleId); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TIMEOUT = 60000; 2 | 3 | export const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari'; 4 | 5 | export const CHROME_APP_ID = 'com.android.chrome'; 6 | -------------------------------------------------------------------------------- /src/lib/deeplink.ts: -------------------------------------------------------------------------------- 1 | import logger from '@wdio/logger'; 2 | import { DEFAULT_TIMEOUT } from './constants'; 3 | import { 4 | assertIdDefined, 5 | Platform, 6 | UNSUPPORTED_PLATFORM_ERROR, 7 | } from './internal/utils'; 8 | import { acceptAlert } from './alert'; 9 | import { APP_RUNNING_STATE, isAppState, openSafari } from './appUtils'; 10 | import { mobile$ } from './select/select'; 11 | import { Type } from './select/type'; 12 | import { Selector } from './select/selector'; 13 | import { 14 | IOS_PREDICATE_ATTRIBUTES, 15 | IOS_PREDICATE_COMPARATOR, 16 | IosSelector, 17 | } from './select/iosSelector'; 18 | 19 | const log = logger('Deeplink'); 20 | 21 | /** 22 | * Accepts an alert in Safari which appears upon opening a deeplink, tapping the "Open" button. 23 | * @internal 24 | */ 25 | function openDeeplinkAlert(timeout: number): void { 26 | try { 27 | acceptAlert(); 28 | } catch (e) { 29 | log.info( 30 | 'Appium version is below 1.17.0, deeplink only works on English iOS devices! Support for Appium <1.17.0 will be dropped in the future.' 31 | ); 32 | // accepting an alert on Safari was added in Appium 1.17.0, apply backward compatibility layer for older versions 33 | const openButton = $('~Open'); 34 | openButton.waitForDisplayed({ timeout: timeout }); 35 | openButton.click(); 36 | } 37 | } 38 | 39 | /** 40 | * @internal 41 | */ 42 | function openDeeplinkIos( 43 | deeplink: string, 44 | bundleId: string, 45 | timeout: number 46 | ): void { 47 | log.info('Opening Deeplink on iOS'); 48 | 49 | log.trace('Launching Safari'); 50 | openSafari(); 51 | 52 | // terminate the app under test 53 | browser.execute('mobile: terminateApp', { bundleId: bundleId }); 54 | isAppState( 55 | APP_RUNNING_STATE.NOT_RUNNING, 56 | true, 57 | undefined, 58 | bundleId, 59 | timeout 60 | ); 61 | 62 | const nameContainsUrl = Selector.custom( 63 | null, 64 | IosSelector.of( 65 | IOS_PREDICATE_ATTRIBUTES.NAME, 66 | IOS_PREDICATE_COMPARATOR.EQUALS, 67 | 'URL' 68 | ) 69 | ); 70 | 71 | const urlButton = mobile$( 72 | Selector.and( 73 | Selector.or( 74 | Selector.type(Type.BUTTON), 75 | Selector.type(Type.TEXT_FIELD) 76 | ), 77 | nameContainsUrl 78 | ) 79 | ); 80 | 81 | // Wait for the url button to appear and click on it so the text field will appear 82 | urlButton.waitForDisplayed({ timeout: timeout }); 83 | urlButton.click(); 84 | 85 | const urlField = mobile$( 86 | Selector.and(Selector.type(Type.TEXT_FIELD), nameContainsUrl) 87 | ); 88 | 89 | // Submit the url and add a break 90 | urlField.setValue(deeplink + '\uE007'); 91 | 92 | openDeeplinkAlert(timeout); 93 | 94 | log.trace('Open button was clicked on Alert'); 95 | if ( 96 | isAppState( 97 | APP_RUNNING_STATE.FOREGROUND, 98 | true, 99 | undefined, 100 | bundleId, 101 | timeout 102 | ) 103 | ) { 104 | log.info( 105 | 'App was opened successfully via deeplink and is running in the foreground' 106 | ); 107 | } else { 108 | const message = 109 | 'Could not find an open button for deeplink: ' + deeplink; 110 | log.error(message); 111 | throw new Error(message); 112 | } 113 | } 114 | 115 | /** 116 | * @internal 117 | */ 118 | function openDeeplinkAndroid(path: string, appId: string): void { 119 | log.info('Opening Deeplink on Android'); 120 | browser.closeApp(); 121 | browser.execute('mobile: deepLink', { 122 | url: path, 123 | package: appId, 124 | }); 125 | } 126 | 127 | /** 128 | * Opens the app with the specified deeplink path routing to the view that should be shown. 129 | * 130 | * @note When using Appium <1.17.0 with iOS, it will only work with English system language. 131 | * Upgrade to Appium >=1.17.0 for support of all locales on iOS. 132 | * Support for Appium <1.17.0 will be dropped in the future. 133 | * @param {string} path to the deeplink 134 | * @param {string} appId ID of the app (Android) 135 | * @param {string} bundleId bundle id of the app (iOS) 136 | * @param {number} timeout how long to wait in each step of the process until the deeplink has been opened 137 | * @category Deeplink 138 | */ 139 | export function openDeeplink( 140 | path: string, 141 | appId?: string, 142 | bundleId?: string, 143 | timeout = DEFAULT_TIMEOUT 144 | ): void { 145 | log.trace('openDeeplink: ' + path); 146 | if (browser.isIOS) { 147 | assertIdDefined(bundleId, Platform.IOS); 148 | openDeeplinkIos(path, bundleId as string, timeout); 149 | } else if (browser.isAndroid) { 150 | assertIdDefined(appId, Platform.ANDROID); 151 | openDeeplinkAndroid(path, appId as string); 152 | } else { 153 | throw new Error(UNSUPPORTED_PLATFORM_ERROR); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/lib/environment.test.ts: -------------------------------------------------------------------------------- 1 | import { isIosSimulator } from './environment'; 2 | 3 | describe('isIosSimulator', function () { 4 | const udidReal1 = '12345678-901A234B567C890D'; 5 | const udidReal2 = '123ab45c6d78e9fab0123b456cd78e90f1abc234'; 6 | 7 | // same length, no dashes 8 | const udidReal0d1 = '123ab45c6d78e9fab0123b456cd78e90f1ab'; 9 | // 1 dash 10 | const udidReal1d1 = '123ab45c-d78e9fab0123b456cd78e90f1ab'; 11 | const udidReal1d2 = '123ab45c6d78e-fab0123b456cd78e90f1ab'; 12 | const udidReal1d3 = '123ab45c6d78e9fab0-23b456cd78e90f1ab'; 13 | const udidReal1d4 = '123ab45c6d78e9fab0123b4-6cd78e90f1ab'; 14 | // 2 dashes 15 | const udidReal2d1 = '123ab45c-d78e-fab0123b456cd78e90f1ab'; 16 | const udidReal2d2 = '123ab45c6d78e-fab0-23b456cd78e90f1ab'; 17 | const udidReal2d3 = '123ab45c6d78e9fab0-23b4-6cd78e90f1ab'; 18 | const udidReal2d4 = '123ab45c-d78e9fab0-23b456cd78e90f1ab'; 19 | const udidReal2d5 = '123ab45c-d78e9fab0123b4-6cd78e90f1ab'; 20 | const udidReal2d6 = '123ab45c6d78e-fab0123b4-6cd78e90f1ab'; 21 | // 3 dashes 22 | const udidReal3d1 = '123ab45c-d78e-fab0-23b456cd78e90f1ab'; 23 | const udidReal3d2 = '123ab45c6d78e-fab0-23b4-6cd78e90f1ab'; 24 | 25 | const udidSim1 = '123A4BC5-D6E7-8901-F2A3-B45CD678E9F0'; 26 | const udidSim2 = 'BF579136-7492-4890-B916-40BF4BE80B8A'; 27 | 28 | it.each` 29 | isIOS | udid | result 30 | ${true} | ${udidReal1} | ${false} 31 | ${true} | ${udidReal2} | ${false} 32 | ${true} | ${udidReal0d1} | ${false} 33 | ${true} | ${udidReal1d1} | ${false} 34 | ${true} | ${udidReal1d2} | ${false} 35 | ${true} | ${udidReal1d3} | ${false} 36 | ${true} | ${udidReal1d4} | ${false} 37 | ${true} | ${udidReal2d1} | ${false} 38 | ${true} | ${udidReal2d2} | ${false} 39 | ${true} | ${udidReal2d3} | ${false} 40 | ${true} | ${udidReal2d4} | ${false} 41 | ${true} | ${udidReal2d5} | ${false} 42 | ${true} | ${udidReal2d6} | ${false} 43 | ${true} | ${udidReal3d1} | ${false} 44 | ${true} | ${udidReal3d2} | ${false} 45 | ${true} | ${udidSim1} | ${true} 46 | ${true} | ${udidSim2} | ${true} 47 | ${false} | ${udidReal1} | ${false} 48 | ${false} | ${udidReal2} | ${false} 49 | ${false} | ${udidReal0d1} | ${false} 50 | ${false} | ${udidReal1d1} | ${false} 51 | ${false} | ${udidReal1d2} | ${false} 52 | ${false} | ${udidReal1d3} | ${false} 53 | ${false} | ${udidReal1d4} | ${false} 54 | ${false} | ${udidReal2d1} | ${false} 55 | ${false} | ${udidReal2d2} | ${false} 56 | ${false} | ${udidReal2d3} | ${false} 57 | ${false} | ${udidReal2d4} | ${false} 58 | ${false} | ${udidReal2d5} | ${false} 59 | ${false} | ${udidReal2d6} | ${false} 60 | ${false} | ${udidReal3d1} | ${false} 61 | ${false} | ${udidReal3d2} | ${false} 62 | ${false} | ${udidSim1} | ${false} 63 | ${false} | ${udidSim2} | ${false} 64 | `( 65 | 'should return "$expectedOutput" when providing "$input"', 66 | ({ isIOS, udid, result }) => { 67 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 68 | // @ts-ignore mock browser for testing 69 | global.browser = { 70 | capabilities: { 71 | udid: udid, 72 | }, 73 | isIOS: isIOS, 74 | }; 75 | 76 | // then 77 | expect(isIosSimulator()).toEqual(result); 78 | } 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /src/lib/environment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns whether the test is currently running on an iOS Simulator or not. 3 | * @category Environment 4 | */ 5 | export function isIosSimulator(): boolean { 6 | /** 7 | * iOS Simulators have a UDID in the format of XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX 8 | * With X being a hexadecimal value. 9 | */ 10 | const udid = browser.capabilities.udid; 11 | return ( 12 | browser.isIOS && 13 | !!udid && 14 | udid.length === 36 && 15 | udid.charAt(8) === '-' && 16 | udid.charAt(13) === '-' && 17 | udid.charAt(18) === '-' && 18 | udid.charAt(23) === '-' 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/gestures.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | let SCREEN_SIZE: WebDriver.RectReturn | undefined; 5 | 6 | /** 7 | * @category Gesture 8 | */ 9 | export type Coordinate = { x: number; y: number }; 10 | 11 | /** 12 | * These values are percentages of the screen. 13 | * @internal 14 | */ 15 | const SWIPE_DIRECTION = { 16 | down: { 17 | start: { x: 50, y: 15 }, 18 | end: { x: 50, y: 85 }, 19 | }, 20 | left: { 21 | start: { x: 95, y: 50 }, 22 | end: { x: 5, y: 50 }, 23 | }, 24 | right: { 25 | start: { x: 5, y: 50 }, 26 | end: { x: 95, y: 50 }, 27 | }, 28 | up: { 29 | start: { x: 50, y: 85 }, 30 | end: { x: 50, y: 15 }, 31 | }, 32 | }; 33 | 34 | /** 35 | * @internal 36 | */ 37 | const CORNER_OFFSET = 50; 38 | 39 | /** 40 | * Calculates the x y coordinates based on a percentage. 41 | * @param coordinate from which to calculate the percentage of 42 | * @param {number} percentage to scale the coordinate 43 | * @return {Coordinate} which was scaled by the percentage 44 | * @author Wim Selles | wswebcreation 45 | * @internal 46 | */ 47 | function calculateXY(coordinate: Coordinate, percentage: number): Coordinate { 48 | return { 49 | x: coordinate.x * percentage, 50 | y: coordinate.y * percentage, 51 | }; 52 | } 53 | 54 | /** 55 | * Gets the screen coordinates based on a device's screen size. 56 | * @param {number} screenSize the size of the screen 57 | * @param {Coordinate} coordinate like { x: 50, y: 50 } 58 | * @return {Coordinate} based on the screen size 59 | * @author Wim Selles | wswebcreation 60 | * @internal 61 | */ 62 | function getDeviceScreenCoordinates( 63 | screenSize: WebDriver.RectReturn, 64 | coordinate: Coordinate 65 | ): Coordinate { 66 | return { 67 | x: Math.round(screenSize.width * (coordinate.x / 100)), 68 | y: Math.round(screenSize.height * (coordinate.y / 100)), 69 | }; 70 | } 71 | 72 | /** 73 | * Swipes from coordinate (from) to the new coordinate (to). 74 | * The given coordinates are in pixels. 75 | * 76 | * @param {Coordinate} from like for example { x: 50, y: 50 } 77 | * @param {Coordinate} to like for example { x: 25, y: 25 } 78 | * @author Wim Selles | wswebcreation 79 | * 80 | * ### Example 81 | * ```js 82 | * // This is a swipe to the left 83 | * const from = { x: 50, y:50 } 84 | * const to = { x: 25, y:50 } 85 | * ``` 86 | * @category Gesture 87 | */ 88 | export function swipe(from: Coordinate, to: Coordinate): void { 89 | browser.touchPerform([ 90 | { 91 | action: 'press', 92 | options: from, 93 | }, 94 | { 95 | action: 'wait', 96 | options: { ms: 1000 }, 97 | }, 98 | { 99 | action: 'moveTo', 100 | options: to, 101 | }, 102 | { 103 | action: 'release', 104 | }, 105 | ]); 106 | browser.pause(1000); 107 | } 108 | 109 | /** 110 | * Swipes from coordinate (from) to the new coordinate (to). 111 | * The given coordinates are percentages of the screen. 112 | * 113 | * @param {Coordinate} from like for example { x: 50, y: 50 } 114 | * @param {Coordinate} to like for example { x: 25, y: 25 } 115 | * @author Wim Selles | wswebcreation 116 | * 117 | * ### Example 118 | * ```js 119 | * // This is a swipe to the left 120 | * const from = { x: 50, y:50 } 121 | * const to = { x: 25, y:50 } 122 | * ``` 123 | * @category Gesture 124 | */ 125 | export function swipeOnPercentage(from: Coordinate, to: Coordinate): void { 126 | SCREEN_SIZE = SCREEN_SIZE || browser.getWindowRect(); 127 | const pressOptions = getDeviceScreenCoordinates(SCREEN_SIZE, from); 128 | const moveToScreenCoordinates = getDeviceScreenCoordinates(SCREEN_SIZE, to); 129 | swipe(pressOptions, moveToScreenCoordinates); 130 | } 131 | 132 | /** 133 | * Swipes up based on a percentage. 134 | * @param {number} percentage between 0 and 1 135 | * @author Wim Selles | wswebcreation 136 | * @category Gesture 137 | */ 138 | export function swipeUp(percentage = 1): void { 139 | swipeOnPercentage( 140 | calculateXY(SWIPE_DIRECTION.up.start, percentage), 141 | calculateXY(SWIPE_DIRECTION.up.end, percentage) 142 | ); 143 | } 144 | 145 | /** 146 | * Check if an element is visible and if not scroll down a portion of the screen to 147 | * check if it visible after a x amount of scrolls. 148 | * 149 | * @param element to check for if displayed 150 | * @param {number} maxScrolls maximum amount of scrolls to perform until the element is visible 151 | * @param {number} amount current amount of scrolls 152 | * @author Wim Selles | wswebcreation 153 | * @category Gesture 154 | */ 155 | export function checkIfDisplayedWithScrollDown( 156 | element: WebdriverIO.Element, 157 | maxScrolls: number, 158 | amount = 0 159 | ): void { 160 | if ( 161 | (!element.isExisting() || !element.isDisplayed()) && 162 | amount <= maxScrolls 163 | ) { 164 | swipeUp(0.85); 165 | checkIfDisplayedWithScrollDown(element, maxScrolls, amount + 1); 166 | } else if (amount > maxScrolls) { 167 | throw new Error( 168 | `The element '${element}' could not be found or is not visible.` 169 | ); 170 | } 171 | } 172 | 173 | /** 174 | * Swipe down based on a percentage. 175 | * @param {number} percentage between 0 and 1 176 | * @author Wim Selles | wswebcreation 177 | * @category Gesture 178 | */ 179 | export function swipeDown(percentage = 1): void { 180 | swipeOnPercentage( 181 | calculateXY(SWIPE_DIRECTION.down.start, percentage), 182 | calculateXY(SWIPE_DIRECTION.down.end, percentage) 183 | ); 184 | } 185 | 186 | /** 187 | * Swipes left based on a percentage. 188 | * @param {number} percentage between 0 and 1 189 | * @author Wim Selles | wswebcreation 190 | * @category Gesture 191 | */ 192 | export function swipeLeft(percentage = 1): void { 193 | swipeOnPercentage( 194 | calculateXY(SWIPE_DIRECTION.left.start, percentage), 195 | calculateXY(SWIPE_DIRECTION.left.end, percentage) 196 | ); 197 | } 198 | 199 | /** 200 | * Swipes right based on a percentage. 201 | * @param {number} percentage between 0 and 1 202 | * @author Wim Selles | wswebcreation 203 | * @category Gesture 204 | */ 205 | export function swipeRight(percentage = 1): void { 206 | swipeOnPercentage( 207 | calculateXY(SWIPE_DIRECTION.right.start, percentage), 208 | calculateXY(SWIPE_DIRECTION.right.end, percentage) 209 | ); 210 | } 211 | 212 | /** 213 | * Calculates the horizontal offset for the swipe based on the element's width. 214 | * @param element to take as a reference for the width 215 | * @internal 216 | */ 217 | function calculateHorizontalSwipeOffset(element: WebdriverIO.Element): number { 218 | return element.getSize().width / 2; 219 | } 220 | 221 | /** 222 | * Swipes right on the {@code element}. 223 | * @param element to be swiped on 224 | * @category Gesture 225 | */ 226 | export function swipeRightOnElement(element: WebdriverIO.Element): void { 227 | /** 228 | * X and Y are at the corners of the element's bounds. 229 | * We need to move inside of the element in order to be able to swipe afterwards. 230 | */ 231 | const positionX = element.getLocation().x + CORNER_OFFSET; 232 | const positionY = element.getLocation().y + CORNER_OFFSET; 233 | 234 | const from = { x: positionX, y: positionY }; 235 | const to = { 236 | x: positionX + calculateHorizontalSwipeOffset(element), 237 | y: positionY, 238 | }; 239 | 240 | swipe(from, to); 241 | } 242 | 243 | /** 244 | * Swipes left on the {@code element}. 245 | * @param element to be swiped on 246 | * @category Gesture 247 | */ 248 | export function swipeLeftOnElement(element: WebdriverIO.Element): void { 249 | /** 250 | * X and Y are at the corners of the element's bounds. 251 | * We need to move inside of the element in order to be able to swipe afterwards. 252 | */ 253 | const positionX = element.getSize().width - CORNER_OFFSET; 254 | const positionY = element.getLocation().y + CORNER_OFFSET; 255 | 256 | const from = { x: positionX, y: positionY }; 257 | const to = { 258 | x: positionX - calculateHorizontalSwipeOffset(element), 259 | y: positionY, 260 | }; 261 | 262 | swipe(from, to); 263 | } 264 | -------------------------------------------------------------------------------- /src/lib/internal/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { isString } from './utils'; 2 | 3 | describe('Internal Utils', function () { 4 | describe('isString', function () { 5 | it('should return true if argument passed is a string', function () { 6 | expect(isString('test')).toBe(true); 7 | }); 8 | 9 | it('should return false if argument passed is a boolean', function () { 10 | expect(isString(true)).toBe(false); 11 | }); 12 | 13 | it('should return false if argument passed is a number', function () { 14 | expect(isString(1)).toBe(false); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/lib/internal/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents the supported platforms. 3 | * @internal 4 | */ 5 | export enum Platform { 6 | IOS = 'IOS', 7 | ANDROID = 'ANDROID', 8 | } 9 | 10 | /** 11 | * Verifies the appId (Android) or bundleId (iOS) was defined or else throws an OS-specific error message. 12 | * @param id appId (Android) or bundleId (iOS) 13 | * @param platform operating system under test 14 | * @internal 15 | */ 16 | export function assertIdDefined( 17 | id: string | undefined, 18 | platform: Platform 19 | ): void { 20 | if (!id) { 21 | if (platform === Platform.IOS) { 22 | throw new Error('No bundleId was specified for iOS'); 23 | } else if (platform === Platform.ANDROID) { 24 | throw new Error('No appId was specified for Android'); 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * Checks whether the supplied argument is of type string or not. 31 | * @param maybeString argument to check if it's a string 32 | * @returns true if it's a string, false in all other cases 33 | * @internal 34 | */ 35 | export function isString(maybeString: T): boolean { 36 | return typeof maybeString === 'string'; 37 | } 38 | 39 | /** 40 | * Represents an error implying the platform used is not supported. 41 | * This is used in cases where the platform is neither Android nor iOS. 42 | * @internal 43 | */ 44 | export const UNSUPPORTED_PLATFORM_ERROR = 45 | 'You are using an unsupported platform, only Android and iOS are supported!'; 46 | 47 | /** 48 | * Represents an error implying the iOS selector was accessed while being null. 49 | * This is used in cases where the selector was not defined, yet it was still accessed. 50 | * @internal 51 | */ 52 | export const IOS_SELECTOR_NULL_ERROR = 53 | 'iOS selector is null, but an attempt was made to access it on iOS. Please define a selector if you want to use it on iOS.'; 54 | 55 | /** 56 | * Represents an error implying the Android selector was accessed while being null. 57 | * This is used in cases where the selector was not defined, yet it was still accessed. 58 | * @internal 59 | */ 60 | export const ANDROID_SELECTOR_NULL_ERROR = 61 | 'Android selector is null, but an attempt was made to access it on Android. Please define a selector if you want to use it on Android.'; 62 | 63 | /** 64 | * Represents an error implying both Android and iOS Selectors used to create a selector were null. 65 | * Since it doesn't make sense to not specify any selectors, this should prevent user errors. 66 | * @internal 67 | */ 68 | export const SELECTOR_NULL_ERROR = 69 | 'Both Android and iOS Selectors are null, please define at least a selector for one platform.'; 70 | 71 | /** 72 | * Represents an error implying an Selector with null as its AndroidSelector was combined with a Selector with null as IosSelector. 73 | * Since this would result in a selector with null on both platforms, it wouldn't be usable on either Android or iOS. 74 | * @internal 75 | */ 76 | export const COMBINATION_SELECTOR_NULL_ERROR = 77 | 'A selector with an Android selector of null cannot be combined with an iOS selector of null, as the resulting selector would be null on both platforms.'; 78 | -------------------------------------------------------------------------------- /src/lib/select/androidSelector.ts: -------------------------------------------------------------------------------- 1 | import { removeStartingTilde } from '../utils'; 2 | import { isString } from '../internal/utils'; 3 | 4 | // see https://developer.android.com/reference/androidx/test/uiautomator/UiSelector 5 | export enum ANDROID_UISELECTOR_PROPERTIES { 6 | CHECKABLE = 'checkable', 7 | CHECKED = 'checked', 8 | CLASS_NAME = 'className', 9 | CLASS_NAME_MATCHES = 'classNameMatches', 10 | CLICKABLE = 'clickable', 11 | DESCRIPTION = 'description', 12 | DESCRIPTION_CONTAINS = 'descriptionContains', 13 | DESCRIPTION_MATCHES = 'descriptionMatches', 14 | DESCRIPTION_STARTS_WITH = 'descriptionStartsWith', 15 | ENABLED = 'enabled', 16 | FOCUSABLE = 'focusable', 17 | FOCUSED = 'focused', 18 | FROM_PARENT = 'fromParent', 19 | INDEX = 'index', 20 | INSTANCE = 'instance', 21 | LONG_CLICKABLE = 'longClickable', 22 | PACKAGE_NAME = 'packageName', 23 | PACKAGE_NAME_MATCHES = 'packageNameMatches', 24 | RESOURCE_ID = 'resourceId', 25 | RESOURCE_ID_MATCHES = 'resourceIdMatches', 26 | SCROLLABLE = 'scrollable', 27 | SELECTED = 'selected', 28 | TEXT = 'text', 29 | TEXT_CONTAINS = 'textContains', 30 | TEXT_MATCHES = 'textMatches', 31 | TEXT_STARTS_WITH = 'textStartsWith', 32 | } 33 | 34 | export class AndroidSelector { 35 | private _property: ANDROID_UISELECTOR_PROPERTIES; 36 | private _value: T; 37 | 38 | private constructor(property: ANDROID_UISELECTOR_PROPERTIES, value: T) { 39 | this._property = property; 40 | this._value = value; 41 | } 42 | 43 | public static of( 44 | property: ANDROID_UISELECTOR_PROPERTIES, 45 | value: T 46 | ): AndroidSelector { 47 | return new AndroidSelector(property, value); 48 | } 49 | 50 | get property(): ANDROID_UISELECTOR_PROPERTIES { 51 | return this._property; 52 | } 53 | 54 | get value(): T { 55 | return this._value; 56 | } 57 | 58 | toString(): string { 59 | let selector = '.' + this.property + '('; 60 | if (isString(this.value)) { 61 | selector += 62 | '"' + 63 | removeStartingTilde((this.value as unknown) as string) + 64 | '")'; 65 | } else { 66 | selector += this.value + ')'; 67 | } 68 | return selector; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/select/iosSelector.ts: -------------------------------------------------------------------------------- 1 | import { removeStartingTilde } from '../utils'; 2 | import { isString } from '../internal/utils'; 3 | 4 | // see https://github.com/facebookarchive/WebDriverAgent/wiki/Predicate-Queries-Construction-Rules 5 | export enum IOS_PREDICATE_ATTRIBUTES { 6 | NAME = 'name', 7 | VALUE = 'value', 8 | LABEL = 'label', 9 | RECT = 'rect', 10 | TYPE = 'type', 11 | ENABLED = 'enabled', 12 | VISIBLE = 'visible', 13 | ACCESSIBLE = 'accessible', 14 | ACCESSIBILITY_CONTAINER = 'accessibilityContainer', 15 | } 16 | 17 | // see https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Predicates/Articles/pSyntax.html 18 | export enum IOS_PREDICATE_COMPARATOR { 19 | EQUALS = '==', 20 | NOT_EQUALS = '!=', 21 | CONTAINS = 'CONTAINS', 22 | BEGINS_WITH = 'BEGINSWITH', 23 | ENDS_WITH = 'ENDSWITH', 24 | LIKE = 'LIKE', 25 | MATCHES = 'MATCHES', 26 | } 27 | 28 | export class IosSelector { 29 | attribute: IOS_PREDICATE_ATTRIBUTES; 30 | comparator: IOS_PREDICATE_COMPARATOR; 31 | value: T; 32 | 33 | private constructor( 34 | attribute: IOS_PREDICATE_ATTRIBUTES, 35 | comparator: IOS_PREDICATE_COMPARATOR, 36 | value: T 37 | ) { 38 | this.attribute = attribute; 39 | this.comparator = comparator; 40 | this.value = value; 41 | } 42 | 43 | public static of( 44 | attribute: IOS_PREDICATE_ATTRIBUTES, 45 | comparator: IOS_PREDICATE_COMPARATOR, 46 | value: T 47 | ): IosSelector { 48 | return new IosSelector(attribute, comparator, value); 49 | } 50 | 51 | toString(): string { 52 | let selector = this.attribute + ' ' + this.comparator + ' '; 53 | if (isString(this.value)) { 54 | selector += 55 | "'" + 56 | removeStartingTilde((this.value as unknown) as string) + 57 | "'"; 58 | } else { 59 | selector += this.value; 60 | } 61 | return selector; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/select/select.android.test.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './selector'; 2 | import { mobile$, mobile$$ } from './select'; 3 | import { 4 | IOS_PREDICATE_ATTRIBUTES, 5 | IOS_PREDICATE_COMPARATOR, 6 | IosSelector, 7 | } from './iosSelector'; 8 | import { ANDROID_SELECTOR_NULL_ERROR } from '../internal/utils'; 9 | 10 | describe('Select', function () { 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 12 | // @ts-ignore mock browser for testing 13 | global.browser = { 14 | isAndroid: true, 15 | isIOS: false, 16 | }; 17 | 18 | const selector = Selector.enabled(); 19 | const selectorAndroid = 'android=new UiSelector().enabled(true)'; 20 | 21 | const mock$ = jest.fn(); 22 | const mock$$ = jest.fn(); 23 | 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 25 | // @ts-ignore mock $ for testing 26 | global.$ = mock$; 27 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 28 | // @ts-ignore mock $$ for testing 29 | global.$$ = mock$$; 30 | 31 | beforeEach(() => { 32 | mock$.mockReset(); 33 | mock$$.mockReset(); 34 | }); 35 | 36 | describe('Android', function () { 37 | it('should return a WebdriverIO.Element with UiSelector when calling mobile$() on Android', function () { 38 | mobile$(selector); 39 | expect(mock$$.mock.calls.length).toBe(0); 40 | expect(mock$.mock.calls.length).toBe(1); 41 | expect(mock$.mock.calls[0][0]).toBe(selectorAndroid); 42 | }); 43 | 44 | it('should return a WebdriverIO.Element with UiSelector when calling mobile$$() on Android', function () { 45 | mobile$$(selector); 46 | expect(mock$.mock.calls.length).toBe(0); 47 | expect(mock$$.mock.calls.length).toBe(1); 48 | expect(mock$$.mock.calls[0][0]).toBe(selectorAndroid); 49 | }); 50 | 51 | it('should throw an error if the Android selector is null and it is being used on the Android platform', function () { 52 | const anyIosSelector = IosSelector.of( 53 | IOS_PREDICATE_ATTRIBUTES.NAME, 54 | IOS_PREDICATE_COMPARATOR.CONTAINS, 55 | '' 56 | ); 57 | const selector = Selector.custom(null, anyIosSelector); 58 | 59 | expect(() => mobile$(selector)).toThrowError( 60 | ANDROID_SELECTOR_NULL_ERROR 61 | ); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/lib/select/select.ios.test.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './selector'; 2 | import { mobile$, mobile$$ } from './select'; 3 | import { 4 | ANDROID_UISELECTOR_PROPERTIES, 5 | AndroidSelector, 6 | } from './androidSelector'; 7 | import { IOS_SELECTOR_NULL_ERROR } from '../internal/utils'; 8 | 9 | describe('Select', function () { 10 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 11 | // @ts-ignore mock browser for testing 12 | global.browser = { 13 | isAndroid: false, 14 | isIOS: true, 15 | }; 16 | 17 | const selector = Selector.enabled(); 18 | const selectorIos = '-ios predicate string:enabled == 1'; 19 | 20 | const mock$ = jest.fn(); 21 | const mock$$ = jest.fn(); 22 | 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 24 | // @ts-ignore mock $ for testing 25 | global.$ = mock$; 26 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 27 | // @ts-ignore mock $$ for testing 28 | global.$$ = mock$$; 29 | 30 | beforeEach(() => { 31 | mock$.mockReset(); 32 | mock$$.mockReset(); 33 | }); 34 | 35 | describe('iOS', function () { 36 | it('should return a WebdriverIO.Element with ios-predicate when calling mobile$() on iOS', function () { 37 | mobile$(selector); 38 | expect(mock$$.mock.calls.length).toBe(0); 39 | expect(mock$.mock.calls.length).toBe(1); 40 | expect(mock$.mock.calls[0][0]).toBe(selectorIos); 41 | }); 42 | 43 | it('should return a WebdriverIO.ElementArray with ios-predicate when calling mobile$$() on iOS', function () { 44 | mobile$$(selector); 45 | expect(mock$.mock.calls.length).toBe(0); 46 | expect(mock$$.mock.calls.length).toBe(1); 47 | expect(mock$$.mock.calls[0][0]).toBe(selectorIos); 48 | }); 49 | 50 | it('should throw an error if the iOS selector is null and it is being used on the iOS platform', function () { 51 | const anyAndroidSelector = AndroidSelector.of( 52 | ANDROID_UISELECTOR_PROPERTIES.CLASS_NAME, 53 | '' 54 | ); 55 | const selector = Selector.custom(anyAndroidSelector, null); 56 | 57 | expect(() => mobile$(selector)).toThrowError( 58 | IOS_SELECTOR_NULL_ERROR 59 | ); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/lib/select/select.test.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './selector'; 2 | import { mobile$, mobile$$ } from './select'; 3 | import { UNSUPPORTED_PLATFORM_ERROR } from '../internal/utils'; 4 | 5 | describe('Select', function () { 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 7 | // @ts-ignore mock browser for testing 8 | global.browser = { 9 | isAndroid: false, 10 | isIOS: false, 11 | }; 12 | 13 | const selectorNullError = 'Selector which has been passed in is: null'; 14 | const selectorUndefinedError = 15 | 'Selector which has been passed in is: undefined'; 16 | 17 | const selector = Selector.enabled(); 18 | 19 | const mock$ = jest.fn(); 20 | const mock$$ = jest.fn(); 21 | 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 23 | // @ts-ignore mock $ for testing 24 | global.$ = mock$; 25 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 26 | // @ts-ignore mock $$ for testing 27 | global.$$ = mock$$; 28 | 29 | beforeEach(() => { 30 | mock$.mockReset(); 31 | mock$$.mockReset(); 32 | }); 33 | 34 | it('should throw an error when calling mobile$() with undefined or null', function () { 35 | expect(() => 36 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 37 | // @ts-ignore to force passing null 38 | mobile$(null) 39 | ).toThrowError(selectorNullError); 40 | 41 | expect(() => 42 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 43 | // @ts-ignore to force passing undefined 44 | mobile$(undefined) 45 | ).toThrowError(selectorUndefinedError); 46 | }); 47 | 48 | it('should throw an error when calling mobile$$() with undefined or null', function () { 49 | expect(() => 50 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 51 | // @ts-ignore to force passing null 52 | mobile$$(null) 53 | ).toThrowError(selectorNullError); 54 | 55 | expect(() => 56 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 57 | // @ts-ignore to force passing undefined 58 | mobile$$(undefined) 59 | ).toThrowError(selectorUndefinedError); 60 | }); 61 | 62 | describe('Unsupported Platform', function () { 63 | it('should throw an error when trying to use mobile$() on a platform other than Android or iOS', function () { 64 | expect(() => mobile$(selector)).toThrowError( 65 | UNSUPPORTED_PLATFORM_ERROR 66 | ); 67 | }); 68 | 69 | it('should throw an error when trying to use mobile$$() on a platform other than Android or iOS', function () { 70 | expect(() => mobile$$(selector)).toThrowError( 71 | UNSUPPORTED_PLATFORM_ERROR 72 | ); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/lib/select/select.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './selector'; 2 | import { 3 | ANDROID_SELECTOR_NULL_ERROR, 4 | IOS_SELECTOR_NULL_ERROR, 5 | UNSUPPORTED_PLATFORM_ERROR, 6 | } from '../internal/utils'; 7 | 8 | /** 9 | * @internal 10 | */ 11 | const Selectors = { 12 | ANDROID: { 13 | UI_SELECTOR_PREFIX: 'android=new UiSelector()', 14 | }, 15 | IOS: { 16 | PREDICATE_PREFIX: '-ios predicate string:', 17 | }, 18 | }; 19 | 20 | /** 21 | * @internal 22 | */ 23 | function buildSelector(selector: Selector): string { 24 | if (!selector) { 25 | throw new Error( 26 | 'Selector which has been passed in is: ' + JSON.stringify(selector) 27 | ); 28 | } 29 | if (browser.isAndroid) { 30 | const androidSelector = selector._android(); 31 | if (!androidSelector) { 32 | throw new Error(ANDROID_SELECTOR_NULL_ERROR); 33 | } 34 | return Selectors.ANDROID.UI_SELECTOR_PREFIX + androidSelector; 35 | } else if (browser.isIOS) { 36 | const iosSelector = selector._ios(); 37 | if (!iosSelector) { 38 | throw new Error(IOS_SELECTOR_NULL_ERROR); 39 | } 40 | return Selectors.IOS.PREDICATE_PREFIX + iosSelector; 41 | } 42 | throw new Error(UNSUPPORTED_PLATFORM_ERROR); 43 | } 44 | 45 | /** 46 | * Selects one element on mobile platforms in a cross-platform way. 47 | * Works in the same way as {@link $} in WebdriverIO. 48 | * Uses {@code UiSelector} on Android and {@code ios predicate} on iOS. 49 | * 50 | * @param selector to use 51 | * @category Select 52 | */ 53 | export function mobile$(selector: Selector): WebdriverIO.Element { 54 | return $(buildSelector(selector)); 55 | } 56 | 57 | /** 58 | * Selects all elements on mobile platforms in a cross-platform way. 59 | * Works in the same way as {@link $$} in WebdriverIO. 60 | * Uses {@code UiSelector} on Android and {@code ios predicate} on iOS. 61 | * 62 | * @param selector to use 63 | * @category Select 64 | */ 65 | export function mobile$$(selector: Selector): WebdriverIO.ElementArray { 66 | return $$(buildSelector(selector)); 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/select/selector.test.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from './selector'; 2 | import { Type } from './type'; 3 | import { 4 | COMBINATION_SELECTOR_NULL_ERROR, 5 | SELECTOR_NULL_ERROR, 6 | } from '../internal/utils'; 7 | import { 8 | IOS_PREDICATE_ATTRIBUTES, 9 | IOS_PREDICATE_COMPARATOR, 10 | IosSelector, 11 | } from './iosSelector'; 12 | import { 13 | ANDROID_UISELECTOR_PROPERTIES, 14 | AndroidSelector, 15 | } from './androidSelector'; 16 | 17 | describe('Selector', function () { 18 | const VALUE = 'TEST VALUE'; 19 | 20 | const anyIosSelector = IosSelector.of( 21 | IOS_PREDICATE_ATTRIBUTES.NAME, 22 | IOS_PREDICATE_COMPARATOR.CONTAINS, 23 | '' 24 | ); 25 | const anyAndroidSelector = AndroidSelector.of( 26 | ANDROID_UISELECTOR_PROPERTIES.CLASS_NAME, 27 | '' 28 | ); 29 | 30 | const nullSelectorAndroid = Selector.custom(null, anyIosSelector); 31 | const nullSelectorIos = Selector.custom(anyAndroidSelector, null); 32 | 33 | describe('combination selectors', function () { 34 | const s1 = Selector.type(Type.TEXT_FIELD); 35 | const s2 = Selector.text(VALUE); 36 | const s3 = Selector.type(Type.BUTTON); 37 | const s4 = Selector.enabled(); 38 | 39 | describe('and', function () { 40 | const combined = Selector.and(s1, s2); 41 | 42 | it('should return the selector for Android when "._android()" is called', function () { 43 | expect(combined._android()).toBe( 44 | `.className("android.widget.EditText").text("${VALUE}")` 45 | ); 46 | }); 47 | 48 | it('should return the selector for iOS when "._ios()" is called', function () { 49 | expect(combined._ios()).toBe( 50 | `(type == 'XCUIElementTypeTextField' && label == '${VALUE}')` 51 | ); 52 | }); 53 | 54 | it('should have an iOS selector of null when combining a selector with one that has an iOS selector of null', function () { 55 | const combined = Selector.and(s1, nullSelectorIos); 56 | 57 | expect(combined._ios()).toBeNull(); 58 | }); 59 | 60 | it('should have an Android selector of null when combining a selector with one that has an Android selector of null', function () { 61 | const combined = Selector.and(nullSelectorAndroid, s1); 62 | 63 | expect(combined._android()).toBeNull(); 64 | }); 65 | 66 | it('should throw an error if a selector with null on Android is combined with one that is null on iOS', function () { 67 | expect(() => 68 | Selector.and(nullSelectorAndroid, nullSelectorIos) 69 | ).toThrowError(COMBINATION_SELECTOR_NULL_ERROR); 70 | }); 71 | }); 72 | 73 | describe('or', function () { 74 | const combined = Selector.or(s1, s2); 75 | 76 | it('should return the selector for Android when "._android()" is called', function () { 77 | expect(combined._android()).toBe( 78 | `.className("android.widget.EditText");new UiSelector().text("${VALUE}")` 79 | ); 80 | }); 81 | 82 | it('should return the selector for iOS when "._ios()" is called', function () { 83 | expect(combined._ios()).toBe( 84 | `(type == 'XCUIElementTypeTextField' || label == '${VALUE}')` 85 | ); 86 | }); 87 | 88 | it('should have an iOS selector of null when combining a selector with one that has an iOS selector of null', function () { 89 | const combined = Selector.or(s1, nullSelectorIos); 90 | 91 | expect(combined._ios()).toBeNull(); 92 | }); 93 | 94 | it('should have an Android selector of null when combining a selector with one that has an Android selector of null', function () { 95 | const combined = Selector.or(nullSelectorAndroid, s1); 96 | 97 | expect(combined._android()).toBeNull(); 98 | }); 99 | 100 | it('should throw an error if a selector with null on Android is combined with one that is null on iOS', function () { 101 | expect(() => 102 | Selector.or(nullSelectorAndroid, nullSelectorIos) 103 | ).toThrowError(COMBINATION_SELECTOR_NULL_ERROR); 104 | }); 105 | }); 106 | 107 | describe('and & or', function () { 108 | const combined = Selector.and(Selector.or(s1, s3), s2); 109 | /** 110 | * (s1 || s3) && s2 111 | * is equivalent to 112 | * (s1 && s2) || (s3 && s2) 113 | */ 114 | it('should return the selector for Android when "._android()" is called', function () { 115 | expect(combined._android()).toBe( 116 | `.className("android.widget.EditText").text("${VALUE}");new UiSelector().className("android.widget.Button").text("${VALUE}")` 117 | ); 118 | }); 119 | 120 | it('should return the selector for iOS when "._ios()" is called', function () { 121 | expect(combined._ios()).toBe( 122 | `((type == 'XCUIElementTypeTextField' || type == 'XCUIElementTypeButton') && label == '${VALUE}')` 123 | ); 124 | }); 125 | }); 126 | 127 | describe('or & and', function () { 128 | const combined = Selector.or(Selector.and(s1, s3), s2); 129 | 130 | it('should return the selector for Android when "._android()" is called', function () { 131 | expect(combined._android()).toBe( 132 | `.className("android.widget.EditText").className("android.widget.Button");new UiSelector().text("${VALUE}")` 133 | ); 134 | }); 135 | 136 | it('should return the selector for iOS when "._ios()" is called', function () { 137 | expect(combined._ios()).toBe( 138 | `((type == 'XCUIElementTypeTextField' && type == 'XCUIElementTypeButton') || label == '${VALUE}')` 139 | ); 140 | }); 141 | }); 142 | 143 | describe('combinations edge cases for Android', function () { 144 | /** 145 | * s2 && (s1 || s3) 146 | * is equivalent to 147 | * (s2 && s1) || (s2 && s3) 148 | */ 149 | it('s2 && (s1 || s3) => (s2 && s1) || (s2 && s3)', function () { 150 | const combined = Selector.and(s2, Selector.or(s1, s3)); 151 | expect(combined._android()).toBe( 152 | `.text("${VALUE}").className("android.widget.EditText");new UiSelector().text("${VALUE}").className("android.widget.Button")` 153 | ); 154 | }); 155 | 156 | /** 157 | * (s1 || s2) && (s3 || s4) 158 | * is equivalent to 159 | * (s1 && s3) || (s1 && s4) || (s2 && s3) || (s2 && s4) 160 | */ 161 | it('(s1 || s2) && (s3 || s4) => (s1 && s3) || (s1 && s4) || (s2 && s3) || (s2 && s4)', function () { 162 | const combined = Selector.and( 163 | Selector.or(s1, s2), 164 | Selector.or(s3, s4) 165 | ); 166 | expect(combined._android()).toBe( 167 | '.className("android.widget.EditText").className("android.widget.Button");' + // (s1 && s3) || 168 | 'new UiSelector().className("android.widget.EditText").enabled(true);' + // (s1 && s4) || 169 | `new UiSelector().text("${VALUE}").className("android.widget.Button");` + // (s2 && s3) || 170 | `new UiSelector().text("${VALUE}").enabled(true)` // (s2 && s4) 171 | ); 172 | }); 173 | 174 | it('(s1 && s2) || (s3 && s4)', function () { 175 | const combined = Selector.or( 176 | Selector.and(s1, s2), 177 | Selector.and(s3, s4) 178 | ); 179 | expect(combined._android()).toBe( 180 | `.className("android.widget.EditText").text("${VALUE}");` + // (s1 && s2) || 181 | 'new UiSelector().className("android.widget.Button").enabled(true)' // (s3 && s4) 182 | ); 183 | }); 184 | 185 | it('multiple levels of nested AND and OR conditions', function () { 186 | const combined = Selector.and( 187 | Selector.or( 188 | Selector.or(Selector.and(s1, s2), Selector.or(s1, s2)), 189 | Selector.and(Selector.and(s1, s2), Selector.or(s1, s2)) 190 | ), 191 | Selector.or( 192 | Selector.or(Selector.and(s1, s2), Selector.or(s1, s2)), 193 | Selector.and(Selector.and(s1, s2), Selector.or(s1, s2)) 194 | ) 195 | ); 196 | expect(combined._android()).toBe( 197 | `.className("android.widget.EditText").text("${VALUE}");new UiSelector().className("android.widget.EditText");new UiSelector().text("${VALUE}");new UiSelector().className("android.widget.EditText").text("${VALUE}").className("android.widget.EditText");new UiSelector().className("android.widget.EditText").text("${VALUE}").text("${VALUE}").className("android.widget.EditText").text("${VALUE}");new UiSelector().className("android.widget.EditText");new UiSelector().text("${VALUE}");new UiSelector().className("android.widget.EditText").text("${VALUE}").className("android.widget.EditText");new UiSelector().className("android.widget.EditText").text("${VALUE}").text("${VALUE}")` 198 | ); 199 | }); 200 | }); 201 | }); 202 | 203 | describe('type', function () { 204 | it.each` 205 | type | androidClassName | iosType 206 | ${Type.LABEL} | ${'android.widget.TextView'} | ${'XCUIElementTypeStaticText'} 207 | ${Type.BUTTON} | ${'android.widget.Button'} | ${'XCUIElementTypeButton'} 208 | ${Type.TEXT_FIELD} | ${'android.widget.EditText'} | ${'XCUIElementTypeTextField'} 209 | `( 210 | 'should return type selector with className "$androidClassName" for Android and type "$iosType" for iOS for enum type "$type"', 211 | ({ type, androidClassName, iosType }) => { 212 | const selector = Selector.type(type); 213 | expect(selector._android()).toBe( 214 | `.className("${androidClassName}")` 215 | ); 216 | expect(selector._ios()).toBe(`type == '${iosType}'`); 217 | } 218 | ); 219 | 220 | it('should throw an exception if the type is not implemented', function () { 221 | expect(() => 222 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 223 | // @ts-ignore to force a non-existing type 224 | Selector.type('awdawjife434637863787h8tefwef') 225 | ).toThrowError('Type not implemented!'); 226 | }); 227 | }); 228 | 229 | describe('text', function () { 230 | const selector = Selector.text(VALUE); 231 | 232 | it('should return the selector for Android when "._android()" is called', function () { 233 | expect(selector._android()).toBe(`.text("${VALUE}")`); 234 | }); 235 | 236 | it('should return the selector for iOS when "._ios()" is called', function () { 237 | expect(selector._ios()).toBe(`label == '${VALUE}'`); 238 | }); 239 | }); 240 | describe('textContains', function () { 241 | const selector = Selector.textContains(VALUE); 242 | 243 | it('should return the selector for Android when "._android()" is called', function () { 244 | expect(selector._android()).toBe(`.textContains("${VALUE}")`); 245 | }); 246 | 247 | it('should return the selector for iOS when "._ios()" is called', function () { 248 | expect(selector._ios()).toBe(`label CONTAINS '${VALUE}'`); 249 | }); 250 | }); 251 | describe('textMatches', function () { 252 | const selector = Selector.textMatches(VALUE); 253 | 254 | it('should return the selector for Android when "._android()" is called', function () { 255 | expect(selector._android()).toBe(`.textMatches("${VALUE}")`); 256 | }); 257 | 258 | it('should return the selector for iOS when "._ios()" is called', function () { 259 | expect(selector._ios()).toBe(`label MATCHES '${VALUE}'`); 260 | }); 261 | }); 262 | describe('textStartsWith', function () { 263 | const selector = Selector.textStartsWith(VALUE); 264 | 265 | it('should return the selector for Android when "._android()" is called', function () { 266 | expect(selector._android()).toBe(`.textStartsWith("${VALUE}")`); 267 | }); 268 | 269 | it('should return the selector for iOS when "._ios()" is called', function () { 270 | expect(selector._ios()).toBe(`label BEGINSWITH '${VALUE}'`); 271 | }); 272 | }); 273 | 274 | describe('accessibilityId', function () { 275 | const selector = Selector.accessibilityId(VALUE); 276 | 277 | it('should return the selector for Android when "._android()" is called', function () { 278 | expect(selector._android()).toBe(`.description("${VALUE}")`); 279 | }); 280 | 281 | it('should return the selector for iOS when "._ios()" is called', function () { 282 | expect(selector._ios()).toBe(`name == '${VALUE}'`); 283 | }); 284 | }); 285 | describe('accessibilityIdContains', function () { 286 | const selector = Selector.accessibilityIdContains(VALUE); 287 | 288 | it('should return the selector for Android when "._android()" is called', function () { 289 | expect(selector._android()).toBe( 290 | `.descriptionContains("${VALUE}")` 291 | ); 292 | }); 293 | 294 | it('should return the selector for iOS when "._ios()" is called', function () { 295 | expect(selector._ios()).toBe(`name CONTAINS '${VALUE}'`); 296 | }); 297 | }); 298 | describe('accessibilityIdMatches', function () { 299 | const selector = Selector.accessibilityIdMatches(VALUE); 300 | 301 | it('should return the selector for Android when "._android()" is called', function () { 302 | expect(selector._android()).toBe(`.descriptionMatches("${VALUE}")`); 303 | }); 304 | 305 | it('should return the selector for iOS when "._ios()" is called', function () { 306 | expect(selector._ios()).toBe(`name MATCHES '${VALUE}'`); 307 | }); 308 | }); 309 | describe('accessibilityIdStartsWith', function () { 310 | const selector = Selector.accessibilityIdStartsWith(VALUE); 311 | 312 | it('should return the selector for Android when "._android()" is called', function () { 313 | expect(selector._android()).toBe( 314 | `.descriptionStartsWith("${VALUE}")` 315 | ); 316 | }); 317 | 318 | it('should return the selector for iOS when "._ios()" is called', function () { 319 | expect(selector._ios()).toBe(`name BEGINSWITH '${VALUE}'`); 320 | }); 321 | }); 322 | 323 | describe('enabled', function () { 324 | const selector = Selector.enabled(); 325 | 326 | it('should return the selector for Android when "._android()" is called', function () { 327 | expect(selector._android()).toBe('.enabled(true)'); 328 | }); 329 | 330 | it('should return the selector for iOS when "._ios()" is called', function () { 331 | expect(selector._ios()).toBe('enabled == 1'); 332 | }); 333 | }); 334 | describe('disabled', function () { 335 | const selector = Selector.disabled(); 336 | 337 | it('should return the selector for Android when "._android()" is called', function () { 338 | expect(selector._android()).toBe('.enabled(false)'); 339 | }); 340 | 341 | it('should return the selector for iOS when "._ios()" is called', function () { 342 | expect(selector._ios()).toBe('enabled == 0'); 343 | }); 344 | }); 345 | 346 | describe('custom', function () { 347 | it('should throw an error if the iOS and Android selector is null when creating a selector', function () { 348 | expect(() => Selector.custom(null, null)).toThrowError( 349 | SELECTOR_NULL_ERROR 350 | ); 351 | }); 352 | }); 353 | }); 354 | -------------------------------------------------------------------------------- /src/lib/select/selector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ANDROID_UISELECTOR_PROPERTIES, 3 | AndroidSelector, 4 | } from './androidSelector'; 5 | import { 6 | IOS_PREDICATE_ATTRIBUTES, 7 | IOS_PREDICATE_COMPARATOR, 8 | IosSelector, 9 | } from './iosSelector'; 10 | import { Type } from './type'; 11 | import logger from '@wdio/logger'; 12 | import { 13 | COMBINATION_SELECTOR_NULL_ERROR, 14 | SELECTOR_NULL_ERROR, 15 | } from '../internal/utils'; 16 | 17 | /** 18 | * @internal 19 | */ 20 | const log = logger('Selector'); 21 | 22 | export class Selector { 23 | private readonly androidSelector: string | null; 24 | private readonly iosSelector: string | null; 25 | 26 | private constructor( 27 | androidSelector: string | null, 28 | iosSelector: string | null 29 | ) { 30 | this.androidSelector = androidSelector; 31 | this.iosSelector = iosSelector; 32 | } 33 | 34 | /** 35 | * Combines two selectors by an AND (&&) condition. 36 | * @note If one of the selectors has a null selector on one platform, the resulting selector will also have null on this platform. 37 | * If one of the selectors has a null selector on Android and the other has a null selector on iOS, an error will be thrown, since 38 | * the resulting selector would be null. 39 | * @param selector1 to be combined with an AND condition with {@code selector2} 40 | * @param selector2 to be combined with an AND condition with {@code selector1} 41 | */ 42 | public static and(selector1: Selector, selector2: Selector): Selector { 43 | let andAndroid: string | null = null; 44 | let andIos: string | null = null; 45 | 46 | const selector1Android = selector1._android(); 47 | const selector2Android = selector2._android(); 48 | if (selector1Android != null && selector2Android != null) { 49 | andAndroid = this.combineAndAndroid(selector1, selector2); 50 | } 51 | 52 | const selector1Ios = selector1._ios(); 53 | const selector2Ios = selector2._ios(); 54 | if (selector1Ios != null && selector2Ios != null) { 55 | andIos = '(' + selector1._ios() + ' && ' + selector2._ios() + ')'; 56 | } 57 | 58 | if (!andAndroid && !andIos) { 59 | throw new Error(COMBINATION_SELECTOR_NULL_ERROR); 60 | } 61 | 62 | return new Selector(andAndroid, andIos); 63 | } 64 | 65 | private static combineAndAndroid( 66 | selector1: Selector, 67 | selector2: Selector 68 | ): string { 69 | // method is only called if selector1 and selector2 are not null 70 | const selector1Android = selector1._android() as string; 71 | const selector2Android = selector2._android() as string; 72 | 73 | const splitOr = ';new UiSelector()'; 74 | const split1 = selector1Android.split(splitOr); 75 | const split2 = selector2Android.split(splitOr); 76 | if (split1.length == 1 && split2.length == 1) { 77 | // standard case, individual selectors were not combined with an "or" condition before 78 | return split1[0] + split2[0]; 79 | } else if (split1.length == 2 && split2.length == 1) { 80 | /** 81 | * When there is an AND condition in a nested OR condition, we have to transform the boolean expression 82 | * to only use AND conditions in the parentheses and connect them together using OR conditions 83 | * since UiSelector's can only be OR combined by a semicolon, which will not take into account any grouping. 84 | */ 85 | 86 | /** 87 | * (s1 || s3) && s2 88 | * is equivalent to 89 | * (s1 && s2) || (s3 && s2) 90 | */ 91 | const s1 = split1[0]; 92 | const s2 = split2[0]; 93 | const s3 = split1[1]; 94 | return this.combineOrAndroid(s1 + s2, s3 + s2); 95 | } else if (split1.length == 1 && split2.length == 2) { 96 | /** 97 | * s2 && (s1 || s3) 98 | * is equivalent to 99 | * (s2 && s1) || (s2 && s3) 100 | */ 101 | const s1 = split2[0]; 102 | const s2 = split1[0]; 103 | const s3 = split2[1]; 104 | return this.combineOrAndroid(s2 + s1, s2 + s3); 105 | } else if (split1.length == 2 && split2.length == 2) { 106 | /** 107 | * (s1 || s2) && (s3 || s4) 108 | * is equivalent to 109 | * (s1 && s3) || (s1 && s4) || (s2 && s3) || (s2 && s4) 110 | */ 111 | const s1 = split1[0]; 112 | const s2 = split1[1]; 113 | const s3 = split2[0]; 114 | const s4 = split2[1]; 115 | const part1 = this.combineOrAndroid(s1 + s3, s1 + s4); // (s1 && s3) || (s1 && s4) 116 | const part2 = this.combineOrAndroid(s2 + s3, s2 + s4); // (s2 && s3) || (s2 && s4) 117 | return this.combineOrAndroid(part1, part2); 118 | } else { 119 | /** 120 | * Here we have multiple levels of nested OR conditions with multiple levels of nested AND conditions. 121 | * These cases should be very rare and if they occur, it may be better to use a different approach in such a case anyways. 122 | * So we don't handle them but log an error to warn the user. 123 | */ 124 | log.error( 125 | 'Using multiple levels of nested OR conditions together with AND conditions can cause unexpected results on Android, please re-write the expression to only use OR conditions between the AND conditions.' 126 | ); 127 | return selector1Android + selector2Android; 128 | } 129 | } 130 | 131 | /** 132 | * Combines two selectors with an OR (||) condition. 133 | * @note If one of the selectors has a null selector on one platform, the resulting selector will also have null on this platform. 134 | * If one of the selectors has a null selector on Android and the other has a null selector on iOS, an error will be thrown, since 135 | * the resulting selector would be null. 136 | * @param selector1 to be combined with an OR condition with {@code selector2} 137 | * @param selector2 to be combined with an OR condition with {@code selector1} 138 | */ 139 | public static or(selector1: Selector, selector2: Selector): Selector { 140 | let orAndroid: string | null = null; 141 | let orIos: string | null = null; 142 | 143 | const selector1Android = selector1._android(); 144 | const selector2Android = selector2._android(); 145 | if (selector1Android != null && selector2Android != null) { 146 | orAndroid = this.combineOrAndroid( 147 | selector1Android, 148 | selector2Android 149 | ); 150 | } 151 | 152 | const selector1Ios = selector1._ios(); 153 | const selector2Ios = selector2._ios(); 154 | if (selector1Ios != null && selector2Ios != null) { 155 | orIos = '(' + selector1Ios + ' || ' + selector2Ios + ')'; 156 | } 157 | 158 | if (!orAndroid && !orIos) { 159 | throw new Error(COMBINATION_SELECTOR_NULL_ERROR); 160 | } 161 | 162 | return new Selector(orAndroid, orIos); 163 | } 164 | 165 | private static combineOrAndroid( 166 | selector1: string, 167 | selector2: string 168 | ): string { 169 | return selector1 + ';new UiSelector()' + selector2; 170 | } 171 | 172 | public static type(type: Type): Selector { 173 | let androidClassName; 174 | let iosType; 175 | 176 | switch (type) { 177 | case Type.LABEL: 178 | androidClassName = 'android.widget.TextView'; 179 | iosType = 'XCUIElementTypeStaticText'; 180 | break; 181 | case Type.BUTTON: 182 | androidClassName = 'android.widget.Button'; 183 | iosType = 'XCUIElementTypeButton'; 184 | break; 185 | case Type.TEXT_FIELD: 186 | androidClassName = 'android.widget.EditText'; 187 | iosType = 'XCUIElementTypeTextField'; 188 | break; 189 | default: 190 | throw new Error('Type not implemented!'); 191 | } 192 | 193 | return this.custom( 194 | AndroidSelector.of( 195 | ANDROID_UISELECTOR_PROPERTIES.CLASS_NAME, 196 | androidClassName 197 | ), 198 | IosSelector.of( 199 | IOS_PREDICATE_ATTRIBUTES.TYPE, 200 | IOS_PREDICATE_COMPARATOR.EQUALS, 201 | iosType 202 | ) 203 | ); 204 | } 205 | 206 | public static text(text: string): Selector { 207 | return this.custom( 208 | AndroidSelector.of(ANDROID_UISELECTOR_PROPERTIES.TEXT, text), 209 | IosSelector.of( 210 | IOS_PREDICATE_ATTRIBUTES.LABEL, 211 | IOS_PREDICATE_COMPARATOR.EQUALS, 212 | text 213 | ) 214 | ); 215 | } 216 | public static textContains(text: string): Selector { 217 | return this.custom( 218 | AndroidSelector.of( 219 | ANDROID_UISELECTOR_PROPERTIES.TEXT_CONTAINS, 220 | text 221 | ), 222 | IosSelector.of( 223 | IOS_PREDICATE_ATTRIBUTES.LABEL, 224 | IOS_PREDICATE_COMPARATOR.CONTAINS, 225 | text 226 | ) 227 | ); 228 | } 229 | public static textMatches(text: string): Selector { 230 | return this.custom( 231 | AndroidSelector.of( 232 | ANDROID_UISELECTOR_PROPERTIES.TEXT_MATCHES, 233 | text 234 | ), 235 | IosSelector.of( 236 | IOS_PREDICATE_ATTRIBUTES.LABEL, 237 | IOS_PREDICATE_COMPARATOR.MATCHES, 238 | text 239 | ) 240 | ); 241 | } 242 | public static textStartsWith(text: string): Selector { 243 | return this.custom( 244 | AndroidSelector.of( 245 | ANDROID_UISELECTOR_PROPERTIES.TEXT_STARTS_WITH, 246 | text 247 | ), 248 | IosSelector.of( 249 | IOS_PREDICATE_ATTRIBUTES.LABEL, 250 | IOS_PREDICATE_COMPARATOR.BEGINS_WITH, 251 | text 252 | ) 253 | ); 254 | } 255 | 256 | public static accessibilityId(accessibilityId: string): Selector { 257 | return this.custom( 258 | AndroidSelector.of( 259 | ANDROID_UISELECTOR_PROPERTIES.DESCRIPTION, 260 | accessibilityId 261 | ), 262 | IosSelector.of( 263 | IOS_PREDICATE_ATTRIBUTES.NAME, 264 | IOS_PREDICATE_COMPARATOR.EQUALS, 265 | accessibilityId 266 | ) 267 | ); 268 | } 269 | public static accessibilityIdContains(accessibilityId: string): Selector { 270 | return this.custom( 271 | AndroidSelector.of( 272 | ANDROID_UISELECTOR_PROPERTIES.DESCRIPTION_CONTAINS, 273 | accessibilityId 274 | ), 275 | IosSelector.of( 276 | IOS_PREDICATE_ATTRIBUTES.NAME, 277 | IOS_PREDICATE_COMPARATOR.CONTAINS, 278 | accessibilityId 279 | ) 280 | ); 281 | } 282 | public static accessibilityIdMatches(accessibilityId: string): Selector { 283 | return this.custom( 284 | AndroidSelector.of( 285 | ANDROID_UISELECTOR_PROPERTIES.DESCRIPTION_MATCHES, 286 | accessibilityId 287 | ), 288 | IosSelector.of( 289 | IOS_PREDICATE_ATTRIBUTES.NAME, 290 | IOS_PREDICATE_COMPARATOR.MATCHES, 291 | accessibilityId 292 | ) 293 | ); 294 | } 295 | public static accessibilityIdStartsWith(accessibilityId: string): Selector { 296 | return this.custom( 297 | AndroidSelector.of( 298 | ANDROID_UISELECTOR_PROPERTIES.DESCRIPTION_STARTS_WITH, 299 | accessibilityId 300 | ), 301 | IosSelector.of( 302 | IOS_PREDICATE_ATTRIBUTES.NAME, 303 | IOS_PREDICATE_COMPARATOR.BEGINS_WITH, 304 | accessibilityId 305 | ) 306 | ); 307 | } 308 | 309 | public static enabled(): Selector { 310 | return this.custom( 311 | AndroidSelector.of(ANDROID_UISELECTOR_PROPERTIES.ENABLED, true), 312 | IosSelector.of( 313 | IOS_PREDICATE_ATTRIBUTES.ENABLED, 314 | IOS_PREDICATE_COMPARATOR.EQUALS, 315 | 1 316 | ) 317 | ); 318 | } 319 | public static disabled(): Selector { 320 | return this.custom( 321 | AndroidSelector.of(ANDROID_UISELECTOR_PROPERTIES.ENABLED, false), 322 | IosSelector.of( 323 | IOS_PREDICATE_ATTRIBUTES.ENABLED, 324 | IOS_PREDICATE_COMPARATOR.EQUALS, 325 | 0 326 | ) 327 | ); 328 | } 329 | 330 | /** 331 | * Allows building a custom selector for Android and iOS. 332 | * @note One of the parameters {@code android} or {@code ios} may be null, in case a selector only needs to be used on one specific platform. 333 | * @param android the custom selector to be used on Android 334 | * @param ios the custom selector to be used on iOS 335 | */ 336 | public static custom( 337 | android: AndroidSelector | null, 338 | ios: IosSelector | null 339 | ): Selector { 340 | if (android == null && ios == null) { 341 | throw new Error(SELECTOR_NULL_ERROR); 342 | } 343 | 344 | let androidSelector: string | null = null; 345 | let iosSelector: string | null = null; 346 | if (android) { 347 | androidSelector = android.toString(); 348 | } 349 | if (ios) { 350 | iosSelector = ios.toString(); 351 | } 352 | return new Selector(androidSelector, iosSelector); 353 | } 354 | 355 | /** 356 | * @return the {@code UiSelector} of this {@link Selector} or {@code null}, 357 | * if no {@link AndroidSelector} was specified. 358 | * @private 359 | * @internal 360 | */ 361 | _android(): string | null { 362 | return this.androidSelector; 363 | } 364 | 365 | /** 366 | * @return the {@code ios predicate} of this {@link Selector} or {@code null}, 367 | * if no {@link IosSelector} was specified. 368 | * @private 369 | * @internal 370 | */ 371 | _ios(): string | null { 372 | return this.iosSelector; 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/lib/select/type.ts: -------------------------------------------------------------------------------- 1 | export enum Type { 2 | LABEL = 'LABEL', 3 | BUTTON = 'BUTTON', 4 | TEXT_FIELD = 'TEXT_FIELD', 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes, if present, the ~ character in the beginning of a string. 3 | * This is useful in cases where the accessibilityId can be passed in by the developer, but needs to be without a ~ in front. This makes 4 | * sure even if the accessibilityId contains a ~ that is not allowed, that it's properly removed, providing greater convenience. 5 | * @category General Utility 6 | */ 7 | export function removeStartingTilde(key: string): string { 8 | if (key.startsWith('~')) { 9 | return key.substring(1); // cut off ~ in front 10 | } else { 11 | return key; 12 | } 13 | } 14 | 15 | /** 16 | * Returns the accessibilityId of an element. 17 | * 18 | * @param {WebdriverIO.Element} element of which to get the accessibilityId 19 | * @return {string} the accessibilityId of {@code element} 20 | * @category General Utility 21 | */ 22 | export function getAccessibilityIdOfElement( 23 | element: WebdriverIO.Element 24 | ): string { 25 | if (browser.isAndroid) { 26 | return element.getAttribute('content-desc'); 27 | } else { 28 | return element.getAttribute('label'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/types/wdio-logger.d.ts: -------------------------------------------------------------------------------- 1 | // Workaround for @wdio/logger not offering any types. 2 | declare module '@wdio/logger'; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "outDir": "build/main", 5 | "rootDir": "src", 6 | "moduleResolution": "node", 7 | "module": "commonjs", 8 | "declaration": true, 9 | "inlineSourceMap": true, 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 11 | 12 | "strict": true /* Enable all strict type-checking options. */, 13 | 14 | /* Strict Type-Checking Options */ 15 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 16 | "strictNullChecks": true /* Enable strict null checks. */, 17 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 18 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 19 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 20 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 21 | 22 | /* Additional Checks */ 23 | "noUnusedLocals": true /* Report errors on unused locals. */, 24 | "noUnusedParameters": true /* Report errors on unused parameters. */, 25 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 26 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 27 | 28 | /* Debugging Options */ 29 | "traceResolution": false /* Report module resolution log messages. */, 30 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 31 | "listFiles": false /* Print names of files part of the compilation. */, 32 | "pretty": true /* Stylize errors and messages using color and context. */, 33 | 34 | /* Experimental Options */ 35 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 36 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 37 | 38 | "lib": ["es2020"], 39 | "types": ["node", "jest", "@wdio/sync"], 40 | "typeRoots": ["node_modules/@types", "src/types"] 41 | }, 42 | "include": ["src/**/*.ts"], 43 | "exclude": ["node_modules/**"], 44 | "compileOnSave": false 45 | } 46 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": [ 9 | "node_modules/**" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------