├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── lib
├── controllers.js
├── helper.js
├── key-map.js
├── logger.js
└── macaca-android.js
├── package.json
└── test
├── macaca-android.test.js
└── mocha.opts
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/.*
2 | **/node_modules
3 | **/coverage
4 | **/test
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "node": true,
5 | "es6": true,
6 | "mocha": true
7 | },
8 | "plugins": [
9 | "mocha"
10 | ],
11 | // https://github.com/feross/eslint-config-standard
12 | "rules": {
13 | "accessor-pairs": 2,
14 | "block-scoped-var": 0,
15 | "brace-style": [2, "1tbs", { "allowSingleLine": true }],
16 | "camelcase": 0,
17 | "comma-dangle": [2, "never"],
18 | "comma-spacing": [2, { "before": false, "after": true }],
19 | "comma-style": [2, "last"],
20 | "complexity": 0,
21 | "consistent-return": 0,
22 | "consistent-this": 0,
23 | "curly": [2, "multi-line"],
24 | "default-case": 0,
25 | "dot-location": [2, "property"],
26 | "dot-notation": 0,
27 | "eol-last": 2,
28 | "eqeqeq": [2, "allow-null"],
29 | "func-names": 0,
30 | "func-style": 0,
31 | "generator-star-spacing": [2, "both"],
32 | "guard-for-in": 0,
33 | "handle-callback-err": [2, "^(err|error|anySpecificError)$" ],
34 | "indent": [2, 2],
35 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }],
36 | "linebreak-style": 0,
37 | "max-depth": 0,
38 | "max-len": 0,
39 | "max-nested-callbacks": 0,
40 | "max-params": 0,
41 | "max-statements": 0,
42 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }],
43 | "new-parens": 2,
44 | "no-alert": 0,
45 | "no-array-constructor": 2,
46 | "no-bitwise": 0,
47 | "no-caller": 2,
48 | "no-catch-shadow": 0,
49 | "no-cond-assign": 2,
50 | "no-console": 0,
51 | "no-constant-condition": 0,
52 | "no-continue": 0,
53 | "no-control-regex": 2,
54 | "no-debugger": 2,
55 | "no-delete-var": 2,
56 | "no-div-regex": 0,
57 | "no-dupe-args": 2,
58 | "no-dupe-keys": 2,
59 | "no-duplicate-case": 2,
60 | "no-else-return": 0,
61 | "no-empty": 0,
62 | "no-empty-character-class": 2,
63 | "no-eq-null": 0,
64 | "no-eval": 2,
65 | "no-ex-assign": 2,
66 | "no-extend-native": 2,
67 | "no-extra-bind": 2,
68 | "no-extra-boolean-cast": 2,
69 | "no-extra-semi": 0,
70 | "no-extra-strict": 0,
71 | "no-fallthrough": 2,
72 | "no-floating-decimal": 2,
73 | "no-func-assign": 2,
74 | "no-implied-eval": 2,
75 | "no-inline-comments": 0,
76 | "no-inner-declarations": [2, "functions"],
77 | "no-invalid-regexp": 2,
78 | "no-irregular-whitespace": 2,
79 | "no-iterator": 2,
80 | "no-label-var": 2,
81 | "no-labels": 2,
82 | "no-lone-blocks": 2,
83 | "no-lonely-if": 0,
84 | "no-loop-func": 0,
85 | "no-mixed-requires": 0,
86 | "no-mixed-spaces-and-tabs": [2, false],
87 | "no-multi-spaces": 2,
88 | "no-multi-str": 2,
89 | "no-multiple-empty-lines": [2, { "max": 1 }],
90 | "no-native-reassign": 2,
91 | "no-negated-in-lhs": 2,
92 | "no-nested-ternary": 0,
93 | "no-new": 0,
94 | "no-new-func": 2,
95 | "no-new-object": 2,
96 | "no-new-require": 2,
97 | "no-new-wrappers": 2,
98 | "no-obj-calls": 2,
99 | "no-octal": 2,
100 | "no-octal-escape": 2,
101 | "no-path-concat": 0,
102 | "no-plusplus": 0,
103 | "no-process-env": 0,
104 | "no-process-exit": 0,
105 | "no-proto": 2,
106 | "no-redeclare": 2,
107 | "no-regex-spaces": 2,
108 | "no-reserved-keys": 0,
109 | "no-restricted-modules": 0,
110 | "no-return-assign": 2,
111 | "no-script-url": 0,
112 | "no-self-compare": 2,
113 | "no-sequences": 2,
114 | "no-shadow": 0,
115 | "no-shadow-restricted-names": 2,
116 | "no-spaced-func": 2,
117 | "no-sparse-arrays": 2,
118 | "no-sync": 0,
119 | "no-ternary": 0,
120 | "no-throw-literal": 2,
121 | "no-trailing-spaces": 2,
122 | "no-undef": 2,
123 | "no-undef-init": 2,
124 | "no-undefined": 0,
125 | "no-underscore-dangle": 0,
126 | "no-unneeded-ternary": 2,
127 | "no-unreachable": 2,
128 | "no-unused-expressions": 0,
129 | "no-unused-vars": [2, { "vars": "all", "args": "none" }],
130 | "no-use-before-define": 0,
131 | "no-var": 0,
132 | "no-void": 0,
133 | "no-warning-comments": 0,
134 | "no-with": 2,
135 | "no-extra-parens": 0,
136 | "object-curly-spacing": 0,
137 | "one-var": [2, { "initialized": "never" }],
138 | "operator-assignment": 0,
139 | "operator-linebreak": [2, "after"],
140 | "padded-blocks": 0,
141 | "quote-props": 0,
142 | "quotes": [1, "single", "avoid-escape"],
143 | "radix": 2,
144 | "semi": [2, "always"],
145 | "semi-spacing": 0,
146 | "sort-vars": 0,
147 | "keyword-spacing": [2],
148 | "space-before-blocks": [2, "always"],
149 | "space-before-function-paren": 0,
150 | "space-in-parens": [2, "never"],
151 | "space-infix-ops": 2,
152 | "space-unary-ops": [2, { "words": true, "nonwords": false }],
153 | "spaced-comment": [2, "always"],
154 | "strict": 0,
155 | "use-isnan": 2,
156 | "valid-jsdoc": 0,
157 | "valid-typeof": 2,
158 | "vars-on-top": 0,
159 | "wrap-iife": [2, "any"],
160 | "wrap-regex": 0,
161 | "yoda": [2, "never"]
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | push:
7 | branches:
8 | - '**'
9 | paths-ignore:
10 | - '**.md'
11 |
12 | jobs:
13 | Runner:
14 | timeout-minutes: 10
15 | runs-on: ${{ matrix.os }}
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | os: [ ubuntu-latest, macOS-latest ]
20 | node-version: [ 16 ]
21 | steps:
22 | - name: Checkout Git Source
23 | uses: actions/checkout@v3
24 |
25 | - name: Set up JDK 8
26 | uses: actions/setup-java@v3
27 | with:
28 | distribution: 'adopt'
29 | java-version: 8
30 |
31 | - name: Setup Android SDK
32 | uses: android-actions/setup-android@v2
33 |
34 | - name: Setup Gradle
35 | uses: gradle/wrapper-validation-action@v1
36 |
37 | - name: Setup Node.js
38 | uses: actions/setup-node@v3
39 | with:
40 | node-version: ${{ matrix.node-version }}
41 |
42 | - name: Install dependencies
43 | run: |
44 | npm i npm@6 -g
45 | npm i
46 |
47 | - name: Continuous integration
48 | run: |
49 | npm run lint
50 | npm run test
51 |
52 | - name: Code coverage
53 | uses: codecov/codecov-action@v3.0.0
54 | with:
55 | token: ${{ secrets.CODECOV_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .nyc_output
3 | coverage
4 | *.sw*
5 | *.un~
6 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to macaca-android
2 |
3 | We love pull requests from everyone.
4 |
5 | Fork, then clone the repo:
6 |
7 | git clone git@github.com:your-username/macaca-android.git
8 |
9 | Set up your machine:
10 |
11 | npm i
12 |
13 | Then make your change and make sure the tests pass:
14 |
15 | make test
16 |
17 | Push to your fork and [submit a pull request][pr].
18 |
19 | [pr]: https://github.com/macacajs/macaca-android/compare/
20 |
21 | At this point you're waiting on us. We like to at least comment on pull requests
22 | within three business days (and, typically, one business day). We may suggest
23 | some changes or improvements or alternatives.
24 |
25 | Some things that will increase the chance that your pull request is accepted:
26 |
27 | * Write tests.
28 | * Follow [JavaScript Style Guide][style].
29 | * Write a [good commit message][commit].
30 |
31 | [style]: https://github.com/airbnb/javascript
32 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT LICENSE
2 |
3 | Copyright (c) 2017 Alibaba Group Holding Limited and other contributors.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # macaca-android
2 |
3 | [![NPM version][npm-image]][npm-url]
4 | [![CI][CI-image]][CI-url]
5 | [![Test coverage][coveralls-image]][coveralls-url]
6 | [![node version][node-image]][node-url]
7 | [![npm download][download-image]][download-url]
8 |
9 | [npm-image]: https://img.shields.io/npm/v/macaca-android.svg
10 | [npm-url]: https://npmjs.org/package/macaca-android
11 | [CI-image]: https://github.com/macacajs/macaca-android/actions/workflows/ci.yml/badge.svg
12 | [CI-url]: https://github.com/macacajs/macaca-android/actions/workflows/ci.yml
13 | [coveralls-image]: https://img.shields.io/coveralls/macacajs/macaca-android.svg
14 | [coveralls-url]: https://coveralls.io/r/macacajs/macaca-android?branch=master
15 | [node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg
16 | [node-url]: http://nodejs.org/download/
17 | [download-image]: https://img.shields.io/npm/dm/macaca-android.svg
18 | [download-url]: https://npmjs.org/package/macaca-android
19 |
20 | > Macaca Android driver
21 |
22 |
23 |
24 | ## Contributors
25 |
26 | |[
xudafeng](https://github.com/xudafeng)
|[
ziczhu](https://github.com/ziczhu)
|[
SamuelZhaoY](https://github.com/SamuelZhaoY)
|[
kobe990](https://github.com/kobe990)
|[
CodeToSurvive1](https://github.com/CodeToSurvive1)
|[
kyowang](https://github.com/kyowang)
|
27 | | :---: | :---: | :---: | :---: | :---: | :---: |
28 | [
qichuan](https://github.com/qichuan)
|[
brucejcw](https://github.com/brucejcw)
|[
snapre](https://github.com/snapre)
|[
yaniswang](https://github.com/yaniswang)
29 |
30 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Sun Apr 24 2022 00:22:35 GMT+0800`.
31 |
32 |
33 |
34 | ## Installment
35 |
36 | ```bash
37 | $ npm i macaca-android -g
38 | ```
39 |
40 | To use a mirror of the Maven center.
41 |
42 | ```bash
43 | $ MAVEN_MIRROR_URL=http://maven.aliyun.com/nexus/content/groups/public/ npm i macaca-android -g
44 | ```
45 |
46 | ## Custom ChromeDriver Version
47 |
48 | [link](//github.com/macacajs/macaca-chromedriver#custom-version)
49 |
50 | ## License
51 |
52 | The MIT License (MIT)
53 |
--------------------------------------------------------------------------------
/lib/controllers.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const temp = require('temp');
5 | const ADB = require('macaca-adb');
6 | const xml2map = require('xml2map2');
7 | const errors = require('webdriver-dfn-error-code').errors;
8 |
9 | const _ = require('./helper');
10 | const keyMap = require('./key-map');
11 | const pkg = require('../package.json');
12 |
13 | const NATIVE = 'NATIVE_APP';
14 |
15 | var controllers = {};
16 |
17 | controllers.isWebContext = function() {
18 | return this.context && this.context !== NATIVE;
19 | };
20 |
21 | controllers.getContext = function * () {
22 | return this.context;
23 | };
24 |
25 | controllers.getContexts = function * () {
26 | const contexts = [NATIVE].concat(yield this.getWebviews());
27 | this.contexts = contexts;
28 | return contexts;
29 | };
30 |
31 | controllers.setContext = function * (name) {
32 | if (name !== NATIVE) {
33 | yield this.getContexts();
34 | if (!~this.contexts.indexOf(name)) {
35 | throw new errors.NoSuchWindow();
36 | }
37 | const result = yield this.proxy.sendCommand('/wd/hub/session/:sessionId/window', 'POST', {
38 | name: name
39 | });
40 | _.parseWebDriverResult(result);
41 | } else {
42 | this.proxy = this.uiautomator;
43 | }
44 | this.context = name;
45 | };
46 |
47 | controllers.getScreenshot = function * () {
48 | const swapFilePath = temp.path({
49 | prefix: `${pkg.name}-screenshot`,
50 | suffix: '.png'
51 | });
52 |
53 | const remoteFile = `${ADB.ANDROID_TMP_DIR}/screenshot.png`;
54 | const cmd = `/system/bin/rm ${remoteFile}; /system/bin/screencap -p ${remoteFile}`;
55 | yield this.adb.shell(cmd);
56 |
57 | yield this.adb.pull(remoteFile, swapFilePath);
58 |
59 | var base64 = null;
60 |
61 | try {
62 | let data = fs.readFileSync(swapFilePath);
63 | base64 = new Buffer(data).toString('base64');
64 | } catch (e) {
65 | throw new errors.NoSuchWindow();
66 | }
67 |
68 | _.rimraf(swapFilePath);
69 | return base64;
70 | };
71 |
72 | controllers.get = function * (url) {
73 | const cmd = `am start -a android.intent.action.VIEW -d ${url}`;
74 | yield this.adb.shell(cmd);
75 | return null;
76 | };
77 |
78 | controllers.url = function * () {
79 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/url', 'get', null);
80 | return result.value;
81 | };
82 |
83 | controllers.back = function * () {
84 | yield this.adb.goBack();
85 | return null;
86 | };
87 |
88 | controllers.tap = function(action) {
89 | return this
90 | .proxyCommand('/wd/hub/session/:sessionId/touch/click', 'post', {
91 | element: action.element
92 | }).then(result => {
93 | return _.parseWebDriverResult(result);
94 | });
95 | };
96 |
97 | controllers.keys = function * (value) {
98 | value = value.join('');
99 | var arrText = [];
100 |
101 | for (var i = 0; i < value.length; i++) {
102 | var key = value.charAt(i);
103 |
104 | const keyEvent = keyMap[key];
105 |
106 | if (keyEvent) {
107 | // update for situation like : xxdd\uE007
108 | // the enter will go before real content.
109 | if (arrText.length) {
110 | yield this.proxyCommand('/wd/hub/session/:sessionId/element/1/value', 'post', {
111 | value: [arrText.join('')]
112 | });
113 | arrText = [];
114 | }
115 | yield this.proxyCommand('/wd/hub/session/:sessionId/keys', 'post', {
116 | value: [keyEvent]
117 | });
118 | } else {
119 | arrText.push(key);
120 | }
121 | }
122 | if (arrText.length) {
123 | yield this.proxyCommand('/wd/hub/session/:sessionId/element/1/value', 'post', {
124 | value: [arrText.join('')]
125 | });
126 | }
127 | return null;
128 | };
129 |
130 | controllers.getSource = function * () {
131 |
132 | if (!this.isWebContext()) {
133 | yield this.adb.shell(`touch ${ADB.ANDROID_TMP_DIR}/macaca-dump.xml`);
134 | }
135 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/source', 'get', null);
136 | var xml = result.value;
137 |
138 | if (this.isWebContext() || (!this.isWebContext() && this.chromedriver)) {
139 | return xml;
140 | }
141 |
142 | const hierarchy = xml2map.tojson(xml).hierarchy;
143 |
144 | // tojson: if 'node' has only one element, the property will become json object instead of JSONArray
145 | // for device under Android API 5.0, 'node' is always an single element, and hence need to be wrapped into array
146 | if (hierarchy.node && !_.isArray(hierarchy.node)) {
147 | hierarchy.node = [hierarchy.node];
148 | }
149 |
150 | var res = _.filter(hierarchy.node, i => i.package !== 'com.android.systemui');
151 |
152 | return JSON.stringify(res && res[0] || []);
153 | };
154 |
155 | controllers.title = function * () {
156 |
157 | if (!this.isWebContext()) {
158 | const focusedActivity = yield this.adb.getFocusedActivity();
159 | return focusedActivity;
160 | }
161 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/title', 'get', null);
162 | return result.value;
163 | };
164 |
165 | module.exports = controllers;
166 |
--------------------------------------------------------------------------------
/lib/helper.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const macacaUtils = require('macaca-utils');
4 | const childProcess = require('child_process');
5 | const errors = require('webdriver-dfn-error-code').errors;
6 | const getErrorByCode = require('webdriver-dfn-error-code').getErrorByCode;
7 |
8 | var _ = macacaUtils.merge({}, macacaUtils);
9 |
10 | _.sleep = function(ms) {
11 | return new Promise(resolve => {
12 | setTimeout(resolve, ms);
13 | });
14 | };
15 |
16 | _.exec = function(cmd, opts) {
17 | return new Promise(function(resolve, reject) {
18 | childProcess.exec(cmd, _.merge({
19 | maxBuffer: 1024 * 512,
20 | wrapArgs: false
21 | }, opts || {}), function(err, stdout) {
22 | if (err) {
23 | return reject(err);
24 | }
25 | resolve(_.trim(stdout));
26 | });
27 | });
28 | };
29 |
30 | _.serialTasks = function () {
31 | return Array.prototype.slice.call(arguments).reduce(
32 | (pre, task) => pre.then(() => task()), Promise.resolve());
33 | };
34 |
35 | _.waitForCondition = function(func, wait/* ms*/, interval/* ms*/) {
36 | wait = wait || 5000;
37 | interval = interval || 500;
38 | let start = Date.now();
39 | let end = start + wait;
40 | const fn = function() {
41 | return new Promise(function(resolve, reject) {
42 | const continuation = (res, rej) => {
43 | let now = Date.now();
44 | if (now < end) {
45 | res(_.sleep(interval).then(fn));
46 | } else {
47 | rej(`Wait For Condition timeout ${wait}`);
48 | }
49 | };
50 | func().then(isOk => {
51 | if (isOk) {
52 | resolve();
53 | } else {
54 | continuation(resolve, reject);
55 | }
56 | }).catch(() => {
57 | continuation(resolve, reject);
58 | });
59 | });
60 | };
61 | return fn();
62 | };
63 |
64 | _.parseWebDriverResult = function(res) {
65 | const code = res.status;
66 | const value = res.value;
67 | if (code === 0) {
68 | return value;
69 | } else {
70 | const errorName = getErrorByCode(code);
71 | const errorMsg = value && value.message;
72 | throw new errors[errorName](errorMsg);
73 | }
74 | };
75 |
76 | module.exports = _;
77 |
--------------------------------------------------------------------------------
/lib/key-map.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // https://github.com/macacajs/webdriver-keycode/blob/master/lib/webdriver-keycode.js
4 |
5 | module.exports = {
6 | // COMMON KEYS
7 | '\uE002': 259, // HELP
8 | '\uE003': 67, // BACK_SPACE
9 | '\uE004': 61, // TAB
10 | '\uE005': 28, // CLEAR
11 | '\uE007': 66, // ENTER
12 | '\uE008': 59, // SHIFT
13 | '\uE009': 113, // CONTROL
14 | '\uE00A': 57, // ALT
15 | '\uE00B': 121, // PAUSE
16 | '\uE00C': 111, // ESCAPE
17 | '\uE00E': 92, // PAGE_UP
18 | '\uE00F': 93, // PAGE_DOWN
19 | '\uE010': 123, // END
20 | '\uE011': 122, // HOME
21 | '\uE012': 21, // ARROW_LEFT
22 | '\uE013': 19, // ARROW_UP
23 | '\uE014': 22, // ARROW_RIGHT
24 | '\uE015': 20, // ARROW_DOWN
25 | '\uE016': 124, // INSERT
26 | '\uE017': 112, // DELETE
27 | '\uE031': 131, // F1
28 | '\uE032': 132, // F2
29 | '\uE033': 133, // F3
30 | '\uE034': 134, // F4
31 | '\uE035': 135, // F5
32 | '\uE036': 136, // F6
33 | '\uE037': 137, // F7
34 | '\uE038': 138, // F8
35 | '\uE039': 139, // F9
36 | '\uE03A': 140, // F10
37 | '\uE03B': 141, // F11
38 | '\uE03C': 142, // F12
39 | '\uE03D': 117, // META
40 |
41 | // Number KEYS
42 | '\u0030': 7, // 0
43 | '\u0031': 8, // 1
44 | '\u0032': 9, // 2
45 | '\u0033': 10, // 3
46 | '\u0034': 11, // 4
47 | '\u0035': 12, // 5
48 | '\u0036': 13, // 6
49 | '\u0037': 14, // 7
50 | '\u0038': 15, // 8
51 | '\u0039': 16, // 9
52 |
53 | // KEYS A-Z
54 | '\u0041': 29, // A
55 | '\u0042': 30, // B
56 | '\u0043': 31, // C
57 | '\u0044': 32, // D
58 | '\u0045': 33, // E
59 | '\u0046': 34, // F
60 | '\u0047': 35, // G
61 | '\u0048': 36, // H
62 | '\u0049': 37, // I
63 | '\u004A': 38, // J
64 | '\u004B': 39, // K
65 | '\u004C': 40, // L
66 | '\u004D': 41, // M
67 | '\u004E': 42, // N
68 | '\u004F': 43, // O
69 | '\u0050': 44, // P
70 | '\u0051': 45, // Q
71 | '\u0052': 46, // R
72 | '\u0053': 47, // S
73 | '\u0054': 48, // T
74 | '\u0055': 49, // U
75 | '\u0056': 50, // V
76 | '\u0057': 51, // W
77 | '\u0058': 52, // X
78 | '\u0059': 53, // Y
79 | '\u005A': 54, // Z
80 |
81 | // KEYS a-z
82 | '\u0061': 29, // a
83 | '\u0062': 30, // b
84 | '\u0063': 31, // c
85 | '\u0064': 32, // d
86 | '\u0065': 33, // e
87 | '\u0066': 34, // f
88 | '\u0067': 35, // g
89 | '\u0068': 36, // h
90 | '\u0069': 37, // i
91 | '\u006A': 38, // j
92 | '\u006B': 39, // k
93 | '\u006C': 40, // l
94 | '\u006D': 41, // m
95 | '\u006E': 42, // n
96 | '\u006F': 43, // o
97 | '\u0070': 44, // p
98 | '\u0071': 45, // q
99 | '\u0072': 46, // r
100 | '\u0073': 47, // s
101 | '\u0074': 48, // t
102 | '\u0075': 49, // u
103 | '\u0076': 50, // v
104 | '\u0077': 51, // w
105 | '\u0078': 52, // x
106 | '\u0079': 53, // y
107 | '\u007A': 54, // z
108 |
109 | // HARD KEYS
110 | '\uE101': 26, // POWER
111 | '\uE102': 24, // VOLUME_UP
112 | '\uE103': 25, // VOLUME_DOWN
113 | '\uE104': 164, // VOLUME_MUTE
114 | '\uE105': 3, // HOME_SCREEN
115 | '\uE106': 4, // BACK
116 | '\uE107': 82, // MENU
117 | '\uE108': 27, // CAMERA
118 | '\uE109': 5, // CALL
119 | '\uE10A': 6, // END_CALL
120 | '\uE10B': 84, // SEARCH
121 | '\uE10C': 21, // DPAD_LEFT
122 | '\uE10D': 19, // DPAD_UP
123 | '\uE10E': 22, // DPAD_RIGHT
124 | '\uE10F': 20, // DPAD_DOWN
125 | '\uE110': 23 // DPAD_CENTER
126 | };
127 |
128 |
--------------------------------------------------------------------------------
/lib/logger.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const logger = require('xlogger');
5 | const options = {
6 | logFileDir: path.join(__dirname, '..', '..', 'logs')
7 | };
8 |
9 | module.exports = logger.Logger(options);
10 |
--------------------------------------------------------------------------------
/lib/macaca-android.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const EOL = require('os').EOL;
5 | const ADB = require('macaca-adb');
6 | const UnlockApk = require('unlock-apk');
7 | const DriverBase = require('driver-base');
8 | const UIAutomatorWD = require('uiautomatorwd');
9 | const ChromeDriver = require('macaca-chromedriver');
10 |
11 | const _ = require('./helper');
12 | const logger = require('./logger');
13 | const controllers = require('./controllers');
14 |
15 | const reuseStatus = {};
16 |
17 | reuseStatus.noReuse = 0;
18 | reuseStatus.reuseEmu = 1;
19 | reuseStatus.reuseEmuApp = 2;
20 | reuseStatus.reuseEmuAppState = 3;
21 |
22 | _.sudoUserPermissionDenied();
23 |
24 | class Android extends DriverBase {
25 | constructor() {
26 | super();
27 | this.adb = null;
28 | this.apkInfo = null;
29 | this.args = null;
30 | this.chromedriver = null;
31 | this.chromeDriverPort = null;
32 | this.proxy = null;
33 | this.udid = null;
34 | this.uiautomator = null;
35 | this.isChrome = null;
36 | this.isVirtual = true;
37 | this.contexts = [];
38 | this.context = null;
39 | this.isWaitActivity = false;
40 | this.isActivityReady = false;
41 | }
42 | }
43 |
44 | Android.prototype.startDevice = function * (caps) {
45 | this.args = _.clone(caps);
46 | this.isChrome = this.args.browserName && this.args.browserName.toLowerCase() === 'chrome';
47 | this.initReuse();
48 | this.initAdb();
49 | yield this.initDevice();
50 | yield this.initUiautomator(caps.permissionPatterns);
51 | yield this.getApkInfo();
52 | yield this.unlock();
53 | yield this.launchApk();
54 |
55 | this.autoAcceptAlerts = Boolean(caps.autoAcceptAlerts);
56 | this.autoDismissAlerts = Boolean(caps.autoDismissAlerts);
57 | this.isWaitActivity = Boolean(caps.isWaitActivity);
58 |
59 | if (this.isWaitActivity) {
60 | yield this.waitActivityReady();
61 | }
62 |
63 | if (this.isChrome) {
64 | yield this.getWebviews();
65 | }
66 | };
67 |
68 | Android.prototype.stopDevice = function * () {
69 | this.chromedriver && this.chromedriver.stop();
70 | if (this.isVirtual && this.args.reuse === reuseStatus.noReuse) {
71 | return ADB
72 | .emuKill()
73 | .catch(e => {
74 | logger.warn(e);
75 | });
76 | }
77 |
78 | return Promise.resolve();
79 | };
80 |
81 | Android.prototype.isProxy = function() {
82 | return !!this.proxy;
83 | };
84 |
85 | Android.prototype.whiteList = function(context) {
86 | var basename = path.basename(context.url);
87 | const whiteList = [
88 | 'context',
89 | 'contexts',
90 | 'screenshot',
91 | 'back',
92 | 'tap',
93 | 'source',
94 | 'keys',
95 | 'url',
96 | 'title'
97 | ];
98 | return !!~whiteList.indexOf(basename);
99 | };
100 |
101 | Android.prototype.proxyCommand = function * (url, method, body) {
102 |
103 | if (this.autoAcceptAlerts) {
104 | const acceptUrl = '/wd/hub/session/:sessionId/accept_alert';
105 | yield this.proxy.sendCommand(acceptUrl, 'POST', {});
106 | } else if (this.autoDismissAlerts) {
107 | const dismissUrl = '/wd/hub/session/:sessionId/dismiss_alert';
108 | yield this.proxy.sendCommand(dismissUrl, 'POST', {});
109 | }
110 |
111 | url = url.replace('property', 'attribute');
112 | return yield this.proxy.sendCommand(url, method, body);
113 | };
114 |
115 | Android.prototype.waitActivityReady = function * () {
116 |
117 | yield _.sleep(1000);
118 |
119 | try {
120 | this.isActivityReady = yield this.adb.isActivityReady(this.apkInfo.package, this.apkInfo.activity);
121 | } catch (e) {
122 | logger.info(`waiting for activity: \`${this.apkInfo.activity}\` ready`);
123 | }
124 |
125 | if (!this.isActivityReady) {
126 | yield this.waitActivityReady();
127 | }
128 | };
129 |
130 | Android.prototype.initAdb = function() {
131 | this.adb = new ADB();
132 | };
133 |
134 | Android.prototype.initReuse = function() {
135 | let reuse = parseInt(this.args.reuse, 10);
136 | if (!reuse && reuse !== reuseStatus.noReuse) {
137 | reuse = reuseStatus.reuseEmu;
138 | }
139 | this.args.reuse = reuse;
140 | };
141 |
142 | Android.prototype.initUiautomator = function * (permissionPatterns) {
143 | this.uiautomator = new UIAutomatorWD();
144 | this.proxy = this.uiautomator;
145 | permissionPatterns = permissionPatterns || '[]';
146 | logger.info(`checking permissionPatterns: ${permissionPatterns}`);
147 | yield this.uiautomator.init(this.adb, permissionPatterns);
148 | };
149 |
150 | Android.prototype.initDevice = function * () {
151 |
152 | if (this.args.udid) {
153 | this.udid = this.args.udid;
154 | this.adb.setDeviceId(this.udid);
155 | return;
156 | }
157 | var devices = yield ADB.getDevices();
158 | var device = devices[0];
159 |
160 | if (device) {
161 | this.adb.setDeviceId(device.udid);
162 | this.udid = device.udid;
163 | } else {
164 | logger.info('no device, now create one from avd');
165 | var env = global.process.env;
166 | var emulatorCommand = path.resolve(env.ANDROID_HOME, 'tools', 'emulator');
167 | var androidCommand = path.resolve(env.ANDROID_HOME, 'tools', 'android');
168 |
169 | var data = yield _.exec(`${androidCommand} list avd`);
170 | data = data.split(EOL);
171 | data.shift();
172 |
173 | if (data.length === 0) {
174 | throw new Error('no avd created! Please create one avd first');
175 | } else {
176 | var avdArr = data.filter(avd => {
177 | return /Name:/.test(avd);
178 | }).map(avd => {
179 | return _.trim(avd.split(':')[1]);
180 | });
181 |
182 | _.exec(`${emulatorCommand} -avd ${avdArr[0]}`);
183 |
184 | var checkEmulator = () => {
185 | return new Promise((resolve, reject) => {
186 | ADB.getBootStatus().then(data => {
187 | resolve(data === 'stopped');
188 | }).catch(() => {
189 | reject('check emulator failed');
190 | });
191 | });
192 | };
193 | yield _.waitForCondition(checkEmulator, 60 * 1000, 2 * 1000);
194 |
195 | devices = yield ADB.getDevices();
196 | device = devices[0];
197 |
198 | if (device) {
199 | this.adb.setDeviceId(device.udid);
200 | this.udid = device.udid;
201 | } else {
202 | throw new Error('emulator start failed or too slow!');
203 | }
204 | }
205 | }
206 | this.isVirtual = device.type === 'virtual';
207 | };
208 |
209 | Android.prototype.getApkInfo = function * () {
210 |
211 | if (this.isChrome) {
212 | this.apkInfo = {
213 | package: 'com.android.browser',
214 | activity: '.BrowserActivity'
215 | };
216 | return;
217 | }
218 | const pkg = this.args.package;
219 | const activity = this.args.activity;
220 | const app = this.args.app;
221 | const androidProcess = this.args.androidProcess;
222 |
223 | if (pkg) {
224 | this.apkInfo = {
225 | package: pkg,
226 | activity: activity,
227 | androidProcess: androidProcess
228 | };
229 | } else if (app) {
230 | this.apkInfo = yield ADB.getApkMainifest(app);
231 | } else {
232 | throw new Error('Either app path or package name should be provided!');
233 | }
234 | };
235 |
236 | Android.prototype.unlock = function * () {
237 |
238 | if (!_.isExistedFile(UnlockApk.apkPath)) {
239 | logger.warn(`unlock apk not found in: ${UnlockApk.apkPath}`);
240 | return;
241 | }
242 |
243 | const isInstalled = yield this.adb.isInstalled(UnlockApk.package);
244 |
245 | if (isInstalled) {
246 | yield this.checkApkVersion(UnlockApk.apkPath, UnlockApk.package);
247 | } else {
248 | yield this.adb.install(UnlockApk.apkPath);
249 | }
250 |
251 | var isScreenLocked = yield this.adb.isScreenLocked();
252 |
253 | if (isScreenLocked) {
254 | yield this.adb.startApp(UnlockApk);
255 | yield _.sleep(5000);
256 | yield this.unlock();
257 | }
258 | };
259 |
260 | Android.prototype.checkApkVersion = function * (app, pkg) {
261 | var newVersion = yield ADB.getApkVersion(app);
262 | var oldVersion = yield this.adb.getInstalledApkVersion(pkg);
263 | if (newVersion > oldVersion) {
264 | yield this.adb.install(app);
265 | }
266 | };
267 |
268 | Android.prototype.launchApk = function * () {
269 |
270 | if (!this.isChrome) {
271 | const reuse = this.args.reuse;
272 | const app = this.args.app;
273 | const pkg = this.apkInfo.package;
274 | const isInstalled = yield this.adb.isInstalled(pkg);
275 | if (!isInstalled && !app) {
276 | throw new Error('App is neither installed, nor provided!');
277 | }
278 | if (isInstalled) {
279 | switch (reuse) {
280 | case reuseStatus.noReuse:
281 | case reuseStatus.reuseEmu:
282 | if (app) {
283 | yield this.adb.unInstall(pkg);
284 | yield this.adb.install(app);
285 | } else {
286 | yield this.adb.clear(pkg);
287 | }
288 | break;
289 | case reuseStatus.reuseEmuApp:
290 | if (app) {
291 | yield this.adb.install(app);
292 | }
293 | break;
294 | case reuseStatus.reuseEmuAppState:
295 | // Keep app state, don't change to main activity.
296 | this.apkInfo.activity = '';
297 | }
298 | } else {
299 | yield this.adb.install(app);
300 | }
301 | }
302 |
303 | logger.debug(`start app with: ${JSON.stringify(this.apkInfo)}`);
304 | yield this.adb.startApp(this.apkInfo);
305 | yield _.sleep(5 * 1000);
306 | };
307 |
308 | Android.prototype.getWebviews = function * () {
309 | if (!this.chromedriver) {
310 | var webviewVersion = null;
311 | try {
312 | webviewVersion = yield this.adb.getWebviewVersion();
313 | } catch (error) {
314 | console.log(error);
315 | logger.info('No webview version found from adb shell!');
316 | webviewVersion = null;
317 | }
318 | try {
319 | if (webviewVersion) {
320 | yield this.initChromeDriver({
321 | webviewVersion: webviewVersion
322 | });
323 | } else {
324 | return [];
325 | }
326 | } catch (error) {
327 | logger.error('initChromeDriver failed!' + error);
328 | this.chromedriver = null;
329 | return [];
330 | }
331 | }
332 | this.proxy = this.chromedriver;
333 |
334 | var webviews = [];
335 |
336 | try {
337 | const result = yield this.proxy.sendCommand('/wd/hub/session/:sessionId/window_handles', 'GET', {});
338 | webviews = _.parseWebDriverResult(result);
339 | } catch (e) {
340 | console.log(e);
341 | }
342 |
343 | return webviews;
344 | };
345 |
346 | Android.prototype.initChromeDriver = function(options) {
347 | return new Promise((resolve, reject) => {
348 | this.chromedriver = new ChromeDriver(options);
349 | if (this.chromedriver.binPathReady) {
350 | logger.info('starting chromedriver service!');
351 | this.chromedriver.start({
352 | chromeOptions: {
353 | androidPackage: this.apkInfo.package,
354 | androidUseRunningApp: true,
355 | androidDeviceSerial: this.udid,
356 | androidProcess: this.apkInfo.androidProcess
357 | }
358 | });
359 | }
360 | this.chromedriver.on(ChromeDriver.BIN_READY, data => {
361 | logger.info(`chromedriver bin file ready: ${data}`);
362 | });
363 | this.chromedriver.on(ChromeDriver.EVENT_READY, data => {
364 | logger.info(`chromedriver ready with: ${JSON.stringify(data)}`);
365 | resolve('');
366 | });
367 | this.chromedriver.on(ChromeDriver.EVENT_ERROR, data => {
368 | logger.error(`chromedriver error with: ${data}`);
369 | reject(data);
370 | });
371 |
372 | logger.info('starting chromedriver service!');
373 |
374 | this.chromedriver.start({
375 | chromeOptions: {
376 | androidPackage: this.apkInfo.package,
377 | androidUseRunningApp: true,
378 | androidDeviceSerial: this.udid,
379 | androidProcess: this.apkInfo.androidProcess
380 | }
381 | });
382 | });
383 | };
384 |
385 | _.extend(Android.prototype, controllers);
386 |
387 | module.exports = Android;
388 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "macaca-android",
3 | "version": "2.2.2",
4 | "description": "Macaca Android driver",
5 | "keywords": [
6 | "android",
7 | "macaca"
8 | ],
9 | "main": "./lib/macaca-android",
10 | "files": [
11 | "lib/**/*.js"
12 | ],
13 | "repository": {
14 | "type": "git",
15 | "url": "git://github.com/macacajs/macaca-android.git"
16 | },
17 | "dependencies": {
18 | "driver-base": "~0.1.0",
19 | "macaca-adb": "~1.0.3",
20 | "macaca-chromedriver": "~1.0.1",
21 | "macaca-utils": "^1.0.0",
22 | "temp": "~0.8.3",
23 | "uiautomatorwd": "^1.2.1",
24 | "unlock-apk": "^1.2.0",
25 | "webdriver-dfn-error-code": "~1.0.1",
26 | "xlogger": "~1.0.0",
27 | "xml2map2": "^1.0.2"
28 | },
29 | "devDependencies": {
30 | "eslint": "^4.14.0",
31 | "eslint-plugin-mocha": "^4.11.0",
32 | "git-contributor": "1",
33 | "husky": "^1.3.1",
34 | "mocha": "*",
35 | "nyc": "^13.3.0"
36 | },
37 | "husky": {
38 | "hooks": {
39 | "pre-commit": "npm run lint"
40 | }
41 | },
42 | "scripts": {
43 | "test": "nyc --reporter=lcov --reporter=text mocha",
44 | "lint": "eslint --fix lib test",
45 | "contributor": "git-contributor"
46 | },
47 | "license": "MIT"
48 | }
49 |
--------------------------------------------------------------------------------
/test/macaca-android.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('assert');
4 |
5 | const Android = require('../lib/macaca-android');
6 |
7 | describe('test', () => {
8 | it('should be ok', () => {
9 | assert.ok(Android);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --reporter spec
2 |
--------------------------------------------------------------------------------