├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── functional-test.yml │ ├── pr-title.yml │ ├── publish.js.yml │ └── unit-test.yml ├── .gitignore ├── .mocharc.js ├── .npmrc ├── .releaserc ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── COMMANDS.md └── static │ └── send-sms-screen.png ├── eslint.config.mjs ├── index.ts ├── keys ├── testkey.pk8 └── testkey.x509.pem ├── lib ├── adb.ts ├── helpers.js ├── logcat.js ├── logger.js ├── options.ts ├── tools │ ├── aab-utils.js │ ├── android-manifest.js │ ├── apk-signing.js │ ├── apk-utils.js │ ├── apks-utils.js │ ├── app-commands.js │ ├── device-settings.js │ ├── emu-constants.ts │ ├── emulator-commands.js │ ├── fs-commands.js │ ├── general-commands.js │ ├── keyboard-commands.js │ ├── lockmgmt.js │ ├── logcat-commands.js │ ├── network-commands.js │ ├── system-calls.js │ └── types.ts └── types.ts ├── package.json ├── test ├── .eslintrc ├── fixtures │ ├── ApiDemos-debug.apk │ ├── ContactManager-old.apk │ ├── ContactManager-selendroid.apk │ ├── ContactManager.apk │ ├── Fingerprint.apk │ ├── TestZip.zip │ ├── appiumtest.keystore │ ├── selendroid-test-app.apk │ └── selendroid │ │ ├── AndroidManifest.xml │ │ └── selendroid.apk ├── functional │ ├── adb-commands-e2e-specs.js │ ├── adb-e2e-specs.js │ ├── adb-emu-commands-e2e-specs.js │ ├── android-manifest-e2e-specs.js │ ├── apk-signing-e2e-specs.js │ ├── apk-utils-e2e-specs.js │ ├── helpers-specs-e2e-specs.js │ ├── lock-mgmt-e2e-specs.js │ ├── logcat-e2e-specs.js │ ├── setup.js │ └── syscalls-e2e-specs.js └── unit │ ├── adb-commands-specs.js │ ├── adb-emu-commands-specs.js │ ├── adb-specs.js │ ├── apk-signing-specs.js │ ├── apk-utils-specs.js │ ├── helper-specs.js │ ├── logcat-specs.js │ └── syscalls-specs.js └── tsconfig.json /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Warning:** 2 | 3 | These issues are not tracked. Please create new issues in the main Appium 4 | repository: https://github.com/appium/appium/issues/new 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/functional-test.yml: -------------------------------------------------------------------------------- 1 | name: Functional Tests 2 | 3 | on: [pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | include: 14 | - platformVersion: "16.0" 15 | apiLevel: 36 16 | emuTag: google_apis 17 | arch: x86_64 18 | - platformVersion: "12.0" 19 | apiLevel: 32 20 | emuTag: google_apis 21 | arch: x86_64 22 | - platformVersion: "11.0" 23 | apiLevel: 30 24 | emuTag: google_apis 25 | arch: x86 26 | - platformVersion: "9.0" 27 | apiLevel: 28 28 | emuTag: default 29 | arch: x86 30 | - platformVersion: "7.1" 31 | apiLevel: 25 32 | emuTag: default 33 | arch: x86 34 | - platformVersion: "5.1" 35 | apiLevel: 22 36 | emuTag: default 37 | arch: x86 38 | fail-fast: false 39 | env: 40 | CI: true 41 | ANDROID_AVD: emulator 42 | ANDROID_SDK_VERSION: "${{ matrix.apiLevel }}" 43 | PLATFORM_VERSION: "${{ matrix.platformVersion }}" 44 | EMU_TAG: "${{ matrix.emuTag }}" 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Enable KVM group perms 49 | run: | 50 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 51 | sudo udevadm control --reload-rules 52 | sudo udevadm trigger --name-match=kvm 53 | - uses: actions/setup-node@v3 54 | with: 55 | node-version: lts/* 56 | check-latest: true 57 | - uses: actions/setup-java@v3 58 | with: 59 | distribution: 'temurin' 60 | java-version: '17' 61 | - name: Setup Android SDK 62 | uses: android-actions/setup-android@v3 63 | - run: npm install --no-package-lock 64 | name: Install dev dependencies 65 | - name: AVD cache 66 | uses: actions/cache@v3 67 | id: avd-cache 68 | with: 69 | path: | 70 | ~/.android/avd/* 71 | ~/.android/adb* 72 | key: avd-${{ matrix.apiLevel }} 73 | # https://github.com/marketplace/actions/android-emulator-runner 74 | - uses: reactivecircus/android-emulator-runner@v2 75 | if: steps.avd-cache.outputs.cache-hit != 'true' 76 | name: Generate AVD snapshot for caching 77 | with: 78 | script: echo "Generated AVD snapshot for caching." 79 | avd-name: $ANDROID_AVD 80 | force-avd-creation: false 81 | api-level: ${{ matrix.apiLevel }} 82 | disable-spellchecker: true 83 | target: ${{ matrix.emuTag }} 84 | emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim 85 | arch: ${{ matrix.arch }} 86 | disable-animations: true 87 | - run: nohup adb logcat > logcat.log & 88 | name: Capture Logcat 89 | - uses: reactivecircus/android-emulator-runner@v2 90 | name: api${{ matrix.apiLevel }}_e2e 91 | with: 92 | script: npm run e2e-test 93 | avd-name: $ANDROID_AVD 94 | force-avd-creation: false 95 | api-level: ${{ matrix.apiLevel }} 96 | disable-spellchecker: true 97 | target: ${{ matrix.emuTag }} 98 | emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim 99 | arch: ${{ matrix.arch }} 100 | disable-animations: true 101 | - name: Save logcat output 102 | if: ${{ always() }} 103 | uses: actions/upload-artifact@master 104 | with: 105 | name: logcat-api${{ matrix.apiLevel }} 106 | path: logcat.log 107 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commits 2 | on: 3 | pull_request: 4 | types: [opened, edited, synchronize, reopened] 5 | 6 | 7 | jobs: 8 | lint: 9 | name: https://www.conventionalcommits.org 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: beemojs/conventional-pr-action@v3 13 | with: 14 | config-preset: angular 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.js.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js LTS 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: lts/* 17 | check-latest: true 18 | - run: npm install --no-package-lock 19 | name: Install dev dependencies 20 | - run: npm run build 21 | name: Run build 22 | - run: npm run test 23 | name: Run unit tests 24 | - run: npx semantic-release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | name: Release 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [pull_request, push] 4 | 5 | 6 | jobs: 7 | prepare_matrix: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | versions: ${{ steps.generate-matrix.outputs.lts }} 11 | steps: 12 | - name: Select all current LTS versions of Node.js 13 | id: generate-matrix 14 | uses: msimerson/node-lts-versions@v1 15 | 16 | test: 17 | needs: 18 | - prepare_matrix 19 | strategy: 20 | matrix: 21 | node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm install --no-package-lock 29 | name: Install dev dependencies 30 | - run: npm run build 31 | name: Build 32 | - run: npm run lint 33 | name: Run linter 34 | - run: npm run test 35 | name: Run unit tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | lib-cov 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | 11 | pids 12 | logs 13 | results 14 | build 15 | 16 | npm-debug.log 17 | node_modules 18 | coverage 19 | coverage-e2e 20 | package-lock.json* 21 | .DS_Store 22 | bugreport-* 23 | .vscode 24 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | require: ['ts-node/register'], 3 | forbidOnly: Boolean(process.env.CI), 4 | color: true 5 | }; 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["@semantic-release/commit-analyzer", { 4 | "preset": "angular", 5 | "releaseRules": [ 6 | {"type": "chore", "release": "patch"} 7 | ] 8 | }], 9 | ["@semantic-release/release-notes-generator", { 10 | "preset": "conventionalcommits", 11 | "presetConfig": { 12 | "types": [ 13 | {"type": "feat", "section": "Features"}, 14 | {"type": "fix", "section": "Bug Fixes"}, 15 | {"type": "perf", "section": "Performance Improvements"}, 16 | {"type": "revert", "section": "Reverts"}, 17 | {"type": "chore", "section": "Miscellaneous Chores"}, 18 | {"type": "refactor", "section": "Code Refactoring"}, 19 | {"type": "docs", "section": "Documentation", "hidden": true}, 20 | {"type": "style", "section": "Styles", "hidden": true}, 21 | {"type": "test", "section": "Tests", "hidden": true}, 22 | {"type": "build", "section": "Build System", "hidden": true}, 23 | {"type": "ci", "section": "Continuous Integration", "hidden": true} 24 | ] 25 | } 26 | }], 27 | ["@semantic-release/changelog", { 28 | "changelogFile": "CHANGELOG.md" 29 | }], 30 | "@semantic-release/npm", 31 | ["@semantic-release/git", { 32 | "assets": ["docs", "package.json", "CHANGELOG.md"], 33 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 34 | }], 35 | "@semantic-release/github" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "dev", 7 | "problemMatcher": [], 8 | "label": "npm: dev", 9 | "detail": "npm run build -- --watch", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright JS Foundation and other contributors, https://js.foundation 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | appium-adb 2 | ========== 3 | 4 | [![NPM version](http://img.shields.io/npm/v/appium-adb.svg)](https://npmjs.org/package/appium-adb) 5 | [![Downloads](http://img.shields.io/npm/dm/appium-adb.svg)](https://npmjs.org/package/appium-adb) 6 | 7 | A wrapper over [Android Debugger Bridge](https://developer.android.com/tools/adb), implemented using ES6 8 | and along with `async/await`. This package is mainly used by Appium to perform all adb operations on Android devices. 9 | 10 | ## Installing 11 | 12 | ```bash 13 | npm install appium-adb 14 | ``` 15 | 16 | ## Watch 17 | 18 | ```bash 19 | npm run dev 20 | ``` 21 | 22 | ## Test 23 | 24 | ### unit tests 25 | 26 | ```bash 27 | npm run test 28 | ``` 29 | 30 | ### functional tests 31 | 32 | By default the functional tests use an avd named `NEXUS_S_18_X86`, with API Level 33 | 18. To change this, you can use the environment variables `PLATFORM_VERSION`, 34 | `API_LEVEL`, and `ANDROID_AVD`. If `PLATFORM_VERSION` is set then it is not 35 | necessary to set `API_LEVEL` as it will be inferred. 36 | 37 | ```bash 38 | npm run e2e-test 39 | ``` 40 | 41 | ## Usage: 42 | 43 | example: 44 | 45 | ```js 46 | import { ADB } from 'appium-adb'; 47 | 48 | const adb = await ADB.createADB(); 49 | console.log(await adb.getPIDsByName('com.android.phone')); 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/COMMANDS.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | # powerAC 4 | 5 | ```javascript 6 | let state = 'off'; 7 | await adb.powerAC(state); 8 | ``` 9 | Possible values: 10 | * on 11 | * off 12 | 13 | # powerCapacity 14 | ```javascript 15 | let batteryPercent = 50; 16 | await adb.powerCapacity(batteryPercent); 17 | ``` 18 | 19 | # powerOFF 20 | ```javascript 21 | await adb.powerOFF(); 22 | ``` 23 | 24 | # gsmCall 25 | ```javascript 26 | let action = 'call'; 27 | let phoneNumber = 4509; 28 | await adb.gsmCall(phoneNumber, action); 29 | ``` 30 | 31 | Possible values: 32 | * call 33 | * accept 34 | * hold 35 | * cancel 36 | 37 | # gsmSignal 38 | ```javascript 39 | let signalStrengh = 0; 40 | await adb.gsmSignal(signalStrengh); 41 | ``` 42 | Possible values: 0..4 43 | 44 | # gsmVoice 45 | ```javascript 46 | let state = 'roaming'; 47 | await adb.gsmVoice(state); 48 | ``` 49 | 50 | Possible values: 51 | 52 | * unregistered 53 | * home 54 | * roaming 55 | * searching 56 | * denied 57 | * off (unregistered alias) 58 | * on (home alias) 59 | 60 | # sendSMS 61 | 62 | ```javascript 63 | let phoneNumber = 4509; 64 | let message = "Hello Appium" 65 | await adb.sendSMS(phoneNumber, message); 66 | ``` 67 | 68 |
69 | 70 | 71 |
72 | 73 | # rotate 74 | 75 | ```javascript 76 | await adb.rotate(); 77 | ``` 78 | 79 | # root 80 | 81 | ```javascript 82 | await adb.root(); 83 | ``` 84 | 85 | # unroot 86 | 87 | ```javascript 88 | await adb.unroot(); 89 | ``` 90 | -------------------------------------------------------------------------------- /docs/static/send-sms-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/docs/static/send-sms-screen.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import appiumConfig from '@appium/eslint-config-appium-ts'; 2 | 3 | export default [ 4 | ...appiumConfig, 5 | { 6 | ignores: [ 7 | 'keys/**', 8 | ], 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {install} from 'source-map-support'; 2 | install(); 3 | 4 | export * from './lib/adb'; 5 | export {getAndroidBinaryPath} from './lib/tools/system-calls'; 6 | export {getSdkRootFromEnv} from './lib/helpers'; 7 | export type * from './lib/tools/types'; 8 | export type * from './lib/types'; 9 | 10 | import {ADB} from './lib/adb'; 11 | export default ADB; 12 | -------------------------------------------------------------------------------- /keys/testkey.pk8: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/keys/testkey.pk8 -------------------------------------------------------------------------------- /keys/testkey.x509.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEqDCCA5CgAwIBAgIJAJNurL4H8gHfMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYD 3 | VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4g 4 | VmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UE 5 | AxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAe 6 | Fw0wODAyMjkwMTMzNDZaFw0zNTA3MTcwMTMzNDZaMIGUMQswCQYDVQQGEwJVUzET 7 | MBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4G 8 | A1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9p 9 | ZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZI 10 | hvcNAQEBBQADggENADCCAQgCggEBANaTGQTexgskse3HYuDZ2CU+Ps1s6x3i/waM 11 | qOi8qM1r03hupwqnbOYOuw+ZNVn/2T53qUPn6D1LZLjk/qLT5lbx4meoG7+yMLV4 12 | wgRDvkxyGLhG9SEVhvA4oU6Jwr44f46+z4/Kw9oe4zDJ6pPQp8PcSvNQIg1QCAcy 13 | 4ICXF+5qBTNZ5qaU7Cyz8oSgpGbIepTYOzEJOmc3Li9kEsBubULxWBjf/gOBzAzU 14 | RNps3cO4JFgZSAGzJWQTT7/emMkod0jb9WdqVA2BVMi7yge54kdVMxHEa5r3b97s 15 | zI5p58ii0I54JiCUP5lyfTwE/nKZHZnfm644oLIXf6MdW2r+6R8CAQOjgfwwgfkw 16 | HQYDVR0OBBYEFEhZAFY9JyxGrhGGBaR0GawJyowRMIHJBgNVHSMEgcEwgb6AFEhZ 17 | AFY9JyxGrhGGBaR0GawJyowRoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UE 18 | CBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMH 19 | QW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAG 20 | CSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJAJNurL4H8gHfMAwGA1Ud 21 | EwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAHqvlozrUMRBBVEY0NqrrwFbinZa 22 | J6cVosK0TyIUFf/azgMJWr+kLfcHCHJsIGnlw27drgQAvilFLAhLwn62oX6snb4Y 23 | LCBOsVMR9FXYJLZW2+TcIkCRLXWG/oiVHQGo/rWuWkJgU134NDEFJCJGjDbiLCpe 24 | +ZTWHdcwauTJ9pUbo8EvHRkU3cYfGmLaLfgn9gP+pWA7LFQNvXwBnDa6sppCccEX 25 | 31I828XzgXpJ4O+mDL1/dBd+ek8ZPUP0IgdyZm5MTYPhvVqGCHzzTy3sIeJFymwr 26 | sBbmg2OAUNLEMO6nwmocSdN2ClirfxqCzJOLSDE4QyS9BAH6EhY6UFcOaE0= 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { system, fs, zip, util } from '@appium/support'; 3 | import { log } from './logger.js'; 4 | import _ from 'lodash'; 5 | import { exec } from 'teen_process'; 6 | 7 | export const APKS_EXTENSION = '.apks'; 8 | export const APK_EXTENSION = '.apk'; 9 | export const APK_INSTALL_TIMEOUT = 60000; 10 | export const DEFAULT_ADB_EXEC_TIMEOUT = 20000; // in milliseconds 11 | const MODULE_NAME = 'appium-adb'; 12 | 13 | /** 14 | * Calculates the absolute path to the current module's root folder 15 | * 16 | * @returns {Promise} The full path to module root 17 | * @throws {Error} If the current module root folder cannot be determined 18 | */ 19 | const getModuleRoot = _.memoize(async function getModuleRoot () { 20 | let moduleRoot = path.dirname(path.resolve(__filename)); 21 | let isAtFsRoot = false; 22 | while (!isAtFsRoot) { 23 | const manifestPath = path.join(moduleRoot, 'package.json'); 24 | try { 25 | if (await fs.exists(manifestPath) && 26 | JSON.parse(await fs.readFile(manifestPath, 'utf8')).name === MODULE_NAME) { 27 | return moduleRoot; 28 | } 29 | } catch {} 30 | moduleRoot = path.dirname(moduleRoot); 31 | isAtFsRoot = moduleRoot.length <= path.dirname(moduleRoot).length; 32 | } 33 | if (isAtFsRoot) { 34 | throw new Error(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`); 35 | } 36 | return moduleRoot; 37 | }); 38 | 39 | /** 40 | * Calculates the absolsute path to the given resource 41 | * 42 | * @param {string} relPath Relative path to the resource starting from the current module root 43 | * @returns {Promise} The full path to the resource 44 | * @throws {Error} If the absolute resource path cannot be determined 45 | */ 46 | export const getResourcePath = _.memoize(async function getResourcePath (relPath) { 47 | const moduleRoot = await getModuleRoot(); 48 | const resultPath = path.resolve(moduleRoot, relPath); 49 | if (!await fs.exists(resultPath)) { 50 | throw new Error(`Cannot find the resource '${relPath}' under the '${moduleRoot}' ` + 51 | `folder of ${MODULE_NAME} Node.js module`); 52 | } 53 | return resultPath; 54 | }); 55 | 56 | /** 57 | * Retrieves the actual path to SDK root folder from the system environment 58 | * 59 | * @return {string|undefined} The full path to the SDK root folder 60 | */ 61 | export function getSdkRootFromEnv () { 62 | return process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; 63 | } 64 | 65 | /** 66 | * Retrieves the actual path to SDK root folder 67 | * 68 | * @param {string?} [customRoot] 69 | * @return {Promise} The full path to the SDK root folder 70 | * @throws {Error} If either the corresponding env variable is unset or is 71 | * pointing to an invalid file system entry 72 | */ 73 | export async function requireSdkRoot (customRoot = null) { 74 | const sdkRoot = customRoot || getSdkRootFromEnv(); 75 | const docMsg = 'Read https://developer.android.com/studio/command-line/variables for more details'; 76 | if (_.isEmpty(sdkRoot)) { 77 | throw new Error(`Neither ANDROID_HOME nor ANDROID_SDK_ROOT environment variable was exported. ${docMsg}`); 78 | } 79 | 80 | if (!await fs.exists(/** @type {string} */ (sdkRoot))) { 81 | throw new Error(`The Android SDK root folder '${sdkRoot}' does not exist on the local file system. ${docMsg}`); 82 | } 83 | const stats = await fs.stat(/** @type {string} */ (sdkRoot)); 84 | if (!stats.isDirectory()) { 85 | throw new Error(`The Android SDK root '${sdkRoot}' must be a folder. ${docMsg}`); 86 | } 87 | return /** @type {string} */ (sdkRoot); 88 | } 89 | 90 | /** 91 | * @param {string} zipPath 92 | * @param {string} dstRoot 93 | */ 94 | export async function unzipFile (zipPath, dstRoot = path.dirname(zipPath)) { 95 | log.debug(`Unzipping '${zipPath}' to '${dstRoot}'`); 96 | await zip.assertValidZip(zipPath); 97 | await zip.extractAllTo(zipPath, dstRoot); 98 | log.debug('Unzip successful'); 99 | } 100 | 101 | /** @type {() => Promise} */ 102 | export const getJavaHome = _.memoize(async function getJavaHome () { 103 | const result = process.env.JAVA_HOME; 104 | if (!result) { 105 | throw new Error('The JAVA_HOME environment variable is not set for the current process'); 106 | } 107 | if (!await fs.exists(result)) { 108 | throw new Error(`The JAVA_HOME location '${result}' must exist`); 109 | } 110 | const stats = await fs.stat(result); 111 | if (!stats.isDirectory()) { 112 | throw new Error(`The JAVA_HOME location '${result}' must be a valid folder`); 113 | } 114 | return result; 115 | }); 116 | 117 | /** @type {() => Promise} */ 118 | export const getJavaForOs = _.memoize(async function getJavaForOs () { 119 | let javaHome; 120 | let errMsg; 121 | try { 122 | javaHome = await getJavaHome(); 123 | } catch (err) { 124 | errMsg = err.message; 125 | } 126 | const executableName = `java${system.isWindows() ? '.exe' : ''}`; 127 | if (javaHome) { 128 | const resultPath = path.resolve(javaHome, 'bin', executableName); 129 | if (await fs.exists(resultPath)) { 130 | return resultPath; 131 | } 132 | } 133 | try { 134 | return await fs.which(executableName); 135 | } catch {} 136 | throw new Error(`The '${executableName}' binary could not be found ` + 137 | `neither in PATH nor under JAVA_HOME (${javaHome ? path.resolve(javaHome, 'bin') : errMsg})`); 138 | }); 139 | 140 | /** 141 | * Transforms given options into the list of `adb install.install-multiple` command arguments 142 | * 143 | * @param {number} apiLevel - The current API level 144 | * @param {InstallOptions} [options={}] - The options mapping to transform 145 | * @returns {string[]} The array of arguments 146 | */ 147 | export function buildInstallArgs (apiLevel, options = {}) { 148 | const result = []; 149 | 150 | if (!util.hasValue(options.replace) || options.replace) { 151 | result.push('-r'); 152 | } 153 | if (options.allowTestPackages) { 154 | result.push('-t'); 155 | } 156 | if (options.useSdcard) { 157 | result.push('-s'); 158 | } 159 | if (options.grantPermissions) { 160 | if (apiLevel < 23) { 161 | log.debug(`Skipping permissions grant option, since ` + 162 | `the current API level ${apiLevel} does not support applications ` + 163 | `permissions customization`); 164 | } else { 165 | result.push('-g'); 166 | } 167 | } 168 | // For multiple-install 169 | if (options.partialInstall) { 170 | result.push('-p'); 171 | } 172 | 173 | return result; 174 | } 175 | 176 | /** 177 | * Extracts various package manifest details 178 | * from the given application file. 179 | * 180 | * @this {import('./adb.js').ADB} 181 | * @param {string} apkPath Full path to the application file. 182 | * @returns {Promise} 183 | */ 184 | export async function readPackageManifest(apkPath) { 185 | await this.initAapt2(); 186 | const aapt2Binary = (/** @type {import('./tools/types').StringRecord} */ (this.binaries)).aapt2; 187 | 188 | const args = ['dump', 'badging', apkPath]; 189 | log.debug(`Reading package manifest: '${util.quote([aapt2Binary, ...args])}'`); 190 | /** @type {string} */ 191 | let stdout; 192 | try { 193 | ({stdout} = await exec(aapt2Binary, args)); 194 | } catch (e) { 195 | const prefix = `Cannot read the manifest from '${apkPath}'`; 196 | const suffix = `Original error: ${e.stderr || e.message}`; 197 | if (_.includes(e.stderr, `Unable to open 'badging'`)) { 198 | throw new Error(`${prefix}. Update build tools to use a newer aapt2 version. ${suffix}`); 199 | } 200 | throw new Error(`${prefix}. ${suffix}`); 201 | } 202 | 203 | const extractValue = ( 204 | /** @type {string} */ line, 205 | /** @type {RegExp} */ propPattern, 206 | /** @type {((x: string) => any)|undefined} */ valueTransformer 207 | ) => { 208 | const match = propPattern.exec(line); 209 | if (match) { 210 | return valueTransformer ? valueTransformer(match[1]) : match[1]; 211 | } 212 | }; 213 | const extractArray = ( 214 | /** @type {string} */ line, 215 | /** @type {RegExp} */ propPattern, 216 | /** @type {((x: string) => any)|undefined} */ valueTransformer 217 | ) => { 218 | let match; 219 | const resultArray = []; 220 | while ((match = propPattern.exec(line))) { 221 | resultArray.push(valueTransformer ? valueTransformer(match[1]) : match[1]); 222 | } 223 | return resultArray; 224 | }; 225 | 226 | const toInt = (/** @type {string} */ x) => parseInt(x, 10); 227 | 228 | /** @type {import('./tools/types').ApkManifest} */ 229 | const result = { 230 | name: '', 231 | versionCode: 0, 232 | minSdkVersion: 0, 233 | compileSdkVersion: 0, 234 | usesPermissions: [], 235 | launchableActivity: { 236 | name: '', 237 | }, 238 | architectures: [], 239 | locales: [], 240 | densities: [], 241 | }; 242 | for (const line of stdout.split('\n')) { 243 | if (line.startsWith('package:')) { 244 | for (const [name, pattern, transformer] of [ 245 | ['name', /name='([^']+)'/], 246 | ['versionCode', /versionCode='([^']+)'/, toInt], 247 | ['versionName', /versionName='([^']+)'/], 248 | ['platformBuildVersionName', /platformBuildVersionName='([^']+)'/], 249 | ['platformBuildVersionCode', /platformBuildVersionCode='([^']+)'/, toInt], 250 | ['compileSdkVersion', /compileSdkVersion='([^']+)'/, toInt], 251 | ['compileSdkVersionCodename', /compileSdkVersionCodename='([^']+)'/], 252 | ]) { 253 | const value = extractValue( 254 | line, 255 | /** @type {RegExp} */ (pattern), 256 | /** @type {((x: string) => any)|undefined} */ (transformer) 257 | ); 258 | if (!_.isUndefined(value)) { 259 | result[/** @type {string} */ (name)] = value; 260 | } 261 | } 262 | } else if (line.startsWith('sdkVersion:') || line.startsWith('minSdkVersion:')) { 263 | const value = extractValue(line, /[sS]dkVersion:'([^']+)'/, toInt); 264 | if (value) { 265 | result.minSdkVersion = value; 266 | } 267 | } else if (line.startsWith('targetSdkVersion:')) { 268 | const value = extractValue(line, /targetSdkVersion:'([^']+)'/, toInt); 269 | if (value) { 270 | result.targetSdkVersion = value; 271 | } 272 | } else if (line.startsWith('uses-permission:')) { 273 | const value = extractValue(line, /name='([^']+)'/); 274 | if (value) { 275 | result.usesPermissions.push(/** @type {string} */ (value)); 276 | } 277 | } else if (line.startsWith('launchable-activity:')) { 278 | for (const [name, pattern] of [ 279 | ['name', /name='([^']+)'/], 280 | ['label', /label='([^']+)'/], 281 | ['icon', /icon='([^']+)'/], 282 | ]) { 283 | const value = extractValue(line, /** @type {RegExp} */ (pattern)); 284 | if (value) { 285 | result.launchableActivity[/** @type {string} */ (name)] = value; 286 | } 287 | } 288 | } else if (line.startsWith('locales:')) { 289 | result.locales = /** @type {string[]} */ (extractArray(line, /'([^']+)'/g)); 290 | } else if (line.startsWith('native-code:')) { 291 | result.architectures = /** @type {string[]} */ (extractArray(line, /'([^']+)'/g)); 292 | } else if (line.startsWith('densities:')) { 293 | result.densities = /** @type {number[]} */ (extractArray(line, /'([^']+)'/g, toInt)); 294 | } 295 | } 296 | return result; 297 | } 298 | 299 | /** 300 | * @typedef {Object} InstallOptions 301 | * @property {boolean} [allowTestPackages=false] - Set to true in order to allow test 302 | * packages installation. 303 | * @property {boolean} [useSdcard=false] - Set to true to install the app on sdcard 304 | * instead of the device memory. 305 | * @property {boolean} [grantPermissions=false] - Set to true in order to grant all the 306 | * permissions requested in the application's manifest 307 | * automatically after the installation is completed 308 | * under Android 6+. 309 | * @property {boolean} [replace=true] - Set it to false if you don't want 310 | * the application to be upgraded/reinstalled 311 | * if it is already present on the device. 312 | * @property {boolean} [partialInstall=false] - Install apks partially. It is used for 'install-multiple'. 313 | * https://android.stackexchange.com/questions/111064/what-is-a-partial-application-install-via-adb 314 | */ 315 | 316 | -------------------------------------------------------------------------------- /lib/logcat.js: -------------------------------------------------------------------------------- 1 | import { logger, util } from '@appium/support'; 2 | import B from 'bluebird'; 3 | import _ from 'lodash'; 4 | import { EventEmitter } from 'node:events'; 5 | import { SubProcess, exec } from 'teen_process'; 6 | import { LRUCache } from 'lru-cache'; 7 | 8 | const log = logger.getLogger('Logcat'); 9 | const MAX_BUFFER_SIZE = 10000; 10 | const LOGCAT_PROC_STARTUP_TIMEOUT = 10000; 11 | const SUPPORTED_FORMATS = ['brief', 'process', 'tag', 'thread', 'raw', 'time', 'threadtime', 'long']; 12 | const SUPPORTED_PRIORITIES = ['v', 'd', 'i', 'w', 'e', 'f', 's']; 13 | const DEFAULT_PRIORITY = 'v'; 14 | const DEFAULT_TAG = '*'; 15 | const DEFAULT_FORMAT = 'threadtime'; 16 | const TRACE_PATTERN = /W\/Trace/; 17 | const EXECVP_ERR_PATTERN = /execvp\(\)/; 18 | 19 | function requireFormat (format) { 20 | if (!SUPPORTED_FORMATS.includes(format)) { 21 | log.info(`The format value '${format}' is unknown. Supported values are: ${SUPPORTED_FORMATS}`); 22 | log.info(`Defaulting to '${DEFAULT_FORMAT}'`); 23 | return DEFAULT_FORMAT; 24 | } 25 | return format; 26 | } 27 | 28 | /** 29 | * 30 | * @param {string} message 31 | * @param {number} timestamp 32 | * @returns {import('./tools/types').LogEntry} 33 | */ 34 | function toLogEntry(message, timestamp) { 35 | return { 36 | timestamp, 37 | level: 'ALL', 38 | message, 39 | }; 40 | } 41 | 42 | /** 43 | * 44 | * @param {string} spec 45 | * @returns {string} 46 | */ 47 | function requireSpec (spec) { 48 | const [tag, priority] = spec.split(':'); 49 | let resultTag = tag; 50 | if (!resultTag) { 51 | log.info(`The tag value in spec '${spec}' cannot be empty`); 52 | log.info(`Defaulting to '${DEFAULT_TAG}'`); 53 | resultTag = DEFAULT_TAG; 54 | } 55 | if (!priority) { 56 | log.info(`The priority value in spec '${spec}' is empty. Defaulting to Verbose (${DEFAULT_PRIORITY})`); 57 | return `${resultTag}:${DEFAULT_PRIORITY}`; 58 | } 59 | if (!SUPPORTED_PRIORITIES.some((p) => _.toLower(priority) === _.toLower(p))) { 60 | log.info(`The priority value in spec '${spec}' is unknown. Supported values are: ${SUPPORTED_PRIORITIES}`); 61 | log.info(`Defaulting to Verbose (${DEFAULT_PRIORITY})`); 62 | return `${resultTag}:${DEFAULT_PRIORITY}`; 63 | } 64 | return spec; 65 | } 66 | 67 | /** 68 | * 69 | * @param {string|string[]} filterSpecs 70 | * @returns {string[]} 71 | */ 72 | function formatFilterSpecs (filterSpecs) { 73 | if (!_.isArray(filterSpecs)) { 74 | filterSpecs = [filterSpecs]; 75 | } 76 | return filterSpecs 77 | .filter((spec) => spec && _.isString(spec) && !spec.startsWith('-')) 78 | .map((spec) => spec.includes(':') ? requireSpec(spec) : spec); 79 | } 80 | 81 | 82 | export class Logcat extends EventEmitter { 83 | constructor (opts = {}) { 84 | super(); 85 | this.adb = opts.adb; 86 | this.clearLogs = opts.clearDeviceLogsOnStart || false; 87 | this.debug = opts.debug; 88 | this.debugTrace = opts.debugTrace; 89 | this.maxBufferSize = opts.maxBufferSize || MAX_BUFFER_SIZE; 90 | /** @type {LRUCache} */ 91 | this.logs = new LRUCache({ 92 | max: this.maxBufferSize, 93 | }); 94 | /** @type {number?} */ 95 | this.logIndexSinceLastRequest = null; 96 | } 97 | 98 | async startCapture (opts = {}) { 99 | let started = false; 100 | return await new B(async (_resolve, _reject) => { 101 | const resolve = function (...args) { 102 | started = true; 103 | _resolve(...args); 104 | }; 105 | const reject = function (...args) { 106 | started = true; 107 | _reject(...args); 108 | }; 109 | 110 | if (this.clearLogs) { 111 | await this.clear(); 112 | } 113 | 114 | const { 115 | format = DEFAULT_FORMAT, 116 | filterSpecs = [], 117 | } = opts; 118 | const cmd = [ 119 | ...this.adb.defaultArgs, 120 | 'logcat', 121 | '-v', requireFormat(format), 122 | ...formatFilterSpecs(filterSpecs), 123 | ]; 124 | log.debug(`Starting logs capture with command: ${util.quote([this.adb.path, ...cmd])}`); 125 | this.proc = new SubProcess(this.adb.path, cmd); 126 | this.proc.on('exit', (code, signal) => { 127 | log.error(`Logcat terminated with code ${code}, signal ${signal}`); 128 | this.proc = null; 129 | if (!started) { 130 | log.warn('Logcat not started. Continuing'); 131 | resolve(); 132 | } 133 | }); 134 | this.proc.on('line-stderr', (line) => { 135 | if (!started && EXECVP_ERR_PATTERN.test(line)) { 136 | log.error('Logcat process failed to start'); 137 | return reject(new Error(`Logcat process failed to start. stderr: ${line}`)); 138 | } 139 | this.outputHandler(line, 'STDERR: '); 140 | resolve(); 141 | }); 142 | this.proc.on('line-stdout', (line) => { 143 | this.outputHandler(line); 144 | resolve(); 145 | }); 146 | await this.proc.start(0); 147 | // resolve after a timeout, even if no output was recorded 148 | setTimeout(resolve, LOGCAT_PROC_STARTUP_TIMEOUT); 149 | }); 150 | } 151 | 152 | /** 153 | * 154 | * @param {string} logLine 155 | * @param {string} [prefix=''] 156 | * @returns {void} 157 | */ 158 | outputHandler (logLine, prefix = '') { 159 | const timestamp = Date.now(); 160 | let recentIndex = -1; 161 | for (const key of this.logs.keys()) { 162 | recentIndex = key; 163 | break; 164 | } 165 | this.logs.set(++recentIndex, [logLine, timestamp]); 166 | if (this.listenerCount('output')) { 167 | this.emit('output', toLogEntry(logLine, timestamp)); 168 | } 169 | if (this.debug && (this.debugTrace || !TRACE_PATTERN.test(logLine))) { 170 | log.debug(prefix + logLine); 171 | } 172 | } 173 | 174 | /** 175 | * 176 | * @returns {Promise} 177 | */ 178 | async stopCapture () { 179 | log.debug('Stopping logcat capture'); 180 | if (!this.proc?.isRunning) { 181 | log.debug('Logcat already stopped'); 182 | this.proc = null; 183 | return; 184 | } 185 | this.proc.removeAllListeners('exit'); 186 | await this.proc.stop(); 187 | this.proc = null; 188 | } 189 | 190 | /** 191 | * @returns {import('./tools/types').LogEntry[]} 192 | */ 193 | getLogs () { 194 | /** @type {import('./tools/types').LogEntry[]} */ 195 | const result = []; 196 | /** @type {number?} */ 197 | let recentLogIndex = null; 198 | for (const [index, [message, timestamp]] of /** @type {Generator} */ (this.logs.rentries())) { 199 | if (this.logIndexSinceLastRequest && index > this.logIndexSinceLastRequest 200 | || !this.logIndexSinceLastRequest) { 201 | recentLogIndex = index; 202 | result.push(toLogEntry(message, timestamp)); 203 | } 204 | } 205 | if (_.isInteger(recentLogIndex)) { 206 | this.logIndexSinceLastRequest = recentLogIndex; 207 | } 208 | return result; 209 | } 210 | 211 | /** 212 | * @returns {import('./tools/types').LogEntry[]} 213 | */ 214 | getAllLogs () { 215 | /** @type {import('./tools/types').LogEntry[]} */ 216 | const result = []; 217 | for (const [message, timestamp] of /** @type {Generator} */ (this.logs.rvalues())) { 218 | result.push(toLogEntry(message, timestamp)); 219 | } 220 | return result; 221 | } 222 | 223 | /** 224 | * @returns {Promise} 225 | */ 226 | async clear () { 227 | log.debug('Clearing logcat logs from device'); 228 | try { 229 | const args = [...this.adb.defaultArgs, 'logcat', '-c']; 230 | await exec(this.adb.path, args); 231 | } catch (err) { 232 | log.warn(`Failed to clear logcat logs: ${err.stderr || err.message}`); 233 | } 234 | } 235 | } 236 | 237 | export default Logcat; 238 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | import { logger } from '@appium/support'; 2 | 3 | export const log = logger.getLogger('ADB'); 4 | -------------------------------------------------------------------------------- /lib/options.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/lib/options.ts -------------------------------------------------------------------------------- /lib/tools/aab-utils.js: -------------------------------------------------------------------------------- 1 | import { log } from '../logger.js'; 2 | import path from 'path'; 3 | import { fs, tempDir, util } from '@appium/support'; 4 | import { LRUCache } from 'lru-cache'; 5 | import { unzipFile } from '../helpers.js'; 6 | import AsyncLock from 'async-lock'; 7 | import B from 'bluebird'; 8 | import crypto from 'crypto'; 9 | 10 | /** @type {LRUCache} */ 11 | const AAB_CACHE = new LRUCache({ 12 | max: 10, 13 | dispose: (extractedFilesRoot) => fs.rimraf(/** @type {string} */ (extractedFilesRoot)), 14 | }); 15 | const AAB_CACHE_GUARD = new AsyncLock(); 16 | const UNIVERSAL_APK = 'universal.apk'; 17 | 18 | process.on('exit', () => { 19 | if (!AAB_CACHE.size) { 20 | return; 21 | } 22 | 23 | const paths = /** @type {string[]} */ ([...AAB_CACHE.values()]); 24 | log.debug(`Performing cleanup of ${paths.length} cached .aab ` + 25 | util.pluralize('package', paths.length)); 26 | for (const appPath of paths) { 27 | try { 28 | // Asynchronous calls are not supported in onExit handler 29 | fs.rimrafSync(appPath); 30 | } catch (e) { 31 | log.warn((/** @type {Error} */ (e)).message); 32 | } 33 | } 34 | }); 35 | 36 | 37 | /** 38 | * Builds a universal .apk from the given .aab package. See 39 | * https://developer.android.com/studio/command-line/bundletool#generate_apks 40 | * for more details. 41 | * 42 | * @this {import('../adb.js').ADB} 43 | * @param {string} aabPath Full path to the source .aab package 44 | * @param {import('./types').ApkCreationOptions} [opts={}] 45 | * @returns The path to the resulting universal .apk. The .apk is stored in the internal cache 46 | * by default. 47 | * @throws {Error} If there was an error while creating the universal .apk 48 | */ 49 | export async function extractUniversalApk (aabPath, opts = {}) { 50 | if (!await fs.exists(aabPath)) { 51 | throw new Error(`The file at '${aabPath}' either does not exist or is not accessible`); 52 | } 53 | 54 | const aabName = path.basename(aabPath); 55 | const apkName = aabName.substring(0, aabName.length - path.extname(aabName).length) + '.apk'; 56 | const tmpRoot = await tempDir.openDir(); 57 | const tmpApksPath = path.join(tmpRoot, `${aabName}.apks`); 58 | try { 59 | return await AAB_CACHE_GUARD.acquire(aabPath, async () => { 60 | const aabHash = await fs.hash(aabPath); 61 | const { 62 | keystore, 63 | keystorePassword, 64 | keyAlias, 65 | keyPassword, 66 | } = opts; 67 | let cacheHash = aabHash; 68 | if (keystore) { 69 | if (!await fs.exists(keystore)) { 70 | throw new Error(`The keystore file at '${keystore}' either does not exist ` + 71 | `or is not accessible`); 72 | } 73 | if (!keystorePassword || !keyAlias || !keyPassword) { 74 | throw new Error('It is mandatory to also provide keystore password, key alias, ' + 75 | 'and key password if the keystore path is set'); 76 | } 77 | const keystoreHash = await fs.hash(keystore); 78 | const keyAliasHash = crypto.createHash('sha1'); 79 | keyAliasHash.update(keyAlias); 80 | cacheHash = [cacheHash, keystoreHash, keyAliasHash.digest('hex')].join(':'); 81 | } 82 | log.debug(`Calculated the cache key for '${aabPath}': ${cacheHash}`); 83 | if (AAB_CACHE.has(cacheHash)) { 84 | const resultPath = path.resolve(/** @type {string} */ (AAB_CACHE.get(cacheHash)), apkName); 85 | if (await fs.exists(resultPath)) { 86 | return resultPath; 87 | } 88 | AAB_CACHE.delete(cacheHash); 89 | } 90 | 91 | await this.initAapt2(); 92 | const args = [ 93 | 'build-apks', 94 | '--aapt2', (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2, 95 | '--bundle', aabPath, 96 | '--output', tmpApksPath, 97 | ...(keystore ? [ 98 | '--ks', keystore, 99 | '--ks-pass', `pass:${keystorePassword}`, 100 | '--ks-key-alias', keyAlias, 101 | '--key-pass', `pass:${keyPassword}`, 102 | ] : []), 103 | '--mode=universal' 104 | ]; 105 | log.debug(`Preparing universal .apks bundle from '${aabPath}'`); 106 | await this.execBundletool(args, `Cannot build a universal .apks bundle from '${aabPath}'`); 107 | 108 | log.debug(`Unpacking universal application bundle at '${tmpApksPath}' to '${tmpRoot}'`); 109 | await unzipFile(tmpApksPath, tmpRoot); 110 | let universalApkPath; 111 | const fileDeletionPromises = []; 112 | const allFileNames = await fs.readdir(tmpRoot); 113 | for (const fileName of allFileNames) { 114 | const fullPath = path.join(tmpRoot, fileName); 115 | if (fileName === UNIVERSAL_APK) { 116 | universalApkPath = fullPath; 117 | } else { 118 | fileDeletionPromises.push(fs.rimraf(fullPath)); 119 | } 120 | } 121 | try { 122 | await B.all(fileDeletionPromises); 123 | } catch {} 124 | if (!universalApkPath) { 125 | log.debug(`The following items were extracted from the .aab bundle: ${allFileNames}`); 126 | throw new Error(`${UNIVERSAL_APK} cannot be found in '${aabPath}' bundle. ` + 127 | `Does the archive contain a valid application bundle?`); 128 | } 129 | const resultPath = path.join(tmpRoot, apkName); 130 | log.debug(`Found ${UNIVERSAL_APK} at '${universalApkPath}'. Caching it to '${resultPath}'`); 131 | await fs.mv(universalApkPath, resultPath); 132 | AAB_CACHE.set(cacheHash, tmpRoot); 133 | return resultPath; 134 | }); 135 | } catch (e) { 136 | await fs.rimraf(tmpRoot); 137 | throw e; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /lib/tools/android-manifest.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { exec } from 'teen_process'; 3 | import { log } from '../logger.js'; 4 | import { 5 | unzipFile, 6 | APKS_EXTENSION, 7 | readPackageManifest, 8 | } from '../helpers.js'; 9 | import { fs, zip, tempDir, util } from '@appium/support'; 10 | import path from 'path'; 11 | 12 | /** 13 | * Extract package and main activity name from application manifest. 14 | * 15 | * @this {import('../adb.js').ADB} 16 | * @param {string} appPath - The full path to application .apk(s) package 17 | * @return {Promise} The parsed application info. 18 | * @throws {error} If there was an error while getting the data from the given 19 | * application package. 20 | */ 21 | export async function packageAndLaunchActivityFromManifest (appPath) { 22 | if (appPath.endsWith(APKS_EXTENSION)) { 23 | appPath = await this.extractBaseApk(appPath); 24 | } 25 | 26 | const { 27 | name: apkPackage, 28 | launchableActivity: { 29 | name: apkActivity, 30 | }, 31 | } = await readPackageManifest.bind(this)(appPath); 32 | log.info(`Package name: '${apkPackage}'`); 33 | log.info(`Main activity name: '${apkActivity}'`); 34 | return {apkPackage, apkActivity}; 35 | } 36 | 37 | /** 38 | * Extract target SDK version from application manifest. 39 | * 40 | * @this {import('../adb.js').ADB} 41 | * @param {string} appPath - The full path to .apk(s) package. 42 | * @return {Promise} The version of the target SDK. 43 | * @throws {error} If there was an error while getting the data from the given 44 | * application package. 45 | */ 46 | export async function targetSdkVersionFromManifest (appPath) { 47 | log.debug(`Extracting target SDK version of '${appPath}'`); 48 | const originalAppPath = appPath; 49 | if (appPath.endsWith(APKS_EXTENSION)) { 50 | appPath = await this.extractBaseApk(appPath); 51 | } 52 | 53 | const {targetSdkVersion} = await readPackageManifest.bind(this)(appPath); 54 | if (!targetSdkVersion) { 55 | throw new Error( 56 | `Cannot extract targetSdkVersion of '${originalAppPath}'. Does ` + 57 | `the package manifest define it?` 58 | ); 59 | } 60 | return targetSdkVersion; 61 | } 62 | 63 | /** 64 | * Extract target SDK version from package information. 65 | * 66 | * @this {import('../adb.js').ADB} 67 | * @param {string} pkg - The class name of the package installed on the device under test. 68 | * @param {string?} [cmdOutput=null] - Optional parameter containing the output of 69 | * _dumpsys package_ command. It may speed up the method execution. 70 | * @return {Promise} The version of the target SDK. 71 | */ 72 | export async function targetSdkVersionUsingPKG (pkg, cmdOutput = null) { 73 | const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]); 74 | const targetSdkVersionMatch = new RegExp(/targetSdk=([^\s\s]+)/g).exec(stdout); 75 | return targetSdkVersionMatch && targetSdkVersionMatch.length >= 2 76 | ? parseInt(targetSdkVersionMatch[1], 10) 77 | : 0; 78 | } 79 | 80 | /** 81 | * Create binary representation of package manifest (usually AndroidManifest.xml). 82 | * `${manifest}.apk` file will be created as the result of this method 83 | * containing the compiled manifest. 84 | * 85 | * @this {import('../adb.js').ADB} 86 | * @param {string} manifest - Full path to the initial manifest template 87 | * @param {string} manifestPackage - The name of the manifest package 88 | * @param {string} targetPackage - The name of the destination package 89 | */ 90 | export async function compileManifest (manifest, manifestPackage, targetPackage) { 91 | const {platform, platformPath} = await getAndroidPlatformAndPath(/** @type {string} */ (this.sdkRoot)); 92 | if (!platform || !platformPath) { 93 | throw new Error('Cannot compile the manifest. The required platform does not exist (API level >= 17)'); 94 | } 95 | const resultPath = `${manifest}.apk`; 96 | const androidJarPath = path.resolve(platformPath, 'android.jar'); 97 | if (await fs.exists(resultPath)) { 98 | await fs.rimraf(resultPath); 99 | } 100 | try { 101 | await this.initAapt2(); 102 | // https://developer.android.com/studio/command-line/aapt2 103 | const args = [ 104 | 'link', 105 | '-o', resultPath, 106 | '--manifest', manifest, 107 | '--rename-manifest-package', manifestPackage, 108 | '--rename-instrumentation-target-package', targetPackage, 109 | '-I', androidJarPath, 110 | '-v', 111 | ]; 112 | log.debug(`Compiling the manifest using '${util.quote([ 113 | (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2, 114 | ...args 115 | ])}'`); 116 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2, args); 117 | } catch (e) { 118 | log.debug('Cannot compile the manifest using aapt2. Defaulting to aapt. ' + 119 | `Original error: ${e.stderr || e.message}`); 120 | await this.initAapt(); 121 | const args = [ 122 | 'package', 123 | '-M', manifest, 124 | '--rename-manifest-package', manifestPackage, 125 | '--rename-instrumentation-target-package', targetPackage, 126 | '-I', androidJarPath, 127 | '-F', resultPath, 128 | '-f', 129 | ]; 130 | log.debug(`Compiling the manifest using '${util.quote([ 131 | (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, 132 | ...args 133 | ])}'`); 134 | try { 135 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, args); 136 | } catch (e1) { 137 | throw new Error(`Cannot compile the manifest. Original error: ${e1.stderr || e1.message}`); 138 | } 139 | } 140 | log.debug(`Compiled the manifest at '${resultPath}'`); 141 | } 142 | 143 | /** 144 | * Replace/insert the specially precompiled manifest file into the 145 | * particular package. 146 | * 147 | * @this {import('../adb.js').ADB} 148 | * @param {string} manifest - Full path to the precompiled manifest 149 | * created by `compileManifest` method call 150 | * without .apk extension 151 | * @param {string} srcApk - Full path to the existing valid application package, where 152 | * this manifest has to be insetred to. This package 153 | * will NOT be modified. 154 | * @param {string} dstApk - Full path to the resulting package. 155 | * The file will be overridden if it already exists. 156 | */ 157 | export async function insertManifest (manifest, srcApk, dstApk) { 158 | log.debug(`Inserting manifest '${manifest}', src: '${srcApk}', dst: '${dstApk}'`); 159 | await zip.assertValidZip(srcApk); 160 | await unzipFile(`${manifest}.apk`); 161 | const manifestName = path.basename(manifest); 162 | try { 163 | await this.initAapt(); 164 | await fs.copyFile(srcApk, dstApk); 165 | log.debug('Moving manifest'); 166 | try { 167 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, [ 168 | 'remove', dstApk, manifestName 169 | ]); 170 | } catch {} 171 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, [ 172 | 'add', dstApk, manifestName 173 | ], {cwd: path.dirname(manifest)}); 174 | } catch (e) { 175 | log.debug('Cannot insert manifest using aapt. Defaulting to zip. ' + 176 | `Original error: ${e.stderr || e.message}`); 177 | const tmpRoot = await tempDir.openDir(); 178 | try { 179 | // Unfortunately NodeJS does not provide any reliable methods 180 | // to replace files inside zip archives without loading the 181 | // whole archive content into RAM 182 | log.debug(`Extracting the source apk at '${srcApk}'`); 183 | await zip.extractAllTo(srcApk, tmpRoot); 184 | log.debug('Moving manifest'); 185 | await fs.mv(manifest, path.resolve(tmpRoot, manifestName)); 186 | log.debug(`Collecting the destination apk at '${dstApk}'`); 187 | await zip.toArchive(dstApk, { 188 | cwd: tmpRoot, 189 | }); 190 | } finally { 191 | await fs.rimraf(tmpRoot); 192 | } 193 | } 194 | log.debug(`Manifest insertion into '${dstApk}' is completed`); 195 | } 196 | 197 | /** 198 | * Check whether package manifest contains Internet permissions. 199 | * 200 | * @this {import('../adb.js').ADB} 201 | * @param {string} appPath - The full path to .apk(s) package. 202 | * @return {Promise} True if the manifest requires Internet access permission. 203 | */ 204 | export async function hasInternetPermissionFromManifest (appPath) { 205 | log.debug(`Checking if '${appPath}' requires internet access permission in the manifest`); 206 | if (appPath.endsWith(APKS_EXTENSION)) { 207 | appPath = await this.extractBaseApk(appPath); 208 | } 209 | 210 | const {usesPermissions} = await readPackageManifest.bind(this)(appPath); 211 | return usesPermissions.some((/** @type {string} */ name) => name === 'android.permission.INTERNET'); 212 | } 213 | 214 | // #region Private functions 215 | 216 | /** 217 | * Retrieve the path to the recent installed Android platform. 218 | * 219 | * @param {string} sdkRoot 220 | * @return {Promise} The resulting path to the newest installed platform. 221 | */ 222 | export async function getAndroidPlatformAndPath (sdkRoot) { 223 | const propsPaths = await fs.glob('*/build.prop', { 224 | cwd: path.resolve(sdkRoot, 'platforms'), 225 | absolute: true, 226 | }); 227 | /** @type {Record} */ 228 | const platformsMapping = {}; 229 | for (const propsPath of propsPaths) { 230 | const propsContent = await fs.readFile(propsPath, 'utf-8'); 231 | const platformPath = path.dirname(propsPath); 232 | const platform = path.basename(platformPath); 233 | const match = /ro\.build\.version\.sdk=(\d+)/.exec(propsContent); 234 | if (!match) { 235 | log.warn(`Cannot read the SDK version from '${propsPath}'. Skipping '${platform}'`); 236 | continue; 237 | } 238 | platformsMapping[parseInt(match[1], 10)] = { 239 | platform, 240 | platformPath, 241 | }; 242 | } 243 | if (_.isEmpty(platformsMapping)) { 244 | log.warn(`Found zero platform folders at '${path.resolve(sdkRoot, 'platforms')}'. ` + 245 | `Do you have any Android SDKs installed?`); 246 | return { 247 | platform: null, 248 | platformPath: null, 249 | }; 250 | } 251 | 252 | const recentSdkVersion = _.keys(platformsMapping).sort().reverse()[0]; 253 | const result = platformsMapping[recentSdkVersion]; 254 | log.debug(`Found the most recent Android platform: ${JSON.stringify(result)}`); 255 | return result; 256 | } 257 | 258 | // #endregion 259 | -------------------------------------------------------------------------------- /lib/tools/apk-signing.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import _fs from 'fs'; 3 | import { exec } from 'teen_process'; 4 | import path from 'path'; 5 | import { log } from '../logger.js'; 6 | import { tempDir, system, mkdirp, fs, util, zip } from '@appium/support'; 7 | import { LRUCache } from 'lru-cache'; 8 | import { 9 | getJavaForOs, 10 | getJavaHome, 11 | APKS_EXTENSION, 12 | getResourcePath, 13 | } from '../helpers.js'; 14 | 15 | const DEFAULT_PRIVATE_KEY = path.join('keys', 'testkey.pk8'); 16 | const DEFAULT_CERTIFICATE = path.join('keys', 'testkey.x509.pem'); 17 | const BUNDLETOOL_TUTORIAL = 'https://developer.android.com/studio/command-line/bundletool'; 18 | const APKSIGNER_VERIFY_FAIL = 'DOES NOT VERIFY'; 19 | const SHA1 = 'sha1'; 20 | const SHA256 = 'sha256'; 21 | const SHA512 = 'sha512'; 22 | const MD5 = 'md5'; 23 | const DEFAULT_CERT_HASH = { 24 | [SHA256]: 'a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc' 25 | }; 26 | const JAVA_PROPS_INIT_ERROR = 'java.lang.Error: Properties init'; 27 | /** @type {LRUCache} */ 28 | const SIGNED_APPS_CACHE = new LRUCache({ 29 | max: 30, 30 | }); 31 | 32 | /** 33 | * Execute apksigner utility with given arguments. 34 | * 35 | * @this {import('../adb.js').ADB} 36 | * @param {string[]} args - The list of tool arguments. 37 | * @return {Promise} - Command stdout 38 | * @throws {Error} If apksigner binary is not present on the local file system 39 | * or the return code is not equal to zero. 40 | */ 41 | export async function executeApksigner (args) { 42 | const apkSignerJar = await getApksignerForOs.bind(this)(); 43 | const fullCmd = [ 44 | await getJavaForOs(), '-Xmx1024M', '-Xss1m', 45 | '-jar', apkSignerJar, 46 | ...args 47 | ]; 48 | log.debug(`Starting apksigner: ${util.quote(fullCmd)}`); 49 | // It is necessary to specify CWD explicitly; see https://github.com/appium/appium/issues/14724#issuecomment-737446715 50 | const {stdout, stderr} = await exec(fullCmd[0], fullCmd.slice(1), { 51 | cwd: path.dirname(apkSignerJar), 52 | // @ts-ignore This works 53 | windowsVerbatimArguments: system.isWindows(), 54 | }); 55 | for (let [name, stream] of [['stdout', stdout], ['stderr', stderr]]) { 56 | if (!_.trim(stream)) { 57 | continue; 58 | } 59 | 60 | if (name === 'stdout') { 61 | // Make the output less talkative 62 | stream = stream.split('\n') 63 | .filter((line) => !line.includes('WARNING:')) 64 | .join('\n'); 65 | } 66 | log.debug(`apksigner ${name}: ${stream}`); 67 | } 68 | return stdout; 69 | } 70 | 71 | /** 72 | * (Re)sign the given apk file on the local file system with the default certificate. 73 | * 74 | * @this {import('../adb.js').ADB} 75 | * @param {string} apk - The full path to the local apk file. 76 | * @throws {Error} If signing fails. 77 | */ 78 | export async function signWithDefaultCert (apk) { 79 | log.debug(`Signing '${apk}' with default cert`); 80 | if (!(await fs.exists(apk))) { 81 | throw new Error(`${apk} file doesn't exist.`); 82 | } 83 | 84 | const args = [ 85 | 'sign', 86 | '--key', await getResourcePath(DEFAULT_PRIVATE_KEY), 87 | '--cert', await getResourcePath(DEFAULT_CERTIFICATE), 88 | apk, 89 | ]; 90 | try { 91 | await this.executeApksigner(args); 92 | } catch (e) { 93 | throw new Error(`Could not sign '${apk}' with the default certificate. ` + 94 | `Original error: ${e.stderr || e.stdout || e.message}`); 95 | } 96 | } 97 | 98 | /** 99 | * (Re)sign the given apk file on the local file system with a custom certificate. 100 | * 101 | * @this {import('../adb.js').ADB} 102 | * @param {string} apk - The full path to the local apk file. 103 | * @throws {Error} If signing fails. 104 | */ 105 | export async function signWithCustomCert (apk) { 106 | log.debug(`Signing '${apk}' with custom cert`); 107 | if (!(await fs.exists(/** @type {string} */(this.keystorePath)))) { 108 | throw new Error(`Keystore: ${this.keystorePath} doesn't exist.`); 109 | } 110 | if (!(await fs.exists(apk))) { 111 | throw new Error(`'${apk}' doesn't exist.`); 112 | } 113 | 114 | try { 115 | await this.executeApksigner(['sign', 116 | '--ks', /** @type {string} */(this.keystorePath), 117 | '--ks-key-alias', /** @type {string} */(this.keyAlias), 118 | '--ks-pass', `pass:${this.keystorePassword}`, 119 | '--key-pass', `pass:${this.keyPassword}`, 120 | apk]); 121 | } catch (err) { 122 | log.warn(`Cannot use apksigner tool for signing. Defaulting to jarsigner. ` + 123 | `Original error: ${err.stderr || err.stdout || err.message}`); 124 | try { 125 | if (await unsignApk(apk)) { 126 | log.debug(`'${apk}' has been successfully unsigned`); 127 | } else { 128 | log.debug(`'${apk}' does not need to be unsigned`); 129 | } 130 | const jarsigner = path.resolve(await getJavaHome(), 'bin', 131 | `jarsigner${system.isWindows() ? '.exe' : ''}`); 132 | /** @type {string[]} */ 133 | const fullCmd = [jarsigner, 134 | '-sigalg', 'MD5withRSA', 135 | '-digestalg', 'SHA1', 136 | '-keystore', /** @type {string} */(this.keystorePath), 137 | '-storepass', /** @type {string} */(this.keystorePassword), 138 | '-keypass', /** @type {string} */(this.keyPassword), 139 | apk, /** @type {string} */(this.keyAlias)]; 140 | log.debug(`Starting jarsigner: ${util.quote(fullCmd)}`); 141 | await exec(fullCmd[0], fullCmd.slice(1), { 142 | // @ts-ignore This works 143 | windowsVerbatimArguments: system.isWindows(), 144 | }); 145 | } catch (e) { 146 | throw new Error(`Could not sign with custom certificate. ` + 147 | `Original error: ${e.stderr || e.message}`); 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * (Re)sign the given apk file on the local file system with either 154 | * custom or default certificate based on _this.useKeystore_ property value 155 | * and Zip-aligns it after signing. 156 | * 157 | * @this {import('../adb.js').ADB} 158 | * @param {string} appPath - The full path to the local .apk(s) file. 159 | * @throws {Error} If signing fails. 160 | */ 161 | export async function sign (appPath) { 162 | if (appPath.endsWith(APKS_EXTENSION)) { 163 | let message = 'Signing of .apks-files is not supported. '; 164 | if (this.useKeystore) { 165 | message += 'Consider manual application bundle signing with the custom keystore ' + 166 | `like it is described at ${BUNDLETOOL_TUTORIAL}`; 167 | } else { 168 | message += `Consider manual application bundle signing with the key at '${DEFAULT_PRIVATE_KEY}' ` + 169 | `and the certificate at '${DEFAULT_CERTIFICATE}'. Read ${BUNDLETOOL_TUTORIAL} for more details.`; 170 | } 171 | log.warn(message); 172 | return; 173 | } 174 | 175 | // it is necessary to apply zipalign only before signing 176 | // if apksigner is used 177 | await this.zipAlignApk(appPath); 178 | 179 | if (this.useKeystore) { 180 | await this.signWithCustomCert(appPath); 181 | } else { 182 | await this.signWithDefaultCert(appPath); 183 | } 184 | } 185 | 186 | /** 187 | * Perform zip-aligning to the given local apk file. 188 | * 189 | * @this {import('../adb.js').ADB} 190 | * @param {string} apk - The full path to the local apk file. 191 | * @returns {Promise} True if the apk has been successfully aligned 192 | * or false if the apk has been already aligned. 193 | * @throws {Error} If zip-align fails. 194 | */ 195 | export async function zipAlignApk (apk) { 196 | await this.initZipAlign(); 197 | try { 198 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).zipalign, ['-c', '4', apk]); 199 | log.debug(`${apk}' is already zip-aligned. Doing nothing`); 200 | return false; 201 | } catch { 202 | log.debug(`'${apk}' is not zip-aligned. Aligning`); 203 | } 204 | try { 205 | await fs.access(apk, _fs.constants.W_OK); 206 | } catch { 207 | throw new Error(`The file at '${apk}' is not writeable. ` + 208 | `Please grant write permissions to this file or to its parent folder '${path.dirname(apk)}' ` + 209 | `for the Appium process, so it can zip-align the file`); 210 | } 211 | const alignedApk = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); 212 | await mkdirp(path.dirname(alignedApk)); 213 | try { 214 | await exec( 215 | (/** @type {import('./types').StringRecord} */ (this.binaries)).zipalign, 216 | ['-f', '4', apk, alignedApk] 217 | ); 218 | await fs.mv(alignedApk, apk, { mkdirp: true }); 219 | return true; 220 | } catch (e) { 221 | if (await fs.exists(alignedApk)) { 222 | await fs.unlink(alignedApk); 223 | } 224 | throw new Error(`zipAlignApk failed. Original error: ${e.stderr || e.message}`); 225 | } 226 | } 227 | 228 | /** 229 | * Check if the app is already signed with the default Appium certificate. 230 | * 231 | * @this {import('../adb.js').ADB} 232 | * @param {string} appPath - The full path to the local .apk(s) file. 233 | * @param {string} pkg - The name of application package. 234 | * @param {import('./types').CertCheckOptions} [opts={}] - Certificate checking options 235 | * @return {Promise} True if given application is already signed. 236 | */ 237 | export async function checkApkCert (appPath, pkg, opts = {}) { 238 | log.debug(`Checking app cert for ${appPath}`); 239 | if (!await fs.exists(appPath)) { 240 | log.debug(`'${appPath}' does not exist`); 241 | return false; 242 | } 243 | 244 | if (path.extname(appPath) === APKS_EXTENSION) { 245 | appPath = await this.extractBaseApk(appPath); 246 | } 247 | 248 | const hashMatches = (apksignerOutput, expectedHashes) => { 249 | for (const [name, value] of _.toPairs(expectedHashes)) { 250 | if (new RegExp(`digest:\\s+${value}\\b`, 'i').test(apksignerOutput)) { 251 | log.debug(`${name} hash did match for '${path.basename(appPath)}'`); 252 | return true; 253 | } 254 | } 255 | return false; 256 | }; 257 | 258 | const { 259 | requireDefaultCert = true, 260 | } = opts; 261 | 262 | const appHash = await fs.hash(appPath); 263 | if (SIGNED_APPS_CACHE.has(appHash)) { 264 | log.debug(`Using the previously cached signature entry for '${path.basename(appPath)}'`); 265 | const {keystorePath, output, expected} = /** @type {import('./types').SignedAppCacheValue} */ ( 266 | SIGNED_APPS_CACHE.get(appHash) 267 | ); 268 | if (this.useKeystore && this.keystorePath === keystorePath || !this.useKeystore) { 269 | return (!this.useKeystore && !requireDefaultCert) || hashMatches(output, expected); 270 | } 271 | } 272 | 273 | const expected = this.useKeystore ? await this.getKeystoreHash() : DEFAULT_CERT_HASH; 274 | try { 275 | await getApksignerForOs.bind(this)(); 276 | const output = await this.executeApksigner(['verify', '--print-certs', appPath]); 277 | const hasMatch = hashMatches(output, expected); 278 | if (hasMatch) { 279 | log.info(`'${appPath}' is signed with the ` + 280 | `${this.useKeystore ? 'keystore' : 'default'} certificate`); 281 | } else { 282 | log.info(`'${appPath}' is signed with a ` + 283 | `non-${this.useKeystore ? 'keystore' : 'default'} certificate`); 284 | } 285 | const isSigned = (!this.useKeystore && !requireDefaultCert) || hasMatch; 286 | if (isSigned) { 287 | SIGNED_APPS_CACHE.set(appHash, { 288 | output, 289 | expected, 290 | keystorePath: /** @type {string} */ (this.keystorePath), 291 | }); 292 | } 293 | return isSigned; 294 | } catch (err) { 295 | // check if there is no signature 296 | if (_.includes(err.stderr, APKSIGNER_VERIFY_FAIL)) { 297 | log.info(`'${appPath}' is not signed`); 298 | return false; 299 | } 300 | const errMsg = err.stderr || err.stdout || err.message; 301 | if (_.includes(errMsg, JAVA_PROPS_INIT_ERROR)) { 302 | // This error pops up randomly and we are not quite sure why. 303 | // My guess - a race condition in java vm initialization. 304 | // Nevertheless, lets make Appium to believe the file is already signed, 305 | // because it would be true for 99% of UIAutomator2-based 306 | // tests, where we presign server binaries while publishing their NPM module. 307 | // If these are not signed, e.g. in case of Espresso, then the next step(s) 308 | // would anyway fail. 309 | // See https://github.com/appium/appium/issues/14724 for more details. 310 | log.warn(errMsg); 311 | log.warn(`Assuming '${appPath}' is already signed and continuing anyway`); 312 | return true; 313 | } 314 | throw new Error(`Cannot verify the signature of '${appPath}'. ` + 315 | `Original error: ${errMsg}`); 316 | } 317 | } 318 | 319 | /** 320 | * Retrieve the the hash of the given keystore. 321 | * 322 | * @this {import('../adb.js').ADB} 323 | * @return {Promise} 324 | * @throws {Error} If getting keystore hash fails. 325 | */ 326 | export async function getKeystoreHash () { 327 | log.debug(`Getting hash of the '${this.keystorePath}' keystore`); 328 | const keytool = path.resolve(await getJavaHome(), 'bin', 329 | `keytool${system.isWindows() ? '.exe' : ''}`); 330 | if (!await fs.exists(keytool)) { 331 | throw new Error(`The keytool utility cannot be found at '${keytool}'`); 332 | } 333 | /** @type {string[]} */ 334 | const args = [ 335 | '-v', '-list', 336 | '-alias', /** @type {string} */ (this.keyAlias), 337 | '-keystore', /** @type {string} */ (this.keystorePath), 338 | '-storepass', /** @type {string} */ (this.keystorePassword), 339 | ]; 340 | log.info(`Running '${keytool}' with arguments: ${util.quote(args)}`); 341 | try { 342 | const {stdout} = await exec(keytool, args, { 343 | // @ts-ignore This property is ok 344 | windowsVerbatimArguments: system.isWindows(), 345 | }); 346 | const result = {}; 347 | for (const hashName of [SHA512, SHA256, SHA1, MD5]) { 348 | const hashRe = new RegExp(`^\\s*${hashName}:\\s*([a-f0-9:]+)`, 'mi'); 349 | const match = hashRe.exec(stdout); 350 | if (!match) { 351 | continue; 352 | } 353 | result[hashName] = match[1].replace(/:/g, '').toLowerCase(); 354 | } 355 | if (_.isEmpty(result)) { 356 | log.debug(stdout); 357 | throw new Error('Cannot parse the hash value from the keytool output'); 358 | } 359 | log.debug(`Keystore hash: ${JSON.stringify(result)}`); 360 | return result; 361 | } catch (e) { 362 | throw new Error(`Cannot get the hash of '${this.keystorePath}' keystore. ` + 363 | `Original error: ${e.stderr || e.message}`); 364 | } 365 | } 366 | 367 | // #region Private functions 368 | 369 | /** 370 | * Get the absolute path to apksigner tool 371 | * 372 | * @this {import('../adb').ADB} 373 | * @returns {Promise} An absolute path to apksigner tool. 374 | * @throws {Error} If the tool is not present on the local file system. 375 | */ 376 | export async function getApksignerForOs () { 377 | return await this.getBinaryFromSdkRoot('apksigner.jar'); 378 | } 379 | 380 | /** 381 | * Unsigns the given apk by removing the 382 | * META-INF folder recursively from the archive. 383 | * !!! The function overwrites the given apk after successful unsigning !!! 384 | * 385 | * @param {string} apkPath The path to the apk 386 | * @returns {Promise} `true` if the apk has been successfully 387 | * unsigned and overwritten 388 | * @throws {Error} if there was an error during the unsign operation 389 | */ 390 | export async function unsignApk (apkPath) { 391 | const tmpRoot = await tempDir.openDir(); 392 | const metaInfFolderName = 'META-INF'; 393 | try { 394 | let hasMetaInf = false; 395 | await zip.readEntries(apkPath, ({entry}) => { 396 | hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`); 397 | // entries iteration stops after `false` is returned 398 | return !hasMetaInf; 399 | }); 400 | if (!hasMetaInf) { 401 | return false; 402 | } 403 | const tmpZipRoot = path.resolve(tmpRoot, 'apk'); 404 | await zip.extractAllTo(apkPath, tmpZipRoot); 405 | await fs.rimraf(path.resolve(tmpZipRoot, metaInfFolderName)); 406 | const tmpResultPath = path.resolve(tmpRoot, path.basename(apkPath)); 407 | await zip.toArchive(tmpResultPath, { 408 | cwd: tmpZipRoot, 409 | }); 410 | await fs.unlink(apkPath); 411 | await fs.mv(tmpResultPath, apkPath); 412 | return true; 413 | } finally { 414 | await fs.rimraf(tmpRoot); 415 | } 416 | } 417 | 418 | // #endregion 419 | -------------------------------------------------------------------------------- /lib/tools/apks-utils.js: -------------------------------------------------------------------------------- 1 | import { exec } from 'teen_process'; 2 | import { log } from '../logger.js'; 3 | import path from 'path'; 4 | import _ from 'lodash'; 5 | import { fs, tempDir, util } from '@appium/support'; 6 | import { LRUCache } from 'lru-cache'; 7 | import { 8 | getJavaForOs, unzipFile, buildInstallArgs, APK_INSTALL_TIMEOUT 9 | } from '../helpers.js'; 10 | import AsyncLock from 'async-lock'; 11 | import B from 'bluebird'; 12 | 13 | const BASE_APK = 'base-master.apk'; 14 | const LANGUAGE_APK = (lang) => `base-${lang}.apk`; 15 | /** @type {LRUCache} */ 16 | const APKS_CACHE = new LRUCache({ 17 | max: 10, 18 | dispose: (extractedFilesRoot) => fs.rimraf(/** @type {string} */(extractedFilesRoot)), 19 | }); 20 | const APKS_CACHE_GUARD = new AsyncLock(); 21 | const BUNDLETOOL_TIMEOUT_MS = 4 * 60 * 1000; 22 | const APKS_INSTALL_TIMEOUT = APK_INSTALL_TIMEOUT * 2; 23 | 24 | process.on('exit', () => { 25 | if (!APKS_CACHE.size) { 26 | return; 27 | } 28 | 29 | const paths = /** @type {string[]} */ ([...APKS_CACHE.values()]); 30 | log.debug(`Performing cleanup of ${paths.length} cached .apks ` + 31 | util.pluralize('package', paths.length)); 32 | for (const appPath of paths) { 33 | try { 34 | // Asynchronous calls are not supported in onExit handler 35 | fs.rimrafSync(appPath); 36 | } catch (e) { 37 | log.warn(e.message); 38 | } 39 | } 40 | }); 41 | 42 | /** 43 | * Extracts the particular apks package into a temporary folder, 44 | * finds and returns the full path to the file contained in this apk. 45 | * The resulting temporary path, where the .apks file has been extracted, 46 | * will be stored into the internal LRU cache for better performance. 47 | * 48 | * @param {string} apks - The full path to the .apks file 49 | * @param {string|string[]} dstPath - The relative path to the destination file, 50 | * which is going to be extracted, where each path component is an array item 51 | * @returns {Promise} Full path to the extracted file 52 | * @throws {Error} If the requested item does not exist in the extracted archive or the provides 53 | * apks file is not a valid bundle 54 | */ 55 | async function extractFromApks (apks, dstPath) { 56 | if (!_.isArray(dstPath)) { 57 | dstPath = [dstPath]; 58 | } 59 | 60 | return await APKS_CACHE_GUARD.acquire(apks, async () => { 61 | // It might be that the original file has been replaced, 62 | // so we need to keep the hash sums instead of the actual file paths 63 | // as caching keys 64 | const apksHash = await fs.hash(apks); 65 | log.debug(`Calculated '${apks}' hash: ${apksHash}`); 66 | 67 | if (APKS_CACHE.has(apksHash)) { 68 | const resultPath = path.resolve(/** @type {string} */(APKS_CACHE.get(apksHash)), ...dstPath); 69 | if (await fs.exists(resultPath)) { 70 | return resultPath; 71 | } 72 | APKS_CACHE.delete(apksHash); 73 | } 74 | 75 | const tmpRoot = await tempDir.openDir(); 76 | log.debug(`Unpacking application bundle at '${apks}' to '${tmpRoot}'`); 77 | await unzipFile(apks, tmpRoot); 78 | const resultPath = path.resolve(tmpRoot, ...(_.isArray(dstPath) ? dstPath : [dstPath])); 79 | if (!await fs.exists(resultPath)) { 80 | throw new Error( 81 | `${_.isArray(dstPath) ? dstPath.join(path.sep) : dstPath} cannot be found in '${apks}' bundle. ` + 82 | `Does the archive contain a valid application bundle?` 83 | ); 84 | } 85 | APKS_CACHE.set(apksHash, tmpRoot); 86 | return resultPath; 87 | }); 88 | } 89 | 90 | /** 91 | * Executes bundletool utility with given arguments and returns the actual stdout 92 | * 93 | * @this {import('../adb.js').ADB} 94 | * @param {Array} args - the list of bundletool arguments 95 | * @param {string} errorMsg - The customized error message string 96 | * @returns {Promise} the actual command stdout 97 | * @throws {Error} If bundletool jar does not exist in PATH or there was an error while 98 | * executing it 99 | */ 100 | export async function execBundletool (args, errorMsg) { 101 | await this.initBundletool(); 102 | args = [ 103 | '-jar', (/** @type {import('./types').StringRecord} */ (this.binaries)).bundletool, 104 | ...args 105 | ]; 106 | const env = process.env; 107 | if (this.adbPort) { 108 | env.ANDROID_ADB_SERVER_PORT = `${this.adbPort}`; 109 | } 110 | if (this.adbHost) { 111 | env.ANDROID_ADB_SERVER_HOST = this.adbHost; 112 | } 113 | log.debug(`Executing bundletool with arguments: ${JSON.stringify(args)}`); 114 | let stdout; 115 | try { 116 | ({stdout} = await exec(await getJavaForOs(), args, { 117 | env, 118 | timeout: BUNDLETOOL_TIMEOUT_MS, 119 | })); 120 | log.debug(`Command stdout: ${_.truncate(stdout, {length: 300})}`); 121 | return stdout; 122 | } catch (e) { 123 | if (e.stdout) { 124 | log.debug(`Command stdout: ${e.stdout}`); 125 | } 126 | if (e.stderr) { 127 | log.debug(`Command stderr: ${e.stderr}`); 128 | } 129 | throw new Error(`${errorMsg}. Original error: ${e.message}`); 130 | } 131 | } 132 | 133 | /** 134 | * 135 | * @this {import('../adb.js').ADB} 136 | * @param {string} specLocation - The full path to the generated device spec location 137 | * @returns {Promise} The same `specLocation` value 138 | * @throws {Error} If it is not possible to retrieve the spec for the current device 139 | */ 140 | export async function getDeviceSpec (specLocation) { 141 | /** @type {string[]} */ 142 | const args = [ 143 | 'get-device-spec', 144 | '--adb', this.executable.path, 145 | '--device-id', /** @type {string} */ (this.curDeviceId), 146 | '--output', specLocation, 147 | ]; 148 | log.debug(`Getting the spec for the device '${this.curDeviceId}'`); 149 | await this.execBundletool(args, 'Cannot retrieve the device spec'); 150 | return specLocation; 151 | } 152 | 153 | /** 154 | * Installs the given apks into the device under test 155 | * 156 | * @this {import('../adb.js').ADB} 157 | * @param {Array} apkPathsToInstall - The full paths to install apks 158 | * @param {import('./types').InstallMultipleApksOptions} [options={}] - Installation options 159 | */ 160 | export async function installMultipleApks (apkPathsToInstall, options = {}) { 161 | const installArgs = buildInstallArgs(await this.getApiLevel(), options); 162 | return await this.adbExec(['install-multiple', ...installArgs, ...apkPathsToInstall], { 163 | // @ts-ignore This validation works 164 | timeout: isNaN(options.timeout) ? undefined : options.timeout, 165 | timeoutCapName: options.timeoutCapName, 166 | }); 167 | } 168 | 169 | /** 170 | * Installs the given .apks package into the device under test 171 | * 172 | * @this {import('../adb.js').ADB} 173 | * @param {string} apks - The full path to the .apks file 174 | * @param {import('./types').InstallApksOptions} [options={}] - Installation options 175 | * @throws {Error} If the .apks bundle cannot be installed 176 | */ 177 | export async function installApks (apks, options = {}) { 178 | const { 179 | grantPermissions, 180 | allowTestPackages, 181 | timeout, 182 | } = options; 183 | 184 | /** @type {string[]} */ 185 | const args = [ 186 | 'install-apks', 187 | '--adb', this.executable.path, 188 | '--apks', apks, 189 | '--timeout-millis', `${timeout || APKS_INSTALL_TIMEOUT}`, 190 | '--device-id', /** @type {string} */ (this.curDeviceId), 191 | ]; 192 | if (allowTestPackages) { 193 | args.push('--allow-test-only'); 194 | } 195 | /** @type {Promise[]} */ 196 | const tasks = [ 197 | this.execBundletool(args, `Cannot install '${path.basename(apks)}' to the device ${this.curDeviceId}`) 198 | ]; 199 | if (grantPermissions) { 200 | tasks.push(this.getApkInfo(apks)); 201 | } 202 | const [, apkInfo] = await B.all(tasks); 203 | if (grantPermissions && apkInfo) { 204 | // TODO: Simplify it after https://github.com/google/bundletool/issues/246 is implemented 205 | await this.grantAllPermissions(apkInfo.name); 206 | } 207 | } 208 | 209 | /** 210 | * Extracts and returns the full path to the master .apk file inside the bundle. 211 | * 212 | * @this {import('../adb.js').ADB} 213 | * @param {string} apks - The full path to the .apks file 214 | * @returns {Promise} The full path to the master bundle .apk 215 | * @throws {Error} If there was an error while extracting/finding the file 216 | */ 217 | export async function extractBaseApk (apks) { 218 | return await extractFromApks(apks, ['splits', BASE_APK]); 219 | } 220 | 221 | /** 222 | * Extracts and returns the full path to the .apk, which contains the corresponding 223 | * resources for the given language in the .apks bundle. 224 | * 225 | * @this {import('../adb.js').ADB} 226 | * @param {string} apks - The full path to the .apks file 227 | * @param {?string} [language=null] - The language abbreviation. The default language is 228 | * going to be selected if it is not set. 229 | * @returns {Promise} The full path to the corresponding language .apk or the master .apk 230 | * if language split is not enabled for the bundle. 231 | * @throws {Error} If there was an error while extracting/finding the file 232 | */ 233 | export async function extractLanguageApk (apks, language = null) { 234 | if (language) { 235 | try { 236 | return await extractFromApks(apks, ['splits', LANGUAGE_APK(language)]); 237 | } catch (e) { 238 | log.debug(e.message); 239 | log.info(`Assuming that splitting by language is not enabled for the '${apks}' bundle ` + 240 | `and returning the main apk instead`); 241 | return await this.extractBaseApk(apks); 242 | } 243 | } 244 | 245 | const defaultLanguages = ['en', 'en_us']; 246 | for (const lang of defaultLanguages) { 247 | try { 248 | return await extractFromApks(apks, ['splits', LANGUAGE_APK(lang)]); 249 | } catch {} 250 | } 251 | 252 | log.info(`Cannot find any split apk for the default languages ${JSON.stringify(defaultLanguages)}. ` + 253 | `Returning the main apk instead.`); 254 | return await this.extractBaseApk(apks); 255 | } 256 | 257 | /** 258 | * 259 | * @param {string} output 260 | * @returns {boolean} 261 | */ 262 | export function isTestPackageOnlyError (output) { 263 | return /\[INSTALL_FAILED_TEST_ONLY\]/.test(output); 264 | } 265 | -------------------------------------------------------------------------------- /lib/tools/emu-constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const POWER_AC_STATES = { 3 | POWER_AC_ON: 'on', 4 | POWER_AC_OFF: 'off' 5 | } as const; 6 | export const GSM_CALL_ACTIONS = { 7 | GSM_CALL: 'call', 8 | GSM_ACCEPT: 'accept', 9 | GSM_CANCEL: 'cancel', 10 | GSM_HOLD: 'hold' 11 | } as const; 12 | export const GSM_VOICE_STATES = { 13 | GSM_VOICE_UNREGISTERED: 'unregistered', 14 | GSM_VOICE_HOME: 'home', 15 | GSM_VOICE_ROAMING: 'roaming', 16 | GSM_VOICE_SEARCHING: 'searching', 17 | GSM_VOICE_DENIED: 'denied', 18 | GSM_VOICE_OFF: 'off', 19 | GSM_VOICE_ON: 'on' 20 | } as const; 21 | export const GSM_SIGNAL_STRENGTHS = [0, 1, 2, 3, 4] as const; 22 | export const NETWORK_SPEED = { 23 | GSM: 'gsm', // GSM/CSD (up: 14.4, down: 14.4). 24 | SCSD: 'scsd', // HSCSD (up: 14.4, down: 57.6). 25 | GPRS: 'gprs', // GPRS (up: 28.8, down: 57.6). 26 | EDGE: 'edge', // EDGE/EGPRS (up: 473.6, down: 473.6). 27 | UMTS: 'umts', // UMTS/3G (up: 384.0, down: 384.0). 28 | HSDPA: 'hsdpa', // HSDPA (up: 5760.0, down: 13,980.0). 29 | LTE: 'lte', // LTE (up: 58,000, down: 173,000). 30 | EVDO: 'evdo', // EVDO (up: 75,000, down: 280,000). 31 | FULL: 'full' // No limit, the default (up: 0.0, down: 0.0). 32 | } as const; 33 | export const SENSORS = { 34 | ACCELERATION: 'acceleration', 35 | GYROSCOPE: 'gyroscope', 36 | MAGNETIC_FIELD: 'magnetic-field', 37 | ORIENTATION: 'orientation', 38 | TEMPERATURE: 'temperature', 39 | PROXIMITY: 'proximity', 40 | LIGHT: 'light', 41 | PRESSURE: 'pressure', 42 | HUMIDITY: 'humidity', 43 | MAGNETIC_FIELD_UNCALIBRATED: 'magnetic-field-uncalibrated', 44 | GYROSCOPE_UNCALIBRATED: 'gyroscope-uncalibrated', 45 | HINGE_ANGLE0: 'hinge-angle0', 46 | HINGE_ANGLE1: 'hinge-angle1', 47 | HINGE_ANGLE2: 'hinge-angle2', 48 | HEART_RATE: 'heart-rate', 49 | RGBC_LIGHT: 'rgbc-light', 50 | } as const; 51 | -------------------------------------------------------------------------------- /lib/tools/fs-commands.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import path from 'path'; 3 | 4 | /** 5 | * Verify whether a remote path exists on the device under test. 6 | * 7 | * @this {import('../adb.js').ADB} 8 | * @param {string} remotePath - The remote path to verify. 9 | * @return {Promise} True if the given path exists on the device. 10 | */ 11 | export async function fileExists (remotePath) { 12 | const passFlag = '__PASS__'; 13 | const checkCmd = `[ -e '${remotePath.replace(/'/g, `\\'`)}' ] && echo ${passFlag}`; 14 | try { 15 | return _.includes(await this.shell([checkCmd]), passFlag); 16 | } catch { 17 | return false; 18 | } 19 | } 20 | 21 | /** 22 | * Get the output of _ls_ command on the device under test. 23 | * 24 | * @this {import('../adb.js').ADB} 25 | * @param {string} remotePath - The remote path (the first argument to the _ls_ command). 26 | * @param {string[]} [opts] - Additional _ls_ options. 27 | * @return {Promise} The _ls_ output as an array of split lines. 28 | * An empty array is returned of the given _remotePath_ 29 | * does not exist. 30 | */ 31 | export async function ls (remotePath, opts = []) { 32 | try { 33 | let args = ['ls', ...opts, remotePath]; 34 | let stdout = await this.shell(args); 35 | let lines = stdout.split('\n'); 36 | return lines.map((l) => l.trim()) 37 | .filter(Boolean) 38 | .filter((l) => l.indexOf('No such file') === -1); 39 | } catch (err) { 40 | if (err.message.indexOf('No such file or directory') === -1) { 41 | throw err; 42 | } 43 | return []; 44 | } 45 | } 46 | 47 | /** 48 | * Get the size of the particular file located on the device under test. 49 | * 50 | * @this {import('../adb.js').ADB} 51 | * @param {string} remotePath - The remote path to the file. 52 | * @return {Promise} File size in bytes. 53 | * @throws {Error} If there was an error while getting the size of the given file. 54 | */ 55 | export async function fileSize (remotePath) { 56 | try { 57 | const files = await this.ls(remotePath, ['-la']); 58 | if (files.length !== 1) { 59 | throw new Error(`Remote path is not a file`); 60 | } 61 | // https://regex101.com/r/fOs4P4/8 62 | const match = /[rwxsStT\-+]{10}[\s\d]*\s[^\s]+\s+[^\s]+\s+(\d+)/.exec(files[0]); 63 | if (!match || _.isNaN(parseInt(match[1], 10))) { 64 | throw new Error(`Unable to parse size from list output: '${files[0]}'`); 65 | } 66 | return parseInt(match[1], 10); 67 | } catch (err) { 68 | throw new Error(`Unable to get file size for '${remotePath}': ${err.message}`); 69 | } 70 | } 71 | 72 | /** 73 | * Forcefully recursively remove a path on the device under test. 74 | * Be careful while calling this method. 75 | * 76 | * @this {import('../adb.js').ADB} 77 | * @param {string} path - The path to be removed recursively. 78 | */ 79 | export async function rimraf (path) { 80 | await this.shell(['rm', '-rf', path]); 81 | } 82 | 83 | /** 84 | * Send a file to the device under test. 85 | * 86 | * @this {import('../adb.js').ADB} 87 | * @param {string} localPath - The path to the file on the local file system. 88 | * @param {string} remotePath - The destination path on the remote device. 89 | * @param {object} [opts] - Additional options mapping. See 90 | * https://github.com/appium/node-teen_process, 91 | * _exec_ method options, for more information about available 92 | * options. 93 | */ 94 | export async function push (localPath, remotePath, opts) { 95 | await this.mkdir(path.posix.dirname(remotePath)); 96 | await this.adbExec(['push', localPath, remotePath], opts); 97 | } 98 | 99 | /** 100 | * Receive a file from the device under test. 101 | * 102 | * @this {import('../adb.js').ADB} 103 | * @param {string} remotePath - The source path on the remote device. 104 | * @param {string} localPath - The destination path to the file on the local file system. 105 | * @param {import('teen_process').TeenProcessExecOptions} [opts={}] - Additional options mapping. See 106 | * https://github.com/appium/node-teen_process, 107 | * _exec_ method options, for more information about available 108 | * options. 109 | */ 110 | export async function pull (remotePath, localPath, opts = {}) { 111 | // pull folder can take more time, increasing time out to 60 secs 112 | await this.adbExec(['pull', remotePath, localPath], {...opts, timeout: opts.timeout ?? 60000}); 113 | } 114 | 115 | /** 116 | * Recursively create a new folder on the device under test. 117 | * 118 | * @this {import('../adb.js').ADB} 119 | * @param {string} remotePath - The new path to be created. 120 | * @return {Promise} mkdir command output. 121 | */ 122 | export async function mkdir (remotePath) { 123 | return await this.shell(['mkdir', '-p', remotePath]); 124 | } 125 | -------------------------------------------------------------------------------- /lib/tools/general-commands.js: -------------------------------------------------------------------------------- 1 | import { log } from '../logger.js'; 2 | import _ from 'lodash'; 3 | import { fs, util } from '@appium/support'; 4 | import { SubProcess, exec } from 'teen_process'; 5 | 6 | 7 | /** 8 | * Get the path to adb executable amd assign it 9 | * to this.executable.path and this.binaries.adb properties. 10 | * 11 | * @this {import('../adb.js').ADB} 12 | * @return {Promise} ADB instance. 13 | */ 14 | export async function getAdbWithCorrectAdbPath () { 15 | this.executable.path = await this.getSdkBinaryPath('adb'); 16 | return this; 17 | } 18 | 19 | /** 20 | * Get the full path to aapt tool and assign it to 21 | * this.binaries.aapt property 22 | * @this {import('../adb.js').ADB} 23 | */ 24 | export async function initAapt () { 25 | await this.getSdkBinaryPath('aapt'); 26 | } 27 | 28 | /** 29 | * Get the full path to aapt2 tool and assign it to 30 | * this.binaries.aapt2 property 31 | * @this {import('../adb.js').ADB} 32 | */ 33 | export async function initAapt2 () { 34 | await this.getSdkBinaryPath('aapt2'); 35 | } 36 | 37 | /** 38 | * Get the full path to zipalign tool and assign it to 39 | * this.binaries.zipalign property 40 | * @this {import('../adb.js').ADB} 41 | */ 42 | export async function initZipAlign () { 43 | await this.getSdkBinaryPath('zipalign'); 44 | } 45 | 46 | /** 47 | * Get the full path to bundletool binary and assign it to 48 | * this.binaries.bundletool property 49 | * @this {import('../adb.js').ADB} 50 | */ 51 | export async function initBundletool () { 52 | try { 53 | (/** @type {import('./types').StringRecord} */(this.binaries)).bundletool = 54 | await fs.which('bundletool.jar'); 55 | } catch { 56 | throw new Error('bundletool.jar binary is expected to be present in PATH. ' + 57 | 'Visit https://github.com/google/bundletool for more details.'); 58 | } 59 | } 60 | 61 | /** 62 | * Retrieve the API level of the device under test. 63 | * 64 | * @this {import('../adb.js').ADB} 65 | * @return {Promise} The API level as integer number, for example 21 for 66 | * Android Lollipop. The result of this method is cached, so all the further 67 | * calls return the same value as the first one. 68 | */ 69 | export async function getApiLevel () { 70 | if (!_.isInteger(this._apiLevel)) { 71 | try { 72 | const strOutput = await this.getDeviceProperty('ro.build.version.sdk'); 73 | let apiLevel = parseInt(strOutput.trim(), 10); 74 | 75 | // Workaround for preview/beta platform API level 76 | const charCodeQ = 'q'.charCodeAt(0); 77 | // 28 is the first API Level, where Android SDK started returning letters in response to getPlatformVersion 78 | const apiLevelDiff = apiLevel - 28; 79 | const codename = String.fromCharCode(charCodeQ + apiLevelDiff); 80 | if (apiLevelDiff >= 0 && (await this.getPlatformVersion()).toLowerCase() === codename) { 81 | log.debug(`Release version is ${codename.toUpperCase()} but found API Level ${apiLevel}. Setting API Level to ${apiLevel + 1}`); 82 | apiLevel++; 83 | } 84 | 85 | this._apiLevel = apiLevel; 86 | log.debug(`Device API level: ${this._apiLevel}`); 87 | if (isNaN(this._apiLevel)) { 88 | throw new Error(`The actual output '${strOutput}' cannot be converted to an integer`); 89 | } 90 | } catch (e) { 91 | throw new Error( 92 | `Error getting device API level. Original error: ${(/** @type {Error} */(e)).message}` 93 | ); 94 | } 95 | } 96 | return /** @type {number} */(this._apiLevel); 97 | } 98 | 99 | /** 100 | * Verify whether a device is connected. 101 | * 102 | * @this {import('../adb.js').ADB} 103 | * @return {Promise} True if at least one device is visible to adb. 104 | */ 105 | export async function isDeviceConnected () { 106 | let devices = await this.getConnectedDevices(); 107 | return devices.length > 0; 108 | } 109 | 110 | /** 111 | * Clear the active text field on the device under test by sending 112 | * special keyevents to it. 113 | * 114 | * @this {import('../adb.js').ADB} 115 | * @param {number} [length=100] - The maximum length of the text in the field to be cleared. 116 | */ 117 | export async function clearTextField (length = 100) { 118 | // assumes that the EditText field already has focus 119 | log.debug(`Clearing up to ${length} characters`); 120 | if (length === 0) { 121 | return; 122 | } 123 | let args = ['input', 'keyevent']; 124 | for (let i = 0; i < length; i++) { 125 | // we cannot know where the cursor is in the text field, so delete both before 126 | // and after so that we get rid of everything 127 | // https://developer.android.com/reference/android/view/KeyEvent.html#KEYCODE_DEL 128 | // https://developer.android.com/reference/android/view/KeyEvent.html#KEYCODE_FORWARD_DEL 129 | args.push('67', '112'); 130 | } 131 | await this.shell(args); 132 | } 133 | 134 | /** 135 | * Send the special keycode to the device under test in order to emulate 136 | * Back button tap. 137 | * @this {import('../adb.js').ADB} 138 | */ 139 | export async function back () { 140 | log.debug('Pressing the BACK button'); 141 | await this.keyevent(4); 142 | } 143 | 144 | /** 145 | * Send the special keycode to the device under test in order to emulate 146 | * Home button tap. 147 | * @this {import('../adb.js').ADB} 148 | */ 149 | export async function goToHome () { 150 | log.debug('Pressing the HOME button'); 151 | await this.keyevent(3); 152 | } 153 | 154 | /** 155 | * @this {import('../adb.js').ADB} 156 | * @return {string} the actual path to adb executable. 157 | */ 158 | export function getAdbPath () { 159 | return this.executable.path; 160 | } 161 | 162 | /** 163 | * Restart the device under test using adb commands. 164 | * 165 | * @this {import('../adb.js').ADB} 166 | * @throws {Error} If start fails. 167 | */ 168 | export async function restart () { 169 | try { 170 | await this.stopLogcat(); 171 | await this.restartAdb(); 172 | await this.waitForDevice(60); 173 | await this.startLogcat(this._logcatStartupParams); 174 | } catch (e) { 175 | const err = /** @type {Error} */ (e); 176 | throw new Error(`Restart failed. Original error: ${err.message}`); 177 | } 178 | } 179 | 180 | /** 181 | * Retrieve the `adb bugreport` command output. This 182 | * operation may take up to several minutes. 183 | * 184 | * @this {import('../adb.js').ADB} 185 | * @param {number} [timeout=120000] - Command timeout in milliseconds 186 | * @returns {Promise} Command stdout 187 | */ 188 | export async function bugreport (timeout = 120000) { 189 | return await this.adbExec(['bugreport'], {timeout}); 190 | } 191 | 192 | /** 193 | * Initiate screenrecord utility on the device 194 | * 195 | * @this {import('../adb.js').ADB} 196 | * @param {string} destination - Full path to the writable media file destination 197 | * on the device file system. 198 | * @param {import('./types').ScreenrecordOptions} [options={}] 199 | * @returns {SubProcess} screenrecord process, which can be then controlled by the client code 200 | */ 201 | export function screenrecord (destination, options = {}) { 202 | const cmd = ['screenrecord']; 203 | const { 204 | videoSize, 205 | bitRate, 206 | timeLimit, 207 | bugReport, 208 | } = options; 209 | if (util.hasValue(videoSize)) { 210 | cmd.push('--size', videoSize); 211 | } 212 | if (util.hasValue(timeLimit)) { 213 | cmd.push('--time-limit', `${timeLimit}`); 214 | } 215 | if (util.hasValue(bitRate)) { 216 | cmd.push('--bit-rate', `${bitRate}`); 217 | } 218 | if (bugReport) { 219 | cmd.push('--bugreport'); 220 | } 221 | cmd.push(destination); 222 | 223 | const fullCmd = [ 224 | ...this.executable.defaultArgs, 225 | 'shell', 226 | ...cmd 227 | ]; 228 | log.debug(`Building screenrecord process with the command line: adb ${util.quote(fullCmd)}`); 229 | return new SubProcess(this.executable.path, fullCmd); 230 | } 231 | 232 | /** 233 | * Retrieves the list of features supported by the device under test 234 | * 235 | * @this {import('../adb.js').ADB} 236 | * @returns {Promise} the list of supported feature names or an empty list. 237 | * An example adb command output: 238 | * ``` 239 | * cmd 240 | * ls_v2 241 | * fixed_push_mkdir 242 | * shell_v2 243 | * abb 244 | * stat_v2 245 | * apex 246 | * abb_exec 247 | * remount_shell 248 | * fixed_push_symlink_timestamp 249 | * ``` 250 | * @throws {Error} if there was an error while retrieving the list 251 | */ 252 | export async function listFeatures () { 253 | this._memoizedFeatures = this._memoizedFeatures 254 | || _.memoize(async () => await this.adbExec(['features']), () => this.curDeviceId); 255 | try { 256 | return (await this._memoizedFeatures()) 257 | .split(/\s+/) 258 | .map((x) => x.trim()) 259 | .filter(Boolean); 260 | } catch (e) { 261 | const err = /** @type {import('teen_process').ExecError} */ (e); 262 | if (_.includes(err.stderr, 'unknown command')) { 263 | return []; 264 | } 265 | throw err; 266 | } 267 | } 268 | 269 | /** 270 | * Checks the state of streamed install feature. 271 | * This feature allows to speed up apk installation 272 | * since it does not require the original apk to be pushed to 273 | * the device under test first, which also saves space. 274 | * Although, it is required that both the device under test 275 | * and the adb server have the mentioned functionality. 276 | * See https://github.com/aosp-mirror/platform_system_core/blob/master/adb/client/adb_install.cpp 277 | * for more details 278 | * 279 | * @this {import('../adb.js').ADB} 280 | * @returns {Promise} `true` if the feature is supported by both adb and the 281 | * device under test 282 | */ 283 | export async function isStreamedInstallSupported () { 284 | const proto = Object.getPrototypeOf(this); 285 | proto._helpOutput = proto._helpOutput || await this.adbExec(['help']); 286 | return proto._helpOutput.includes('--streaming') 287 | && (await this.listFeatures()).includes('cmd'); 288 | } 289 | 290 | /** 291 | * Checks whether incremental install feature is supported by ADB. 292 | * Read https://developer.android.com/preview/features#incremental 293 | * for more details on it. 294 | * 295 | * @this {import('../adb.js').ADB} 296 | * @returns {Promise} `true` if the feature is supported by both adb and the 297 | * device under test 298 | */ 299 | export async function isIncrementalInstallSupported () { 300 | const {binary} = await this.getVersion(); 301 | if (!binary) { 302 | return false; 303 | } 304 | return util.compareVersions(`${binary.version}`, '>=', '30.0.1') 305 | && (await this.listFeatures()).includes('abb_exec'); 306 | } 307 | 308 | /** 309 | * Takes a screenshot of the given display or the default display. 310 | * 311 | * @this {import('../adb.js').ADB} 312 | * @param {number|string?} displayId A valid display identifier. If 313 | * no identifier is provided then the screenshot of the default display is returned. 314 | * Note that only recent Android APIs provide multi-screen support. 315 | * @returns {Promise} PNG screenshot payload 316 | */ 317 | export async function takeScreenshot (displayId) { 318 | const args = [...this.executable.defaultArgs, 'exec-out', 'screencap', '-p']; 319 | // @ts-ignore This validation works as expected 320 | const displayIdStr = isNaN(displayId) ? null : `${displayId}`; 321 | if (displayIdStr) { 322 | args.push('-d', displayIdStr); 323 | } 324 | const displayDescr = displayIdStr ? 'default display' : `display #${displayIdStr}`; 325 | let stdout; 326 | try { 327 | ({stdout} = await exec( 328 | this.executable.path, args, {encoding: 'binary', isBuffer: true} 329 | )); 330 | } catch (e) { 331 | const err = /** @type {import('teen_process').ExecError} */ (e); 332 | throw new Error( 333 | `Screenshot of the ${displayDescr} failed. ` + 334 | // @ts-ignore The output is a buffer 335 | `Code: '${err.code}', output: '${(err.stderr.length ? err.stderr : err.stdout).toString('utf-8')}'` 336 | ); 337 | } 338 | if (stdout.length === 0) { 339 | throw new Error(`Screenshot of the ${displayDescr} returned no data`); 340 | } 341 | return stdout; 342 | } 343 | -------------------------------------------------------------------------------- /lib/tools/keyboard-commands.js: -------------------------------------------------------------------------------- 1 | import { log } from '../logger.js'; 2 | import _ from 'lodash'; 3 | import { waitForCondition } from 'asyncbox'; 4 | import B from 'bluebird'; 5 | 6 | const KEYCODE_ESC = 111; 7 | const KEYCODE_BACK = 4; 8 | 9 | /** 10 | * Hides software keyboard if it is visible. 11 | * Noop if the keyboard is already hidden. 12 | * 13 | * @this {import('../adb.js').ADB} 14 | * @param {number} [timeoutMs=1000] For how long to wait (in milliseconds) 15 | * until the keyboard is actually hidden. 16 | * @returns {Promise} `false` if the keyboard was already hidden 17 | * @throws {Error} If the keyboard cannot be hidden. 18 | */ 19 | export async function hideKeyboard (timeoutMs = 1000) { 20 | let {isKeyboardShown, canCloseKeyboard} = await this.isSoftKeyboardPresent(); 21 | if (!isKeyboardShown) { 22 | log.info('Keyboard has no UI; no closing necessary'); 23 | return false; 24 | } 25 | // Try ESC then BACK if the first one fails 26 | for (const keyCode of [KEYCODE_ESC, KEYCODE_BACK]) { 27 | if (canCloseKeyboard) { 28 | await this.keyevent(keyCode); 29 | } 30 | try { 31 | return await waitForCondition(async () => { 32 | ({isKeyboardShown} = await this.isSoftKeyboardPresent()); 33 | return !isKeyboardShown; 34 | }, {waitMs: timeoutMs, intervalMs: 500}); 35 | } catch {} 36 | } 37 | throw new Error(`The software keyboard cannot be hidden`); 38 | } 39 | 40 | /** 41 | * Retrieve the state of the software keyboard on the device under test. 42 | * 43 | * @this {import('../adb.js').ADB} 44 | * @return {Promise} The keyboard state. 45 | */ 46 | export async function isSoftKeyboardPresent () { 47 | try { 48 | const stdout = await this.shell(['dumpsys', 'input_method']); 49 | const inputShownMatch = /mInputShown=(\w+)/.exec(stdout); 50 | const inputViewShownMatch = /mIsInputViewShown=(\w+)/.exec(stdout); 51 | return { 52 | isKeyboardShown: !!(inputShownMatch && inputShownMatch[1] === 'true'), 53 | canCloseKeyboard: !!(inputViewShownMatch && inputViewShownMatch[1] === 'true'), 54 | }; 55 | } catch (e) { 56 | throw new Error(`Error finding softkeyboard. Original error: ${e.message}`); 57 | } 58 | } 59 | 60 | /** 61 | * Send the particular keycode to the device under test. 62 | * 63 | * @this {import('../adb.js').ADB} 64 | * @param {string|number} keycode - The actual key code to be sent. 65 | */ 66 | export async function keyevent (keycode) { 67 | // keycode must be an int. 68 | const code = parseInt(`${keycode}`, 10); 69 | await this.shell(['input', 'keyevent', `${code}`]); 70 | } 71 | 72 | 73 | /** 74 | * Retrieve the list of available input methods (IMEs) for the device under test. 75 | * 76 | * @this {import('../adb.js').ADB} 77 | * @return {Promise} The list of IME names or an empty list. 78 | */ 79 | export async function availableIMEs () { 80 | try { 81 | return getIMEListFromOutput(await this.shell(['ime', 'list', '-a'])); 82 | } catch (e) { 83 | const err = /** @type {Error} */ (e); 84 | throw new Error(`Error getting available IME's. Original error: ${err.message}`); 85 | } 86 | } 87 | 88 | /** 89 | * Retrieve the list of enabled input methods (IMEs) for the device under test. 90 | * 91 | * @this {import('../adb.js').ADB} 92 | * @return {Promise} The list of enabled IME names or an empty list. 93 | */ 94 | export async function enabledIMEs () { 95 | try { 96 | return getIMEListFromOutput(await this.shell(['ime', 'list'])); 97 | } catch (e) { 98 | const err = /** @type {Error} */ (e); 99 | throw new Error(`Error getting enabled IME's. Original error: ${err.message}`); 100 | } 101 | } 102 | 103 | /** 104 | * Enable the particular input method on the device under test. 105 | * 106 | * @this {import('../adb.js').ADB} 107 | * @param {string} imeId - One of existing IME ids. 108 | */ 109 | export async function enableIME (imeId) { 110 | await this.shell(['ime', 'enable', imeId]); 111 | } 112 | 113 | /** 114 | * Disable the particular input method on the device under test. 115 | * 116 | * @this {import('../adb.js').ADB} 117 | * @param {string} imeId - One of existing IME ids. 118 | */ 119 | export async function disableIME (imeId) { 120 | await this.shell(['ime', 'disable', imeId]); 121 | } 122 | 123 | /** 124 | * Set the particular input method on the device under test. 125 | * 126 | * @this {import('../adb.js').ADB} 127 | * @param {string} imeId - One of existing IME ids. 128 | */ 129 | export async function setIME (imeId) { 130 | await this.shell(['ime', 'set', imeId]); 131 | } 132 | 133 | /** 134 | * Get the default input method on the device under test. 135 | * 136 | * @this {import('../adb.js').ADB} 137 | * @return {Promise} The name of the default input method 138 | */ 139 | export async function defaultIME () { 140 | try { 141 | let engine = await this.getSetting('secure', 'default_input_method'); 142 | if (engine === 'null') { 143 | return null; 144 | } 145 | return engine.trim(); 146 | } catch (e) { 147 | const err = /** @type {Error} */ (e); 148 | throw new Error(`Error getting default IME. Original error: ${err.message}`); 149 | } 150 | } 151 | 152 | /** 153 | * Send the particular text or a number to the device under test. 154 | * The text gets properly escaped before being passed to ADB. 155 | * Noop if the text is empty. 156 | * 157 | * @this {import('../adb.js').ADB} 158 | * @param {string|number} text - The actual text to be sent. 159 | * @throws {Error} If it is impossible to escape the given string 160 | */ 161 | export async function inputText (text) { 162 | if (text === '') { 163 | return; 164 | } 165 | 166 | const originalStr = `${text}`; 167 | const escapedText = originalStr.replace(/\$/g, '\\$').replace(/ /g, '%s'); 168 | let args = ['input', 'text', originalStr]; 169 | // https://stackoverflow.com/questions/25791423/adb-shell-input-text-does-not-take-ampersand-character/25791498 170 | const adbInputEscapePattern = /[()<>|;&*\\~^"']/g; 171 | if (escapedText !== originalStr || adbInputEscapePattern.test(originalStr)) { 172 | if (_.every(['"', `'`], (c) => originalStr.includes(c))) { 173 | throw new Error( 174 | `Did not know how to escape a string that contains both types of quotes (" and ')` 175 | ); 176 | } 177 | const q = originalStr.includes('"') ? `'` : '"'; 178 | args = [`input text ${q}${escapedText}${q}`]; 179 | } 180 | await this.shell(args); 181 | } 182 | 183 | /** 184 | * Executes the given function with the given input method context 185 | * and then restores the IME to the original value 186 | * 187 | * @this {import('../adb.js').ADB} 188 | * @param {string} ime - Valid IME identifier 189 | * @param {Function} fn - Function to execute 190 | * @returns {Promise} The result of the given function 191 | */ 192 | export async function runInImeContext (ime, fn) { 193 | const originalIme = await this.defaultIME(); 194 | if (originalIme === ime) { 195 | log.debug(`The original IME is the same as '${ime}'. There is no need to reset it`); 196 | } else { 197 | await this.enableIME(ime); 198 | await this.setIME(ime); 199 | // https://github.com/appium/appium/issues/15943 200 | await B.delay(500); 201 | } 202 | try { 203 | return await fn(); 204 | } finally { 205 | if (originalIme && originalIme !== ime) { 206 | await this.setIME(originalIme); 207 | } 208 | } 209 | } 210 | 211 | // #region Private function 212 | 213 | /** 214 | * @param {string} stdout 215 | * @returns {string[]} 216 | */ 217 | function getIMEListFromOutput (stdout) { 218 | /** @type {string[]} */ 219 | const engines = []; 220 | for (const line of stdout.split('\n')) { 221 | if (line.length > 0 && line[0] !== ' ') { 222 | // remove newline and trailing colon, and add to the list 223 | engines.push(line.trim().replace(/:$/, '')); 224 | } 225 | } 226 | return engines; 227 | } 228 | 229 | // #endregion 230 | -------------------------------------------------------------------------------- /lib/tools/lockmgmt.js: -------------------------------------------------------------------------------- 1 | import { log } from '../logger.js'; 2 | import _ from 'lodash'; 3 | import B from 'bluebird'; 4 | import { waitForCondition } from 'asyncbox'; 5 | 6 | const CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR = `Credential can't be null or empty`; 7 | const CREDENTIAL_DID_NOT_MATCH_ERROR = `didn't match`; 8 | const SUPPORTED_LOCK_CREDENTIAL_TYPES = ['password', 'pin', 'pattern']; 9 | const KEYCODE_POWER = 26; 10 | const KEYCODE_WAKEUP = 224; // works over API Level 20 11 | const HIDE_KEYBOARD_WAIT_TIME = 100; 12 | 13 | /** 14 | * @param {string} verb 15 | * @param {string?} [oldCredential=null] 16 | * @param {...string} args 17 | */ 18 | function buildCommand (verb, oldCredential = null, ...args) { 19 | const cmd = ['locksettings', verb]; 20 | if (!_.isEmpty(oldCredential)) { 21 | cmd.push('--old', /** @type {string} */ (oldCredential)); 22 | } 23 | if (!_.isEmpty(args)) { 24 | cmd.push(...args); 25 | } 26 | return cmd; 27 | } 28 | 29 | /** 30 | * Performs swipe up gesture on the screen 31 | * 32 | * @this {import('../adb.js').ADB} 33 | * @param {string} windowDumpsys The output of `adb shell dumpsys window` command 34 | * @throws {Error} If the display size cannot be retrieved 35 | */ 36 | async function swipeUp (windowDumpsys) { 37 | const dimensionsMatch = /init=(\d+)x(\d+)/.exec(windowDumpsys); 38 | if (!dimensionsMatch) { 39 | throw new Error('Cannot retrieve the display size'); 40 | } 41 | const displayWidth = parseInt(dimensionsMatch[1], 10); 42 | const displayHeight = parseInt(dimensionsMatch[2], 10); 43 | const x0 = displayWidth / 2; 44 | const y0 = displayHeight / 5 * 4; 45 | const x1 = x0; 46 | const y1 = displayHeight / 5; 47 | await this.shell([ 48 | 'input', 'touchscreen', 'swipe', 49 | ...([x0, y0, x1, y1].map((c) => `${Math.trunc(c)}`)) 50 | ]); 51 | } 52 | 53 | /** 54 | * Check whether the device supports lock settings management with `locksettings` 55 | * command line tool. This tool has been added to Android toolset since API 27 Oreo 56 | * 57 | * @this {import('../adb.js').ADB} 58 | * @return {Promise} True if the management is supported. The result is cached per ADB instance 59 | */ 60 | export async function isLockManagementSupported () { 61 | if (!_.isBoolean(this._isLockManagementSupported)) { 62 | const passFlag = '__PASS__'; 63 | let output = ''; 64 | try { 65 | output = await this.shell([`locksettings help && echo ${passFlag}`]); 66 | } catch {} 67 | this._isLockManagementSupported = _.includes(output, passFlag); 68 | log.debug(`Extended lock settings management is ` + 69 | `${this._isLockManagementSupported ? '' : 'not '}supported`); 70 | } 71 | return this._isLockManagementSupported; 72 | } 73 | 74 | /** 75 | * Check whether the given credential is matches to the currently set one. 76 | * 77 | * @this {import('../adb.js').ADB} 78 | * @param {string?} [credential=null] The credential value. It could be either 79 | * pin, password or a pattern. A pattern is specified by a non-separated list 80 | * of numbers that index the cell on the pattern in a 1-based manner in left 81 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1, 82 | * whereas the bottom-right cell is indexed with 9. Example: 1234. 83 | * null/empty value assumes the device has no lock currently set. 84 | * @return {Promise} True if the given credential matches to the device's one 85 | * @throws {Error} If the verification faces an unexpected error 86 | */ 87 | export async function verifyLockCredential (credential = null) { 88 | try { 89 | const {stdout, stderr} = await this.shell(buildCommand('verify', credential), { 90 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL 91 | }); 92 | if (_.includes(stdout, 'verified successfully')) { 93 | return true; 94 | } 95 | if ([`didn't match`, CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR] 96 | .some((x) => _.includes(stderr || stdout, x))) { 97 | return false; 98 | } 99 | throw new Error(stderr || stdout); 100 | } catch (e) { 101 | throw new Error(`Device lock credential verification failed. ` + 102 | `Original error: ${e.stderr || e.stdout || e.message}`); 103 | } 104 | } 105 | 106 | /** 107 | * Clears current lock credentials. Usually it takes several seconds for a device to 108 | * sync the credential state after this method returns. 109 | * 110 | * @this {import('../adb.js').ADB} 111 | * @param {string?} [credential=null] The credential value. It could be either 112 | * pin, password or a pattern. A pattern is specified by a non-separated list 113 | * of numbers that index the cell on the pattern in a 1-based manner in left 114 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1, 115 | * whereas the bottom-right cell is indexed with 9. Example: 1234. 116 | * null/empty value assumes the device has no lock currently set. 117 | * @throws {Error} If operation faces an unexpected error 118 | */ 119 | export async function clearLockCredential (credential = null) { 120 | try { 121 | const {stdout, stderr} = await this.shell(buildCommand('clear', credential), { 122 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL 123 | }); 124 | if (!['user has no password', 'Lock credential cleared'] 125 | .some((x) => _.includes(stderr || stdout, x))) { 126 | throw new Error(stderr || stdout); 127 | } 128 | } catch (e) { 129 | throw new Error(`Cannot clear device lock credential. ` + 130 | `Original error: ${e.stderr || e.stdout || e.message}`); 131 | } 132 | } 133 | 134 | /** 135 | * Checks whether the device is locked with a credential (either pin or a password 136 | * or a pattern). 137 | * 138 | * @this {import('../adb.js').ADB} 139 | * @returns {Promise} `true` if the device is locked 140 | * @throws {Error} If operation faces an unexpected error 141 | */ 142 | export async function isLockEnabled () { 143 | try { 144 | const {stdout, stderr} = await this.shell(buildCommand('get-disabled'), { 145 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL 146 | }); 147 | if (/\bfalse\b/.test(stdout) 148 | || [CREDENTIAL_DID_NOT_MATCH_ERROR, CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR].some( 149 | (x) => _.includes(stderr || stdout, x))) { 150 | return true; 151 | } 152 | if (/\btrue\b/.test(stdout)) { 153 | return false; 154 | } 155 | throw new Error(stderr || stdout); 156 | } catch (e) { 157 | throw new Error(`Cannot check if device lock is enabled. Original error: ${e.message}`); 158 | } 159 | } 160 | 161 | /** 162 | * Sets the device lock. 163 | * 164 | * @this {import('../adb.js').ADB} 165 | * @param {string} credentialType One of: password, pin, pattern. 166 | * @param {string} credential A non-empty credential value to be set. 167 | * Make sure your new credential matches to the actual system security requirements, 168 | * e.g. a minimum password length. A pattern is specified by a non-separated list 169 | * of numbers that index the cell on the pattern in a 1-based manner in left 170 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1, 171 | * whereas the bottom-right cell is indexed with 9. Example: 1234. 172 | * @param {string?} [oldCredential=null] An old credential string. 173 | * It is only required to be set in case you need to change the current 174 | * credential rather than to set a new one. Setting it to a wrong value will 175 | * make this method to fail and throw an exception. 176 | * @throws {Error} If there was a failure while verifying input arguments or setting 177 | * the credential 178 | */ 179 | export async function setLockCredential ( 180 | credentialType, credential, oldCredential = null) { 181 | if (!SUPPORTED_LOCK_CREDENTIAL_TYPES.includes(credentialType)) { 182 | throw new Error(`Device lock credential type '${credentialType}' is unknown. ` + 183 | `Only the following credential types are supported: ${SUPPORTED_LOCK_CREDENTIAL_TYPES}`); 184 | } 185 | if (_.isEmpty(credential) && !_.isInteger(credential)) { 186 | throw new Error('Device lock credential cannot be empty'); 187 | } 188 | const cmd = buildCommand(`set-${credentialType}`, oldCredential, credential); 189 | try { 190 | const {stdout, stderr} = await this.shell(cmd, { 191 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL 192 | }); 193 | if (!_.includes(stdout, 'set to')) { 194 | throw new Error(stderr || stdout); 195 | } 196 | } catch (e) { 197 | throw new Error(`Setting of device lock ${credentialType} credential failed. ` + 198 | `Original error: ${e.stderr || e.stdout || e.message}`); 199 | } 200 | } 201 | 202 | /** 203 | * Retrieve the screen lock state of the device under test. 204 | * 205 | * @this {import('../adb.js').ADB} 206 | * @return {Promise} True if the device is locked. 207 | */ 208 | export async function isScreenLocked () { 209 | const [windowOutput, powerOutput] = await B.all([ 210 | this.shell(['dumpsys', 'window']), 211 | this.shell(['dumpsys', 'power']), 212 | ]); 213 | return isShowingLockscreen(windowOutput) 214 | || isCurrentFocusOnKeyguard(windowOutput) 215 | || !isScreenOnFully(windowOutput) 216 | || isInDozingMode(powerOutput) 217 | || isScreenStateOff(windowOutput); 218 | } 219 | 220 | /** 221 | * Dismisses keyguard overlay. 222 | * @this {import('../adb.js').ADB} 223 | */ 224 | export async function dismissKeyguard () { 225 | log.info('Waking up the device to dismiss the keyguard'); 226 | // Screen off once to force pre-inputted text field clean after wake-up 227 | // Just screen on if the screen defaults off 228 | await this.cycleWakeUp(); 229 | 230 | if (await this.getApiLevel() > 21) { 231 | await this.shell(['wm', 'dismiss-keyguard']); 232 | return; 233 | } 234 | 235 | const stdout = await this.shell(['dumpsys', 'window']); 236 | if (!isCurrentFocusOnKeyguard(stdout)) { 237 | log.debug('The keyguard seems to be inactive'); 238 | return; 239 | } 240 | 241 | log.debug('Swiping up to dismiss the keyguard'); 242 | if (await this.hideKeyboard()) { 243 | await B.delay(HIDE_KEYBOARD_WAIT_TIME); 244 | } 245 | log.debug('Dismissing notifications from the unlock view'); 246 | await this.shell(['service', 'call', 'notification', '1']); 247 | await this.back(); 248 | await swipeUp.bind(this)(stdout); 249 | } 250 | 251 | /** 252 | * Presses the corresponding key combination to make sure the device's screen 253 | * is not turned off and is locked if the latter is enabled. 254 | * @this {import('../adb.js').ADB} 255 | */ 256 | export async function cycleWakeUp () { 257 | await this.keyevent(KEYCODE_POWER); 258 | await this.keyevent(KEYCODE_WAKEUP); 259 | } 260 | 261 | /** 262 | * Send the special keycode to the device under test in order to lock it. 263 | * @this {import('../adb.js').ADB} 264 | */ 265 | export async function lock () { 266 | if (await this.isScreenLocked()) { 267 | log.debug('Screen is already locked. Doing nothing.'); 268 | return; 269 | } 270 | log.debug('Pressing the KEYCODE_POWER button to lock screen'); 271 | await this.keyevent(26); 272 | 273 | const timeoutMs = 5000; 274 | try { 275 | await waitForCondition(async () => await this.isScreenLocked(), { 276 | waitMs: timeoutMs, 277 | intervalMs: 500, 278 | }); 279 | } catch { 280 | throw new Error(`The device screen is still not locked after ${timeoutMs}ms timeout`); 281 | } 282 | } 283 | 284 | // #region Private functions 285 | 286 | /** 287 | * Checks mScreenOnFully in dumpsys output to determine if screen is showing 288 | * Default is true. 289 | * Note: this key 290 | * 291 | * @param {string} dumpsys 292 | * @returns {boolean} 293 | */ 294 | function isScreenOnFully (dumpsys) { 295 | const m = /mScreenOnFully=\w+/gi.exec(dumpsys); 296 | return !m || // if information is missing we assume screen is fully on 297 | (m && m.length > 0 && m[0].split('=')[1] === 'true') || false; 298 | } 299 | 300 | /** 301 | * Checks mCurrentFocus in dumpsys output to determine if Keyguard is activated 302 | * 303 | * @param {string} dumpsys 304 | * @returns {boolean} 305 | */ 306 | function isCurrentFocusOnKeyguard (dumpsys) { 307 | const m = /mCurrentFocus.+Keyguard/gi.exec(dumpsys); 308 | return Boolean(m?.length && m[0]); 309 | } 310 | 311 | /** 312 | * Check the current device power state to determine if it is locked 313 | * 314 | * @param {string} dumpsys The `adb shell dumpsys power` output 315 | * @returns {boolean} True if lock screen is shown 316 | */ 317 | function isInDozingMode(dumpsys) { 318 | // On some phones/tablets we were observing mWakefulness=Dozing 319 | // while on others it was getWakefulnessLocked()=Dozing 320 | return /^[\s\w]+wakefulness[^=]*=Dozing$/im.test(dumpsys); 321 | } 322 | 323 | /** 324 | * Checks mShowingLockscreen or mDreamingLockscreen in dumpsys output to determine 325 | * if lock screen is showing 326 | * 327 | * A note: `adb shell dumpsys trust` performs better while detecting the locked screen state 328 | * in comparison to `adb dumpsys window` output parsing. 329 | * But the trust command does not work for `Swipe` unlock pattern. 330 | * 331 | * In some Android devices (Probably around Android 10+), `mShowingLockscreen` and `mDreamingLockscreen` 332 | * do not work to detect lock status. Instead, keyguard preferences helps to detect the lock condition. 333 | * Some devices such as Android TV do not have keyguard, so we should keep 334 | * screen condition as this primary method. 335 | * 336 | * @param {string} dumpsys - The output of dumpsys window command. 337 | * @return {boolean} True if lock screen is showing. 338 | */ 339 | export function isShowingLockscreen (dumpsys) { 340 | return _.some(['mShowingLockscreen=true', 'mDreamingLockscreen=true'], (x) => dumpsys.includes(x)) 341 | // `mIsShowing` and `mInputRestricted` are `true` in lock condition. `false` is unlock condition. 342 | || _.every([/KeyguardStateMonitor[\n\s]+mIsShowing=true/, /\s+mInputRestricted=true/], (x) => x.test(dumpsys)); 343 | } 344 | 345 | /** 346 | * Checks screenState has SCREEN_STATE_OFF in dumpsys output to determine 347 | * possible lock screen. 348 | * 349 | * @param {string} dumpsys - The output of dumpsys window command. 350 | * @return {boolean} True if lock screen is showing. 351 | */ 352 | export function isScreenStateOff(dumpsys) { 353 | return /\s+screenState=SCREEN_STATE_OFF/i.test(dumpsys); 354 | } 355 | 356 | // #endregion 357 | -------------------------------------------------------------------------------- /lib/tools/logcat-commands.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { Logcat } from '../logcat'; 3 | 4 | /** 5 | * Start the logcat process to gather logs. 6 | * 7 | * @this {import('../adb.js').ADB} 8 | * @param {import('./types').LogcatOpts} [opts={}] 9 | * @throws {Error} If restart fails. 10 | */ 11 | export async function startLogcat (opts = {}) { 12 | if (!_.isEmpty(this.logcat)) { 13 | throw new Error("Trying to start logcat capture but it's already started!"); 14 | } 15 | 16 | this.logcat = new Logcat({ 17 | adb: this.executable, 18 | debug: false, 19 | debugTrace: false, 20 | clearDeviceLogsOnStart: !!this.clearDeviceLogsOnStart, 21 | }); 22 | await this.logcat.startCapture(opts); 23 | this._logcatStartupParams = opts; 24 | } 25 | 26 | /** 27 | * Stop the active logcat process which gathers logs. 28 | * The call will be ignored if no logcat process is running. 29 | * @this {import('../adb.js').ADB} 30 | */ 31 | export async function stopLogcat () { 32 | if (_.isEmpty(this.logcat)) { 33 | return; 34 | } 35 | try { 36 | await this.logcat.stopCapture(); 37 | } finally { 38 | this.logcat = undefined; 39 | } 40 | } 41 | 42 | /** 43 | * Retrieve the output from the currently running logcat process. 44 | * The logcat process should be executed by {2link #startLogcat} method. 45 | * 46 | * @this {import('../adb.js').ADB} 47 | * @return {import('./types').LogEntry[]} The collected logcat output. 48 | * @throws {Error} If logcat process is not running. 49 | */ 50 | export function getLogcatLogs () { 51 | if (_.isEmpty(this.logcat)) { 52 | throw new Error(`Can't get logcat logs since logcat hasn't started`); 53 | } 54 | return this.logcat.getLogs(); 55 | } 56 | 57 | /** 58 | * Set the callback for the logcat output event. 59 | * 60 | * @this {import('../adb.js').ADB} 61 | * @param {import('./types').LogcatListener} listener - Listener function 62 | * @throws {Error} If logcat process is not running. 63 | */ 64 | export function setLogcatListener (listener) { 65 | if (_.isEmpty(this.logcat)) { 66 | throw new Error("Logcat process hasn't been started"); 67 | } 68 | this.logcat.on('output', listener); 69 | } 70 | 71 | /** 72 | * Removes the previously set callback for the logcat output event. 73 | * 74 | * @this {import('../adb.js').ADB} 75 | * @param {import('./types').LogcatListener} listener 76 | * The listener function, which has been previously 77 | * passed to `setLogcatListener` 78 | * @throws {Error} If logcat process is not running. 79 | */ 80 | export function removeLogcatListener (listener) { 81 | if (_.isEmpty(this.logcat)) { 82 | throw new Error("Logcat process hasn't been started"); 83 | } 84 | this.logcat.removeListener('output', listener); 85 | } 86 | -------------------------------------------------------------------------------- /lib/tools/network-commands.js: -------------------------------------------------------------------------------- 1 | 2 | import { EOL } from 'os'; 3 | import _ from 'lodash'; 4 | import { log } from '../logger.js'; 5 | 6 | /** 7 | * Get TCP port forwarding with adb on the device under test. 8 | * 9 | * @this {import('../adb.js').ADB} 10 | * @return {Promise} The output of the corresponding adb command. 11 | * An array contains each forwarding line of output 12 | */ 13 | export async function getForwardList () { 14 | log.debug(`List forwarding ports`); 15 | const connections = await this.adbExec(['forward', '--list']); 16 | return connections.split(EOL).filter((line) => Boolean(line.trim())); 17 | } 18 | 19 | /** 20 | * Setup TCP port forwarding with adb on the device under test. 21 | * 22 | * @this {import('../adb.js').ADB} 23 | * @param {string|number} systemPort - The number of the local system port. 24 | * @param {string|number} devicePort - The number of the remote device port. 25 | */ 26 | export async function forwardPort (systemPort, devicePort) { 27 | log.debug(`Forwarding system: ${systemPort} to device: ${devicePort}`); 28 | await this.adbExec(['forward', `tcp:${systemPort}`, `tcp:${devicePort}`]); 29 | } 30 | 31 | /** 32 | * Remove TCP port forwarding with adb on the device under test. The forwarding 33 | * for the given port should be setup with {@link #forwardPort} first. 34 | * 35 | * @this {import('../adb.js').ADB} 36 | * @param {string|number} systemPort - The number of the local system port 37 | * to remove forwarding on. 38 | */ 39 | export async function removePortForward (systemPort) { 40 | log.debug(`Removing forwarded port socket connection: ${systemPort} `); 41 | await this.adbExec(['forward', `--remove`, `tcp:${systemPort}`]); 42 | } 43 | 44 | /** 45 | * Get TCP port forwarding with adb on the device under test. 46 | * 47 | * @this {import('../adb.js').ADB} 48 | * @return {Promise} The output of the corresponding adb command. 49 | * An array contains each forwarding line of output 50 | */ 51 | export async function getReverseList () { 52 | log.debug(`List reverse forwarding ports`); 53 | const connections = await this.adbExec(['reverse', '--list']); 54 | return connections.split(EOL).filter((line) => Boolean(line.trim())); 55 | } 56 | 57 | /** 58 | * Setup TCP port forwarding with adb on the device under test. 59 | * Only available for API 21+. 60 | * 61 | * @this {import('../adb.js').ADB} 62 | * @param {string|number} devicePort - The number of the remote device port. 63 | * @param {string|number} systemPort - The number of the local system port. 64 | */ 65 | export async function reversePort (devicePort, systemPort) { 66 | log.debug(`Forwarding device: ${devicePort} to system: ${systemPort}`); 67 | await this.adbExec(['reverse', `tcp:${devicePort}`, `tcp:${systemPort}`]); 68 | } 69 | 70 | /** 71 | * Remove TCP port forwarding with adb on the device under test. The forwarding 72 | * for the given port should be setup with {@link #forwardPort} first. 73 | * 74 | * @this {import('../adb.js').ADB} 75 | * @param {string|number} devicePort - The number of the remote device port 76 | * to remove forwarding on. 77 | */ 78 | export async function removePortReverse (devicePort) { 79 | log.debug(`Removing reverse forwarded port socket connection: ${devicePort} `); 80 | await this.adbExec(['reverse', `--remove`, `tcp:${devicePort}`]); 81 | } 82 | 83 | /** 84 | * Setup TCP port forwarding with adb on the device under test. The difference 85 | * between {@link #forwardPort} is that this method does setup for an abstract 86 | * local port. 87 | * 88 | * @this {import('../adb.js').ADB} 89 | * @param {string|number} systemPort - The number of the local system port. 90 | * @param {string|number} devicePort - The number of the remote device port. 91 | */ 92 | export async function forwardAbstractPort (systemPort, devicePort) { 93 | log.debug(`Forwarding system: ${systemPort} to abstract device: ${devicePort}`); 94 | await this.adbExec(['forward', `tcp:${systemPort}`, `localabstract:${devicePort}`]); 95 | } 96 | 97 | /** 98 | * Execute ping shell command on the device under test. 99 | * 100 | * @this {import('../adb.js').ADB} 101 | * @return {Promise} True if the command output contains 'ping' substring. 102 | * @throws {Error} If there was an error while executing 'ping' command on the 103 | * device under test. 104 | */ 105 | export async function ping () { 106 | let stdout = await this.shell(['echo', 'ping']); 107 | if (stdout.indexOf('ping') === 0) { 108 | return true; 109 | } 110 | throw new Error(`ADB ping failed, returned ${stdout}`); 111 | } 112 | 113 | /** 114 | * Returns the list of TCP port states of the given family. 115 | * Could be empty if no ports are opened. 116 | * 117 | * @this {import('../adb.js').ADB} 118 | * @param {import('./types').PortFamily} [family='4'] 119 | * @returns {Promise} 120 | */ 121 | export async function listPorts(family = '4') { 122 | const sourceProcName = `/proc/net/tcp${family === '6' ? '6' : ''}`; 123 | const output = await this.shell(['cat', sourceProcName]); 124 | const lines = output.split('\n'); 125 | if (_.isEmpty(lines)) { 126 | log.debug(output); 127 | throw new Error(`Cannot parse the payload of ${sourceProcName}`); 128 | } 129 | // sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 130 | const colHeaders = lines[0].split(/\s+/).filter(Boolean); 131 | const localAddressCol = colHeaders.findIndex((x) => x === 'local_address'); 132 | const stateCol = colHeaders.findIndex((x) => x === 'st'); 133 | if (localAddressCol < 0 || stateCol < 0) { 134 | log.debug(lines[0]); 135 | throw new Error(`Cannot parse the header row of ${sourceProcName} payload`); 136 | } 137 | /** @type {import('./types').PortInfo[]} */ 138 | const result = []; 139 | // 2: 1002000A:D036 24CE3AD8:01BB 08 00000000:00000000 00:00000000 00000000 10132 0 49104 1 0000000000000000 21 4 20 10 -1 140 | for (const line of lines.slice(1)) { 141 | const values = line.split(/\s+/).filter(Boolean); 142 | const portStr = values[localAddressCol]?.split(':')?.[1]; 143 | const stateStr = values[stateCol]; 144 | if (!portStr || !stateStr) { 145 | continue; 146 | } 147 | result.push({ 148 | port: parseInt(portStr, 16), 149 | family, 150 | state: parseInt(stateStr, 16), 151 | }); 152 | }; 153 | return result; 154 | } 155 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import type {Logcat} from './logcat'; 2 | import type {StringRecord} from './tools/types'; 3 | 4 | export interface ADBOptions { 5 | sdkRoot?: string; 6 | udid?: string; 7 | appDeviceReadyTimeout?: number; 8 | useKeystore?: boolean; 9 | keystorePath?: string; 10 | keystorePassword?: string; 11 | keyAlias?: string; 12 | keyPassword?: string; 13 | executable?: ADBExecutable; 14 | tmpDir?: string; 15 | curDeviceId?: string; 16 | emulatorPort?: number; 17 | logcat?: Logcat; 18 | binaries?: StringRecord; 19 | suppressKillServer?: boolean; 20 | adbPort?: number; 21 | adbHost?: string; 22 | adbExecTimeout?: number; 23 | remoteAppsCacheLimit?: number; 24 | buildToolsVersion?: string; 25 | allowOfflineDevices?: boolean; 26 | allowDelayAdb?: boolean; 27 | remoteAdbHost?: string; 28 | remoteAdbPort?: number; 29 | clearDeviceLogsOnStart?: boolean; 30 | } 31 | 32 | export interface ADBExecutable { 33 | path: string; 34 | defaultArgs: string[]; 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appium-adb", 3 | "version": "12.12.5", 4 | "description": "Android Debug Bridge interface", 5 | "main": "./build/index.js", 6 | "scripts": { 7 | "build": "tsc -b", 8 | "clean": "npm run build -- --clean", 9 | "dev": "npm run build -- --watch", 10 | "lint": "eslint .", 11 | "lint:fix": "npm run lint -- --fix", 12 | "prepare": "npm run rebuild", 13 | "rebuild": "npm run clean && npm run build", 14 | "format": "prettier -w ./lib", 15 | "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"", 16 | "e2e-test": "mocha --exit --timeout 5m \"./test/functional/**/*-specs.js\"" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/appium/appium-adb.git" 21 | }, 22 | "author": "Appium Contributors", 23 | "license": "Apache-2.0", 24 | "bugs": { 25 | "url": "https://github.com/appium/appium-adb/issues" 26 | }, 27 | "engines": { 28 | "node": ">=14", 29 | "npm": ">=8" 30 | }, 31 | "bin": {}, 32 | "directories": { 33 | "lib": "lib" 34 | }, 35 | "prettier": { 36 | "bracketSpacing": false, 37 | "printWidth": 100, 38 | "singleQuote": true 39 | }, 40 | "files": [ 41 | "index.ts", 42 | "keys", 43 | "lib", 44 | "build/index.*", 45 | "build/lib", 46 | "CHANGELOG.md" 47 | ], 48 | "homepage": "https://github.com/appium/appium-adb", 49 | "dependencies": { 50 | "@appium/support": "^6.0.0", 51 | "async-lock": "^1.0.0", 52 | "asyncbox": "^3.0.0", 53 | "bluebird": "^3.4.7", 54 | "ini": "^5.0.0", 55 | "lodash": "^4.0.0", 56 | "lru-cache": "^10.0.0", 57 | "semver": "^7.0.0", 58 | "source-map-support": "^0.x", 59 | "teen_process": "^2.2.0" 60 | }, 61 | "devDependencies": { 62 | "@appium/eslint-config-appium-ts": "^1.x", 63 | "@appium/test-support": "^3.0.1", 64 | "@semantic-release/changelog": "^6.0.1", 65 | "@semantic-release/git": "^10.0.1", 66 | "@types/async-lock": "^1.4.0", 67 | "@types/bluebird": "^3.5.38", 68 | "@types/ini": "^4.1.0", 69 | "@types/lodash": "^4.14.195", 70 | "@types/mocha": "^10.0.1", 71 | "@types/node": "^24.0.0", 72 | "@types/semver": "^7.5.0", 73 | "@types/source-map-support": "^0.5.6", 74 | "@types/teen_process": "^2.0.0", 75 | "chai": "^5.1.1", 76 | "chai-as-promised": "^8.0.0", 77 | "conventional-changelog-conventionalcommits": "^9.0.0", 78 | "mocha": "^11.0.1", 79 | "prettier": "^3.0.0", 80 | "rimraf": "^5.0.0", 81 | "semantic-release": "^24.0.0", 82 | "ts-node": "^10.9.1", 83 | "typescript": "^5.4.2" 84 | }, 85 | "types": "./build/index.d.ts" 86 | } 87 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "func-names": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/ApiDemos-debug.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ApiDemos-debug.apk -------------------------------------------------------------------------------- /test/fixtures/ContactManager-old.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager-old.apk -------------------------------------------------------------------------------- /test/fixtures/ContactManager-selendroid.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager-selendroid.apk -------------------------------------------------------------------------------- /test/fixtures/ContactManager.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager.apk -------------------------------------------------------------------------------- /test/fixtures/Fingerprint.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/Fingerprint.apk -------------------------------------------------------------------------------- /test/fixtures/TestZip.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/TestZip.zip -------------------------------------------------------------------------------- /test/fixtures/appiumtest.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/appiumtest.keystore -------------------------------------------------------------------------------- /test/fixtures/selendroid-test-app.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/selendroid-test-app.apk -------------------------------------------------------------------------------- /test/fixtures/selendroid/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/fixtures/selendroid/selendroid.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/selendroid/selendroid.apk -------------------------------------------------------------------------------- /test/functional/adb-commands-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import path from 'path'; 3 | import { apiLevel, platformVersion, MOCHA_TIMEOUT } from './setup'; 4 | import { fs, tempDir } from '@appium/support'; 5 | import _ from 'lodash'; 6 | import { waitForCondition } from 'asyncbox'; 7 | 8 | const DEFAULT_IMES = [ 9 | 'com.android.inputmethod.latin/.LatinIME', 10 | 'com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME', 11 | 'io.appium.android.ime/.UnicodeIME', 12 | ]; 13 | const CONTACT_MANAGER_PATH = apiLevel < 23 14 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk') 15 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk'); 16 | const CONTACT_MANAGER_PKG = apiLevel < 23 17 | ? 'com.example.android.contactmanager' 18 | : 'com.saucelabs.ContactManager'; 19 | const CONTACT_MANAGER_ACTIVITY = apiLevel < 23 20 | ? 'ContactManager' 21 | : 'com.saucelabs.ContactManager.ContactManager'; 22 | 23 | 24 | describe('adb commands', function () { 25 | this.timeout(MOCHA_TIMEOUT); 26 | 27 | let adb; 28 | let chai; 29 | let expect; 30 | const androidInstallTimeout = 90000; 31 | before(async function () { 32 | chai = await import('chai'); 33 | const chaiAsPromised = await import('chai-as-promised'); 34 | 35 | chai.should(); 36 | chai.use(chaiAsPromised.default); 37 | expect = chai.expect; 38 | 39 | adb = await ADB.createADB({ adbExecTimeout: 60000 }); 40 | }); 41 | it('getApiLevel should get correct api level', async function () { 42 | (await adb.getApiLevel()).should.equal(apiLevel); 43 | }); 44 | it('getPlatformVersion should get correct platform version', async function () { 45 | const actualPlatformVersion = await adb.getPlatformVersion(); 46 | parseFloat(platformVersion).should.equal(parseFloat(actualPlatformVersion)); 47 | }); 48 | it('availableIMEs should get list of available IMEs', async function () { 49 | (await adb.availableIMEs()).should.have.length.above(0); 50 | }); 51 | it('enabledIMEs should get list of enabled IMEs', async function () { 52 | (await adb.enabledIMEs()).should.have.length.above(0); 53 | }); 54 | it('defaultIME should get default IME', async function () { 55 | const defaultIME = await adb.defaultIME(); 56 | if (defaultIME) { 57 | DEFAULT_IMES.should.include(defaultIME); 58 | } 59 | }); 60 | it('enableIME and disableIME should enable and disable IME', async function () { 61 | const imes = await adb.availableIMEs(); 62 | if (imes.length < 2) { 63 | return this.skip(); 64 | } 65 | 66 | const ime = _.last(imes); 67 | await adb.disableIME(ime); 68 | (await adb.enabledIMEs()).should.not.include(ime); 69 | await adb.enableIME(ime); 70 | (await adb.enabledIMEs()).should.include(ime); 71 | }); 72 | it('processExists should be able to find ui process', async function () { 73 | (await adb.processExists('com.android.systemui')).should.be.true; 74 | }); 75 | it('ping should return true', async function () { 76 | (await adb.ping()).should.be.true; 77 | }); 78 | it('getPIDsByName should return pids', async function () { 79 | (await adb.getPIDsByName('com.android.phone')).should.have.length.above(0); 80 | }); 81 | it('killProcessesByName should kill process', async function () { 82 | if (process.env.CI) { 83 | this.retries(2); 84 | } 85 | 86 | await adb.install(CONTACT_MANAGER_PATH, { 87 | timeout: androidInstallTimeout, 88 | grantPermissions: true, 89 | }); 90 | await adb.startApp({pkg: CONTACT_MANAGER_PKG, activity: CONTACT_MANAGER_ACTIVITY}); 91 | await adb.killProcessesByName(CONTACT_MANAGER_PKG); 92 | await waitForCondition(async () => (await adb.getPIDsByName(CONTACT_MANAGER_PKG)).length === 0, { 93 | waitMs: 5000, 94 | intervalMs: 500, 95 | }); 96 | }); 97 | it('killProcessByPID should kill process', async function () { 98 | await adb.install(CONTACT_MANAGER_PATH, { 99 | timeout: androidInstallTimeout, 100 | grantPermissions: true, 101 | }); 102 | await adb.startApp({pkg: CONTACT_MANAGER_PKG, activity: CONTACT_MANAGER_ACTIVITY}); 103 | let pids = await adb.getPIDsByName(CONTACT_MANAGER_PKG); 104 | pids.should.have.length.above(0); 105 | await adb.killProcessByPID(pids[0]); 106 | await waitForCondition(async () => (await adb.getPIDsByName(CONTACT_MANAGER_PKG)).length === 0, { 107 | waitMs: 5000, 108 | intervalMs: 500, 109 | }); 110 | }); 111 | it('should get device language and country', async function () { 112 | if (parseInt(apiLevel, 10) >= 23 || process.env.CI) { 113 | return this.skip(); 114 | } 115 | 116 | ['en', 'fr'].should.contain(await adb.getDeviceSysLanguage()); 117 | ['US', 'EN_US', 'EN', 'FR'].should.contain(await adb.getDeviceSysCountry()); 118 | }); 119 | it('should forward the port', async function () { 120 | await adb.forwardPort(4724, 4724); 121 | }); 122 | it('should remove forwarded port', async function () { 123 | await adb.forwardPort(8200, 6790); 124 | (await adb.adbExec([`forward`, `--list`])).should.contain('tcp:8200'); 125 | await adb.removePortForward(8200); 126 | (await adb.adbExec([`forward`, `--list`])).should.not.contain('tcp:8200'); 127 | 128 | }); 129 | it('should reverse forward the port', async function () { 130 | await adb.reversePort(4724, 4724); 131 | }); 132 | it('should remove reverse forwarded port', async function () { 133 | await adb.reversePort(6790, 8200); 134 | (await adb.adbExec([`reverse`, `--list`])).should.contain('tcp:6790'); 135 | await adb.removePortReverse(6790); 136 | (await adb.adbExec([`reverse`, `--list`])).should.not.contain('tcp:6790'); 137 | 138 | }); 139 | it('should start logcat from adb', async function () { 140 | await adb.startLogcat(); 141 | let logs = adb.logcat.getLogs(); 142 | logs.should.have.length.above(0); 143 | await adb.stopLogcat(); 144 | }); 145 | it('should get model', async function () { 146 | (await adb.getModel()).should.not.be.null; 147 | }); 148 | it('should get manufacturer', async function () { 149 | (await adb.getManufacturer()).should.not.be.null; 150 | }); 151 | it('should get screen size', async function () { 152 | (await adb.getScreenSize()).should.not.be.null; 153 | }); 154 | it('should get screen density', async function () { 155 | (await adb.getScreenDensity()).should.not.be.null; 156 | }); 157 | it('should be able to toggle gps location provider', async function () { 158 | await adb.toggleGPSLocationProvider(true); 159 | (await adb.getLocationProviders()).should.include('gps'); 160 | await adb.toggleGPSLocationProvider(false); 161 | (await adb.getLocationProviders()).should.not.include('gps'); 162 | 163 | // To avoid side effects for other tests, especially on Android 16+ 164 | await adb.toggleGPSLocationProvider(true); 165 | }); 166 | it('should be able to toggle airplane mode', async function () { 167 | await adb.setAirplaneMode(true); 168 | (await adb.isAirplaneModeOn()).should.be.true; 169 | await adb.setAirplaneMode(false); 170 | (await adb.isAirplaneModeOn()).should.be.false; 171 | }); 172 | describe('app permissions', function () { 173 | before(async function () { 174 | let deviceApiLevel = await adb.getApiLevel(); 175 | if (deviceApiLevel < 23) { 176 | //test should skip if the device API < 23 177 | return this.skip(); 178 | } 179 | let isInstalled = await adb.isAppInstalled('io.appium.android.apis'); 180 | if (isInstalled) { 181 | await adb.uninstallApk('io.appium.android.apis'); 182 | } 183 | }); 184 | it('should install and grant all permission', async function () { 185 | let apiDemos = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk'); 186 | await adb.install(apiDemos, {timeout: androidInstallTimeout}); 187 | (await adb.isAppInstalled('io.appium.android.apis')).should.be.true; 188 | await adb.grantAllPermissions('io.appium.android.apis'); 189 | let requestedPermissions = await adb.getReqPermissions('io.appium.android.apis'); 190 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.have.members(requestedPermissions); 191 | }); 192 | it('should revoke permission', async function () { 193 | await adb.revokePermission('io.appium.android.apis', 'android.permission.RECEIVE_SMS'); 194 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.not.have.members(['android.permission.RECEIVE_SMS']); 195 | }); 196 | it('should grant permission', async function () { 197 | await adb.grantPermission('io.appium.android.apis', 'android.permission.RECEIVE_SMS'); 198 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.include.members(['android.permission.RECEIVE_SMS']); 199 | }); 200 | }); 201 | 202 | describe('push file', function () { 203 | function getRandomDir () { 204 | return `/data/local/tmp/test${Math.random()}`; 205 | } 206 | 207 | let localFile; 208 | let tempFile; 209 | let tempRoot; 210 | const stringData = `random string data ${Math.random()}`; 211 | before(async function () { 212 | tempRoot = await tempDir.openDir(); 213 | localFile = path.join(tempRoot, 'local.tmp'); 214 | tempFile = path.join(tempRoot, 'temp.tmp'); 215 | 216 | await fs.writeFile(localFile, stringData); 217 | }); 218 | after(async function () { 219 | if (tempRoot) { 220 | await fs.rimraf(tempRoot); 221 | } 222 | }); 223 | afterEach(async function () { 224 | if (await fs.exists(tempFile)) { 225 | await fs.unlink(tempFile); 226 | } 227 | }); 228 | it('should push file to a valid location', async function () { 229 | let remoteFile = `${getRandomDir()}/remote.txt`; 230 | 231 | await adb.push(localFile, remoteFile); 232 | 233 | // get the file and its contents, to check 234 | await adb.pull(remoteFile, tempFile); 235 | let remoteData = await fs.readFile(tempFile); 236 | remoteData.toString().should.equal(stringData); 237 | }); 238 | it('should throw error if it cannot write to the remote file', async function () { 239 | await adb.push(localFile, '/foo/bar/remote.txt').should.be.rejectedWith(/\/foo/); 240 | }); 241 | }); 242 | 243 | describe('bugreport', function () { 244 | it('should return the report as a raw string', async function () { 245 | if (process.env.CI) { 246 | // skip the test on CI, since it takes a lot of time 247 | return this.skip; 248 | } 249 | const BUG_REPORT_TIMEOUT = 2 * 60 * 1000; // 2 minutes 250 | this.timeout(BUG_REPORT_TIMEOUT); 251 | (await adb.bugreport()).should.be.a('string'); 252 | }); 253 | }); 254 | 255 | describe('features', function () { 256 | it('should return the features as a list', async function () { 257 | _.isArray(await adb.listFeatures()).should.be.true; 258 | }); 259 | }); 260 | 261 | describe('launchable activity', function () { 262 | it('should resolve the name of the launchable activity', async function () { 263 | (await adb.resolveLaunchableActivity(CONTACT_MANAGER_PKG)).should.not.be.empty; 264 | }); 265 | }); 266 | 267 | describe('isStreamedInstallSupported', function () { 268 | it('should return boolean value', async function () { 269 | _.isBoolean(await adb.isStreamedInstallSupported()).should.be.true; 270 | }); 271 | }); 272 | 273 | describe('isIncrementalInstallSupported', function () { 274 | it('should return boolean value', async function () { 275 | _.isBoolean(await adb.isIncrementalInstallSupported()).should.be.true; 276 | }); 277 | }); 278 | 279 | describe('addToDeviceIdleWhitelist', function () { 280 | it('should add package to the whitelist', async function () { 281 | await adb.install(CONTACT_MANAGER_PATH, { 282 | timeout: androidInstallTimeout, 283 | grantPermissions: true, 284 | }); 285 | if (await adb.addToDeviceIdleWhitelist(CONTACT_MANAGER_PKG)) { 286 | const pkgList = await adb.getDeviceIdleWhitelist(); 287 | pkgList.some((item) => item.includes(CONTACT_MANAGER_PKG)).should.be.true; 288 | } 289 | }); 290 | }); 291 | 292 | describe('takeScreenshot', function () { 293 | it('should return screenshot', async function () { 294 | _.isEmpty(await adb.takeScreenshot()).should.be.false; 295 | }); 296 | }); 297 | 298 | describe('listPorts', function () { 299 | it('should list opened ports', async function () { 300 | (_.isEmpty(await adb.listPorts()) && _.isEmpty(await adb.listPorts('6'))).should.be.false; 301 | }); 302 | }); 303 | }); 304 | -------------------------------------------------------------------------------- /test/functional/adb-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import {ADB} from '../../lib/adb'; 3 | import { fs } from '@appium/support'; 4 | import path from 'path'; 5 | 6 | describe('ADB', function () { 7 | let chai; 8 | let should; 9 | 10 | before(async function () { 11 | chai = await import('chai'); 12 | const chaiAsPromised = await import('chai-as-promised'); 13 | 14 | should = chai.should(); 15 | chai.use(chaiAsPromised.default); 16 | }); 17 | 18 | it('should correctly return adb if present', async function () { 19 | let adb = await ADB.createADB(); 20 | should.exist(adb.executable.path); 21 | }); 22 | it('should throw when ANDROID_HOME is ivalid', async function () { 23 | let opts = {sdkRoot: '/aasdasdds'}; 24 | await ADB.createADB(opts).should.eventually.be.rejected; 25 | }); 26 | it.skip('should error out if binary not persent', async function () { 27 | // TODO write a negative test 28 | }); 29 | it('should initialize aapt', async function () { 30 | let adb = new ADB(); 31 | await adb.initAapt(); 32 | adb.binaries.aapt.should.contain('aapt'); 33 | }); 34 | it('should initialize aapt using the enforced build tools path', async function () { 35 | const buildToolsRoot = path.resolve(process.env.ANDROID_HOME, 'build-tools'); 36 | const buildToolsVersion = _.first(await fs.readdir(buildToolsRoot)); 37 | const adb = new ADB({buildToolsVersion}); 38 | await adb.initAapt(); 39 | adb.binaries.aapt.should.contain('aapt'); 40 | }); 41 | it('should initialize zipAlign', async function () { 42 | let adb = new ADB(); 43 | await adb.initZipAlign(); 44 | adb.binaries.zipalign.should.contain('zipalign'); 45 | }); 46 | it('should correctly initialize adb from parent', async function () { 47 | let adb = await ADB.createADB(); 48 | should.exist(adb.executable.path); 49 | let clone = adb.clone(); 50 | should.exist(clone.executable.path); 51 | adb.executable.path.should.equal(clone.executable.path); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/functional/adb-emu-commands-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | 3 | describe('adb emu commands', function () { 4 | let adb; 5 | let chai; 6 | 7 | before(async function () { 8 | chai = await import('chai'); 9 | const chaiAsPromised = await import('chai-as-promised'); 10 | 11 | chai.should(); 12 | chai.use(chaiAsPromised.default); 13 | 14 | if (process.env.REAL_DEVICE) { 15 | return this.skip(); 16 | } 17 | 18 | adb = await ADB.createADB(); 19 | const devices = await adb.getConnectedEmulators(); 20 | adb.setDevice(devices[0]); 21 | }); 22 | 23 | describe('execEmuConsoleCommand', function () { 24 | it('should print name', async function () { 25 | const name = await adb.execEmuConsoleCommand(['avd', 'name']); 26 | name.should.not.be.empty; 27 | }); 28 | 29 | it('should fail if the command is unknown', async function () { 30 | await adb.execEmuConsoleCommand(['avd', 'namer']).should.eventually 31 | .be.rejected; 32 | }); 33 | }); 34 | 35 | describe('getEmuVersionInfo', function () { 36 | it('should get version info', async function () { 37 | const {revision, buildId} = await adb.getEmuVersionInfo(); 38 | revision.should.not.be.empty; 39 | (buildId > 0).should.be.true; 40 | }); 41 | }); 42 | 43 | describe('getEmuImageProperties', function () { 44 | it('should get emulator image properties', async function () { 45 | if (process.env.CI) { 46 | return this.skip(); 47 | } 48 | 49 | const name = await adb.execEmuConsoleCommand(['avd', 'name']); 50 | const {target} = await adb.getEmuImageProperties(name); 51 | const apiMatch = /\d+/.exec(target); 52 | (parseInt(apiMatch[0], 10) > 0).should.be.true; 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/functional/android-manifest-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import path from 'path'; 3 | import { fs } from '@appium/support'; 4 | import { apiLevel } from './setup'; 5 | 6 | 7 | // All paths below assume tests run under /build/test/ so paths are relative from 8 | // that directory. 9 | const contactManagerPath = apiLevel < 23 10 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk') 11 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk'); 12 | const contactMangerSelendroidPath = path.resolve(__dirname, '..', 'fixtures', 'ContactManager-selendroid.apk'); 13 | const tmpDir = path.resolve(__dirname, '..', 'temp'); 14 | const srcManifest = path.resolve(__dirname, '..', 'fixtures', 'selendroid', 'AndroidManifest.xml'); 15 | const serverPath = path.resolve(__dirname, '..', 'fixtures', 'selendroid', 'selendroid.apk'); 16 | const CONTACT_MANAGER_PKG = apiLevel < 23 17 | ? 'com.example.android.contactmanager' 18 | : 'com.saucelabs.ContactManager'; 19 | 20 | describe('Android-manifest', function () { 21 | let adb; 22 | let chai; 23 | 24 | before(async function () { 25 | chai = await import('chai'); 26 | const chaiAsPromised = await import('chai-as-promised'); 27 | 28 | chai.should(); 29 | chai.use(chaiAsPromised.default); 30 | 31 | adb = await ADB.createADB(); 32 | }); 33 | it('packageAndLaunchActivityFromManifest should parse package and Activity', async function () { 34 | let {apkPackage, apkActivity} = await adb.packageAndLaunchActivityFromManifest(contactManagerPath); 35 | apkPackage.should.equal(CONTACT_MANAGER_PKG); 36 | apkActivity.endsWith('.ContactManager').should.be.true; 37 | }); 38 | it('hasInternetPermissionFromManifest should be true', async function () { 39 | let flag = await adb.hasInternetPermissionFromManifest(contactMangerSelendroidPath); 40 | flag.should.be.true; 41 | }); 42 | it('hasInternetPermissionFromManifest should be false', async function () { 43 | let flag = await adb.hasInternetPermissionFromManifest(contactManagerPath); 44 | flag.should.be.false; 45 | }); 46 | // TODO fix this test 47 | it.skip('should compile and insert manifest', async function () { 48 | let appPackage = CONTACT_MANAGER_PKG, 49 | newServerPath = path.resolve(tmpDir, `selendroid.${appPackage}.apk`), 50 | newPackage = 'com.example.android.contactmanager.selendroid', 51 | dstDir = path.resolve(tmpDir, appPackage), 52 | dstManifest = path.resolve(dstDir, 'AndroidManifest.xml'); 53 | // deleting temp directory if present 54 | try { 55 | await fs.rimraf(tmpDir); 56 | } catch (e) { 57 | console.log(`Unable to delete temp directory. It might not be present. ${e.message}`); // eslint-disable-line no-console 58 | } 59 | await fs.mkdir(tmpDir); 60 | await fs.mkdir(dstDir); 61 | await fs.writeFile(dstManifest, await fs.readFile(srcManifest, 'utf8'), 'utf8'); 62 | await adb.compileManifest(dstManifest, newPackage, appPackage); 63 | (await fs.fileExists(dstManifest)).should.be.true; 64 | await adb.insertManifest(dstManifest, serverPath, newServerPath); 65 | (await fs.fileExists(newServerPath)).should.be.true; 66 | // deleting temp directory 67 | try { 68 | await fs.rimraf(tmpDir); 69 | } catch (e) { 70 | console.log(`Unable to delete temp directory. It might not be present. ${e.message}`); // eslint-disable-line no-console 71 | } 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/functional/apk-signing-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import { unsignApk } from '../../lib/tools/apk-signing'; 5 | import { apiLevel } from './setup'; 6 | 7 | const fixturesRoot = path.resolve(__dirname, '..', 'fixtures'); 8 | const selendroidTestApp = path.resolve(fixturesRoot, 'selendroid-test-app.apk'); 9 | const contactManagerPath = apiLevel < 23 10 | ? path.resolve(fixturesRoot, 'ContactManager-old.apk') 11 | : path.resolve(fixturesRoot, 'ContactManager.apk'); 12 | const tmp = os.tmpdir(); 13 | const keystorePath = path.resolve(fixturesRoot, 'appiumtest.keystore'); 14 | const keyAlias = 'appiumtest'; 15 | const CONTACT_MANAGER_APP_ID = apiLevel < 23 16 | ? 'com.example.android.contactmanager' 17 | : 'com.saucelabs.ContactManager'; 18 | 19 | describe('Apk-signing', function () { 20 | let adb; 21 | let chai; 22 | 23 | before(async function () { 24 | chai = await import('chai'); 25 | const chaiAsPromised = await import('chai-as-promised'); 26 | 27 | chai.should(); 28 | chai.use(chaiAsPromised.default); 29 | 30 | adb = await ADB.createADB(); 31 | }); 32 | it('checkApkCert should return false for unsigned apk', async function () { 33 | await unsignApk(selendroidTestApp); 34 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.false; 35 | }); 36 | it('checkApkCert should return true for signed apk', async function () { 37 | (await adb.checkApkCert(contactManagerPath, CONTACT_MANAGER_APP_ID)).should.be.true; 38 | }); 39 | it('signWithDefaultCert should sign apk', async function () { 40 | await unsignApk(selendroidTestApp); 41 | (await adb.signWithDefaultCert(selendroidTestApp)); 42 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.true; 43 | }); 44 | it('signWithCustomCert should sign apk with custom certificate', async function () { 45 | await unsignApk(selendroidTestApp); 46 | adb.keystorePath = keystorePath; 47 | adb.keyAlias = keyAlias; 48 | adb.useKeystore = true; 49 | adb.keystorePassword = 'android'; 50 | adb.keyPassword = 'android'; 51 | adb.tmpDir = tmp; 52 | (await adb.signWithCustomCert(selendroidTestApp)); 53 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.true; 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/functional/apk-utils-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import path from 'path'; 3 | import { retryInterval } from 'asyncbox'; 4 | import { MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT, apiLevel } from './setup'; 5 | 6 | const START_APP_WAIT_DURATION = 60000; 7 | const START_APP_WAIT_DURATION_FAIL = process.env.CI ? 20000 : 10000; 8 | const CONTACT_MANAGER_APP_ID = apiLevel < 23 9 | ? 'com.example.android.contactmanager' 10 | : 'com.saucelabs.ContactManager'; 11 | const CONTACT_MANAGER_ACTIVITY = apiLevel < 23 12 | ? 'ContactManager' 13 | : 'com.saucelabs.ContactManager.ContactManager'; 14 | 15 | describe('apk utils', function () { 16 | this.timeout(MOCHA_TIMEOUT); 17 | 18 | let adb; 19 | let chai; 20 | 21 | const contactManagerPath = apiLevel < 23 22 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk') 23 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk'); 24 | const apiDemosPath = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk'); 25 | const deviceTempPath = '/data/local/tmp/'; 26 | const assertPackageAndActivity = async () => { 27 | let {appPackage, appActivity} = await adb.getFocusedPackageAndActivity(); 28 | appPackage.should.equal(CONTACT_MANAGER_APP_ID); 29 | appActivity.should.equal('.ContactManager'); 30 | }; 31 | 32 | before(async function () { 33 | chai = await import('chai'); 34 | const chaiAsPromised = await import('chai-as-promised'); 35 | 36 | chai.should(); 37 | chai.use(chaiAsPromised.default); 38 | 39 | adb = await ADB.createADB({ 40 | adbExecTimeout: process.env.CI ? 60000 : 40000, 41 | }); 42 | }); 43 | it('should be able to check status of third party app', async function () { 44 | (await adb.isAppInstalled('com.android.phone')).should.be.true; 45 | }); 46 | it('should be able to install/remove app and detect its status', async function () { 47 | const apkNameOnDevice = apiLevel < 23 48 | ? 'ContactManager-old.apk' 49 | : 'ContactManager.apk'; 50 | (await adb.isAppInstalled('foo')).should.be.false; 51 | await adb.install(contactManagerPath, { 52 | grantPermissions: true 53 | }); 54 | (await adb.isAppInstalled(CONTACT_MANAGER_APP_ID)).should.be.true; 55 | (await adb.uninstallApk(CONTACT_MANAGER_APP_ID)).should.be.true; 56 | (await adb.isAppInstalled(CONTACT_MANAGER_APP_ID)).should.be.false; 57 | (await adb.uninstallApk(CONTACT_MANAGER_APP_ID)).should.be.false; 58 | await adb.rimraf(deviceTempPath + apkNameOnDevice); 59 | await adb.push(contactManagerPath, deviceTempPath); 60 | await adb.installFromDevicePath(deviceTempPath + apkNameOnDevice); 61 | 62 | // to ensure that the app is installed with grantPermissions. 63 | await adb.grantAllPermissions(CONTACT_MANAGER_APP_ID); 64 | }); 65 | describe('startUri', function () { 66 | it('should be able to start a uri', async function () { 67 | if (apiLevel < 23 || apiLevel > 28) { 68 | return this.skip(); 69 | } 70 | await adb.goToHome(); 71 | let res = await adb.getFocusedPackageAndActivity(); 72 | res.appPackage.should.not.equal('com.android.contacts'); 73 | await adb.install(contactManagerPath, { 74 | grantPermissions: true, 75 | }); 76 | await adb.startUri('content://contacts/people', 'com.android.contacts'); 77 | await retryInterval(10, 500, async () => { 78 | res = await adb.dumpWindows(); 79 | // depending on apilevel, app might show up as active in one of these 80 | // two dumpsys output formats 81 | let focusRe1 = '(mCurrentFocus.+\\.PeopleActivity)'; 82 | let focusRe2 = '(mFocusedApp.+\\.PeopleActivity)'; 83 | res.should.match(new RegExp(`${focusRe1}|${focusRe2}`)); 84 | }); 85 | await adb.goToHome(); 86 | }); 87 | }); 88 | describe('startApp', function () { 89 | it('should be able to start with normal package and activity', async function () { 90 | await adb.install(contactManagerPath, { 91 | grantPermissions: true 92 | }); 93 | await adb.startApp({ 94 | pkg: CONTACT_MANAGER_APP_ID, 95 | activity: CONTACT_MANAGER_ACTIVITY, 96 | waitDuration: START_APP_WAIT_DURATION, 97 | }); 98 | await retryInterval(10, 500, async () => { 99 | // It might be too fast to check the package and activity 100 | // because the started app could take a bit time 101 | // to come to the foreground in machine time. 102 | await assertPackageAndActivity(); 103 | }); 104 | 105 | 106 | }); 107 | it('should be able to start with an intent and no activity', async function () { 108 | this.timeout(MOCHA_LONG_TIMEOUT); 109 | await adb.install(contactManagerPath, { 110 | grantPermissions: true 111 | }); 112 | await adb.startApp({ 113 | action: 'android.intent.action.WEB_SEARCH', 114 | pkg: 'com.google.android.googlequicksearchbox', 115 | optionalIntentArguments: '-e query foo', 116 | waitDuration: START_APP_WAIT_DURATION, 117 | stopApp: false 118 | }); 119 | let {appPackage} = await adb.getFocusedPackageAndActivity(); 120 | const expectedPkgPossibilities = [ 121 | 'com.android.browser', 122 | 'org.chromium.webview_shell', 123 | 'com.google.android.googlequicksearchbox' 124 | ]; 125 | expectedPkgPossibilities.should.include(appPackage); 126 | }); 127 | it('should throw an error for unknown activity for intent', async function () { 128 | this.timeout(MOCHA_LONG_TIMEOUT); 129 | await adb.install(contactManagerPath, { 130 | grantPermissions: true 131 | }); 132 | await adb.startApp({ 133 | action: 'android.intent.action.DEFAULT', 134 | pkg: 'com.google.android.telephony', 135 | optionalIntentArguments: '-d tel:555-5555', 136 | waitDuration: START_APP_WAIT_DURATION, 137 | stopApp: false 138 | }).should.eventually.be.rejectedWith(/Cannot start the .* application/); 139 | }); 140 | it('should throw error for wrong activity', async function () { 141 | await adb.install(contactManagerPath, { 142 | grantPermissions: true 143 | }); 144 | await adb.startApp({ 145 | pkg: CONTACT_MANAGER_APP_ID, 146 | activity: 'ContactManage', 147 | waitDuration: START_APP_WAIT_DURATION_FAIL, 148 | }).should.eventually.be.rejectedWith('Activity'); 149 | }); 150 | it('should throw error for wrong wait activity', async function () { 151 | await adb.install(contactManagerPath, { 152 | grantPermissions: true 153 | }); 154 | await adb.startApp({ 155 | pkg: CONTACT_MANAGER_APP_ID, 156 | activity: CONTACT_MANAGER_ACTIVITY, 157 | waitActivity: 'foo', 158 | waitDuration: START_APP_WAIT_DURATION_FAIL, 159 | }).should.eventually.be.rejectedWith('foo'); 160 | }); 161 | it('should start activity with wait activity', async function () { 162 | await adb.install(contactManagerPath, { 163 | grantPermissions: true 164 | }); 165 | await adb.startApp({ 166 | pkg: CONTACT_MANAGER_APP_ID, 167 | activity: CONTACT_MANAGER_ACTIVITY, 168 | waitActivity: '.ContactManager', 169 | waitDuration: START_APP_WAIT_DURATION, 170 | }); 171 | await assertPackageAndActivity(); 172 | }); 173 | it('should start activity when wait activity is a wildcard', async function () { 174 | await adb.install(contactManagerPath, { 175 | grantPermissions: true 176 | }); 177 | await adb.startApp({ 178 | pkg: CONTACT_MANAGER_APP_ID, 179 | activity: CONTACT_MANAGER_ACTIVITY, 180 | waitActivity: '*', 181 | waitDuration: START_APP_WAIT_DURATION, 182 | }); 183 | await assertPackageAndActivity(); 184 | }); 185 | it('should start activity when wait activity contains a wildcard', async function () { 186 | await adb.install(contactManagerPath, { 187 | grantPermissions: true 188 | }); 189 | await adb.startApp({ 190 | pkg: CONTACT_MANAGER_APP_ID, 191 | activity: CONTACT_MANAGER_ACTIVITY, 192 | waitActivity: '*.ContactManager', 193 | waitDuration: START_APP_WAIT_DURATION, 194 | }); 195 | await assertPackageAndActivity(); 196 | }); 197 | it('should throw error for wrong activity when wait activity contains a wildcard', async function () { 198 | await adb.install(contactManagerPath, { 199 | grantPermissions: true 200 | }); 201 | await adb.startApp({ 202 | pkg: CONTACT_MANAGER_APP_ID, 203 | activity: 'SuperManager', 204 | waitActivity: '*.ContactManager', 205 | waitDuration: START_APP_WAIT_DURATION_FAIL, 206 | }).should.eventually.be.rejectedWith('Activity'); 207 | }); 208 | it('should throw error for wrong wait activity which contains wildcard', async function () { 209 | await adb.install(contactManagerPath, { 210 | grantPermissions: true 211 | }); 212 | await adb.startApp({ 213 | pkg: CONTACT_MANAGER_APP_ID, 214 | activity: CONTACT_MANAGER_ACTIVITY, 215 | waitActivity: '*.SuperManager', 216 | waitDuration: START_APP_WAIT_DURATION_FAIL, 217 | }).should.eventually.be.rejectedWith('SuperManager'); 218 | }); 219 | it('should start activity with comma separated wait packages list', async function () { 220 | await adb.install(contactManagerPath, { 221 | grantPermissions: true 222 | }); 223 | await adb.startApp({ 224 | pkg: CONTACT_MANAGER_APP_ID, 225 | waitPkg: `com.android.settings, ${CONTACT_MANAGER_APP_ID}`, 226 | activity: CONTACT_MANAGER_ACTIVITY, 227 | waitActivity: '.ContactManager', 228 | waitDuration: START_APP_WAIT_DURATION, 229 | }); 230 | await assertPackageAndActivity(); 231 | }); 232 | it('should throw error for wrong activity when packages provided as comma separated list', async function () { 233 | await adb.install(contactManagerPath, { 234 | grantPermissions: true 235 | }); 236 | await adb.startApp({ 237 | pkg: CONTACT_MANAGER_APP_ID, 238 | waitPkg: 'com.android.settings, com.example.somethingelse', 239 | activity: 'SuperManager', 240 | waitActivity: '*.ContactManager', 241 | waitDuration: START_APP_WAIT_DURATION_FAIL, 242 | }).should.eventually.be.rejectedWith('Activity'); 243 | }); 244 | }); 245 | it('should start activity when start activity is an inner class', async function () { 246 | await adb.install(contactManagerPath, { 247 | grantPermissions: true 248 | }); 249 | await adb.startApp({ 250 | pkg: 'com.android.settings', 251 | activity: '.Settings$NotificationAppListActivity', 252 | waitDuration: START_APP_WAIT_DURATION, 253 | }); 254 | let {appPackage, appActivity} = await adb.getFocusedPackageAndActivity(); 255 | appPackage.should.equal('com.android.settings'); 256 | 257 | // The appActivity is different depending on the API level. 258 | if (await adb.getApiLevel() > 35) { 259 | appActivity.should.equal('.spa.SpaActivity'); 260 | } else { 261 | appActivity.should.equal('.Settings$NotificationAppListActivity'); 262 | }; 263 | }); 264 | it('getFocusedPackageAndActivity should be able get package and activity', async function () { 265 | await adb.install(contactManagerPath, { 266 | grantPermissions: true 267 | }); 268 | await adb.startApp({ 269 | pkg: CONTACT_MANAGER_APP_ID, 270 | activity: CONTACT_MANAGER_ACTIVITY, 271 | waitActivity: '.ContactManager', 272 | waitDuration: START_APP_WAIT_DURATION, 273 | }); 274 | await assertPackageAndActivity(); 275 | }); 276 | it('extractStringsFromApk should get strings for default language', async function () { 277 | let {apkStrings} = await adb.extractStringsFromApk(contactManagerPath, null, '/tmp'); 278 | apkStrings.save.should.equal('Save'); 279 | }); 280 | it('extractStringsFromApk should get strings for non-default language', async function () { 281 | let {apkStrings} = await adb.extractStringsFromApk(apiDemosPath, 'fr', '/tmp'); 282 | apkStrings.linear_layout_8_horizontal.should.equal('Horizontal'); 283 | }); 284 | it('extractStringsFromApk should get strings for en language', async function () { 285 | let {apkStrings} = await adb.extractStringsFromApk(apiDemosPath, 'en', '/tmp'); 286 | apkStrings.linear_layout_8_horizontal.should.equal('Horizontal'); 287 | }); 288 | describe('activateApp', function () { 289 | it('should be able to activate with normal package and activity', async function () { 290 | if (await adb.getApiLevel() < 23) { 291 | return this.skip(); 292 | } 293 | 294 | await adb.install(contactManagerPath, { 295 | grantPermissions: true 296 | }); 297 | await adb.startApp({ 298 | pkg: CONTACT_MANAGER_APP_ID, 299 | activity: CONTACT_MANAGER_ACTIVITY, 300 | waitDuration: START_APP_WAIT_DURATION, 301 | }); 302 | await retryInterval(10, 500, async () => { 303 | await adb.goToHome(); 304 | const {appPackage} = await adb.getFocusedPackageAndActivity(); 305 | appPackage.should.not.eql(CONTACT_MANAGER_APP_ID); 306 | }); 307 | await retryInterval(10, 500, async () => { 308 | await adb.activateApp(CONTACT_MANAGER_APP_ID); 309 | const {appPackage} = await adb.getFocusedPackageAndActivity(); 310 | appPackage.should.eql(CONTACT_MANAGER_APP_ID); 311 | }); 312 | }); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /test/functional/helpers-specs-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import { 2 | requireSdkRoot, 3 | readPackageManifest, 4 | } from '../../lib/helpers.js'; 5 | import {ADB} from '../../lib/adb'; 6 | import path from 'node:path'; 7 | import { getAndroidPlatformAndPath } from '../../lib/tools/android-manifest'; 8 | 9 | describe('Helpers', function () { 10 | let chai; 11 | 12 | before(async function () { 13 | chai = await import('chai'); 14 | const chaiAsPromised = await import('chai-as-promised'); 15 | 16 | chai.should(); 17 | chai.use(chaiAsPromised.default); 18 | }); 19 | 20 | it('getAndroidPlatformAndPath should return platform and path for android', async function () { 21 | const sdkRoot = await requireSdkRoot(); 22 | const {platform, platformPath} = await getAndroidPlatformAndPath(sdkRoot); 23 | platform.should.exist; 24 | platformPath.should.exist; 25 | }); 26 | 27 | it('should read package manifest', async function () { 28 | const expected = { 29 | name: 'io.appium.android.apis', 30 | versionCode: 24, 31 | minSdkVersion: 17, 32 | compileSdkVersion: 31, 33 | usesPermissions: [ 34 | 'android.permission.READ_CONTACTS', 35 | 'android.permission.WRITE_CONTACTS', 36 | 'android.permission.VIBRATE', 37 | 'android.permission.ACCESS_COARSE_LOCATION', 38 | 'android.permission.INTERNET', 39 | 'android.permission.SET_WALLPAPER', 40 | 'android.permission.WRITE_EXTERNAL_STORAGE', 41 | 'android.permission.SEND_SMS', 42 | 'android.permission.RECEIVE_SMS', 43 | 'android.permission.NFC', 44 | 'android.permission.RECORD_AUDIO', 45 | 'android.permission.CAMERA', 46 | 'android.permission.READ_EXTERNAL_STORAGE' 47 | ], 48 | launchableActivity: { 49 | 'name': 'io.appium.android.apis.ApiDemos', 50 | }, 51 | architectures: [], 52 | locales: [ 53 | '--_--', 54 | 'af', 55 | 'am', 56 | 'ar', 57 | 'as', 58 | 'az', 59 | 'be', 60 | 'bg', 61 | 'bn', 62 | 'bs', 63 | 'ca', 64 | 'cs', 65 | 'da', 66 | 'de', 67 | 'el', 68 | 'en-AU', 69 | 'en-CA', 70 | 'en-GB', 71 | 'en-IN', 72 | 'en-XC', 73 | 'es', 74 | 'es-US', 75 | 'et', 76 | 'eu', 77 | 'fa', 78 | 'fi', 79 | 'fr', 80 | 'fr-CA', 81 | 'gl', 82 | 'gu', 83 | 'hi', 84 | 'hr', 85 | 'hu', 86 | 'hy', 87 | 'in', 88 | 'is', 89 | 'it', 90 | 'iw', 91 | 'ja', 92 | 'ka', 93 | 'kk', 94 | 'km', 95 | 'kn', 96 | 'ko', 97 | 'ky', 98 | 'lo', 99 | 'lt', 100 | 'lv', 101 | 'mk', 102 | 'ml', 103 | 'mn', 104 | 'mr', 105 | 'ms', 106 | 'my', 107 | 'nb', 108 | 'ne', 109 | 'nl', 110 | 'or', 111 | 'pa', 112 | 'pl', 113 | 'pt', 114 | 'pt-BR', 115 | 'pt-PT', 116 | 'ro', 117 | 'ru', 118 | 'si', 119 | 'sk', 120 | 'sl', 121 | 'sq', 122 | 'sr', 123 | 'sr-Latn', 124 | 'sv', 125 | 'sw', 126 | 'ta', 127 | 'te', 128 | 'th', 129 | 'tl', 130 | 'tr', 131 | 'uk', 132 | 'ur', 133 | 'uz', 134 | 'vi', 135 | 'zh-CN', 136 | 'zh-HK', 137 | 'zh-TW', 138 | 'zu' 139 | ], 140 | densities: [ 141 | 120, 142 | 160, 143 | 240, 144 | 320, 145 | 480, 146 | 640, 147 | 65535 148 | ], 149 | versionName: '4.1.1', 150 | platformBuildVersionName: '12', 151 | platformBuildVersionCode: 31, 152 | compileSdkVersionCodename: '12', 153 | targetSdkVersion: 31, 154 | }; 155 | 156 | const adb = await ADB.createADB(); 157 | const apiDemosPath = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk'); 158 | const manifest = await readPackageManifest.bind(adb)(apiDemosPath); 159 | expected.should.eql(manifest); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/functional/lock-mgmt-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | 3 | describe('Lock Management', function () { 4 | let adb; 5 | let chai; 6 | 7 | before(async function () { 8 | chai = await import('chai'); 9 | const chaiAsPromised = await import('chai-as-promised'); 10 | 11 | chai.should(); 12 | chai.use(chaiAsPromised.default); 13 | 14 | adb = await ADB.createADB(); 15 | if (!await adb.isLockManagementSupported()) { 16 | return this.skip(); 17 | } 18 | }); 19 | 20 | it('lock credential cleanup should work', async function () { 21 | await adb.clearLockCredential(); 22 | await adb.verifyLockCredential().should.eventually.be.true; 23 | await adb.isLockEnabled().should.eventually.be.false; 24 | }); 25 | 26 | describe('Lock and unlock life cycle', function () { 27 | const password = '1234'; 28 | 29 | before(function () { 30 | if (process.env.CI) { 31 | // We don't want to lock the device for all other tests if this test fails 32 | return this.skip(); 33 | } 34 | }); 35 | afterEach(async function () { 36 | await adb.clearLockCredential(password); 37 | }); 38 | 39 | it('device lock and unlock scenario should work', async function () { 40 | await adb.setLockCredential('password', password); 41 | await adb.keyevent(26); 42 | await adb.isLockEnabled().should.eventually.be.true; 43 | await adb.isScreenLocked().should.eventually.be.true; 44 | await adb.clearLockCredential(password); 45 | await adb.cycleWakeUp(); 46 | await adb.dismissKeyguard(); 47 | await adb.isLockEnabled().should.eventually.be.false; 48 | await adb.isScreenLocked().should.eventually.be.false; 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/functional/logcat-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import { Logcat } from '../../lib/logcat'; 3 | import { MOCHA_TIMEOUT } from './setup'; 4 | 5 | describe('logcat', function () { 6 | this.timeout(MOCHA_TIMEOUT); 7 | 8 | async function runClearDeviceLogTest (adb, logcat, clear = true) { 9 | let logs = await adb.adbExec(['logcat', '-d']); 10 | await logcat.startCapture(); 11 | await logcat.stopCapture(); 12 | let newLogs = await adb.adbExec(['logcat', '-d']); 13 | if (clear) { 14 | newLogs.should.not.include(logs); 15 | } else { 16 | newLogs.should.include(logs); 17 | } 18 | } 19 | 20 | let adb; 21 | let logcat; 22 | let chai; 23 | 24 | before(async function () { 25 | chai = await import('chai'); 26 | const chaiAsPromised = await import('chai-as-promised'); 27 | 28 | chai.should(); 29 | chai.use(chaiAsPromised.default); 30 | 31 | adb = await ADB.createADB(); 32 | }); 33 | afterEach(async function () { 34 | if (logcat) { 35 | await logcat.stopCapture(); 36 | } 37 | }); 38 | describe('clearDeviceLogsOnStart = false', function () { 39 | before(function () { 40 | logcat = new Logcat({ 41 | adb: adb.executable, 42 | debug: false, 43 | debugTrace: false, 44 | }); 45 | }); 46 | it('getLogs should return logs', async function () { 47 | await logcat.startCapture(); 48 | let logs = logcat.getLogs(); 49 | logs.should.have.length.above(0); 50 | }); 51 | it('getAllLogs should return all logs', async function () { 52 | await logcat.startCapture(); 53 | let logs = logcat.getAllLogs(); 54 | logs.should.have.length.above(0); 55 | }); 56 | it('should not affect device logs', async function () { 57 | if (process.env.CI) { 58 | return this.skip(); 59 | } 60 | await runClearDeviceLogTest(adb, logcat, false); 61 | }); 62 | }); 63 | describe('clearDeviceLogsOnStart = true', function () { 64 | before(function () { 65 | logcat = new Logcat({ 66 | adb: adb.executable, 67 | debug: false, 68 | debugTrace: false, 69 | clearDeviceLogsOnStart: true, 70 | }); 71 | }); 72 | it('should clear the logs before starting capture', async function () { 73 | await runClearDeviceLogTest(adb, logcat, true); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/functional/setup.js: -------------------------------------------------------------------------------- 1 | // https://developer.android.com/guide/topics/manifest/uses-sdk-element.html 2 | const API_LEVEL_MAP = { 3 | 4.1: '16', 4 | 4.2: '17', 5 | 4.3: '18', 6 | 4.4: '19', 7 | 5: '21', 8 | 5.1: '22', 9 | 6: '23', 10 | 7: '24', 11 | 7.1: '25', 12 | 8.0: '26', 13 | 8.1: '27', 14 | 9: '28', 15 | 10: '29', 16 | 11: '30', 17 | 12: '32', // and 31 18 | 13: '33', 19 | 14: '34', 20 | 15: '35', 21 | 16: '36', 22 | }; 23 | 24 | const avdName = process.env.ANDROID_AVD || 'NEXUS_S_18_X86'; 25 | const platformVersion = process.env.PLATFORM_VERSION || 4.3; 26 | 27 | const apiLevel = parseInt(process.env.ANDROID_SDK_VERSION 28 | || process.env.API_LEVEL 29 | || API_LEVEL_MAP[platformVersion], 10); 30 | 31 | const MOCHA_TIMEOUT = process.env.CI ? 240000 : 60000; 32 | const MOCHA_LONG_TIMEOUT = MOCHA_TIMEOUT * 10; 33 | 34 | export { apiLevel, platformVersion, avdName, MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT }; 35 | -------------------------------------------------------------------------------- /test/functional/syscalls-e2e-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import { apiLevel, avdName, MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT } from './setup'; 3 | import path from 'path'; 4 | import { getResourcePath } from '../../lib/helpers.js'; 5 | import { fs } from '@appium/support'; 6 | import _ from 'lodash'; 7 | 8 | const DEFAULT_CERTIFICATE = path.join('keys', 'testkey.x509.pem'); 9 | 10 | describe('System calls', function () { 11 | this.timeout(MOCHA_TIMEOUT); 12 | 13 | let adb; 14 | let chai; 15 | 16 | before(async function () { 17 | chai = await import('chai'); 18 | const chaiAsPromised = await import('chai-as-promised'); 19 | 20 | chai.should(); 21 | chai.use(chaiAsPromised.default); 22 | 23 | adb = await ADB.createADB(); 24 | }); 25 | it('getConnectedDevices should get devices', async function () { 26 | let devices = await adb.getConnectedDevices(); 27 | devices.should.have.length.above(0); 28 | }); 29 | it('getDevicesWithRetry should get devices', async function () { 30 | let devices = await adb.getDevicesWithRetry(); 31 | devices.should.have.length.above(0); 32 | }); 33 | it('adbExec should get devices when with devices', async function () { 34 | (await adb.adbExec('devices')).should.contain('List of devices attached'); 35 | }); 36 | it('isDeviceConnected should be true', async function () { 37 | (await adb.isDeviceConnected()).should.be.true; 38 | }); 39 | it('shell should execute command in adb shell ', async function () { 40 | (await adb.shell(['getprop', 'ro.build.version.sdk'])).should.equal(`${apiLevel}`); 41 | }); 42 | it('shell should return stderr from adb with full output', async function () { 43 | const minStderrApiLevel = 24; 44 | let fullShellOutput = await adb.shell(['content', 'read', '--uri', 'content://doesnotexist'], {outputFormat: adb.EXEC_OUTPUT_FORMAT.FULL}); 45 | let outputWithError = apiLevel < minStderrApiLevel ? fullShellOutput.stdout : fullShellOutput.stderr; 46 | outputWithError.should.contain('Error while accessing provider'); 47 | }); 48 | it('shell should return stdout from adb shell with full output', async function () { 49 | let fullShellOutput = await adb.shell(['getprop', 'ro.build.version.sdk'], {outputFormat: adb.EXEC_OUTPUT_FORMAT.FULL}); 50 | fullShellOutput.stderr.should.equal(''); 51 | fullShellOutput.stdout.should.equal(`${apiLevel}`); 52 | }); 53 | it('getConnectedEmulators should get all connected emulators', async function () { 54 | (await adb.getConnectedEmulators()).length.should.be.above(0); 55 | }); 56 | it('getRunningAVD should get all connected avd', async function () { 57 | (await adb.getRunningAVD(avdName)).should.not.be.null; 58 | }); 59 | it('getRunningAVDWithRetry should get all connected avds', async function () { 60 | (await adb.getRunningAVDWithRetry(avdName)).should.not.be.null; 61 | }); 62 | // Skipping for now. Will unskip depending on how it behaves on CI 63 | it.skip('launchAVD should get all connected avds', async function () { 64 | this.timeout(MOCHA_LONG_TIMEOUT); 65 | let proc = await adb.launchAVD(avdName); 66 | (await adb.getConnectedEmulators()).length.should.be.above(0); 67 | proc.stop(); 68 | }); 69 | it('waitForDevice should get all connected avds', async function () { 70 | await adb.waitForDevice(2); 71 | }); 72 | it('reboot should reboot the device', async function () { 73 | if (process.env.CI) { 74 | // The test makes CI unstable 75 | return this.skip(); 76 | } 77 | this.timeout(MOCHA_LONG_TIMEOUT); 78 | try { 79 | await adb.reboot(); 80 | await adb.ping(); 81 | } catch (e) { 82 | e.message.should.include('must be root'); 83 | } 84 | }); 85 | it('fileExists should detect when files do and do not exist', async function () { 86 | (await adb.fileExists('/foo/bar/baz.zip')).should.be.false; 87 | (await adb.fileExists('/data/local/tmp')).should.be.true; 88 | }); 89 | it('ls should list files', async function () { 90 | (await adb.ls('/foo/bar')).should.eql([]); 91 | await adb.shell(['touch', '/data/local/tmp/test']); 92 | (await adb.ls('/data/local/tmp')).should.contain('test'); 93 | }); 94 | it('should check if the given certificate is already installed', async function () { 95 | const certBuffer = await fs.readFile(await getResourcePath(DEFAULT_CERTIFICATE)); 96 | (await adb.isMitmCertificateInstalled(certBuffer)).should.be.false; 97 | }); 98 | it('should return version', async function () { 99 | const {binary, bridge} = await adb.getVersion(); 100 | if (binary) { 101 | _.has(binary, 'version').should.be.true; 102 | _.has(binary, 'build').should.be.true; 103 | } 104 | _.has(bridge, 'version').should.be.true; 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/unit/adb-emu-commands-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import { withMocks } from '@appium/test-support'; 3 | import _ from 'lodash'; 4 | 5 | const emulators = [ 6 | { udid: 'emulator-5554', state: 'device', port: 5554 }, 7 | { udid: 'emulator-5556', state: 'device', port: 5556 }, 8 | ]; 9 | const fingerprintId = 1111; 10 | 11 | const adb = new ADB(); 12 | 13 | describe('adb emulator commands', withMocks({adb}, function (mocks) { 14 | let chai; 15 | 16 | before(async function () { 17 | chai = await import('chai'); 18 | const chaiAsPromised = await import('chai-as-promised'); 19 | 20 | chai.should(); 21 | chai.use(chaiAsPromised.default); 22 | }); 23 | 24 | afterEach(function () { 25 | mocks.verify(); 26 | }); 27 | 28 | describe('emu', function () { 29 | describe('isEmulatorConnected', function () { 30 | it('should verify emulators state', async function () { 31 | mocks.adb.expects('getConnectedEmulators') 32 | .atLeast(3) 33 | .returns(emulators); 34 | adb.curDeviceId = 'emulator-5554'; 35 | (await adb.isEmulatorConnected()).should.equal(true); 36 | adb.curDeviceId = 'emulator-5556'; 37 | (await adb.isEmulatorConnected()).should.equal(true); 38 | adb.curDeviceId = 'emulator-5558'; 39 | (await adb.isEmulatorConnected()).should.equal(false); 40 | }); 41 | }); 42 | describe('verifyEmulatorConnected', function () { 43 | it('should throw an exception on emulator not connected', async function () { 44 | adb.curDeviceId = 'emulator-5558'; 45 | mocks.adb.expects('isEmulatorConnected') 46 | .once() 47 | .returns(false); 48 | await adb.verifyEmulatorConnected().should.eventually.be.rejected; 49 | }); 50 | }); 51 | describe('fingerprint', function () { 52 | it('should throw exception on undefined fingerprintId', async function () { 53 | await adb.fingerprint().should.eventually.be.rejected; 54 | }); 55 | it('should throw exception on apiLevel lower than 23', async function () { 56 | mocks.adb.expects('getApiLevel') 57 | .once().withExactArgs() 58 | .returns(21); 59 | await adb.fingerprint(fingerprintId).should.eventually.be.rejected; 60 | }); 61 | it('should call adbExec with the correct args', async function () { 62 | mocks.adb.expects('getApiLevel') 63 | .once().withExactArgs() 64 | .returns(23); 65 | mocks.adb.expects('isEmulatorConnected') 66 | .once().withExactArgs() 67 | .returns(true); 68 | mocks.adb.expects('resetTelnetAuthToken') 69 | .once().withExactArgs() 70 | .returns(); 71 | mocks.adb.expects('adbExec') 72 | .once().withExactArgs(['emu', 'finger', 'touch', fingerprintId]) 73 | .returns(); 74 | await adb.fingerprint(fingerprintId); 75 | }); 76 | }); 77 | describe('rotate', function () { 78 | it('should call adbExec with the correct args', async function () { 79 | mocks.adb.expects('isEmulatorConnected') 80 | .once().withExactArgs() 81 | .returns(true); 82 | mocks.adb.expects('resetTelnetAuthToken') 83 | .once().withExactArgs() 84 | .returns(); 85 | mocks.adb.expects('adbExec') 86 | .once().withExactArgs(['emu', 'rotate']) 87 | .returns(); 88 | await adb.rotate(); 89 | }); 90 | }); 91 | describe('power methods', function () { 92 | it('should throw exception on invalid power ac state', async function () { 93 | await adb.powerAC('dead').should.eventually.be.rejectedWith('Wrong power AC state'); 94 | }); 95 | it('should set the power ac off', async function () { 96 | mocks.adb.expects('isEmulatorConnected') 97 | .once().withExactArgs() 98 | .returns(true); 99 | mocks.adb.expects('resetTelnetAuthToken') 100 | .once().withExactArgs() 101 | .returns(); 102 | mocks.adb.expects('adbExec') 103 | .once().withExactArgs(['emu', 'power', 'ac', adb.POWER_AC_STATES.POWER_AC_OFF]) 104 | .returns(); 105 | await adb.powerAC('off'); 106 | }); 107 | it('should set the power ac on', async function () { 108 | mocks.adb.expects('isEmulatorConnected') 109 | .once().withExactArgs() 110 | .returns(true); 111 | mocks.adb.expects('resetTelnetAuthToken') 112 | .once().withExactArgs() 113 | .returns(); 114 | mocks.adb.expects('adbExec') 115 | .once().withExactArgs(['emu', 'power', 'ac', adb.POWER_AC_STATES.POWER_AC_ON]) 116 | .returns(); 117 | await adb.powerAC('on'); 118 | }); 119 | it('should throw exception on invalid power battery percent', async function () { 120 | await adb.powerCapacity(-1).should.eventually.be.rejectedWith('should be valid integer between 0 and 100'); 121 | await adb.powerCapacity('a').should.eventually.be.rejectedWith('should be valid integer between 0 and 100'); 122 | await adb.powerCapacity(500).should.eventually.be.rejectedWith('should be valid integer between 0 and 100'); 123 | }); 124 | it('should set the power capacity', async function () { 125 | mocks.adb.expects('isEmulatorConnected') 126 | .once().withExactArgs() 127 | .returns(true); 128 | mocks.adb.expects('resetTelnetAuthToken') 129 | .once().withExactArgs() 130 | .returns(); 131 | mocks.adb.expects('adbExec') 132 | .once().withExactArgs(['emu', 'power', 'capacity', '0']) 133 | .returns(); 134 | await adb.powerCapacity(0); 135 | }); 136 | it('should call methods to power off the emulator', async function () { 137 | mocks.adb.expects('powerAC') 138 | .once().withExactArgs('off') 139 | .returns(); 140 | mocks.adb.expects('powerCapacity') 141 | .once().withExactArgs(0) 142 | .returns(); 143 | await adb.powerOFF(); 144 | }); 145 | }); 146 | describe('sendSMS', function () { 147 | it('should throw exception on invalid message', async function () { 148 | await adb.sendSMS('+549341312345678').should.be.rejected; 149 | }); 150 | it('should throw exception on invalid phoneNumber', async function () { 151 | await adb.sendSMS('', 'Hello Appium').should.be.rejected; 152 | }); 153 | it('should call adbExec with the correct args', async function () { 154 | let phoneNumber = 4509; 155 | let message = ' Hello Appium '; 156 | mocks.adb.expects('isEmulatorConnected') 157 | .once().withExactArgs() 158 | .returns(true); 159 | mocks.adb.expects('resetTelnetAuthToken') 160 | .once().withExactArgs() 161 | .returns(); 162 | mocks.adb.expects('adbExec') 163 | .once().withExactArgs(['emu', 'sms', 'send', `${phoneNumber}`, message]) 164 | .returns(); 165 | await adb.sendSMS(phoneNumber, message); 166 | }); 167 | }); 168 | describe('gsm signal method', function () { 169 | it('should throw exception on invalid strength', async function () { 170 | await adb.gsmSignal(5).should.eventually.be.rejectedWith('Invalid signal strength'); 171 | }); 172 | it('should call adbExecEmu with the correct args', async function () { 173 | let signalStrength = 0; 174 | mocks.adb.expects('isEmulatorConnected') 175 | .once().withExactArgs() 176 | .returns(true); 177 | mocks.adb.expects('resetTelnetAuthToken') 178 | .once().withExactArgs() 179 | .returns(); 180 | mocks.adb.expects('adbExec') 181 | .once().withExactArgs(['emu', 'gsm', 'signal-profile', `${signalStrength}`]) 182 | .returns(); 183 | await adb.gsmSignal(signalStrength); 184 | }); 185 | }); 186 | describe('gsm call methods', function () { 187 | it('should throw exception on invalid action', async function () { 188 | await adb.gsmCall('+549341312345678').should.be.rejected; 189 | }); 190 | it('should throw exception on invalid phoneNumber', async function () { 191 | await adb.gsmCall('', 'call').should.be.rejected; 192 | }); 193 | it('should set the correct method for making gsm call', async function () { 194 | let phoneNumber = 4509; 195 | mocks.adb.expects('isEmulatorConnected') 196 | .once().withExactArgs() 197 | .returns(true); 198 | mocks.adb.expects('resetTelnetAuthToken') 199 | .once().withExactArgs() 200 | .returns(); 201 | mocks.adb.expects('adbExec') 202 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_CALL, `${phoneNumber}`]) 203 | .returns(); 204 | await adb.gsmCall(phoneNumber, 'call'); 205 | }); 206 | it('should set the correct method for accepting gsm call', async function () { 207 | let phoneNumber = 4509; 208 | mocks.adb.expects('isEmulatorConnected') 209 | .once().withExactArgs() 210 | .returns(true); 211 | mocks.adb.expects('resetTelnetAuthToken') 212 | .once().withExactArgs() 213 | .returns(); 214 | mocks.adb.expects('adbExec') 215 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_ACCEPT, `${phoneNumber}`]) 216 | .returns(); 217 | await adb.gsmCall(phoneNumber, 'accept'); 218 | }); 219 | it('should set the correct method for refusing gsm call', async function () { 220 | let phoneNumber = 4509; 221 | mocks.adb.expects('isEmulatorConnected') 222 | .once().withExactArgs() 223 | .returns(true); 224 | mocks.adb.expects('resetTelnetAuthToken') 225 | .once().withExactArgs() 226 | .returns(); 227 | mocks.adb.expects('adbExec') 228 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_CANCEL, `${phoneNumber}`]) 229 | .returns(); 230 | await adb.gsmCall(phoneNumber, 'cancel'); 231 | }); 232 | it('should set the correct method for holding gsm call', async function () { 233 | let phoneNumber = 4509; 234 | mocks.adb.expects('isEmulatorConnected') 235 | .once().withExactArgs() 236 | .returns(true); 237 | mocks.adb.expects('resetTelnetAuthToken') 238 | .once().withExactArgs() 239 | .returns(); 240 | mocks.adb.expects('adbExec') 241 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_HOLD, `${phoneNumber}`]) 242 | .returns(); 243 | await adb.gsmCall(phoneNumber, 'hold'); 244 | }); 245 | }); 246 | describe('network speed method', function () { 247 | it('should throw exception on invalid speed', async function () { 248 | await adb.networkSpeed('light').should.eventually.be.rejectedWith('Invalid network speed'); 249 | }); 250 | for (let [key, value] of _.toPairs(adb.NETWORK_SPEED)) { 251 | it(`should set network speed(${key}) correctly`, async function () { 252 | mocks.adb.expects('isEmulatorConnected') 253 | .once().withExactArgs() 254 | .returns(true); 255 | mocks.adb.expects('resetTelnetAuthToken') 256 | .once().withExactArgs() 257 | .returns(); 258 | mocks.adb.expects('adbExec') 259 | .once().withExactArgs(['emu', 'network', 'speed', value]) 260 | .returns(); 261 | await adb.networkSpeed(value); 262 | }); 263 | } 264 | }); 265 | describe('gsm voice method', function () { 266 | it('should throw exception on invalid strength', async function () { 267 | await adb.gsmVoice('weird').should.eventually.be.rejectedWith('Invalid gsm voice state'); 268 | }); 269 | it('should set gsm voice to unregistered', async function () { 270 | mocks.adb.expects('isEmulatorConnected') 271 | .once().withExactArgs() 272 | .returns(true); 273 | mocks.adb.expects('resetTelnetAuthToken') 274 | .once().withExactArgs() 275 | .returns(); 276 | mocks.adb.expects('adbExec') 277 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_UNREGISTERED]) 278 | .returns(); 279 | await adb.gsmVoice('unregistered'); 280 | }); 281 | it('should set gsm voice to home', async function () { 282 | mocks.adb.expects('isEmulatorConnected') 283 | .once().withExactArgs() 284 | .returns(true); 285 | mocks.adb.expects('resetTelnetAuthToken') 286 | .once().withExactArgs() 287 | .returns(); 288 | mocks.adb.expects('adbExec') 289 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_HOME]) 290 | .returns(); 291 | await adb.gsmVoice('home'); 292 | }); 293 | it('should set gsm voice to roaming', async function () { 294 | mocks.adb.expects('isEmulatorConnected') 295 | .once().withExactArgs() 296 | .returns(true); 297 | mocks.adb.expects('resetTelnetAuthToken') 298 | .once().withExactArgs() 299 | .returns(); 300 | mocks.adb.expects('adbExec') 301 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_ROAMING]) 302 | .returns(); 303 | await adb.gsmVoice('roaming'); 304 | }); 305 | it('should set gsm voice to searching', async function () { 306 | mocks.adb.expects('isEmulatorConnected') 307 | .once().withExactArgs() 308 | .returns(true); 309 | mocks.adb.expects('resetTelnetAuthToken') 310 | .once().withExactArgs() 311 | .returns(); 312 | mocks.adb.expects('adbExec') 313 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_SEARCHING]) 314 | .returns(); 315 | await adb.gsmVoice('searching'); 316 | }); 317 | it('should set gsm voice to denied', async function () { 318 | mocks.adb.expects('isEmulatorConnected') 319 | .once().withExactArgs() 320 | .returns(true); 321 | mocks.adb.expects('resetTelnetAuthToken') 322 | .once().withExactArgs() 323 | .returns(); 324 | mocks.adb.expects('adbExec') 325 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_DENIED]) 326 | .returns(); 327 | await adb.gsmVoice('denied'); 328 | }); 329 | it('should set gsm voice to off', async function () { 330 | mocks.adb.expects('isEmulatorConnected') 331 | .once().withExactArgs() 332 | .returns(true); 333 | mocks.adb.expects('resetTelnetAuthToken') 334 | .once().withExactArgs() 335 | .returns(); 336 | mocks.adb.expects('adbExec') 337 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_OFF]) 338 | .returns(); 339 | await adb.gsmVoice('off'); 340 | }); 341 | it('should set gsm voice to on', async function () { 342 | mocks.adb.expects('isEmulatorConnected') 343 | .once().withExactArgs() 344 | .returns(true); 345 | mocks.adb.expects('resetTelnetAuthToken') 346 | .once().withExactArgs() 347 | .returns(); 348 | mocks.adb.expects('adbExec') 349 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_ON]) 350 | .returns(); 351 | await adb.gsmVoice('on'); 352 | }); 353 | }); 354 | describe('sensorSet method', function () { 355 | it('should throw exception on missing sensor name', async function () { 356 | await adb.sensorSet('sensor').should.eventually.be.rejected; 357 | }); 358 | it('should throw exception on missing sensor value', async function () { 359 | await adb.sensorSet('light').should.eventually.be.rejected; 360 | }); 361 | it('should call adb emu sensor set with the correct values', async function () { 362 | mocks.adb.expects('isEmulatorConnected') 363 | .once().withExactArgs() 364 | .returns(true); 365 | mocks.adb.expects('resetTelnetAuthToken') 366 | .once().withExactArgs() 367 | .returns(); 368 | mocks.adb.expects('adbExec') 369 | .once().withExactArgs(['emu', 'sensor', 'set', 'humidity', `${100}`]) 370 | .returns(); 371 | await adb.sensorSet('humidity', 100); 372 | }); 373 | }); 374 | }); 375 | })); 376 | -------------------------------------------------------------------------------- /test/unit/adb-specs.js: -------------------------------------------------------------------------------- 1 | import { ADB, DEFAULT_ADB_PORT } from '../../lib/adb'; 2 | 3 | 4 | describe('ADB', function () { 5 | let chai; 6 | 7 | before(async function () { 8 | chai = await import('chai'); 9 | const chaiAsPromised = await import('chai-as-promised'); 10 | 11 | chai.should(); 12 | chai.use(chaiAsPromised.default); 13 | }); 14 | 15 | describe('clone', function () { 16 | it('should copy all options', function () { 17 | const original = new ADB({ 18 | executable: {path: 'var/adb', defaultArgs: ['-a']}, 19 | }); 20 | const clone = original.clone(); 21 | 22 | clone.executable.path.should.equal(original.executable.path); 23 | clone.executable.defaultArgs.should.deep.equal(original.executable.defaultArgs); 24 | }); 25 | 26 | it('should replace specified options', function () { 27 | const original = new ADB({ 28 | executable: {path: 'adb', defaultArgs: ['-a']}, 29 | }); 30 | const clone = original.clone({ 31 | remoteAdbHost: 'example.com', 32 | }); 33 | 34 | clone.executable.path.should.equal(original.executable.path); 35 | clone.executable.defaultArgs.should.deep.equal(['-a', '-H', 'example.com', '-P', String(DEFAULT_ADB_PORT)]); 36 | clone.remoteAdbHost.should.equal('example.com'); 37 | clone.adbHost.should.not.equal(original.adbHost); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/unit/apk-signing-specs.js: -------------------------------------------------------------------------------- 1 | import {ADB} from '../../lib/adb'; 2 | import * as helpers from '../../lib/helpers.js'; 3 | import path from 'path'; 4 | import * as teen_process from 'teen_process'; 5 | import * as appiumSupport from '@appium/support'; 6 | import { withMocks } from '@appium/test-support'; 7 | import * as apkSigningHelpers from '../../lib/tools/apk-signing'; 8 | 9 | const selendroidTestApp = path.resolve(__dirname, '..', 'fixtures', 'selendroid-test-app.apk'); 10 | const keystorePath = path.resolve(__dirname, '..', 'fixtures', 'appiumtest.keystore'); 11 | const defaultKeyPath = path.resolve(__dirname, '..', '..', 'keys', 'testkey.pk8'); 12 | const defaultCertPath = path.resolve(__dirname, '..', '..', 'keys', 'testkey.x509.pem'); 13 | const keyAlias = 'appiumtest'; 14 | const password = 'android'; 15 | const selendroidTestAppPackage = 'io.selendroid.testapp'; 16 | const javaDummyPath = 'java_dummy_path'; 17 | const javaHome = 'java_home'; 18 | const apksignerDummyPath = '/path/to/apksigner'; 19 | const tempDir = appiumSupport.tempDir; 20 | const fs = appiumSupport.fs; 21 | 22 | const adb = new ADB(); 23 | adb.keystorePath = keystorePath; 24 | adb.keyAlias = keyAlias; 25 | adb.keystorePassword = password; 26 | adb.keyPassword = password; 27 | 28 | describe('signing', withMocks({ 29 | teen_process, 30 | helpers, 31 | adb, 32 | appiumSupport, 33 | fs, 34 | tempDir, 35 | apkSigningHelpers, 36 | }, function (mocks) { 37 | let chai; 38 | 39 | before(async function () { 40 | chai = await import('chai'); 41 | const chaiAsPromised = await import('chai-as-promised'); 42 | 43 | chai.should(); 44 | chai.use(chaiAsPromised.default); 45 | }); 46 | 47 | afterEach(function () { 48 | mocks.verify(); 49 | }); 50 | 51 | describe('signWithDefaultCert', function () { 52 | it('should call exec with correct args', async function () { 53 | mocks.apkSigningHelpers.expects('getApksignerForOs') 54 | .returns(apksignerDummyPath); 55 | mocks.adb.expects('executeApksigner') 56 | .once().withExactArgs(['sign', 57 | '--key', defaultKeyPath, 58 | '--cert', defaultCertPath, 59 | selendroidTestApp 60 | ]).returns(''); 61 | await adb.signWithDefaultCert(selendroidTestApp); 62 | }); 63 | 64 | it('should fail if apksigner fails', async function () { 65 | mocks.apkSigningHelpers.expects('getApksignerForOs') 66 | .returns(apksignerDummyPath); 67 | mocks.adb.expects('executeApksigner') 68 | .once().withExactArgs(['sign', 69 | '--key', defaultKeyPath, 70 | '--cert', defaultCertPath, 71 | selendroidTestApp 72 | ]).throws(); 73 | mocks.helpers.expects('getJavaForOs') 74 | .once().returns(javaDummyPath); 75 | await adb.signWithDefaultCert(selendroidTestApp).should.eventually.be.rejected; 76 | }); 77 | 78 | it('should throw error for invalid file path', async function () { 79 | let dummyPath = 'dummyPath'; 80 | await adb.signWithDefaultCert(dummyPath).should.eventually.be.rejected; 81 | }); 82 | }); 83 | 84 | describe('signWithCustomCert', function () { 85 | it('should call exec with correct args', async function () { 86 | adb.useKeystore = true; 87 | 88 | mocks.apkSigningHelpers.expects('getApksignerForOs') 89 | .returns(apksignerDummyPath); 90 | mocks.adb.expects('executeApksigner') 91 | .withExactArgs(['sign', 92 | '--ks', keystorePath, 93 | '--ks-key-alias', keyAlias, 94 | '--ks-pass', `pass:${password}`, 95 | '--key-pass', `pass:${password}`, 96 | selendroidTestApp 97 | ]).returns(''); 98 | await adb.signWithCustomCert(selendroidTestApp); 99 | }); 100 | 101 | it('should fallback to jarsigner if apksigner fails', async function () { 102 | let jarsigner = path.resolve(javaHome, 'bin', 'jarsigner'); 103 | if (appiumSupport.system.isWindows()) { 104 | jarsigner = jarsigner + '.exe'; 105 | } 106 | adb.useKeystore = true; 107 | 108 | mocks.apkSigningHelpers.expects('getApksignerForOs') 109 | .returns(apksignerDummyPath); 110 | mocks.adb.expects('executeApksigner') 111 | .once().withExactArgs(['sign', 112 | '--ks', keystorePath, 113 | '--ks-key-alias', keyAlias, 114 | '--ks-pass', `pass:${password}`, 115 | '--key-pass', `pass:${password}`, 116 | selendroidTestApp 117 | ]).throws(); 118 | mocks.teen_process.expects('exec') 119 | .once().withExactArgs(jarsigner, [ 120 | '-sigalg', 'MD5withRSA', 121 | '-digestalg', 'SHA1', 122 | '-keystore', keystorePath, 123 | '-storepass', password, 124 | '-keypass', password, 125 | selendroidTestApp, keyAlias], 126 | { windowsVerbatimArguments: appiumSupport.system.isWindows() }) 127 | .returns({}); 128 | mocks.helpers.expects('getJavaHome') 129 | .returns(javaHome); 130 | mocks.apkSigningHelpers.expects('unsignApk') 131 | .withExactArgs(selendroidTestApp) 132 | .returns(true); 133 | await adb.signWithCustomCert(selendroidTestApp); 134 | }); 135 | }); 136 | 137 | // Skipping as unable to mock mkdirp, this case is covered in e2e tests for now. 138 | // TODO: find ways to mock mkdirp 139 | describe.skip('zipAlignApk', function () { 140 | it('should call exec with correct args', async function () { 141 | let alignedApk = 'dummy_path'; 142 | mocks.tempDir.expects('path') 143 | .once().withExactArgs({prefix: 'appium', suffix: '.tmp'}) 144 | .returns(alignedApk); 145 | mocks.adb.expects('initZipAlign') 146 | .once().withExactArgs() 147 | .returns(''); 148 | mocks.appiumSupport.expects('mkdirp') 149 | .once().withExactArgs(path.dirname(alignedApk)) 150 | .returns({}); 151 | mocks.teen_process.expects('exec') 152 | .once().withExactArgs(adb.binaries.zipalign, ['-f', '4', selendroidTestApp, alignedApk]); 153 | mocks.fs.expects('mv') 154 | .once().withExactArgs(alignedApk, selendroidTestApp, { mkdirp: true }) 155 | .returns(''); 156 | await adb.zipAlignApk(selendroidTestApp); 157 | }); 158 | }); 159 | 160 | describe('checkApkCert', function () { 161 | beforeEach(function () { 162 | mocks.fs.expects('hash') 163 | .returns(Math.random().toString(36)); 164 | }); 165 | 166 | it('should return false for apk not present', async function () { 167 | (await adb.checkApkCert('dummyPath', 'dummyPackage')).should.be.false; 168 | }); 169 | 170 | it('should check default signature when not using keystore', async function () { 171 | adb.useKeystore = false; 172 | 173 | mocks.apkSigningHelpers.expects('getApksignerForOs') 174 | .once().returns(apksignerDummyPath); 175 | mocks.adb.expects('executeApksigner') 176 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) 177 | .returns(` 178 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US 179 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc 180 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bd85b0bfaa5af81 181 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f5378e87`); 182 | (await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage)).should.be.true; 183 | }); 184 | 185 | it('should check non default signature when not using keystore', async function () { 186 | adb.useKeystore = false; 187 | 188 | mocks.apkSigningHelpers.expects('getApksignerForOs') 189 | .once().returns(apksignerDummyPath); 190 | mocks.adb.expects('executeApksigner') 191 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) 192 | .returns(` 193 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US 194 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc 195 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bdcccccfaa5af81 196 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f53ccccc`); 197 | (await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage, { 198 | requireDefaultCert: false, 199 | })).should.be.true; 200 | }); 201 | 202 | it('should fail if apksigner is not found', async function () { 203 | adb.useKeystore = false; 204 | 205 | mocks.apkSigningHelpers.expects('getApksignerForOs') 206 | .throws(); 207 | mocks.helpers.expects('getJavaForOs') 208 | .returns(javaDummyPath); 209 | await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage) 210 | .should.eventually.be.rejected; 211 | }); 212 | 213 | it('should call getKeystoreHash when using keystore', async function () { 214 | adb.useKeystore = true; 215 | 216 | mocks.adb.expects('getKeystoreHash') 217 | .once().returns({ 218 | 'md5': 'e89b158e4bcf988ebd09eb83f53ccccc', 219 | 'sha1': '61ed377e85d386a8dfee6b864bdcccccfaa5af81', 220 | 'sha256': 'a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc', 221 | }); 222 | mocks.apkSigningHelpers.expects('getApksignerForOs') 223 | .once().returns(apksignerDummyPath); 224 | mocks.adb.expects('executeApksigner') 225 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp]) 226 | .returns(` 227 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US 228 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc 229 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bdcccccfaa5af81 230 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f53ccccc`); 231 | await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage).should.eventually.be.true; 232 | }); 233 | }); 234 | })); 235 | -------------------------------------------------------------------------------- /test/unit/logcat-specs.js: -------------------------------------------------------------------------------- 1 | import * as teen_process from 'teen_process'; 2 | import events from 'events'; 3 | import { Logcat } from '../../lib/logcat'; 4 | import { withMocks } from '@appium/test-support'; 5 | 6 | describe('logcat', withMocks({teen_process}, function (mocks) { 7 | const adb = {path: 'dummyPath', defaultArgs: []}; 8 | const logcat = new Logcat({adb, debug: false, debugTrace: false}); 9 | let chai; 10 | 11 | before(async function () { 12 | chai = await import('chai'); 13 | const chaiAsPromised = await import('chai-as-promised'); 14 | 15 | chai.should(); 16 | chai.use(chaiAsPromised.default); 17 | }); 18 | 19 | afterEach(function () { 20 | mocks.verify(); 21 | }); 22 | 23 | describe('startCapture', function () { 24 | it('should correctly call subprocess and should resolve promise', async function () { 25 | let conn = new events.EventEmitter(); 26 | conn.start = () => { }; 27 | mocks.teen_process.expects('SubProcess') 28 | .withArgs('dummyPath', ['logcat', '-v', 'brief', 'yolo2:d', '*:v']) 29 | .onFirstCall() 30 | .returns(conn); 31 | setTimeout(function () { 32 | conn.emit('line-stdout', '- beginning of system\r'); 33 | }, 0); 34 | await logcat.startCapture({ 35 | format: 'brief', 36 | filterSpecs: ['yolo2:d', ':k', '-asd:e'], 37 | }); 38 | let logs = logcat.getLogs(); 39 | logs.should.have.length.above(0); 40 | }); 41 | it('should correctly call subprocess and should reject promise', async function () { 42 | let conn = new events.EventEmitter(); 43 | conn.start = () => { }; 44 | mocks.teen_process.expects('SubProcess') 45 | .withArgs('dummyPath', ['logcat', '-v', 'threadtime']) 46 | .onFirstCall() 47 | .returns(conn); 48 | setTimeout(function () { 49 | conn.emit('line-stderr', 'execvp()'); 50 | }, 0); 51 | await logcat.startCapture().should.eventually.be.rejectedWith('Logcat'); 52 | }); 53 | it('should correctly call subprocess and should resolve promise if it fails on startup', async function () { 54 | let conn = new events.EventEmitter(); 55 | conn.start = () => { }; 56 | mocks.teen_process.expects('SubProcess') 57 | .withArgs('dummyPath', ['logcat', '-v', 'threadtime']) 58 | .onFirstCall() 59 | .returns(conn); 60 | setTimeout(function () { 61 | conn.emit('line-stderr', 'something'); 62 | }, 0); 63 | await logcat.startCapture().should.eventually.not.be.rejectedWith('Logcat'); 64 | }); 65 | }); 66 | 67 | describe('clear', function () { 68 | it('should call logcat clear', async function () { 69 | mocks.teen_process.expects('exec') 70 | .once().withExactArgs(adb.path, adb.defaultArgs.concat(['logcat', '-c'])); 71 | await logcat.clear(); 72 | }); 73 | it('should not fail if logcat clear fails', async function () { 74 | mocks.teen_process.expects('exec') 75 | .once().withExactArgs(adb.path, adb.defaultArgs.concat(['logcat', '-c'])) 76 | .throws('Failed to clear'); 77 | await logcat.clear().should.eventually.not.be.rejected; 78 | }); 79 | }); 80 | })); 81 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@appium/tsconfig/tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "build", 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "types": ["node"], 9 | "strict": false 10 | }, 11 | "include": ["index.ts", "lib"] 12 | } 13 | --------------------------------------------------------------------------------