diff --git a/.eslintrc.json b/.eslintrc.json index 69a602c5c..7abb5b8b3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,11 +1,20 @@ { - "extends": "@appium/eslint-config-appium", + "extends": ["@appium/eslint-config-appium-ts"], "overrides": [ { "files": "test/**/*.js", "rules": { "func-names": "off" } + }, + { + "files": ["./**/scripts/**/*.js"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + }, + "parserOptions": { + "sourceType": "script" + } } ] } diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 20cd35996..79eeab4c9 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -2,7 +2,6 @@ name: Unit Tests on: [pull_request, push] - jobs: prepare_matrix: runs-on: ubuntu-latest @@ -13,9 +12,9 @@ jobs: id: generate-matrix run: echo "versions=$(curl -s https://endoflife.date/api/nodejs.json | jq -c '[[.[] | select(.lts != false)][:3] | .[].cycle | tonumber]')" >> "$GITHUB_OUTPUT" - test: + unit-test: needs: - - prepare_matrix + - prepare_matrix strategy: matrix: node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }} diff --git a/.gitignore b/.gitignore index c120c4f7c..3f200e3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ coverage .nyc_output/ test-results.xml .DS_Store +docs/reference/ +site/ +.vscode/ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..424f44d49 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint:commit -- --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..26e0c553e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint:staged diff --git a/.mocharc.js b/.mocharc.js index 66d4a976a..70579e22e 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,4 +1,5 @@ module.exports = { - require: ['@babel/register'], - forbidOnly: Boolean(process.env.CI) + require: ['ts-node/register'], + forbidOnly: Boolean(process.env.CI), + color: true, }; diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..555040e78 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "problemMatcher": [], + "label": "npm: dev", + "detail": "npm run build -- --watch", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/.wallaby.js b/.wallaby.js new file mode 100644 index 000000000..746dbd6ff --- /dev/null +++ b/.wallaby.js @@ -0,0 +1,42 @@ +'use strict'; + +module.exports = (wallaby) => { + return { + compilers: { + '**/*.js': wallaby.compilers.typeScript({ + allowJs: true, + allowSyntheticDefaultImports: true, + resolveJsonModule: true, + isolatedModules: true, + }), + '**/*.ts?(x)': wallaby.compilers.typeScript(), + }, + debug: true, + env: { + type: 'node', + }, + files: ['index.ts', 'lib/**/*'], + testFramework: 'mocha', + tests: ['test/unit/**/*-specs.js'], + workers: { + restart: true, + }, + setup(wallaby) { + // copied out of `./test/setup.js` + + const chai = require('chai'); + const chaiAsPromised = require('chai-as-promised'); + + // The `chai` global is set if a test needs something special. + // Most tests won't need this. + global.chai = chai.use(chaiAsPromised); + + // `should()` is only necessary when working with some `null` or `undefined` values. + global.should = chai.should(); + + const mocha = wallaby.testFramework; + mocha.timeout(10000); + }, + runMode: 'onsave', + }; +}; diff --git a/babel.config.json b/babel.config.json deleted file mode 100644 index 048e9cf60..000000000 --- a/babel.config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "node": "14" - }, - "shippedProposals": true - } - ] - ], - "plugins": [ - "source-map-support", - "@babel/plugin-transform-runtime" - ], - "comments": false, - "sourceMaps": "both", - "env": { - "test": { - "retainLines": true, - "comments": true - } - } -} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..dd1f77d70 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,11 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [0], + 'body-max-line-length': [0], + 'footer-max-line-length': [0], + 'header-max-length': [0], + 'subject-case': [0], + 'subject-full-stop': [0], + }, +}; diff --git a/index.js b/index.js index 794d5b9ba..679d76477 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,8 @@ -// transpile:main +import {install} from 'source-map-support'; -import { AndroidUiautomator2Driver } from './lib/driver'; +install(); -export { AndroidUiautomator2Driver }; +import {AndroidUiautomator2Driver} from './lib/driver'; + +export {AndroidUiautomator2Driver}; export default AndroidUiautomator2Driver; diff --git a/lib/commands/actions.js b/lib/commands/actions.js index 648eca198..2f44dab19 100644 --- a/lib/commands/actions.js +++ b/lib/commands/actions.js @@ -1,110 +1,124 @@ +// @ts-check - -let commands = {}, helpers = {}, extensions = {}; - -commands.pressKeyCode = async function (keycode, metastate = null, flags = null) { - return await this.uiautomator2.jwproxy.command('/appium/device/press_keycode', 'POST', { - keycode, - metastate, - flags, - }); -}; - -commands.longPressKeyCode = async function (keycode, metastate = null, flags = null) { - return await this.uiautomator2.jwproxy.command('/appium/device/long_press_keycode', 'POST', { - keycode, - metastate, - flags - }); -}; - -commands.doSwipe = async function (swipeOpts) { - return await this.uiautomator2.jwproxy.command(`/touch/perform`, 'POST', swipeOpts); -}; - -commands.doDrag = async function (dragOpts) { - return await this.uiautomator2.jwproxy.command(`/touch/drag`, 'POST', dragOpts); -}; - -commands.getOrientation = async function () { - return await this.uiautomator2.jwproxy.command(`/orientation`, 'GET', {}); -}; - -commands.setOrientation = async function (orientation) { - orientation = orientation.toUpperCase(); - return await this.uiautomator2.jwproxy.command(`/orientation`, 'POST', {orientation}); -}; - -/** - * @typedef {Object} PressKeyOptions - * @property {number} keycode A valid Android key code. See https://developer.android.com/reference/android/view/KeyEvent - * for the list of available key codes - * @property {number?} metastate An integer in which each bit set to 1 represents a pressed meta key. See - * https://developer.android.com/reference/android/view/KeyEvent for more details. - * @property {string?} flags Flags for the particular key event. See - * https://developer.android.com/reference/android/view/KeyEvent for more details. - * @property {boolean} isLongPress [false] Whether to emulate long key press -*/ +import {mixin} from './mixins'; /** - * Emulates single key press of the key with the given code. - * - * @param {PressKeyOptions} opts + * @type {import('./mixins').UIA2ActionsMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -commands.mobilePressKey = async function mobilePressKey(opts = {}) { - const { - keycode, - metastate, - flags, - isLongPress = false, - } = opts; - - return await this.uiautomator2.jwproxy.command( - `/appium/device/${isLongPress ? 'long_' : ''}press_keycode`, - 'POST', { - keycode, - metastate, - flags - } - ); +const ActionsMixin = { + async pressKeyCode(keycode, metastate, flags) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/press_keycode', + 'POST', + { + keycode, + metastate, + flags, + } + ); + }, + + async longPressKeyCode(keycode, metastate, flags) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/long_press_keycode', + 'POST', + { + keycode, + metastate, + flags, + } + ); + }, + + async doSwipe(swipeOpts) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/perform`, + 'POST', + swipeOpts + ); + }, + + async doDrag(dragOpts) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/drag`, + 'POST', + dragOpts + ); + }, + + async getOrientation() { + return /** @type {import('@appium/types').Orientation} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/orientation`, + 'GET', + {} + ) + ); + }, + + async setOrientation(orientation) { + orientation = /** @type {import('@appium/types').Orientation} */ (orientation.toUpperCase()); + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/orientation`, + 'POST', + {orientation} + ); + }, + + async mobilePressKey(opts) { + const {keycode, metastate, flags, isLongPress = false} = opts; + + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/appium/device/${isLongPress ? 'long_' : ''}press_keycode`, + 'POST', + { + keycode, + metastate, + flags, + } + ); + }, + + /** + * See https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-scheduleaction + * @param {Record} opts + */ + async mobileScheduleAction(opts = {}) { + return await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/schedule_action', + 'POST', + opts + ); + }, + + /** + * @see https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-getactionhistory + */ + async mobileGetActionHistory(opts) { + return /** @type {import('./types').ActionResult} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/action_history', + 'POST', + opts ?? {} + ) + ); + }, + + /** + * @see https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-unscheduleaction + */ + async mobileUnscheduleAction(opts) { + return await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/unschedule_action', + 'POST', + opts ?? {} + ); + }, }; -/** - * See https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-scheduleaction - * @param {Record} opts - */ -commands.mobileScheduleAction = async function mobileScheduleAction (opts = {}) { - return await this.uiautomator2.jwproxy.command('/appium/schedule_action', 'POST', opts); -}; - -/** - * @typedef {Object} ActionResult - * @property {number} repeats - * @property {Record[][]} - */ +mixin(ActionsMixin); /** - * @typedef {Object} ActionArgs - * @property {string} name + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server */ - -/** - * See https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-getactionhistory - * @param {ActionArgs} opts - * @returns {Promise} - */ -commands.mobileGetActionHistory = async function mobileGetActionHistory (opts) { - return await this.uiautomator2.jwproxy.command('/appium/action_history', 'POST', opts ?? {}); -}; - -/** - * See https://github.com/appium/appium-uiautomator2-driver/blob/master/docs/scheduled-actions.md#mobile-unscheduleaction - * @param {ActionArgs} opts - */ -commands.mobileUnscheduleAction = async function mobileUnscheduleAction (opts) { - return await this.uiautomator2.jwproxy.command('/appium/unschedule_action', 'POST', opts ?? {}); -}; - -Object.assign(extensions, commands, helpers); -export { commands, helpers }; -export default extensions; diff --git a/lib/commands/alert.js b/lib/commands/alert.js index 815688209..0cb573448 100644 --- a/lib/commands/alert.js +++ b/lib/commands/alert.js @@ -1,53 +1,45 @@ -let commands = {}, helpers = {}, extensions = {}; +// @ts-check -commands.getAlertText = async function () { - return await this.uiautomator2.jwproxy.command('/alert/text', 'GET', {}); -}; - -/** - * @typedef {Object} AcceptAlertOptions - * @property {?string} buttonLabel - The name of the button to click in order to - * accept the alert. If the name is not provided - * then the script will try to detect the button - * automatically. - */ +import {mixin} from './mixins'; /** - * @param {AcceptAlertOptions} opts - * @throws {InvalidElementStateError} If no matching button, that can accept the alert, - * can be found - * @throws {NoAlertOpenError} If no alert is present + * @type {import('./mixins').UIA2AlertMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -commands.mobileAcceptAlert = async function (opts = {}) { - return await this.uiautomator2.jwproxy.command('/alert/accept', 'POST', opts); -}; - -commands.postAcceptAlert = async function () { - return await this.mobileAcceptAlert(); +const AlertMixin = { + async getAlertText() { + return String( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/alert/text', + 'GET', + {} + ) + ); + }, + async mobileAcceptAlert(opts = {}) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/alert/accept', + 'POST', + opts + ); + }, + async postAcceptAlert() { + await this.mobileAcceptAlert(); + }, + async mobileDismissAlert(opts = {}) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/alert/dismiss', + 'POST', + opts + ); + }, + async postDismissAlert() { + await this.mobileDismissAlert(); + }, }; -/** - * @typedef {Object} DismissAlertOptions - * @property {?string} buttonLabel - The name of the button to click in order to - * dismiss the alert. If the name is not provided - * then the script will try to detect the button - * automatically. - */ +mixin(AlertMixin); /** - * @param {DismissAlertOptions} opts - * @throws {InvalidElementStateError} If no matching button, that can dismiss the alert, - * can be found - * @throws {NoAlertOpenError} If no alert is present + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server */ -commands.mobileDismissAlert = async function (opts = {}) { - return await this.uiautomator2.jwproxy.command('/alert/dismiss', 'POST', opts); -}; - -commands.postDismissAlert = async function () { - return await this.mobileDismissAlert(); -}; - -Object.assign(extensions, commands, helpers); -export { commands, helpers }; -export default extensions; diff --git a/lib/commands/app-strings.js b/lib/commands/app-strings.js index 497ff3ee2..648095728 100644 --- a/lib/commands/app-strings.js +++ b/lib/commands/app-strings.js @@ -1,76 +1,97 @@ +// @ts-check + +import {mixin} from './mixins'; import _ from 'lodash'; -import { fs, tempDir } from 'appium/support'; +import {fs, tempDir} from 'appium/support'; -const commands = {}; +/** + * @type {import('./mixins').UIA2AppStringsMixin} + * @satisfies {import('@appium/types').ExternalDriver} + */ +const AppStringsMixin = { + async getStrings(language) { + const adb = /** @type {ADB} */ (this.adb); + if (!language) { + language = await adb.getDeviceLanguage(); + this.log.info(`No language specified, returning strings for: ${language}`); + } -commands.getStrings = async function (language) { - if (!language) { - language = await this.adb.getDeviceLanguage(); - this.log.info(`No language specified, returning strings for: ${language}`); - } + /** + * Clients require the resulting mapping to have both keys + * and values of type string + * @param {StringRecord} mapping + */ + const preprocessStringsMap = function (mapping) { + /** @type {StringRecord} */ + const result = {}; + for (const [key, value] of _.toPairs(mapping)) { + result[key] = _.isString(value) ? value : JSON.stringify(value); + } + return result; + }; - // Clients require the resulting mapping to have both keys - // and values of type string - const preprocessStringsMap = function (mapping) { - const result = {}; - for (const [key, value] of _.toPairs(mapping)) { - result[key] = _.isString(value) ? value : JSON.stringify(value); + if (this.apkStrings[language]) { + // Return cached strings + return preprocessStringsMap(this.apkStrings[language]); } - return result; - }; - if (this.apkStrings[language]) { - // Return cached strings - return preprocessStringsMap(this.apkStrings[language]); - } + if (!this.opts.app && !this.opts.appPackage) { + this.log.errorAndThrow("One of 'app' or 'appPackage' capabilities should must be specified"); + throw new Error(); // unreachable + } + + let app = this.opts.app; + const tmpRoot = await tempDir.openDir(); + try { + if (!app) { + try { + app = await adb.pullApk(/** @type {string} */ (this.opts.appPackage), tmpRoot); + } catch (err) { + this.log.errorAndThrow( + `Failed to pull an apk from '${this.opts.appPackage}'. Original error: ${ + /** @type {Error} */ (err).message + }` + ); + throw new Error(); // unreachable + } + } - if (!this.opts.app && !this.opts.appPackage) { - this.log.errorAndThrow("One of 'app' or 'appPackage' capabilities should must be specified"); - } + if (!(await fs.exists(app))) { + this.log.errorAndThrow(`The app at '${app}' does not exist`); + throw new Error(); // unreachable + } - let app = this.opts.app; - const tmpRoot = await tempDir.openDir(); - try { - if (!app) { try { - app = await this.adb.pullApk(this.opts.appPackage, tmpRoot); + const {apkStrings} = await adb.extractStringsFromApk(app, language, tmpRoot); + this.apkStrings[language] = apkStrings; + return preprocessStringsMap(apkStrings); } catch (err) { - this.log.errorAndThrow(`Failed to pull an apk from '${this.opts.appPackage}'. Original error: ${err.message}`); + this.log.errorAndThrow( + `Cannot extract strings from '${app}'. Original error: ${ + /** @type {Error} */ (err).message + }` + ); + throw new Error(); // unreachable } + } finally { + await fs.rimraf(tmpRoot); } + }, - if (!await fs.exists(app)) { - this.log.errorAndThrow(`The app at '${app}' does not exist`); - } - - try { - const {apkStrings} = await this.adb.extractStringsFromApk(app, language, tmpRoot); - this.apkStrings[language] = apkStrings; - return preprocessStringsMap(apkStrings); - } catch (err) { - this.log.errorAndThrow(`Cannot extract strings from '${app}'. Original error: ${err.message}`); - } - } finally { - await fs.rimraf(tmpRoot); - } + /** + * Retrives app strings from its resources for the given language + * or the default device language. + * + * @returns App strings map + */ + async mobileGetAppStrings(opts) { + return await this.getStrings(opts?.language); + }, }; -/** - * @typedef {Object} GetAppStringsOptions - * @property {string?} language The language abbreviation to fetch app strings mapping for. If no - * language is provided then strings for the default language on the device under test - * would be returned. Examples: en, fr - */ +mixin(AppStringsMixin); /** - * Retrives app strings from its resources for the given language - * or the default device language. - * - * @param {GetAppStringsOptions} opts - * @returns {Promise} App strings map + * @typedef {import('appium-adb').ADB} ADB + * @typedef {import('@appium/types').StringRecord} StringRecord */ -commands.mobileGetAppStrings = async function mobileGetAppStrings (opts = {}) { - return await this.getStrings(opts.language); -}; - -export default commands; \ No newline at end of file diff --git a/lib/commands/battery.js b/lib/commands/battery.js index 11ea8910c..17ce56ebd 100644 --- a/lib/commands/battery.js +++ b/lib/commands/battery.js @@ -1,34 +1,33 @@ -let extensions = {}, commands = {}; +// @ts-check -/** - * @typedef {Object} BatteryInfo - * - * @property {number} level - Battery level in range [0.0, 1.0], where - * 1.0 means 100% charge. - * -1 is returned if the actual value cannot be - * retrieved from the system. - * @property {number} state - Battery state. The following values are possible: - * BATTERY_STATUS_UNKNOWN = 1 - * BATTERY_STATUS_CHARGING = 2 - * BATTERY_STATUS_DISCHARGING = 3 - * BATTERY_STATUS_NOT_CHARGING = 4 - * BATTERY_STATUS_FULL = 5 - * -1 is returned if the actual value cannot be retrieved from the system. - */ +import {mixin} from './mixins'; /** - * Reads the battery information from the device under test. - * - * @returns {BatteryInfo} The actual battery info + * @type {import('./mixins').UIA2BatteryMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -commands.mobileGetBatteryInfo = async function () { - const result = await this.uiautomator2.jwproxy.command('/appium/device/battery_info', 'GET', {}); - // Give it the same name as in iOS - result.state = result.status; - delete result.status; - return result; +const BatteryMixin = { + /** + * Reads the battery information from the device under test. + * + * @returns The actual battery info + */ + async mobileGetBatteryInfo() { + const result = /** @type {import('./types').MapKey} */ ( + await /** @type {import('../uiautomator2').UiAutomator2Server} */ ( + this.uiautomator2 + ).jwproxy.command('/appium/device/battery_info', 'GET', {}) + ); + const batteryInfo = /** @type {any} */ (result); + // Give it the same name as in iOS + batteryInfo.state = result.status; + delete batteryInfo.status; + return /** @type {BatteryInfo} */ (batteryInfo); + }, }; -Object.assign(extensions, commands); -export { commands }; -export default extensions; +mixin(BatteryMixin); + +/** + * @typedef {import('./types').BatteryInfo} BatteryInfo + */ diff --git a/lib/commands/element.js b/lib/commands/element.js index 0fbd9674a..2cc94d586 100644 --- a/lib/commands/element.js +++ b/lib/commands/element.js @@ -1,144 +1,241 @@ -import _ from 'lodash'; -import { util } from 'appium/support'; -import { PROTOCOLS, W3C_ELEMENT_KEY } from 'appium/driver'; -import { requireArgs } from '../utils'; +// @ts-check -let commands = {}, helpers = {}, extensions = {}; +import B from 'bluebird'; +import _ from 'lodash'; +import {util} from 'appium/support'; +import {PROTOCOLS, W3C_ELEMENT_KEY} from 'appium/driver'; +import {requireArgs} from '../utils'; +import {mixin} from './mixins'; -function toBool (s) { - return _.isString(s) ? (s.toLowerCase() === 'true') : !!s; +/** + * @param {any} s + * @returns {boolean} + */ +function toBool(s) { + return _.isString(s) ? s.toLowerCase() === 'true' : !!s; } -commands.active = async function active () { - return await this.uiautomator2.jwproxy.command('/element/active', 'GET'); -}; - -commands.getAttribute = async function (attribute, elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/attribute/${attribute}`, 'GET', {}); -}; - -commands.elementDisplayed = async function (elementId) { - return toBool(await this.getAttribute('displayed', elementId)); -}; - -commands.elementEnabled = async function (elementId) { - return toBool(await this.getAttribute('enabled', elementId)); -}; - -commands.elementSelected = async function (elementId) { - return toBool(await this.getAttribute('selected', elementId)); -}; - -commands.getName = async function (elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/name`, 'GET', {}); -}; - -commands.getLocation = async function (elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/location`, 'GET', {}); -}; - -commands.getSize = async function (elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/size`, 'GET', {}); -}; - -commands.touchLongClick = async function (element, x, y, duration) { - let params = {element, x, y, duration}; - return await this.uiautomator2.jwproxy.command(`/touch/longclick`, 'POST', {params}); -}; - -commands.touchDown = async function (element, x, y) { - let params = {element, x, y}; - return await this.uiautomator2.jwproxy.command(`/touch/down`, 'POST', {params}); -}; - -commands.touchUp = async function (element, x, y) { - let params = {element, x, y}; - return await this.uiautomator2.jwproxy.command(`/touch/up`, 'POST', {params}); -}; - -commands.touchMove = async function (element, x, y) { - let params = {element, x, y}; - return await this.uiautomator2.jwproxy.command(`/touch/move`, 'POST', {params}); -}; - -helpers.doSetElementValue = async function (params) { - return await this.uiautomator2.jwproxy.command(`/element/${params.elementId}/value`, 'POST', params); -}; - -commands.setValueImmediate = async function (keys, elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/value`, 'POST', { - elementId, - text: _.isArray(keys) ? keys.join('') : keys, - replace: false - }); -}; - -commands.getText = async function (elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/text`, 'GET', {}); -}; - -commands.click = async function (element) { - return await this.uiautomator2.jwproxy.command(`/element/${element}/click`, 'POST', {element}); -}; - -commands.getElementScreenshot = async function (element) { - return await this.uiautomator2.jwproxy.command(`/element/${element}/screenshot`, 'GET', {}); -}; - -commands.tap = async function (elementId = null, x = null, y = null, count = 1) { - const areCoordinatesDefined = util.hasValue(x) && util.hasValue(y); - if (!util.hasValue(elementId) && !areCoordinatesDefined) { - throw new Error(`Either element id to tap or both absolute coordinates should be defined`); - } - - for (let i = 0; i < count; i++) { - if (util.hasValue(elementId) && !areCoordinatesDefined) { - // we are either tapping on the default location of the element - // or an offset from the top left corner - await this.uiautomator2.jwproxy.command(`/element/${elementId}/click`, 'POST'); - } else { - await this.uiautomator2.jwproxy.command(`/appium/tap`, 'POST', { - x, y, - [W3C_ELEMENT_KEY]: elementId, - }); +/** + * @type {import('./mixins').UIA2ElementMixin} + * @satisfies {import('@appium/types').ExternalDriver} + */ +const ElementMixin = { + async active() { + return /** @type {import('@appium/types').Element} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/element/active', + 'GET' + ) + ); + }, + async getAttribute(attribute, elementId) { + return String( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/attribute/${attribute}`, + 'GET', + {} + ) + ); + }, + async elementDisplayed(elementId) { + return toBool(await this.getAttribute('displayed', elementId)); + }, + async elementEnabled(elementId) { + return toBool(await this.getAttribute('enabled', elementId)); + }, + async elementSelected(elementId) { + return toBool(await this.getAttribute('selected', elementId)); + }, + async getName(elementId) { + return /** @type {string} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/name`, + 'GET', + {} + ) + ); + }, + async getLocation(elementId) { + return /** @type {import('@appium/types').Position} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/location`, + 'GET', + {} + ) + ); + }, + async getSize(elementId) { + return /** @type {import('@appium/types').Size} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/size`, + 'GET', + {} + ) + ); + }, + async touchLongClick(element, x, y, duration) { + let params = {element, x, y, duration}; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/longclick`, + 'POST', + {params} + ); + }, + async touchDown(element, x, y) { + let params = {element, x, y}; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/down`, + 'POST', + {params} + ); + }, + async touchUp(element, x, y) { + let params = {element, x, y}; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/up`, + 'POST', + {params} + ); + }, + async touchMove(element, x, y) { + let params = {element, x, y}; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/touch/move`, + 'POST', + {params} + ); + }, + async doSetElementValue(params) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${params.elementId}/value`, + 'POST', + params + ); + }, + async setValueImmediate(keys, elementId) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/value`, + 'POST', + { + elementId, + text: _.isArray(keys) ? keys.join('') : keys, + replace: false, + } + ); + }, + async getText(elementId) { + return String( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/text`, + 'GET', + {} + ) + ); + }, + async click(element) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${element}/click`, + 'POST', + {element} + ); + }, + async getElementScreenshot(element) { + return String( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${element}/screenshot`, + 'GET', + {} + ) + ); + }, + + async tap(elementId = null, x = null, y = null, count = 1) { + const areCoordinatesDefined = util.hasValue(x) && util.hasValue(y); + if (!util.hasValue(elementId) && !areCoordinatesDefined) { + throw new Error(`Either element id to tap or both absolute coordinates should be defined`); } - } -}; -commands.clear = async function (elementId) { - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/clear`, 'POST', {elementId}); -}; - -commands.getElementRect = async function (elementId) { - if (this.isWebContext()) { - this.log.debug(`Detected downstream chromedriver protocol: ${this.chromedriver.jwproxy.downstreamProtocol}`); - if (this.chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP) { - const {x, y} = await this.chromedriver.jwproxy.command(`/element/${elementId}/location`, 'GET'); - const {width, height} = await this.chromedriver.jwproxy.command(`/element/${elementId}/size`, 'GET'); - return {x, y, width, height}; + for (let i = 0; i < count; i++) { + if (util.hasValue(elementId) && !areCoordinatesDefined) { + // we are either tapping on the default location of the element + // or an offset from the top left corner + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/click`, + 'POST' + ); + } else { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/appium/tap`, + 'POST', + { + x, + y, + [W3C_ELEMENT_KEY]: elementId, + } + ); + } } - return await this.chromedriver.jwproxy.command(`/element/${elementId}/rect`, 'GET'); - } - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/rect`, 'GET'); -}; - -/** - * @typedef {Object} ReplaceValueOptions - * @property {string} elementId The id of the element whose content will be replaced - * @property {string} text The actual text to set - */ + }, + + async clear(elementId) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/clear`, + 'POST', + { + elementId, + } + ); + }, + + async getElementRect(elementId) { + const chromedriver = /** @type {import('appium-chromedriver').default} */ (this.chromedriver); + if (this.isWebContext()) { + this.log.debug( + `Detected downstream chromedriver protocol: ${chromedriver.jwproxy.downstreamProtocol}` + ); + if (chromedriver.jwproxy.downstreamProtocol === PROTOCOLS.MJSONWP) { + const [{x, y}, {width, height}] = + /** @type {[import('@appium/types').Position, import('@appium/types').Size]} */ ( + await B.all([ + chromedriver.jwproxy.command(`/element/${elementId}/location`, 'GET'), + chromedriver.jwproxy.command(`/element/${elementId}/size`, 'GET'), + ]) + ); + return {x, y, width, height}; + } + return /** @type {import('@appium/types').Rect} */ ( + await chromedriver.jwproxy.command(`/element/${elementId}/rect`, 'GET') + ); + } + return /** @type {import('@appium/types').Rect} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/rect`, + 'GET' + ) + ); + }, + + /** + * Sends text to the given element by replacing its previous content + * + * @param {import('./types').ReplaceValueOptions} opts + * @throws {Error} If there was a faulre while setting the text + */ + async mobileReplaceElementValue(opts) { + const {elementId, text} = requireArgs(['elementId', 'text'], opts); + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/element/${elementId}/value`, + 'POST', + { + text, + replace: true, + } + ); + }, +}; + +mixin(ElementMixin); /** - * Sends text to the given element by replacing its previous content - * - * @param {ReplaceValueOptions} opts - * @throws {Error} If there was a faulre while setting the text + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server */ -commands.mobileReplaceElementValue = async function mobileReplaceElementValue (opts = {}) { - const {elementId, text} = requireArgs(['elementId', 'text'], opts); - return await this.uiautomator2.jwproxy.command(`/element/${elementId}/value`, 'POST', {text, replace: true}); -}; - -Object.assign(extensions, commands, helpers); -export { commands, helpers }; -export default extensions; diff --git a/lib/commands/find.js b/lib/commands/find.js index 3d4067a75..f444d50b0 100644 --- a/lib/commands/find.js +++ b/lib/commands/find.js @@ -1,6 +1,7 @@ -import CssConverter from '../css-converter'; +// @ts-check -const helpers = {}; +import CssConverter from '../css-converter'; +import {mixin} from './mixins'; // we override the xpath search for this first-visible-child selector, which // looks like /*[@firstVisible="true"] @@ -10,25 +11,43 @@ const MAGIC_SCROLLABLE_SEL = /\/\/\*\[@scrollable ?= ?('|")true\1\]/; const MAGIC_SCROLLABLE_BY = 'new UiSelector().scrollable(true)'; /** - * Overriding helpers.doFindElementOrEls functionality of appium-android-driver, - * this.element initialized in find.js of appium-android-drive. + * @type {import('./mixins').UIA2FindMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -helpers.doFindElementOrEls = async function (params) { - if (params.strategy === 'xpath' && MAGIC_FIRST_VIS_CHILD_SEL.test(params.selector)) { - let elementId = params.context; - return await this.uiautomator2.jwproxy.command(`/appium/element/${elementId}/first_visible`, 'GET', {}); - } - if (params.strategy === 'xpath' && MAGIC_SCROLLABLE_SEL.test(params.selector)) { - params.strategy = '-android uiautomator'; - params.selector = MAGIC_SCROLLABLE_BY; - } - if (params.strategy === 'css selector') { - params.strategy = '-android uiautomator'; - params.selector = new CssConverter(params.selector, this.opts.appPackage) - .toUiAutomatorSelector(); - } - return await this.uiautomator2.jwproxy.command(`/element${params.multiple ? 's' : ''}`, 'POST', params); +const FindMixin = { + /** + * @privateRemarks Overriding helpers.doFindElementOrEls functionality of appium-android-driver, + * this.element initialized in find.js of appium-android-drive. + */ + async doFindElementOrEls(params) { + const uiautomator2 = /** @type {import('../uiautomator2').UiAutomator2Server} */ ( + this.uiautomator2 + ); + if (params.strategy === 'xpath' && MAGIC_FIRST_VIS_CHILD_SEL.test(params.selector)) { + let elementId = params.context; + return /** @type {Element} */ ( + await uiautomator2.jwproxy.command(`/appium/element/${elementId}/first_visible`, 'GET', {}) + ); + } + if (params.strategy === 'xpath' && MAGIC_SCROLLABLE_SEL.test(params.selector)) { + params.strategy = '-android uiautomator'; + params.selector = MAGIC_SCROLLABLE_BY; + } + if (params.strategy === 'css selector') { + params.strategy = '-android uiautomator'; + params.selector = new CssConverter( + params.selector, + this.opts.appPackage + ).toUiAutomatorSelector(); + } + return /** @type {Element|Element[]} */ ( + await uiautomator2.jwproxy.command(`/element${params.multiple ? 's' : ''}`, 'POST', params) + ); + }, }; -export { helpers }; -export default helpers; +mixin(FindMixin); + +/** + * @typedef {import('@appium/types').Element} Element + */ diff --git a/lib/commands/general.js b/lib/commands/general.js index 3bbd935e6..d8f678b61 100644 --- a/lib/commands/general.js +++ b/lib/commands/general.js @@ -1,356 +1,291 @@ +// @ts-check + import _ from 'lodash'; import B from 'bluebird'; -import { errors } from 'appium/driver'; -import { APK_EXTENSION } from '../extensions'; - -let extensions = {}, - commands = {}, - helpers = {}; - -commands.getPageSource = async function () { - return await this.uiautomator2.jwproxy.command('/source', 'GET', {}); -}; - -commands.getClipboard = async function () { - return (await this.adb.getApiLevel() < 29) - ? (await this.uiautomator2.jwproxy.command('/appium/device/get_clipboard', 'POST', {})) - : (await this.adb.getClipboard()); -}; - -// Need to override this for correct unicode support -commands.doSendKeys = async function (params) { - await this.uiautomator2.jwproxy.command('/keys', 'POST', params); -}; - -// uiautomator2 doesn't support metastate for keyevents -commands.keyevent = async function (keycode, metastate) { - this.log.debug(`Ignoring metastate ${metastate}`); - await this.adb.keyevent(keycode); -}; - -// Use ADB since we don't have UiAutomator -commands.back = async function () { - await this.adb.keyevent(4); -}; - -commands.getDisplayDensity = async function getDisplayDensity () { - return await this.uiautomator2.jwproxy.command('/appium/device/display_density', 'GET', {}); -}; - -// memoized in constructor -commands.getWindowSize = async function () { - return await this.uiautomator2.jwproxy.command('/window/current/size', 'GET', {}); -}; - -// For W3C -commands.getWindowRect = async function () { - const {width, height} = await this.getWindowSize(); - return { - width, - height, - x: 0, - y: 0, - }; -}; - -extensions.executeMobile = async function (mobileCommand, opts = {}) { - const mobileCommandsMapping = { - shell: 'mobileShell', - - execEmuConsoleCommand: 'mobileExecEmuConsoleCommand', - - dragGesture: 'mobileDragGesture', - flingGesture: 'mobileFlingGesture', - doubleClickGesture: 'mobileDoubleClickGesture', - clickGesture: 'mobileClickGesture', - longClickGesture: 'mobileLongClickGesture', - pinchCloseGesture: 'mobilePinchCloseGesture', - pinchOpenGesture: 'mobilePinchOpenGesture', - swipeGesture: 'mobileSwipeGesture', - scrollGesture: 'mobileScrollGesture', - scrollBackTo: 'mobileScrollBackTo', - scroll: 'mobileScroll', - viewportScreenshot: 'mobileViewportScreenshot', - viewportRect: 'mobileViewPortRect', - - deepLink: 'mobileDeepLink', - - startLogsBroadcast: 'mobileStartLogsBroadcast', - stopLogsBroadcast: 'mobileStopLogsBroadcast', - - acceptAlert: 'mobileAcceptAlert', - dismissAlert: 'mobileDismissAlert', - - batteryInfo: 'mobileGetBatteryInfo', - - deviceInfo: 'mobileGetDeviceInfo', - - getDeviceTime: 'mobileGetDeviceTime', - - changePermissions: 'mobileChangePermissions', - getPermissions: 'mobileGetPermissions', - - performEditorAction: 'mobilePerformEditorAction', - - startScreenStreaming: 'mobileStartScreenStreaming', - stopScreenStreaming: 'mobileStopScreenStreaming', - - getNotifications: 'mobileGetNotifications', - openNotifications: 'openNotifications', - - listSms: 'mobileListSms', - - type: 'mobileType', - replaceElementValue: 'mobileReplaceElementValue', - - pushFile: 'mobilePushFile', - pullFile: 'mobilePullFile', - pullFolder: 'mobilePullFolder', - deleteFile: 'mobileDeleteFile', - - isAppInstalled: 'mobileIsAppInstalled', - queryAppState: 'mobileQueryAppState', - activateApp: 'mobileActivateApp', - removeApp: 'mobileRemoveApp', - terminateApp: 'mobileTerminateApp', - installApp: 'mobileInstallApp', - clearApp: 'mobileClearApp', - backgroundApp: 'mobileBackgroundApp', - getCurrentActivity: 'getCurrentActivity', - getCurrentPackage: 'getCurrentPackage', - - startActivity: 'mobileStartActivity', - startService: 'mobileStartService', - stopService: 'mobileStopService', - broadcast: 'mobileBroadcast', - - getContexts: 'mobileGetContexts', - - getAppStrings: 'mobileGetAppStrings', - - installMultipleApks: 'mobileInstallMultipleApks', - - lock: 'mobileLock', - unlock: 'mobileUnlock', - isLocked: 'isLocked', - - refreshGpsCache: 'mobileRefreshGpsCache', - - startMediaProjectionRecording: 'mobileStartMediaProjectionRecording', - isMediaProjectionRecordingRunning: 'mobileIsMediaProjectionRecordingRunning', - stopMediaProjectionRecording: 'mobileStopMediaProjectionRecording', - - getConnectivity: 'mobileGetConnectivity', - setConnectivity: 'mobileSetConnectivity', - toggleGps: 'toggleLocationServices', - isGpsEnables: 'isLocationServicesEnabled', - - hideKeyboard: 'hideKeyboard', - isKeyboardShown: 'isKeyboardShown', - - pressKey: 'mobilePressKey', - - getDisplayDensity: 'getDisplayDensity', - getSystemBars: 'getSystemBars', - - fingerprint: 'mobileFingerprint', - sendSms: 'mobileSendSms', - gsmCall: 'mobileGsmCall', - gsmSignal: 'mobileGsmSignal', - gsmVoice: 'mobileGsmVoice', - powerAc: 'mobilePowerAC', - powerCapacity: 'mobilePowerCapacity', - networkSpeed: 'mobileNetworkSpeed', - sensorSet: 'sensorSet', - - getPerformanceData: 'mobileGetPerformanceData', - getPerformanceDataTypes: 'getPerformanceDataTypes', - - statusBar: 'mobilePerformStatusBarCommand', - - screenshots: 'mobileScreenshots', - - scheduleAction: 'mobileScheduleAction', - getActionHistory: 'mobileGetActionHistory', - unscheduleAction: 'mobileUnscheduleAction', - }; - - if (!_.has(mobileCommandsMapping, mobileCommand)) { - throw new errors.UnknownCommandError(`Unknown mobile command "${mobileCommand}". ` + - `Only ${_.keys(mobileCommandsMapping)} commands are supported.`); - } - return await this[mobileCommandsMapping[mobileCommand]](opts); -}; - -commands.mobileViewportScreenshot = async function () { - return await this.getViewportScreenshot(); -}; - -/** - * @typedef {object} Rectangle - * @property {number} left - The left coordinate of the Rectangle. - * @property {number} top - The top coordinate of the Rectangle. - * @property {number} width - The width of Rectangle. - * @property {number} height - The height of Rectangle. - */ - -/** - * Returns the viewport coordinates. - * @returns {Rectangle} The viewport coordinates. - */ -commands.mobileViewPortRect = async function mobileViewPortRect () { - return await this.getViewPortRect(); -}; - -commands.setUrl = async function (url) { - await this.adb.startUri(url, this.opts.appPackage); -}; - -/** - * @typedef {object} DeepLinkOpts - * @property {!string} url - The name of URL to start. - * @property {!string} package - The name of the package to start the URI with. - * @property {?boolean} waitForLaunch [true] - if `false` then adb won't wait - * for the started activity to return the control - */ - -/** - * Start URL that take users directly to specific content in the app - * @param {DeepLinkOpts} opts - */ -commands.mobileDeepLink = async function (opts = {}) { - const { - url, - package: pkg, - waitForLaunch, - } = opts; - return await this.adb.startUri(url, pkg, { waitForLaunch }); -}; - -commands.openNotifications = async function () { - return await this.uiautomator2.jwproxy.command('/appium/device/open_notifications', 'POST', {}); -}; - -commands.updateSettings = async function (settings) { - await this.settings.update(settings); - await this.uiautomator2.jwproxy.command('/appium/settings', 'POST', {settings}); -}; - -commands.getSettings = async function () { - const driverSettings = this.settings.getSettings(); - const serverSettings = await this.uiautomator2.jwproxy.command('/appium/settings', 'GET'); - return {...driverSettings, ...serverSettings}; -}; +import {errors, PROTOCOLS} from 'appium/driver'; +import {APK_EXTENSION} from '../extensions'; +import {mixin} from './mixins'; +import {AndroidUiautomator2Driver} from '../driver'; /** - * Overriding appium-android-driver's wrapBootstrapDisconnect, - * unlike in appium-android-driver avoiding adb restarting as it intern - * kills UiAutomator2 server running in the device. - **/ -helpers.wrapBootstrapDisconnect = async function (wrapped) { - await wrapped(); -}; - -// Stop proxying to any Chromedriver and redirect to uiautomator2 -helpers.suspendChromedriverProxy = function () { - this.chromedriver = null; - this.proxyReqRes = this.uiautomator2.proxyReqRes.bind(this.uiautomator2); - this.proxyCommand = this.uiautomator2.proxyCommand.bind(this.uiautomator2); - this.jwpProxyActive = true; -}; - -/** - * The list of available info entries can be found at - * https://github.com/appium/appium-uiautomator2-server/blob/master/app/src/main/java/io/appium/uiautomator2/handler/GetDeviceInfo.java - */ -commands.mobileGetDeviceInfo = async function () { - return await this.uiautomator2.jwproxy.command('/appium/device/info', 'GET'); -}; - -/** - * @typedef {Object} TypingOptions - * @property {!string|number|boolean} text - The text to type + * Massages the arguments going into an execute method. + * @remarks A similar method is implemented in `appium-xcuitest-driver`, but it + * appears the methods in here handle unwrapping of `Element` objects, so we do + * not do that here. + * @param {readonly any[] | readonly [StringRecord] | Readonly} [args] + * @internal + * @returns {StringRecord} */ - -/** - * Types the given Unicode string. - * It is expected that the focus is already put - * to the destination input field before this method is called. - * - * @param {TypingOptions} opts - * @returns {boolean} `true` if the input text has been successfully sent to adb - * @throws {Error} if `text` property has not been provided - */ -commands.mobileType = async function mobileType (opts = {}) { - const { - text, - } = opts; - if (_.isUndefined(text)) { - this.log.errorAndThrow(`The 'text' argument is mandatory`); +function preprocessExecuteMethodArgs(args) { + if (_.isArray(args)) { + args = _.first(args); + } + const executeMethodArgs = /** @type {StringRecord} */ (args ?? {}); + /** + * Renames the deprecated `element` key to `elementId`. Historically, + * all of the pre-Execute-Method-Map execute methods accepted an `element` _or_ and `elementId` param. + * This assigns the `element` value to `elementId` if `elementId` is not already present. + */ + if (!('elementId' in executeMethodArgs) && 'element' in executeMethodArgs) { + executeMethodArgs.elementId = executeMethodArgs.element; + delete executeMethodArgs.element; } - return await this.adb.typeUnicode(text); -}; - -/** - * @typedef {Object} InstallOptions - * @property {boolean} allowTestPackages [false] - Set to true in order to allow test - * packages installation. - * @property {boolean} useSdcard [false] - Set to true to install the app on sdcard - * instead of the device memory. - * @property {boolean} grantPermissions [false] - Set to true in order to grant all the - * permissions requested in the application's manifest - * automatically after the installation is completed - * under Android 6+. - * @property {boolean} replace [true] - Set it to false if you don't want - * the application to be upgraded/reinstalled - * if it is already present on the device. - * @property {boolean} partialInstall [false] - Install apks partially. It is used for 'install-multiple'. - * https://android.stackexchange.com/questions/111064/what-is-a-partial-application-install-via-adb - */ + return executeMethodArgs; +} /** - * @typedef {Object} InstallMultipleApksOptions - * @property {Array} apks - The list of APKs to install. Each APK should be a path to a apk - * or downloadable URL as HTTP/HTTPS. - * @property {InstallOptions} options + * Type guard to check if a script is an execute method. + * @param {any} script + * @internal + * @returns {script is keyof import('../execute-method-map').Uiautomator2ExecuteMethodMap} */ +function isExecuteMethod(script) { + return script in AndroidUiautomator2Driver.executeMethodMap; +} /** - * Install multiple APKs with `install-multiple` option. - * - * @param {InstallMultipleApksOptions} opts - * @throws {Error} if an error occured while installing the given APKs. + * @type {import('./mixins').UIA2GeneralMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -commands.mobileInstallMultipleApks = async function (opts = {}) { - if (!_.isArray(opts.apks) || _.isEmpty(opts.apks)) { - throw new errors.InvalidArgumentError('No apks are given to install'); - } - const apks = await B.all(opts.apks - .map((app) => this.helpers.configureApp(app, [APK_EXTENSION]))); - await this.adb.installMultipleApks(apks, opts.options); +const GeneralMixin = { + async getPageSource() { + return String( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/source', + 'GET', + {} + ) + ); + }, + + async getClipboard() { + const adb = /** @type {ADB} */ (this.adb); + return String( + (await adb.getApiLevel()) < 29 + ? await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/get_clipboard', + 'POST', + {} + ) + : await adb.getClipboard() + ); + }, + + async doSendKeys(params) { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/keys', + 'POST', + params + ); + }, + + async keyevent(keycode, metastate) { + this.log.debug(`Ignoring metastate ${metastate}`); + await /** @type {ADB} */ (this.adb).keyevent(keycode); + }, + + async back() { + await /** @type {ADB} */ (this.adb).keyevent(4); + }, + + async getDisplayDensity() { + return /** @type {number} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/display_density', + 'GET', + {} + ) + ); + }, + + async getWindowSize() { + return /** @type {import('@appium/types').Size} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/window/current/size', + 'GET', + {} + ) + ); + }, + + // For W3C + async getWindowRect() { + const {width, height} = await this.getWindowSize(); + return { + width, + height, + x: 0, + y: 0, + }; + }, + + /** + * @override + * @privateRemarks Because the "mobile" commands (execute methods) in this + * driver universally accept an options object, this method will _not_ call + * into `BaseDriver.executeMethod`. + */ + async execute(script, args) { + if (isExecuteMethod(script)) { + const executeMethodArgs = preprocessExecuteMethodArgs(args); + this.log.info(`Executing native command '${script}'`); + return await this.executeMobile(script, executeMethodArgs); + } + if (!this.isWebContext()) { + throw new errors.NotImplementedError(); + } + const endpoint = + /** @type {import('appium-chromedriver').Chromedriver} */ (this.chromedriver).jwproxy + .downstreamProtocol === PROTOCOLS.MJSONWP + ? '/execute' + : '/execute/sync'; + return await /** @type {import('appium-chromedriver').Chromedriver} */ ( + this.chromedriver + ).jwproxy.command(endpoint, 'POST', { + script, + args, + }); + }, + + /** + * @param script Must be of the form `mobile: `, which differs from its parent class implementation. + * @override + */ + async executeMobile(script, opts = {}) { + if (!isExecuteMethod(script)) { + const commandNames = _.map(_.keys(AndroidUiautomator2Driver.executeMethodMap), (value) => + value.slice(8) + ); + throw new errors.UnknownCommandError( + `Unknown mobile command "${script}". ` + + `Only ${commandNames.join(', ')} commands are supported.` + ); + } + const methodName = + AndroidUiautomator2Driver.executeMethodMap[ + /** @type {keyof import('../execute-method-map').Uiautomator2ExecuteMethodMap} */ (script) + ].command; + + return await /** @type {(opts?: any) => Promise} */ (this[methodName])(opts); + }, + + async mobileViewportScreenshot() { + return await this.getViewportScreenshot(); + }, + + /** + * Returns the viewport coordinates. + * @returns The viewport coordinates. + */ + async mobileViewPortRect() { + return await this.getViewPortRect(); + }, + + async setUrl(url) { + await /** @type {ADB} */ (this.adb).startUri(url, /** @type {string} */ (this.opts.appPackage)); + }, + + /** + * Start URL that take users directly to specific content in the app + */ + async mobileDeepLink(opts) { + const {url, package: pkg, waitForLaunch} = opts; + return await /** @type {ADB} */ (this.adb).startUri(url, pkg, {waitForLaunch}); + }, + + async openNotifications() { + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/open_notifications', + 'POST', + {} + ); + }, + + /** + * Overriding appium-android-driver's wrapBootstrapDisconnect, + * unlike in appium-android-driver avoiding adb restarting as it intern + * kills UiAutomator2 server running in the device. + **/ + async wrapBootstrapDisconnect(wrapped) { + await wrapped(); + }, + + // Stop proxying to any Chromedriver and redirect to uiautomator2 + suspendChromedriverProxy() { + this.chromedriver = undefined; + this.proxyReqRes = /** @type {UiAutomator2Server} */ (this.uiautomator2).proxyReqRes.bind( + this.uiautomator2 + ); + this.proxyCommand = /** @type {typeof this.proxyCommand} */ ( + /** @type {UiAutomator2Server} */ (this.uiautomator2).proxyCommand.bind(this.uiautomator2) + ); + this.jwpProxyActive = true; + }, + + /** + * The list of available info entries can be found at + * https://github.com/appium/appium-uiautomator2-server/blob/master/app/src/main/java/io/appium/uiautomator2/handler/GetDeviceInfo.java + */ + async mobileGetDeviceInfo() { + return /** @type {StringRecord} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/device/info', + 'GET' + ) + ); + }, + + /** + * Types the given Unicode string. + * It is expected that the focus is already put + * to the destination input field before this method is called. + * + * @returns `true` if the input text has been successfully sent to adb + * @throws {Error} if `text` property has not been provided + */ + async mobileType(opts) { + const {text} = opts; + if (_.isUndefined(text)) { + this.log.errorAndThrow(`The 'text' argument is mandatory`); + throw new Error(); // unreachable + } + return await /** @type {ADB} */ (this.adb).typeUnicode(String(text)); + }, + + /** + * Install multiple APKs with `install-multiple` option. + * + * @throws {Error} if an error occured while installing the given APKs. + */ + async mobileInstallMultipleApks(opts) { + if (!_.isArray(opts.apks) || _.isEmpty(opts.apks)) { + throw new errors.InvalidArgumentError('No apks are given to install'); + } + const apks = await B.all( + opts.apks.map((app) => this.helpers.configureApp(app, [APK_EXTENSION])) + ); + await /** @type {ADB} */ (this.adb).installMultipleApks(apks, opts.options); + }, + + /** + * Puts the app to background and waits the given number of seconds Then restores the app + * if necessary. The call is blocking. + */ + async mobileBackgroundApp(opts = {}) { + const {seconds = -1} = opts; + await this.background(seconds); + }, }; +mixin(GeneralMixin); + /** - * @typedef {Object} BackgroundAppOptions - * @property {number} seconds The amount of seconds to wait between - * putting the app to background and restoring it. Any negative value - * means to not restore the app after putting it to background. + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server + * @typedef {import('appium-adb').ADB} ADB */ /** - * Puts the app to background and waits the given number of seconds Then restores the app - * if necessary. The call is blocking. - * - * @param {BackgroundAppOptions} opts + * @template [T=any] + * @typedef {import('@appium/types').StringRecord} StringRecord */ -commands.mobileBackgroundApp = async function mobileBackgroundApp (opts = {}) { - const { - seconds = -1, - } = opts; - return await this.background(seconds); -}; - -Object.assign(extensions, commands, helpers); - -export default extensions; diff --git a/lib/commands/gestures.js b/lib/commands/gestures.js index b8bbd2c7a..27641657e 100644 --- a/lib/commands/gestures.js +++ b/lib/commands/gestures.js @@ -1,397 +1,283 @@ -import { util } from 'appium/support'; +// @ts-check +import {mixin} from './mixins'; +import {util} from 'appium/support'; import _ from 'lodash'; -import { errors } from 'appium/driver'; - -const commands = {}; - - -function toOrigin (element) { - return element ? util.wrapElement(util.unwrapElement(element)) : undefined; -} - -function toPoint (x, y) { - return _.isFinite(x) && _.isFinite(y) ? {x, y} : undefined; -} - -function toRect (left, top, width, height) { - return [left, top, width, height].some((v) => !_.isFinite(v)) - ? undefined - : {left, top, width, height}; -} - -/** - * @typedef {Object} ClickOptions - * @property {?string} elementId - The id of the element to be clicked. - * If the element is missing then both click offset coordinates must be provided. - * If both the element id and offset are provided then the coordinates - * are parsed as relative offsets from the top left corner of the element. - * @property {?number} x - The x coordinate to click on - * @property {?number} y - The y coordinate to click on - */ +import {errors} from 'appium/driver'; /** - * Performs a simple click/tap gesture * - * @param {?ClickOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobileClickGesture = async function mobileClickGesture (opts = {}) { - const { - elementId, - x, y, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/click', 'POST', { - origin: toOrigin(elementId), - offset: toPoint(x, y), - }); -}; - -/** - * @typedef {Object} LongClickOptions - * @property {?string} elementId - The id of the element to be clicked. - * If the element is missing then both click offset coordinates must be provided. - * If both the element id and offset are provided then the coordinates - * are parsed as relative offsets from the top left corner of the element. - * @property {?number} x - The x coordinate to click on - * @property {?number} y - The y coordinate to click on - * @property {?number} duration [500] - Click duration in milliseconds. - * The value must not be negative - */ - -/** - * Performs a click that lasts for the given duration - * - * @param {?LongClickOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobileLongClickGesture = async function mobileLongClickGesture (opts = {}) { - const { - elementId, - x, y, - duration, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/long_click', 'POST', { - origin: toOrigin(elementId), - offset: toPoint(x, y), - duration, - }); -}; - -/** - * @typedef {Object} DoubleClickOptions - * @property {?string} elementId - The id of the element to be double clicked. - * If the element is missing then both click offset coordinates must be provided. - * If both the element id and offset are provided then the coordinates - * are parsed as relative offsets from the top left corner of the element. - * @property {?number} x - The x coordinate to double click on - * @property {?number} y - The y coordinate to double click on + * @param {import('@appium/types').Element|string} [element] + * @returns {import('@appium/types').Element|undefined} */ +function toOrigin(element) { + return element ? util.wrapElement(util.unwrapElement(element)) : undefined; +} /** - * Performs a click that lasts for the given duration * - * @param {?DoubleClickOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobileDoubleClickGesture = async function mobileDoubleClickGesture (opts = {}) { - const { - elementId, - x, y, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/double_click', 'POST', { - origin: toOrigin(elementId), - offset: toPoint(x, y), - }); -}; - -/** - * @typedef {Object} DragOptions - * @property {?string} elementId - The id of the element to be dragged. - * If the element id is missing then the start coordinates must be provided. - * If both the element id and the start coordinates are provided then these - * coordinates are considered as offsets from the top left element corner. - * @property {?number} startX - The x coordinate where the dragging starts - * @property {?number} startY - The y coordinate where the dragging starts - * @property {!number} endX - The x coordinate where the dragging ends - * @property {!number} endY - The y coordinate where the dragging ends - * @property {?number} speed [2500 * displayDensity] - The speed at which to perform - * this gesture in pixels per second. The value must not be negative + * @param {number} [x] + * @param {number} [y] + * @returns {Partial|undefined} */ +function toPoint(x, y) { + return _.isFinite(x) && _.isFinite(y) ? {x, y} : undefined; +} /** - * Drags this object to the specified location. * - * @param {?DragOptions} opts - * @throws {Error} if provided options are not valid + * @param {number} [left] + * @param {number} [top] + * @param {number} [width] + * @param {number} [height] + * @returns {Partial|undefined} */ -commands.mobileDragGesture = async function mobileDragGesture (opts = {}) { - const { - elementId, - startX, startY, - endX, endY, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/drag', 'POST', { - origin: toOrigin(elementId), - start: toPoint(startX, startY), - end: toPoint(endX, endY), - speed, - }); -}; +function toRect(left, top, width, height) { + return [left, top, width, height].some((v) => !_.isFinite(v)) + ? undefined + : {left, top, width, height}; +} /** - * @typedef {Object} FlingOptions - * @property {?string} elementId - The id of the element to be flinged. - * If the element id is missing then fling bounding area must be provided. - * If both the element id and the fling bounding area are provided then this - * area is effectively ignored. - * @property {?number} left - The left coordinate of the fling bounding area - * @property {?number} top - The top coordinate of the fling bounding area - * @property {?number} width - The width of the fling bounding area - * @property {?number} height - The height of the fling bounding area - * @property {!string} direction - Direction of the fling. - * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive) - * @property {?number} speed [7500 * displayDensity] - The speed at which to perform this - * gesture in pixels per second. The value must be greater than the minimum fling - * velocity for the given view (50 by default) + * @type {import('./mixins').UIA2GesturesMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ +const GesturesMixin = { + /** + * Performs a simple click/tap gesture + * + * @throws {Error} if provided options are not valid + */ + async mobileClickGesture(opts = {}) { + const {elementId, x, y} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/click', + 'POST', + { + origin: toOrigin(elementId), + offset: toPoint(x, y), + } + ); + }, -/** - * Drags to the specified location. - * - * @param {?FlingOptions} opts - * @throws {Error} if provided options are not valid - * @returns {boolean} True if the object can still scroll in the given direction. - */ -commands.mobileFlingGesture = async function mobileFlingGesture (opts = {}) { - const { - elementId, - left, top, width, height, - direction, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/fling', 'POST', { - origin: toOrigin(elementId), - area: toRect(left, top, width, height), - direction, - speed, - }); -}; + /** + * Performs a click that lasts for the given duration + * + * @throws {Error} if provided options are not valid + */ + async mobileLongClickGesture(opts = {}) { + const {elementId, x, y, duration} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/long_click', + 'POST', + { + origin: toOrigin(elementId), + offset: toPoint(x, y), + duration, + } + ); + }, -/** - * @typedef {Object} PinchOptions - * @property {?string} elementId - The id of the element to be pinched. - * If the element id is missing then pinch bounding area must be provided. - * If both the element id and the pinch bounding area are provided then the - * area is effectively ignored. - * @property {?number} left - The left coordinate of the pinch bounding area - * @property {?number} top - The top coordinate of the pinch bounding area - * @property {?number} width - The width of the pinch bounding area - * @property {?number} height - The height of the pinch bounding area - * @property {!number} percent - The size of the pinch as a percentage of the pinch area size. - * Valid values must be float numbers in range 0..1, where 1.0 is 100% - * @property {?number} speed [2500 * displayDensity] - The speed at which to perform - * this gesture in pixels per second. The value must not be negative - */ + /** + * Performs a click that lasts for the given duration + * + * @throws {Error} if provided options are not valid + */ + async mobileDoubleClickGesture(opts = {}) { + const {elementId, x, y} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/double_click', + 'POST', + { + origin: toOrigin(elementId), + offset: toPoint(x, y), + } + ); + }, -/** - * Performs a pinch close gesture. - * - * @param {?PinchOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobilePinchCloseGesture = async function mobilePinchCloseGesture (opts = {}) { - const { - elementId, - left, top, width, height, - percent, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/pinch_close', 'POST', { - origin: toOrigin(elementId), - area: toRect(left, top, width, height), - percent, - speed, - }); -}; + /** + * Drags this object to the specified location. + * + * @throws {Error} if provided options are not valid + */ + async mobileDragGesture(opts) { + const {elementId, startX, startY, endX, endY, speed} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/drag', + 'POST', + { + origin: toOrigin(elementId), + start: toPoint(startX, startY), + end: toPoint(endX, endY), + speed, + } + ); + }, -/** - * Performs a pinch open gesture. - * - * @param {?PinchOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobilePinchOpenGesture = async function mobilePinchOpenGesture (opts = {}) { - const { - elementId, - left, top, width, height, - percent, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/pinch_open', 'POST', { - origin: toOrigin(elementId), - area: toRect(left, top, width, height), - percent, - speed, - }); -}; + /** + * Drags to the specified location. + * + * @throws {Error} if provided options are not valid + * @returns True if the object can still scroll in the given direction. + */ + async mobileFlingGesture(opts) { + const {elementId, left, top, width, height, direction, speed} = opts; + return /** @type {boolean} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/fling', + 'POST', + { + origin: toOrigin(elementId), + area: toRect(left, top, width, height), + direction, + speed, + } + ) + ); + }, -/** - * @typedef {Object} SwipeOptions - * @property {?string} elementId - The id of the element to be swiped. - * If the element id is missing then swipe bounding area must be provided. - * If both the element id and the swipe bounding area are provided then the - * area is effectively ignored. - * @property {?number} left - The left coordinate of the swipe bounding area - * @property {?number} top - The top coordinate of the swipe bounding area - * @property {?number} width - The width of the swipe bounding area - * @property {?number} height - The height of the swipe bounding area - * @property {!string} direction - Direction of the swipe. - * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive) - * @property {!number} percent - The size of the swipe as a percentage of the swipe area size. - * Valid values must be float numbers in range 0..1, where 1.0 is 100% - * @property {?number} speed [5000 * displayDensity] - The speed at which to perform this - * gesture in pixels per second. The value must not be negative - */ + /** + * Performs a pinch close gesture. + * + * @throws {Error} if provided options are not valid + */ + async mobilePinchCloseGesture(opts) { + const {elementId, left, top, width, height, percent, speed} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/pinch_close', + 'POST', + { + origin: toOrigin(elementId), + area: toRect(left, top, width, height), + percent, + speed, + } + ); + }, -/** - * Performs a swipe gesture. - * - * @param {?SwipeOptions} opts - * @throws {Error} if provided options are not valid - */ -commands.mobileSwipeGesture = async function mobileSwipeGesture (opts = {}) { - const { - elementId, - left, top, width, height, - direction, - percent, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/swipe', 'POST', { - origin: toOrigin(elementId), - area: toRect(left, top, width, height), - direction, - percent, - speed, - }); -}; + /** + * Performs a pinch open gesture. + * + * @throws {Error} if provided options are not valid + */ + async mobilePinchOpenGesture(opts) { + const {elementId, left, top, width, height, percent, speed} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/pinch_open', + 'POST', + { + origin: toOrigin(elementId), + area: toRect(left, top, width, height), + percent, + speed, + } + ); + }, -/** - * @typedef {Object} ScrollOptions - * @property {?string} elementId - The id of the element to be scrolled. - * If the element id is missing then scroll bounding area must be provided. - * If both the element id and the scroll bounding area are provided then this - * area is effectively ignored. - * @property {?number} left - The left coordinate of the scroll bounding area - * @property {?number} top - The top coordinate of the scroll bounding area - * @property {?number} width - The width of the scroll bounding area - * @property {?number} height - The height of the scroll bounding area - * @property {!string} direction - Direction of the scroll. - * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive) - * @property {!number} percent - The size of the scroll as a percentage of the scrolling area size. - * Valid values must be float numbers greater than zero, where 1.0 is 100% - * @property {?number} speed [5000 * displayDensity] - The speed at which to perform this gesture - * in pixels per second. The value must not be negative - */ + /** + * Performs a swipe gesture. + * + * @throws {Error} if provided options are not valid + */ + async mobileSwipeGesture(opts) { + const {elementId, left, top, width, height, direction, percent, speed} = opts; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/swipe', + 'POST', + { + origin: toOrigin(elementId), + area: toRect(left, top, width, height), + direction, + percent, + speed, + } + ); + }, -/** - * Performs a scroll gesture. - * - * @param {?ScrollOptions} opts - * @throws {Error} if provided options are not valid - * @returns {boolean} True if the object can still scroll in the given direction. - */ -commands.mobileScrollGesture = async function mobileScrollGesture (opts = {}) { - const { - elementId, - left, top, width, height, - direction, - percent, - speed, - } = opts; - return await this.uiautomator2.jwproxy.command('/appium/gestures/scroll', 'POST', { - origin: toOrigin(elementId), - area: toRect(left, top, width, height), - direction, - percent, - speed, - }); -}; + /** + * Performs a scroll gesture. + * + * @throws {Error} if provided options are not valid + * @returns True if the object can still scroll in the given direction. + */ + async mobileScrollGesture(opts) { + const {elementId, left, top, width, height, direction, percent, speed} = opts; + return /** @type {boolean} */ ( + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/appium/gestures/scroll', + 'POST', + { + origin: toOrigin(elementId), + area: toRect(left, top, width, height), + direction, + percent, + speed, + } + ) + ); + }, -/** - * @typedef {Object} ScrollElementToElementOpts - * @property {string} elementId The identifier of the scrollable element, - * which is going to be scrolled. It is required this element - * is a valid scrollable container and it was located by `-android uiautomator` - * strategy. - * @property {string} elementToId The identifier of the item, which belongs - * to the scrollable element above, and which should become visible after - * the scrolling operation is finished. It is required this element - * was located by `-android uiautomator` strategy. - */ + /** + * Scrolls the given scrollable element `elementId` until `elementToId` + * becomes visible. This function returns immediately if the `elementToId` + * is already visible in the view port. Otherwise it would scroll + * to the very beginning of the scrollable control and tries to reach the destination element + * by scrolling its parent to the end step by step. The scroll direction (vertical or horizontal) + * is detected automatically. + * + * @throws {Error} if the scrolling operation cannot be performed + */ + async mobileScrollBackTo(opts) { + const {elementId, elementToId} = opts; + if (!elementId || !elementToId) { + throw new errors.InvalidArgumentError( + `Both elementId and elementToId arguments must be provided` + ); + } + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + `/appium/element/${util.unwrapElement(elementId)}/scroll_to/${util.unwrapElement( + elementToId + )}`, + 'POST', + {} + ); + }, -/** - * Scrolls the given scrollable element `elementId` until `elementToId` - * becomes visible. This function returns immediately if the `elementToId` - * is already visible in the view port. Otherwise it would scroll - * to the very beginning of the scrollable control and tries to reach the destination element - * by scrolling its parent to the end step by step. The scroll direction (vertical or horizontal) - * is detected automatically. - * - * @param {ScrollElementToElementOpts} opts - * @throws {Error} if the scrolling operation cannot be performed - */ -commands.mobileScrollBackTo = async function (opts = {}) { - const {elementId, elementToId} = opts; - if (!elementId || !elementToId) { - throw new errors.InvalidArgumentError(`Both elementId and elementToId arguments must be provided`); - } - return await this.uiautomator2.jwproxy.command( - `/appium/element/${util.unwrapElement(elementId)}/scroll_to/${util.unwrapElement(elementToId)}`, 'POST', {}); + /** + * Scrolls the given scrollable element until the element identified + * by `strategy` and `selector` becomes visible. This function returns immediately if the + * destination element is already visible in the view port. Otherwise it would scroll + * to the very beginning of the scrollable control and tries to reach the destination element + * by scrolling its parent to the end step by step. The scroll direction (vertical or horizontal) + * is detected automatically. + * + * @throws {Error} if the scrolling operation cannot be performed + */ + async mobileScroll(opts) { + const { + element, + elementId, // `element` is deprecated, use `elementId` instead + strategy, + selector, + maxSwipes, + } = opts; + if (!strategy || !selector) { + throw new errors.InvalidArgumentError( + `Both strategy and selector arguments must be provided` + ); + } + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/touch/scroll', + 'POST', + { + origin: toOrigin(elementId || element), + params: {strategy, selector, maxSwipes}, + } + ); + }, }; -/** - * @typedef {Object} ScrollOpts - * @property {?string} elementId The identifier of an element. It is required this element - * is a valid scrollable container and it was located by `-android uiautomator` - * strategy. If this property is not provided then the first currently available scrollable view - * is selected for the interaction. - * @property {!string} strategy The following strategies are supported: - * - `accessibility id` (UiSelector().description) - * - `class name` (UiSelector().className) - * - `-android uiautomator` (UiSelector) - * @property {!string} selector The corresponding lookup value for the given - * strategy. - * @property {?number} maxSwipes The maximum number of swipes to perform - * on the target scrollable view in order to reach the destination element. - * In case this value is unset then it would be retrieved from the scrollable - * element itself (vua `getMaxSearchSwipes()` property). - */ +mixin(GesturesMixin); /** - * Scrolls the given scrollable element until the element identified - * by `strategy` and `selector` becomes visible. This function returns immediately if the - * destination element is already visible in the view port. Otherwise it would scroll - * to the very beginning of the scrollable control and tries to reach the destination element - * by scrolling its parent to the end step by step. The scroll direction (vertical or horizontal) - * is detected automatically. - * - * @param {ScrollOpts} opts - * @throws {Error} if the scrolling operation cannot be performed + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server */ -commands.mobileScroll = async function (opts = {}) { - const { - element, elementId, // `element` is deprecated, use `elementId` instead - strategy, selector, maxSwipes - } = opts; - if (!strategy || !selector) { - throw new errors.InvalidArgumentError(`Both strategy and selector arguments must be provided`); - } - return await this.uiautomator2.jwproxy.command('/touch/scroll', 'POST', { - origin: toOrigin(elementId || element), - params: {strategy, selector, maxSwipes}, - }); -}; - -export default commands; diff --git a/lib/commands/index.js b/lib/commands/index.js index 0757569bf..290ac0aa8 100644 --- a/lib/commands/index.js +++ b/lib/commands/index.js @@ -1,31 +1,11 @@ -import alertCmds from './alert'; -import findCmds from './find'; -import generalCmds from './general'; -import touchCmds from './touch'; -import elementCmds from './element'; -import actionsCmds from './actions'; -import viewportCmds from './viewport'; -import screenshotCmds from './screenshot'; -import batteryCmds from './battery'; -import gesturesCmds from './gestures'; -import appStringsCmds from './app-strings'; - -let commands = {}; -Object.assign( - commands, - alertCmds, - findCmds, - generalCmds, - touchCmds, - actionsCmds, - elementCmds, - viewportCmds, - screenshotCmds, - batteryCmds, - gesturesCmds, - appStringsCmds, - // add other command types here -); - -export default commands; - +import './alert'; +import './find'; +import './general'; +import './touch'; +import './element'; +import './actions'; +import './viewport'; +import './screenshot'; +import './battery'; +import './gestures'; +import './app-strings'; diff --git a/lib/commands/mixins.ts b/lib/commands/mixins.ts new file mode 100644 index 000000000..88103f886 --- /dev/null +++ b/lib/commands/mixins.ts @@ -0,0 +1,167 @@ +/** + * @module + * @privateRemarks These mixins are kind of a mishmash of stuff from `appium-android-driver`, unique things, and stuff from `ExternalDriver`. Ideally, we should be pulling the method definitions right out of `ExternalDriver` whenever possible. Also note that the mixins contain _more stuff than just commands or execute methods_. + */ + +import type {Element, ExternalDriver, StringRecord} from '@appium/types'; +import type { + ActionsMixin, + AlertMixin, + ElementMixin, + ExecuteMixin, + FindMixin, + GeneralMixin, + NetworkMixin, + TouchMixin, +} from 'appium-android-driver'; +import type {EmptyObject} from 'type-fest'; +import {AndroidUiautomator2Driver} from '../driver'; +import type * as types from './types'; + +type UIA2Mixin = ThisType & T; + +export type UIA2ActionsMixin = UIA2Mixin< + Pick< + ActionsMixin, + 'pressKeyCode' | 'longPressKeyCode' | 'doSwipe' | 'doDrag' | 'getOrientation' | 'setOrientation' + > +> & { + mobilePressKey(opts: types.PressKeyOptions): Promise; + mobileScheduleAction(opts?: StringRecord): Promise; + mobileGetActionHistory(opts?: types.ActionArgs): Promise; + mobileUnscheduleAction(opts?: types.ActionArgs): Promise; +}; + +export type UIA2AlertMixin = UIA2Mixin< + Pick +> & { + mobileAcceptAlert(opts?: types.AcceptAlertOptions): Promise; + mobileDismissAlert(opts?: types.DismissAlertOptions): Promise; +}; + +export type UIA2AppStringsMixin = UIA2Mixin> & { + mobileGetAppStrings(opts?: types.GetAppStringsOptions): Promise; +}; + +export type UIA2BatteryMixin = UIA2Mixin & { + mobileGetBatteryInfo(): Promise; +}; + +export type UIA2ElementMixin = UIA2Mixin< + Pick< + ElementMixin, + | 'getAttribute' + | 'elementDisplayed' + | 'elementEnabled' + | 'elementSelected' + | 'getName' + | 'getLocation' + | 'getSize' + | 'touchLongClick' + | 'touchDown' + | 'touchUp' + | 'touchMove' + | 'doSetElementValue' + | 'setValueImmediate' + | 'getText' + | 'click' + | 'tap' + | 'clear' + | 'getElementRect' + > +> & { + active(): Promise; + mobileReplaceElementValue(opts: types.ReplaceValueOptions): Promise; + getElementScreenshot(elementId: string): Promise; +}; + +export type UIA2FindMixin = UIA2Mixin>; + +export type UIA2GeneralMixin = UIA2Mixin< + Pick< + GeneralMixin & NetworkMixin & ActionsMixin & ExecuteMixin, + | 'getPageSource' + | 'doSendKeys' + | 'back' + | 'getDisplayDensity' + | 'getWindowSize' + | 'getWindowRect' + | 'setUrl' + | 'wrapBootstrapDisconnect' + | 'keyevent' + | 'execute' + | 'executeMobile' + > +> & { + getClipboard(): Promise; + mobileViewportScreenshot(): Promise; + mobileViewPortRect(): Promise; + mobileDeepLink(opts: types.DeepLinkOpts): Promise; + openNotifications(): Promise; + suspendChromedriverProxy(): void; + mobileGetDeviceInfo(): Promise; + mobileType(opts: types.TypingOptions): Promise; + mobileInstallMultipleApks(opts: types.InstallMultipleApksOptions): Promise; + mobileBackgroundApp(opts?: types.BackgroundAppOptions): Promise; +}; + +export type UIA2ViewportMixin = UIA2Mixin & { + getStatusBarHeight(): Promise; + getDevicePixelRatio(): Promise; + getViewportScreenshot(): Promise; + getViewPortRect(): Promise; +}; + +export type UIA2GesturesMixin = UIA2Mixin & { + mobileClickGesture(opts?: types.ClickOptions): Promise; + mobileDoubleClickGesture(opts?: types.ClickOptions): Promise; + mobileDragGesture(opts: types.DragOptions): Promise; + mobileFlingGesture(opts: types.FlingOptions): Promise; + mobilePinchCloseGesture(opts: types.PinchOptions): Promise; + mobilePinchOpenGesture(opts: types.PinchOptions): Promise; + mobileSwipeGesture(opts: types.SwipeOptions): Promise; + mobileScrollGesture(opts: types.ScrollGestureOptions): Promise; + mobileScrollBackTo(opts: types.ScrollElementToElementOpts): Promise; + mobileScroll(opts: types.ScrollOptions): Promise; + mobileLongClickGesture(opts: types.LongClickOptions): Promise; +}; + +export type UIA2ScreenshotMixin = UIA2Mixin> & { + mobileScreenshots(opts: types.ScreenshotsOpts): Promise>; +}; + +export type UIA2TouchMixin = UIA2Mixin< + // Required needed because ExternalDriver's methods are all optional + Required< + Pick + > +>; + +declare module '../driver' { + interface AndroidUiautomator2Driver + extends UIA2ActionsMixin, + UIA2AlertMixin, + UIA2AppStringsMixin, + UIA2BatteryMixin, + UIA2ElementMixin, + UIA2FindMixin, + UIA2GeneralMixin, + UIA2GesturesMixin, + UIA2ScreenshotMixin, + UIA2TouchMixin, + UIA2ViewportMixin {} +} + +/** + * This function assigns a mixin `T` to the `AndroidUiautomator2Driver` class' prototype. + * + * While each mixin has its own interface which is (in isolation) unrelated to + * `AndroidUiautomator2Driver`, the constraint on this generic type `T` is that it must be a + * partial of `AndroidUiautomator2Driver`'s interface. This enforces that it does not + * conflict with the existing interface of `AndroidUiautomator2Driver`. In that way, you + * can think of it as a type guard. + * @param mixin Mixin implementation + */ +export function mixin>(mixin: T): void { + Object.assign(AndroidUiautomator2Driver.prototype, mixin); +} diff --git a/lib/commands/screenshot.js b/lib/commands/screenshot.js index 9020bf368..8d2cb4399 100644 --- a/lib/commands/screenshot.js +++ b/lib/commands/screenshot.js @@ -1,91 +1,95 @@ +// @ts-check + +import {mixin} from './mixins'; import _ from 'lodash'; import B from 'bluebird'; -const commands = {}; - // Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0" const DISPLAY_PATTERN = /^Display\s+(\d+)\s+\(.+display\s+(\d+)\).+displayName="([^"]*)/gm; -commands.getScreenshot = async function () { - if (this.mjpegStream) { - const data = await this.mjpegStream.lastChunkPNGBase64(); - if (data) { - return data; - } - this.log.warn('Tried to get screenshot from active MJPEG stream, but there ' + - 'was no data yet. Falling back to regular screenshot methods.'); - } - return await this.uiautomator2.jwproxy.command('/screenshot', 'GET'); -}; - -/** - * @typedef {Object} ScreenshotsInfo - * - * A dictionary where each key contains a unique display identifier - * and values are dictionaries with following items: - * - id: Display identifier - * - name: Display name, could be empty - * - isDefault: Whether this display is the default one - * - payload: The actual PNG screenshot data encoded to base64 string - */ - -/** - * @typedef {Object} ScreenshotsOpts - * @property {number|string?} displayId Android display identifier to take a screenshot for. - * If not provided then screenshots of all displays are going to be returned. - * If no matches were found then an error is thrown. - */ - /** - * Retrieves screenshots of each display available to Android. - * This functionality is only supported since Android 10. - * - * @param {ScreenshotsOpts} opts - * @returns {Promise} + * @type {import('./mixins').UIA2ScreenshotMixin} + * @satisfies {import('@appium/types').ExternalDriver} */ -commands.mobileScreenshots = async function mobileScreenshots (opts = {}) { - const displaysInfo = await this.adb.shell(['dumpsys', 'SurfaceFlinger', '--display-id']); - const infos = {}; - let match; - while ((match = DISPLAY_PATTERN.exec(displaysInfo))) { - infos[match[1]] = { - id: match[1], - isDefault: match[2] === '0', - name: match[3], - }; - } - if (_.isEmpty(infos)) { - this.log.debug(displaysInfo); - throw new Error('Cannot determine the information about connected Android displays'); - } - this.log.info(`Parsed Android display infos: ${JSON.stringify(infos)}`); +const ScreenshotMixin = { + async getScreenshot() { + if (this.mjpegStream) { + const data = await this.mjpegStream.lastChunkPNGBase64(); + if (data) { + return data; + } + this.log.warn( + 'Tried to get screenshot from active MJPEG stream, but there ' + + 'was no data yet. Falling back to regular screenshot methods.' + ); + } + return String( + await /** @type {import('../uiautomator2').UiAutomator2Server} */ ( + this.uiautomator2 + ).jwproxy.command('/screenshot', 'GET') + ); + }, - const toB64Screenshot = async (dispId) => (await this.adb.takeScreenshot(dispId)) - .toString('base64'); + /** + * Retrieves screenshots of each display available to Android. + * This functionality is only supported since Android 10. + */ + async mobileScreenshots(opts = {}) { + const displaysInfo = await /** @type {import('appium-adb').ADB} */ (this.adb).shell([ + 'dumpsys', + 'SurfaceFlinger', + '--display-id', + ]); + /** @type {import('@appium/types').StringRecord} */ + const infos = {}; + let match; + while ((match = DISPLAY_PATTERN.exec(displaysInfo))) { + infos[match[1]] = /** @type {any} */ ({ + id: match[1], + isDefault: match[2] === '0', + name: match[3], + }); + } + if (_.isEmpty(infos)) { + this.log.debug(displaysInfo); + throw new Error('Cannot determine the information about connected Android displays'); + } + this.log.info(`Parsed Android display infos: ${JSON.stringify(infos)}`); - const {displayId} = opts; - const displayIdStr = isNaN(displayId) ? null : `${displayId}`; - if (displayIdStr) { - if (!infos[displayIdStr]) { - throw new Error( - `The provided display identifier '${displayId}' is not known. ` + - `Only the following displays have been detected: ${JSON.stringify(infos)}` + /** + * @param {string} dispId + */ + const toB64Screenshot = async (dispId) => + (await /** @type {import('appium-adb').ADB} */ (this.adb).takeScreenshot(dispId)).toString( + 'base64' ); - } - return { - [displayIdStr]: { - ...infos[displayIdStr], - payload: await toB64Screenshot(displayIdStr), + + const {displayId} = opts; + const displayIdStr = _.isNaN(displayId) ? null : `${displayId}`; + if (displayIdStr) { + if (!infos[displayIdStr]) { + throw new Error( + `The provided display identifier '${displayId}' is not known. ` + + `Only the following displays have been detected: ${JSON.stringify(infos)}` + ); } - }; - } + return { + [displayIdStr]: { + ...infos[displayIdStr], + payload: await toB64Screenshot(displayIdStr), + }, + }; + } - const allInfos = _.values(infos); - const screenshots = await B.all(allInfos.map(({id}) => toB64Screenshot(id))); - for (const [info, payload] of _.zip(allInfos, screenshots)) { - info.payload = payload; - } - return infos; + const allInfos = _.values(infos); + const screenshots = await B.all(allInfos.map(({id}) => toB64Screenshot(id))); + for (const [info, payload] of /** @type {[import('./types').Screenshot, string][]} */ ( + _.zip(allInfos, screenshots) + )) { + info.payload = payload; + } + return infos; + }, }; -export default commands; +mixin(ScreenshotMixin); diff --git a/lib/commands/touch.js b/lib/commands/touch.js index 659b300f7..3082be378 100644 --- a/lib/commands/touch.js +++ b/lib/commands/touch.js @@ -1,38 +1,71 @@ -const commands = {}; +// @ts-check -commands.doPerformMultiAction = async function (elementId, states) { - let opts; - if (elementId) { - opts = { - elementId, - actions: states - }; +import {mixin} from './mixins'; - return await this.uiautomator2.jwproxy.command('/touch/multi/perform', 'POST', opts); - } else { - opts = { - actions: states - }; - return await this.uiautomator2.jwproxy.command('/touch/multi/perform', 'POST', opts); - } -}; +/** + * @type {import('./mixins').UIA2TouchMixin} + * @satisfies {import('@appium/types').ExternalDriver} + */ +const TouchMixin = { + async doPerformMultiAction(elementId, states) { + let opts; + if (elementId) { + opts = { + elementId, + actions: states, + }; + + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/touch/multi/perform', + 'POST', + opts + ); + } else { + opts = { + actions: states, + }; + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/touch/multi/perform', + 'POST', + opts + ); + } + }, -commands.performActions = async function (actions) { - this.log.debug(`Received the following W3C actions: ${JSON.stringify(actions, null, ' ')}`); - // This is mandatory, since Selenium API uses MOUSE as the default pointer type - const preprocessedActions = actions - .map((action) => Object.assign({}, action, action.type === 'pointer' ? { - parameters: { - pointerType: 'touch' + async performActions(actions) { + this.log.debug(`Received the following W3C actions: ${JSON.stringify(actions, null, ' ')}`); + // This is mandatory, since Selenium API uses MOUSE as the default pointer type + const preprocessedActions = actions.map((action) => + Object.assign( + {}, + action, + action.type === 'pointer' + ? { + parameters: { + pointerType: 'touch', + }, + } + : {} + ) + ); + this.log.debug(`Preprocessed actions: ${JSON.stringify(preprocessedActions, null, ' ')}`); + await /** @type {UiAutomator2Server} */ (this.uiautomator2).jwproxy.command( + '/actions', + 'POST', + { + actions: preprocessedActions, } - } : {})); - this.log.debug(`Preprocessed actions: ${JSON.stringify(preprocessedActions, null, ' ')}`); - return await this.uiautomator2.jwproxy.command('/actions', 'POST', {actions: preprocessedActions}); -}; + ); + }, -// eslint-disable-next-line require-await -commands.releaseActions = async function releaseActions () { - this.log.info('On this platform, releaseActions is a no-op'); + // eslint-disable-next-line require-await + async releaseActions() { + this.log.info('On this platform, releaseActions is a no-op'); + }, }; -export default commands; +mixin(TouchMixin); + +/** + * @typedef {import('../uiautomator2').UiAutomator2Server} UiAutomator2Server + */ diff --git a/lib/commands/types.ts b/lib/commands/types.ts new file mode 100644 index 000000000..0dd102841 --- /dev/null +++ b/lib/commands/types.ts @@ -0,0 +1,473 @@ +import {Rect, StringRecord} from '@appium/types'; + +/** + * Represents options for pressing a key on an Android device. + */ +export interface PressKeyOptions { + /** + * A valid Android key code. See https://developer.android.com/reference/android/view/KeyEvent + * for the list of available key codes. + */ + keycode: number; + /** + * An integer in which each bit set to 1 represents a pressed meta key. See + * https://developer.android.com/reference/android/view/KeyEvent for more details. + */ + metastate?: number; + /** + * Flags for the particular key event. See + * https://developer.android.com/reference/android/view/KeyEvent for more details. + */ + flags?: string; + /** + * Whether to emulate long key press. Defaults to `false`. + */ + isLongPress: boolean; +} + +export interface AcceptAlertOptions { + /** + * The name of the button to click in order to accept the alert. If the name is not provided + * then the script will try to detect the button automatically. + */ + buttonLabel?: string; +} + +export interface DismissAlertOptions { + /** + * The name of the button to click in order to dismiss the alert. If the name is not provided + * then the script will try to detect the button automatically. + */ + buttonLabel?: string; +} + +export interface GetAppStringsOptions { + /** + * The language abbreviation to fetch app strings mapping for. If no + * language is provided then strings for the default language on the device under test + * would be returned. Examples: en, fr + */ + language?: string; +} + +export type BatteryState = -1 | 1 | 2 | 3 | 4 | 5; + +export interface BatteryInfo { + /** + * Battery level in range [0.0, 1.0], where 1.0 means 100% charge. + * -1 is returned if the actual value cannot be retrieved from the system. + */ + level: number; + /** + * Battery state. The following values are possible: + * BATTERY_STATUS_UNKNOWN = 1 + * BATTERY_STATUS_CHARGING = 2 + * BATTERY_STATUS_DISCHARGING = 3 + * BATTERY_STATUS_NOT_CHARGING = 4 + * BATTERY_STATUS_FULL = 5 + * -1 is returned if the actual value cannot be retrieved from the system. + */ + state: BatteryState; +} + +export interface ReplaceValueOptions { + /** + * The id of the element whose content will be replaced. + */ + elementId: string; + /** + * The actual text to set. + */ + text: string; +} + +export type MapKey = Pick> & { + [P in N]: T[K]; +}; + +export interface DeepLinkOpts { + /** + * The name of URL to start. + */ + url: string; + /** + * The name of the package to start the URI with. + */ + package: string; + /** + * If `false` then adb won't wait for the started activity to return the control. + * @defaultValue true + */ + waitForLaunch?: boolean; +} + +export interface TypingOptions { + /** + * The text to type. Can be a string, number or boolean. + */ + text: string | number | boolean; +} +export interface InstallOptions { + /** + * Set to true in order to allow test packages installation. + * @defaultValue false + */ + allowTestPackages?: boolean; + /** + * Set to true to install the app on sdcard instead of the device memory. + * @defaultValue false + */ + useSdcard?: boolean; + /** + * Set to true in order to grant all the permissions requested in the application's manifest + * automatically after the installation is completed under Android 6+. + * @defaultValue false + */ + grantPermissions?: boolean; + /** + * Set it to false if you don't want the application to be upgraded/reinstalled + * if it is already present on the device. + * @defaultValue true + */ + replace?: boolean; + /** + * Install apks partially. It is used for 'install-multiple'. + * https://android.stackexchange.com/questions/111064/what-is-a-partial-application-install-via-adb + * @defaultValue false + */ + partialInstall?: boolean; +} + +export interface InstallMultipleApksOptions { + /** + * The list of APKs to install. Each APK should be a path to a apk + * or downloadable URL as HTTP/HTTPS. + */ + apks: string[]; + /** + * The installation options. + */ + options?: InstallOptions; +} +export interface BackgroundAppOptions { + /** + * The amount of seconds to wait between putting the app to background and restoring it. + * Any negative value means to not restore the app after putting it to background. + * @defaultValue -1 + */ + seconds?: number; +} + +export interface ClickOptions { + /** + * The id of the element to be clicked. If the element is missing then both click offset coordinates must be provided. If both the element id and offset are provided then the coordinates are parsed as relative offsets from the top left corner of the element. + */ + elementId?: string; + /** + * The x coordinate to click on. + */ + x?: number; + /** + * The y coordinate to click on. + */ + y?: number; +} + +export interface LongClickOptions { + /** + * The id of the element to be clicked. + * If the element is missing then both click offset coordinates must be provided. + * If both the element id and offset are provided then the coordinates + * are parsed as relative offsets from the top left corner of the element. + */ + elementId?: string; + /** + * The x coordinate to click on. + */ + x?: number; + /** + * The y coordinate to click on. + */ + y?: number; + /** + * Click duration in milliseconds. The value must not be negative. + * Default is 500. + */ + duration?: number; +} + +export type DoubleClickOptions = ClickOptions; + +export interface DragOptions { + /** + * The id of the element to be dragged. + * If the element id is missing then the start coordinates must be provided. + * If both the element id and the start coordinates are provided then these + * coordinates are considered as offsets from the top left element corner. + */ + elementId?: string; + /** + * The x coordinate where the dragging starts + */ + startX?: number; + /** + * The y coordinate where the dragging starts + */ + startY?: number; + /** + * The x coordinate where the dragging ends + */ + endX?: number; + /** + * The y coordinate where the dragging ends + */ + endY?: number; + /** + * The speed at which to perform this gesture in pixels per second. + * The value must not be negative. + * Default is 2500 * displayDensity. + */ + speed?: number; +} + +export interface FlingOptions { + /** + * The id of the element to be flinged. + * If the element id is missing then fling bounding area must be provided. + * If both the element id and the fling bounding area are provided then this + * area is effectively ignored. + */ + elementId?: string; + /** + * The left coordinate of the fling bounding area. + */ + left?: number; + /** + * The top coordinate of the fling bounding area. + */ + top?: number; + /** + * The width of the fling bounding area. + */ + width?: number; + /** + * The height of the fling bounding area. + */ + height?: number; + /** + * Direction of the fling. + * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive). + */ + direction: string; + /** + * The speed at which to perform this gesture in pixels per second. + * The value must be greater than the minimum fling velocity for the given view (50 by default). + * Default is 7500 * displayDensity. + */ + speed?: number; +} + +export interface PinchOptions { + /** + * The id of the element to be pinched. + * If the element id is missing then pinch bounding area must be provided. + * If both the element id and the pinch bounding area are provided then the + * area is effectively ignored. + */ + elementId?: string; + /** + * The left coordinate of the pinch bounding area. + */ + left?: number; + /** + * The top coordinate of the pinch bounding area. + */ + top?: number; + /** + * The width of the pinch bounding area. + */ + width?: number; + /** + * The height of the pinch bounding area. + */ + height?: number; + /** + * The size of the pinch as a percentage of the pinch area size. + * Valid values must be float numbers in range 0..1, where 1.0 is 100% + */ + percent: number; + /** + * The speed at which to perform this gesture in pixels per second. + * The value must not be negative. + * Default is 2500 * displayDensity. + */ + speed?: number; +} + +export interface SwipeOptions { + /** + * The id of the element to be swiped. + * If the element id is missing then swipe bounding area must be provided. + * If both the element id and the swipe bounding area are provided then the + * area is effectively ignored. + */ + elementId?: string; + /** + * The left coordinate of the swipe bounding area. + */ + left?: number; + /** + * The top coordinate of the swipe bounding area. + */ + top?: number; + /** + * The width of the swipe bounding area. + */ + width?: number; + /** + * The height of the swipe bounding area. + */ + height?: number; + /** + * Direction of the swipe. + * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive). + */ + direction: string; + /** + * The size of the swipe as a percentage of the swipe area size. + * Valid values must be float numbers in range 0..1, where 1.0 is 100%. + */ + percent: number; + /** + * The speed at which to perform this gesture in pixels per second. + * The value must not be negative. + * Default is 5000 * displayDensity. + */ + speed?: number; +} +export interface ScrollGestureOptions { + /** + * The id of the element to be scrolled. + * If the element id is missing then scroll bounding area must be provided. + * If both the element id and the scroll bounding area are provided then this + * area is effectively ignored. + */ + elementId?: string; + /** + * The left coordinate of the scroll bounding area. + */ + left?: number; + /** + * The top coordinate of the scroll bounding area. + */ + top?: number; + /** + * The width of the scroll bounding area. + */ + width?: number; + /** + * The height of the scroll bounding area. + */ + height?: number; + /** + * Direction of the scroll. + * Acceptable values are: `up`, `down`, `left` and `right` (case insensitive). + */ + direction: string; + /** + * The size of the scroll as a percentage of the scrolling area size. + * Valid values must be float numbers greater than zero, where 1.0 is 100%. + */ + percent: number; + /** + * The speed at which to perform this gesture in pixels per second. + * The value must not be negative. + * Default is 5000 * displayDensity. + */ + speed?: number; +} + +export interface ScrollElementToElementOpts { + /** + * The identifier of the scrollable element, which is going to be scrolled. + * It is required this element is a valid scrollable container and it was located + * by `-android uiautomator` strategy. + */ + elementId: string; + /** + * The identifier of the item, which belongs to the scrollable element above, + * and which should become visible after the scrolling operation is finished. + * It is required this element was located by `-android uiautomator` strategy. + */ + elementToId: string; +} + +export interface ScrollOptions { + /** + * The identifier of an element. It is required this element is a valid scrollable container + * and it was located by `-android uiautomator` strategy. + * If this property is not provided then the first currently available scrollable view + * is selected for the interaction. + */ + elementId?: string; + /** + * The following strategies are supported: + * - `accessibility id` (UiSelector().description) + * - `class name` (UiSelector().className) + * - `-android uiautomator` (UiSelector) + */ + strategy: string; + /** + * The corresponding lookup value for the given strategy. + */ + selector: string; + /** + * The maximum number of swipes to perform on the target scrollable view in order to reach + * the destination element. In case this value is unset then it would be retrieved from the + * scrollable element itself (via `getMaxSearchSwipes()` property). + */ + maxSwipes?: number; + /** + * @deprecated + */ + element?: string; +} + +export type RelativeRect = Pick & {left: Rect['x']; top: Rect['y']}; + +export interface Screenshot { + /** + * Display identifier + */ + id: string; + /** + * Display name + */ + name?: string; + /** + * Is this the default display + */ + isDefault: boolean; + /** + * Actual PNG screenshot encoded as base64 + */ + payload: string; +} + +export interface ScreenshotsOpts { + /** + * Android display identifier to take a screenshot for. + * If not provided then screenshots of all displays are going to be returned. + * If no matches were found then an error is thrown. + */ + displayId?: number | string; +} + +export interface ActionResult { + repeats: number; + stepResults: StringRecord[][]; +} + +export interface ActionArgs { + name: string; +} diff --git a/lib/commands/viewport.js b/lib/commands/viewport.js index 5781ff5ac..4d3d4034e 100644 --- a/lib/commands/viewport.js +++ b/lib/commands/viewport.js @@ -1,38 +1,50 @@ -import { imageUtil } from 'appium/support'; +// @ts-check +import {imageUtil} from 'appium/support'; +import {mixin} from './mixins'; -let extensions = {}, commands = {}; +/** + * @type {import('./mixins').UIA2ViewportMixin} + * @satisfies {import('@appium/types').ExternalDriver} + */ +const ViewportMixin = { + // memoized in constructor + async getStatusBarHeight() { + const {statusBar} = /** @type {{statusBar: number}} */ ( + await /** @type {import('../uiautomator2').UiAutomator2Server} */ ( + this.uiautomator2 + ).jwproxy.command(`/appium/device/system_bars`, 'GET', {}) + ); + return statusBar; + }, -// memoized in constructor -commands.getStatusBarHeight = async function () { - const {statusBar} = await this.uiautomator2.jwproxy.command(`/appium/device/system_bars`, 'GET', {}); - return statusBar; -}; - -// memoized in constructor -commands.getDevicePixelRatio = async function () { - return await this.uiautomator2.jwproxy.command('/appium/device/pixel_ratio', 'GET', {}); -}; + // memoized in constructor + async getDevicePixelRatio() { + return String( + await /** @type {import('../uiautomator2').UiAutomator2Server} */ ( + this.uiautomator2 + ).jwproxy.command('/appium/device/pixel_ratio', 'GET', {}) + ); + }, -commands.getViewportScreenshot = async function () { - const screenshot = await this.getScreenshot(); - const rect = await this.getViewPortRect(); - return await imageUtil.cropBase64Image(screenshot, rect); -}; + async getViewportScreenshot() { + const screenshot = await this.getScreenshot(); + const rect = await this.getViewPortRect(); + return await imageUtil.cropBase64Image(screenshot, rect); + }, -commands.getViewPortRect = async function () { - const windowSize = await this.getWindowSize(); - const statusBarHeight = await this.getStatusBarHeight(); - // android returns the upscaled window size, so to get the true size of the - // rect we have to downscale - return { - left: 0, - top: statusBarHeight, - width: windowSize.width, - height: windowSize.height - statusBarHeight - }; + async getViewPortRect() { + const windowSize = await this.getWindowSize(); + const statusBarHeight = await this.getStatusBarHeight(); + // android returns the upscaled window size, so to get the true size of the + // rect we have to downscale + return { + left: 0, + top: statusBarHeight, + width: windowSize.width, + height: windowSize.height - statusBarHeight, + }; + }, }; -Object.assign(extensions, commands); -export { commands }; -export default extensions; +mixin(ViewportMixin); diff --git a/lib/constraints.ts b/lib/constraints.ts new file mode 100644 index 000000000..b7708d2d4 --- /dev/null +++ b/lib/constraints.ts @@ -0,0 +1,53 @@ +import {Constraints} from '@appium/types'; +import {commonCapConstraints} from 'appium-android-driver'; + +const UIAUTOMATOR2_CONSTRAINTS = { + app: { + presence: true, + isString: true, + }, + automationName: { + isString: true, + }, + launchTimeout: { + isNumber: true, + }, + uiautomator2ServerLaunchTimeout: { + isNumber: true, + }, + uiautomator2ServerInstallTimeout: { + isNumber: true, + }, + uiautomator2ServerReadTimeout: { + isNumber: true, + }, + systemPort: { + isNumber: true, + }, + mjpegServerPort: { + isNumber: true, + }, + mjpegScreenshotUrl: { + isString: true, + }, + skipServerInstallation: { + isBoolean: true, + }, + androidCoverageEndIntent: { + isString: true, + }, + disableSuppressAccessibilityService: { + isBoolean: true, + }, + forceAppLaunch: { + isBoolean: true, + }, + shouldTerminateApp: { + isBoolean: true, + }, + ...commonCapConstraints, +} as const satisfies Constraints; + +export default UIAUTOMATOR2_CONSTRAINTS; + +export type Uiautomator2Constraints = typeof UIAUTOMATOR2_CONSTRAINTS; diff --git a/lib/css-converter.js b/lib/css-converter.js index 6bad97876..18f075411 100644 --- a/lib/css-converter.js +++ b/lib/css-converter.js @@ -46,6 +46,7 @@ const ALL_ATTRS = [ ...STR_ATTRS, ]; +/** @type {[string, string[]][]} */ const ATTRIBUTE_ALIASES = [ [RESOURCE_ID, ['id']], ['description', [ @@ -78,10 +79,12 @@ function toSnakeCase (str) { * @returns {string} Either 'true' or 'false'. If value is empty, return 'true' */ function requireBoolean (css) { + // @ts-ignore Attributes should exist const val = _.toLower((css.value ?? css.argument)?.value) || 'true'; // an omitted boolean attribute means 'true' (e.g.: input[checked] means checked is true) if (['true', 'false'].includes(val)) { return val; } + // @ts-ignore The attribute should exist throw new Error(`'${css.name}' must be true, false or empty. Found '${css.value}'`); } @@ -149,6 +152,7 @@ class CssConverter { * @returns {string} CSS attribute parsed as UiSelector */ parseAttr (cssAttr) { + // @ts-ignore Value should be present const attrValue = cssAttr.value?.value; if (!_.isString(attrValue) && !_.isEmpty(attrValue)) { throw new Error(`'${cssAttr.name}=${attrValue}' is an invalid attribute. ` + @@ -205,9 +209,10 @@ class CssConverter { * Convert a CSS pseudo class to a UiSelector * * @param {import('css-selector-parser').AstPseudoClass} cssPseudo CSS Pseudo class - * @returns {string?} Pseudo selector parsed as UiSelector + * @returns {string|null|undefined} Pseudo selector parsed as UiSelector */ parsePseudo (cssPseudo) { + // @ts-ignore The attribute should exist const argValue = cssPseudo.argument?.value; if (!_.isString(argValue) && !_.isEmpty(argValue)) { throw new Error(`'${cssPseudo.name}=${argValue}'. ` + @@ -236,6 +241,7 @@ class CssConverter { } let uiAutomatorSelector = 'new UiSelector()'; + // @ts-ignore the attribute should exist const tagName = cssRule.tag?.name; if (tagName && tagName !== '*') { let androidClass = [tagName]; @@ -248,9 +254,11 @@ class CssConverter { uiAutomatorSelector += `.classNameMatches("${tagName}")`; } } else if (!_.isEmpty(cssRule.classNames)) { + // @ts-ignore the attribute should exist uiAutomatorSelector += `.classNameMatches("${cssRule.classNames.join('\\.')}")`; } if (!_.isEmpty(cssRule.ids)) { + // @ts-ignore The attribute should exist uiAutomatorSelector += `.resourceId("${this.formatIdLocator(cssRule.ids[0])}")`; } if (cssRule.attributes) { diff --git a/lib/desired-caps.js b/lib/desired-caps.js deleted file mode 100644 index 5ea32ffb7..000000000 --- a/lib/desired-caps.js +++ /dev/null @@ -1,70 +0,0 @@ -import { commonCapConstraints } from 'appium-android-driver'; - -let uiautomatorCapConstraints = { - app: { - isString: true - }, - automationName: { - isString: true - }, - browserName: { - isString: true - }, - launchTimeout: { - isNumber: true - }, - skipUnlock: { - isBoolean: true - }, - uiautomator2ServerLaunchTimeout: { - isNumber: true - }, - uiautomator2ServerInstallTimeout: { - isNumber: true - }, - uiautomator2ServerReadTimeout: { - isNumber: true - }, - disableWindowAnimation: { - isBoolean: true - }, - systemPort: { - isNumber: true - }, - mjpegServerPort: { - isNumber: true - }, - mjpegScreenshotUrl: { - isString: true - }, - skipServerInstallation: { - isBoolean: true - }, - androidCoverageEndIntent: { - isString: true - }, - userProfile: { - isNumber: true - }, - appWaitForLaunch: { - isBoolean: true - }, - disableSuppressAccessibilityService: { - isBoolean: true - }, - forceAppLaunch: { - isBoolean: true - }, - shouldTerminateApp: { - isBoolean: true - } -}; - -let desiredCapConstraints = {}; -Object.assign( - desiredCapConstraints, - uiautomatorCapConstraints, - commonCapConstraints -); - -export default desiredCapConstraints; diff --git a/lib/driver.js b/lib/driver.ts similarity index 57% rename from lib/driver.js rename to lib/driver.ts index 222d80077..2d39645bf 100644 --- a/lib/driver.js +++ b/lib/driver.ts @@ -1,24 +1,45 @@ -import _ from 'lodash'; -import { BaseDriver, DeviceSettings } from 'appium/driver'; -import { - UiAutomator2Server, SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID -} from './uiautomator2'; -import { newMethodMap } from './method-map'; -import { fs, util, mjpeg } from 'appium/support'; -import { retryInterval } from 'asyncbox'; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { + DefaultCreateSessionResult, + DriverData, + ExternalDriver, + InitialOpts, + Orientation, + RouteMatcher, + SingularSessionData, + StringRecord, +} from '@appium/types'; +import {DEFAULT_ADB_PORT} from 'appium-adb'; +import AndroidDriver, {SETTINGS_HELPER_PKG_ID, androidHelpers} from 'appium-android-driver'; +import {BaseDriver, DeviceSettings} from 'appium/driver'; +import {fs, mjpeg, util} from 'appium/support'; +import {retryInterval} from 'asyncbox'; import B from 'bluebird'; -import commands from './commands/index'; -import { DEFAULT_ADB_PORT } from 'appium-adb'; +import _ from 'lodash'; +import os from 'node:os'; +import path from 'node:path'; +import {checkPortStatus, findAPortNotInUse} from 'portscanner'; +import type {ExecError} from 'teen_process'; +import UIAUTOMATOR2_CONSTRAINTS, {type Uiautomator2Constraints} from './constraints'; +import {executeMethodMap} from './execute-method-map'; +import {APKS_EXTENSION, APK_EXTENSION} from './extensions'; import uiautomator2Helpers from './helpers'; -import { androidHelpers, androidCommands, SETTINGS_HELPER_PKG_ID, } from 'appium-android-driver'; -import desiredCapConstraints from './desired-caps'; -import { findAPortNotInUse, checkPortStatus } from 'portscanner'; -import os from 'os'; -import path from 'path'; -import { APK_EXTENSION, APKS_EXTENSION } from './extensions'; - - -const helpers = Object.assign({}, uiautomator2Helpers, androidHelpers); +import {newMethodMap} from './method-map'; +import type { + Uiautomator2Settings, + Uiautomator2CreateResult, + Uiautomator2DeviceDetails, + Uiautomator2DeviceInfo, + Uiautomator2DriverCaps, + Uiautomator2DriverOpts, + Uiautomator2SessionCaps, + Uiautomator2SessionInfo, + Uiautomator2StartSessionOpts, + W3CUiautomator2DriverCaps, +} from './types'; +import {SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID, UiAutomator2Server} from './uiautomator2'; + +const helpers = {...uiautomator2Helpers, ...androidHelpers}; // The range of ports we can use on the system for communicating to the // UiAutomator2 HTTP server on the device @@ -45,7 +66,7 @@ const LOCALHOST_IP4 = '127.0.0.1'; // TODO: Add the list of paths that we never want to proxy to UiAutomator2 server. // TODO: Need to segregate the paths better way using regular expressions wherever applicable. // (Not segregating right away because more paths to be added in the NO_PROXY list) -const NO_PROXY = [ +const NO_PROXY: RouteMatcher[] = [ ['DELETE', new RegExp('^/session/[^/]+/actions')], ['GET', new RegExp('^/session/(?!.*/)')], ['GET', new RegExp('^/session/[^/]+/alert_[^/]+')], @@ -114,7 +135,7 @@ const NO_PROXY = [ ]; // This is a set of methods and paths that we never want to proxy to Chromedriver. -const CHROME_NO_PROXY = [ +const CHROME_NO_PROXY: RouteMatcher[] = [ ['GET', new RegExp('^/session/[^/]+/appium')], ['GET', new RegExp('^/session/[^/]+/context')], ['GET', new RegExp('^/session/[^/]+/element/[^/]+/rect')], @@ -139,56 +160,100 @@ const CHROME_NO_PROXY = [ ['POST', new RegExp('^/session/[^/]+/se/log$')], ]; -const MEMOIZED_FUNCTIONS = [ - 'getStatusBarHeight', - 'getDevicePixelRatio', -]; +const MEMOIZED_FUNCTIONS = ['getStatusBarHeight', 'getDevicePixelRatio'] as const; + +class AndroidUiautomator2Driver + extends AndroidDriver + implements + ExternalDriver< + Uiautomator2Constraints, + string, + StringRecord, + Uiautomator2Settings, + Uiautomator2CreateResult + > +{ + static newMethodMap = newMethodMap; -class AndroidUiautomator2Driver extends BaseDriver { + static executeMethodMap = executeMethodMap; - static newMethodMap = newMethodMap; + uiautomator2?: UiAutomator2Server; + + /** + * @privateRemarks moved from `this.opts` + */ + systemPort: number | undefined; - constructor (opts = {}, shouldValidateCaps = true) { + _hasSystemPortInCaps: boolean | undefined; + + mjpegStream?: mjpeg.MJpegStream; + + override caps: Uiautomator2DriverCaps; + + override opts: Uiautomator2DriverOpts; + + override desiredCapConstraints: Uiautomator2Constraints; + + constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) { // `shell` overwrites adb.shell, so remove + // @ts-expect-error FIXME: what is this? delete opts.shell; super(opts, shouldValidateCaps); + this.locatorStrategies = [ 'xpath', 'id', 'class name', 'accessibility id', 'css selector', - '-android uiautomator' + '-android uiautomator', ]; - this.desiredCapConstraints = desiredCapConstraints; - this.uiautomator2 = null; + this.desiredCapConstraints = _.cloneDeep(UIAUTOMATOR2_CONSTRAINTS); this.jwpProxyActive = false; this.jwpProxyAvoid = NO_PROXY; this.apkStrings = {}; // map of language -> strings obj - this.settings = new DeviceSettings({ignoreUnimportantViews: false, allowInvisibleElements: false}, - this.onSettingsUpdate.bind(this)); + this.settings = new DeviceSettings( + {ignoreUnimportantViews: false, allowInvisibleElements: false}, + this.onSettingsUpdate.bind(this) + ); // handle webview mechanics from AndroidDriver - this.chromedriver = null; this.sessionChromedrivers = {}; + this.caps = {} as Uiautomator2DriverCaps; + this.opts = opts as Uiautomator2DriverOpts; // memoize functions here, so that they are done on a per-instance basis for (const fn of MEMOIZED_FUNCTIONS) { - this[fn] = _.memoize(this[fn]); + this[fn] = _.memoize(this[fn]) as any; } } - validateDesiredCaps (caps) { - return super.validateDesiredCaps(caps) && androidHelpers.validateDesiredCaps(caps); + override validateDesiredCaps(caps: any): caps is Uiautomator2DriverCaps { + return ( + BaseDriver.prototype.validateDesiredCaps.call(this, caps) && + androidHelpers.validateDesiredCaps(caps) + ); } - async createSession (...args) { + override async createSession( + w3cCaps1: W3CUiautomator2DriverCaps, + w3cCaps2?: W3CUiautomator2DriverCaps, + w3cCaps3?: W3CUiautomator2DriverCaps, + driverData?: DriverData[] + ): Promise { try { // TODO handle otherSessionData for multiple sessions - let [sessionId, caps] = await super.createSession(...args); - - let serverDetails = { + const [sessionId, caps] = (await BaseDriver.prototype.createSession.call( + this, + w3cCaps1, + w3cCaps2, + w3cCaps3, + driverData + )) as DefaultCreateSessionResult; + + const startSessionOpts: Uiautomator2StartSessionOpts = { + ...caps, platform: 'LINUX', webStorageEnabled: false, takesScreenshot: true, @@ -197,160 +262,184 @@ class AndroidUiautomator2Driver extends BaseDriver { networkConnectionEnabled: true, locationContextEnabled: false, warnings: {}, - desired: this.caps, + desired: caps, }; - this.caps = Object.assign(serverDetails, this.caps); - - this.curContext = this.defaultContextName(); - - let defaultOpts = { + const defaultOpts = { fullReset: false, autoLaunch: true, adbPort: DEFAULT_ADB_PORT, - androidInstallTimeout: 90000 + androidInstallTimeout: 90000, }; _.defaults(this.opts, defaultOpts); if (this.isChromeSession) { this.log.info("We're going to run a Chrome-based session"); - let {pkg, activity} = helpers.getChromePkg(this.opts.browserName); + const {pkg, activity} = helpers.getChromePkg(this.opts.browserName!); this.opts.appPackage = this.caps.appPackage = pkg; this.opts.appActivity = this.caps.appActivity = activity; this.log.info(`Chrome-type package and activity are ${pkg} and ${activity}`); } + // @ts-expect-error FIXME: missing CLI option? if (this.opts.reboot) { - this.setAvdFromCapabilities(caps); + this.setAvdFromCapabilities(startSessionOpts); } if (this.opts.app) { // find and copy, or download and unzip an app url or path - this.opts.app = await this.helpers.configureApp(this.opts.app, [APK_EXTENSION, APKS_EXTENSION]); + this.opts.app = await this.helpers.configureApp(this.opts.app, [ + APK_EXTENSION, + APKS_EXTENSION, + ]); await this.checkAppPresent(); } else if (this.opts.appPackage) { // the app isn't an actual app file but rather something we want to // assume is on the device and just launch via the appPackage this.log.info(`Starting '${this.opts.appPackage}' directly on the device`); } else { - this.log.info(`Neither 'app' nor 'appPackage' was set. Starting UiAutomator2 ` + - 'without the target application'); + this.log.info( + `Neither 'app' nor 'appPackage' was set. Starting UiAutomator2 ` + + 'without the target application' + ); } this.opts.adbPort = this.opts.adbPort || DEFAULT_ADB_PORT; - await this.startUiAutomator2Session(); - await this.fillDeviceDetails(); + const result = await this.startUiAutomator2Session(startSessionOpts); + if (this.opts.mjpegScreenshotUrl) { this.log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`); this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl); await this.mjpegStream.start(); } - return [sessionId, this.caps]; + return [sessionId, result]; } catch (e) { await this.deleteSession(); throw e; } } - async fillDeviceDetails () { - this.caps.pixelRatio = await this.getDevicePixelRatio(); - this.caps.statBarHeight = await this.getStatusBarHeight(); - this.caps.viewportRect = await this.getViewPortRect(); + async getDeviceDetails(): Promise { + const [pixelRatio, statBarHeight, viewportRect] = await B.all([ + this.getDevicePixelRatio(), + this.getStatusBarHeight(), + this.getViewPortRect(), + ]); + return {pixelRatio, statBarHeight, viewportRect}; } - get driverData () { + override get driverData() { // TODO fill out resource info here return {}; } - async getSession () { - let sessionData = await super.getSession(); + override async getSession(): Promise> { + const sessionData = await BaseDriver.prototype.getSession.call(this); this.log.debug('Getting session details from server to mix in'); - let uia2Data = await this.uiautomator2.jwproxy.command('/', 'GET', {}); - return Object.assign({}, sessionData, uia2Data); + const uia2Data = (await this.uiautomator2!.jwproxy.command('/', 'GET', {})) as any; + return {...sessionData, ...uia2Data}; } - isEmulator () { + override isEmulator() { return helpers.isEmulator(this.adb, this.opts); } - setAvdFromCapabilities (caps) { + override setAvdFromCapabilities(caps: Uiautomator2StartSessionOpts) { if (this.opts.avd) { this.log.info('avd name defined, ignoring device name and platform version'); } else { if (!caps.deviceName) { - this.log.errorAndThrow('avd or deviceName should be specified when reboot option is enables'); + this.log.errorAndThrow( + 'avd or deviceName should be specified when reboot option is enables' + ); + throw new Error(); // unreachable } if (!caps.platformVersion) { - this.log.errorAndThrow('avd or platformVersion should be specified when reboot option is enabled'); + this.log.errorAndThrow( + 'avd or platformVersion should be specified when reboot option is enabled' + ); + throw new Error(); // unreachable } - let avdDevice = caps.deviceName.replace(/[^a-zA-Z0-9_.]/g, '-'); + const avdDevice = caps.deviceName.replace(/[^a-zA-Z0-9_.]/g, '-'); this.opts.avd = `${avdDevice}__${caps.platformVersion}`; } } - async allocateSystemPort () { - const forwardPort = async (localPort) => { - this.log.debug(`Forwarding UiAutomator2 Server port ${DEVICE_PORT} to local port ${localPort}`); + async allocateSystemPort() { + const forwardPort = async (localPort: number) => { + this.log.debug( + `Forwarding UiAutomator2 Server port ${DEVICE_PORT} to local port ${localPort}` + ); if ((await checkPortStatus(localPort, LOCALHOST_IP4)) === 'open') { - this.log.errorAndThrow(`UiAutomator2 Server cannot start because the local port #${localPort} is busy. ` + - `Make sure the port you provide via 'systemPort' capability is not occupied. ` + - `This situation might often be a result of an inaccurate sessions management, e.g. ` + - `old automation sessions on the same device must always be closed before starting new ones.`); + this.log.errorAndThrow( + `UiAutomator2 Server cannot start because the local port #${localPort} is busy. ` + + `Make sure the port you provide via 'systemPort' capability is not occupied. ` + + `This situation might often be a result of an inaccurate sessions management, e.g. ` + + `old automation sessions on the same device must always be closed before starting new ones.` + ); } - await this.adb.forwardPort(localPort, DEVICE_PORT); + await this.adb!.forwardPort(localPort, DEVICE_PORT); }; - if (this.opts.systemPort) { + if (this.systemPort) { this._hasSystemPortInCaps = true; - return await forwardPort(this.opts.systemPort); + return await forwardPort(this.systemPort); } await DEVICE_PORT_ALLOCATION_GUARD(async () => { const [startPort, endPort] = DEVICE_PORT_RANGE; try { - this.opts.systemPort = await findAPortNotInUse(startPort, endPort); + this.systemPort = await findAPortNotInUse(startPort, endPort); } catch (e) { this.log.errorAndThrow( `Cannot find any free port in range ${startPort}..${endPort}}. ` + - `Please set the available port number by providing the systemPort capability or ` + - `double check the processes that are locking ports within this range and terminate ` + - `these which are not needed anymore`); + `Please set the available port number by providing the systemPort capability or ` + + `double check the processes that are locking ports within this range and terminate ` + + `these which are not needed anymore` + ); + throw new Error(); // unreachable } - await forwardPort(this.opts.systemPort); + await forwardPort(this.systemPort); }); } - async releaseSystemPort () { - if (!this.opts.systemPort || !this.adb) { + async releaseSystemPort() { + if (!this.systemPort || !this.adb) { return; } if (this._hasSystemPortInCaps) { - await this.adb.removePortForward(this.opts.systemPort); + await this.adb.removePortForward(this.systemPort); } else { - await DEVICE_PORT_ALLOCATION_GUARD(async () => await this.adb.removePortForward(this.opts.systemPort)); + await DEVICE_PORT_ALLOCATION_GUARD( + async () => await this.adb!.removePortForward(this.systemPort!) + ); } } - async allocateMjpegServerPort () { + async allocateMjpegServerPort() { if (this.opts.mjpegServerPort) { - this.log.debug(`MJPEG broadcasting requested, forwarding MJPEG server port ${MJPEG_SERVER_DEVICE_PORT} ` + - `to local port ${this.opts.mjpegServerPort}`); - await this.adb.forwardPort(this.opts.mjpegServerPort, MJPEG_SERVER_DEVICE_PORT); + this.log.debug( + `MJPEG broadcasting requested, forwarding MJPEG server port ${MJPEG_SERVER_DEVICE_PORT} ` + + `to local port ${this.opts.mjpegServerPort}` + ); + await this.adb!.forwardPort(this.opts.mjpegServerPort, MJPEG_SERVER_DEVICE_PORT); } } - async releaseMjpegServerPort () { + async releaseMjpegServerPort() { if (this.opts.mjpegServerPort) { - await this.adb.removePortForward(this.opts.mjpegServerPort); + await this.adb!.removePortForward(this.opts.mjpegServerPort); } } - async startUiAutomator2Session () { + async startUiAutomator2Session( + caps: Uiautomator2StartSessionOpts + ): Promise { // get device udid for this session - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(this.opts); + const {udid, emPort} = await helpers.getDeviceInfoFromCaps(this.opts); this.opts.udid = udid; + // @ts-expect-error do not put random stuff on opts this.opts.emPort = emPort; // now that we know our java version and device info, we can create our @@ -360,11 +449,14 @@ class AndroidUiautomator2Driver extends BaseDriver { const apiLevel = await this.adb.getApiLevel(); if (apiLevel < 21) { - this.log.errorAndThrow('UIAutomator2 is only supported since Android 5.0 (Lollipop). ' + - 'You could still use other supported backends in order to automate older Android versions.'); + this.log.errorAndThrow( + 'UIAutomator2 is only supported since Android 5.0 (Lollipop). ' + + 'You could still use other supported backends in order to automate older Android versions.' + ); } - if (apiLevel >= 28) { // Android P + if (apiLevel >= 28) { + // Android P this.log.info('Relaxing hidden api policy'); await this.adb.setHiddenApiPolicy('1', !!this.opts.ignoreHiddenApiPolicyError); } @@ -372,7 +464,9 @@ class AndroidUiautomator2Driver extends BaseDriver { // check if we have to enable/disable gps before running the application if (util.hasValue(this.opts.gpsEnabled)) { if (this.isEmulator()) { - this.log.info(`Trying to ${this.opts.gpsEnabled ? 'enable' : 'disable'} gps location provider`); + this.log.info( + `Trying to ${this.opts.gpsEnabled ? 'enable' : 'disable'} gps location provider` + ); await this.adb.toggleGPSLocationProvider(this.opts.gpsEnabled); } else { this.log.warn(`Sorry! 'gpsEnabled' capability is only available for emulators`); @@ -382,18 +476,25 @@ class AndroidUiautomator2Driver extends BaseDriver { // get appPackage et al from manifest if necessary const appInfo = await helpers.getLaunchInfo(this.adb, this.opts); // and get it onto our 'opts' object so we use it from now on - Object.assign(this.opts, appInfo || {}); + this.opts = {...this.opts, ...(appInfo ?? {})}; // set actual device name, udid, platform version, screen size, screen density, model and manufacturer details - this.caps.deviceName = this.adb.curDeviceId; - this.caps.deviceUDID = this.opts.udid; + const sessionInfo: Uiautomator2SessionInfo = { + deviceName: this.adb.curDeviceId!, + deviceUDID: this.opts.udid!, + }; + + const capsWithSessionInfo = { + ...caps, + ...sessionInfo, + }; // start an avd, set the language/locale, pick an emulator, etc... // TODO with multiple devices we'll need to parameterize this await helpers.initDevice(this.adb, this.opts); // Prepare the device by forwarding the UiAutomator2 port - // This call mutates this.opts.systemPort if it is not set explicitly + // This call mutates this.systemPort if it is not set explicitly await this.allocateSystemPort(); // Prepare the device by forwarding the UiAutomator2 MJPEG server port (if @@ -401,10 +502,11 @@ class AndroidUiautomator2Driver extends BaseDriver { await this.allocateMjpegServerPort(); // set up the modified UiAutomator2 server etc - await this.initUiAutomator2Server(); + const uiautomator2 = await this.initUiAutomator2Server(); // Should be after installing io.appium.settings in helpers.initDevice - if (this.opts.disableWindowAnimation && (await this.adb.getApiLevel() < 26)) { // API level 26 is Android 8.0. + if (this.opts.disableWindowAnimation && (await this.adb.getApiLevel()) < 26) { + // API level 26 is Android 8.0. // Granting android.permission.SET_ANIMATION_SCALE is necessary to handle animations under API level 26 // Read https://github.com/appium/appium/pull/11640#issuecomment-438260477 // `--no-window-animation` works over Android 8 to disable all of animations @@ -422,25 +524,29 @@ class AndroidUiautomator2Driver extends BaseDriver { await this.initAUT(); // Adding AUT package name in the capabilities if package name not exist in caps - if (!this.caps.appPackage && appInfo) { - this.caps.appPackage = appInfo.appPackage; + if (!capsWithSessionInfo.appPackage && appInfo) { + capsWithSessionInfo.appPackage = appInfo.appPackage; } // launch UiAutomator2 and wait till its online and we have a session - await this.uiautomator2.startSession(this.caps); + await uiautomator2.startSession(capsWithSessionInfo); - await this.addDeviceInfoToCaps(); + const capsWithSessionAndDeviceInfo = { + ...capsWithSessionInfo, + ...(await this.getDeviceInfoFromUia2()), + }; // Unlock the device after the session is started. if (!this.opts.skipUnlock) { // unlock the device to prepare it for testing - await helpers.unlock(this, this.adb, this.caps); + await helpers.unlock(this as any, this.adb, this.caps); } else { this.log.debug(`'skipUnlock' capability set, so skipping device unlock`); } - if (this.isChromeSession) { // start a chromedriver session - await this.startChromeSession(this); + if (this.isChromeSession) { + // start a chromedriver session + await this.startChromeSession(); } else if (this.opts.autoLaunch && this.opts.appPackage) { await this.ensureAppStarts(); } @@ -448,7 +554,7 @@ class AndroidUiautomator2Driver extends BaseDriver { // if the initial orientation is requested, set it if (util.hasValue(this.opts.orientation)) { this.log.debug(`Setting initial orientation to '${this.opts.orientation}'`); - await this.setOrientation(this.opts.orientation); + await this.setOrientation(this.opts.orientation as Orientation); } // if we want to immediately get into a webview, set our context @@ -463,30 +569,29 @@ class AndroidUiautomator2Driver extends BaseDriver { // now that everything has started successfully, turn on proxying so all // subsequent session requests go straight to/from uiautomator2 this.jwpProxyActive = true; + + return {...capsWithSessionAndDeviceInfo, ...(await this.getDeviceDetails())}; } - async addDeviceInfoToCaps () { - const { - apiVersion, + async getDeviceInfoFromUia2(): Promise { + const {apiVersion, platformVersion, manufacturer, model, realDisplaySize, displayDensity} = + await this.mobileGetDeviceInfo(); + return { + deviceApiLevel: _.parseInt(apiVersion), platformVersion, - manufacturer, - model, - realDisplaySize, - displayDensity, - } = await this.mobileGetDeviceInfo(); - this.caps.deviceApiLevel = parseInt(apiVersion, 10); - this.caps.platformVersion = platformVersion; - this.caps.deviceScreenSize = realDisplaySize; - this.caps.deviceScreenDensity = displayDensity; - this.caps.deviceModel = model; - this.caps.deviceManufacturer = manufacturer; + deviceManufacturer: manufacturer, + deviceModel: model, + deviceScreenSize: realDisplaySize, + deviceScreenDensity: displayDensity, + }; } - async initUiAutomator2Server () { + async initUiAutomator2Server() { // broken out for readability const uiautomator2Opts = { + // @ts-expect-error FIXME: maybe `address` instead of `host`? host: this.opts.remoteAdbHost || this.opts.host || LOCALHOST_IP4, - systemPort: this.opts.systemPort, + systemPort: this.systemPort, devicePort: DEVICE_PORT, adb: this.adb, apk: this.opts.app, @@ -501,28 +606,37 @@ class AndroidUiautomator2Driver extends BaseDriver { // uiautomator2 with the appropriate options this.uiautomator2 = new UiAutomator2Server(this.log, uiautomator2Opts); this.proxyReqRes = this.uiautomator2.proxyReqRes.bind(this.uiautomator2); - this.proxyCommand = this.uiautomator2.proxyCommand.bind(this.uiautomator2); + this.proxyCommand = this.uiautomator2.proxyCommand.bind( + this.uiautomator2 + ) as typeof this.proxyCommand; if (this.opts.skipServerInstallation) { this.log.info(`'skipServerInstallation' is set. Skipping UIAutomator2 server installation.`); } else { await this.uiautomator2.installServerApk(this.opts.uiautomator2ServerInstallTimeout); try { - await this.adb.addToDeviceIdleWhitelist( - SETTINGS_HELPER_PKG_ID, SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID, + await this.adb!.addToDeviceIdleWhitelist( + SETTINGS_HELPER_PKG_ID, + SERVER_PACKAGE_ID, + SERVER_TEST_PACKAGE_ID ); } catch (e) { - this.log.warn(`Cannot add server packages to the Doze whitelist. Original error: ` + - (e.stderr || e.message)); + const err = e as ExecError; + this.log.warn( + `Cannot add server packages to the Doze whitelist. Original error: ` + + (err.stderr || err.message) + ); } } + + return this.uiautomator2; } - async initAUT () { + async initAUT() { // Uninstall any uninstallOtherPackages which were specified in caps if (this.opts.uninstallOtherPackages) { await helpers.uninstallOtherPackages( - this.adb, + this.adb!, helpers.parseArray(this.opts.uninstallOtherPackages), [SETTINGS_HELPER_PKG_ID, SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID] ); @@ -534,61 +648,80 @@ class AndroidUiautomator2Driver extends BaseDriver { try { otherApps = helpers.parseArray(this.opts.otherApps); } catch (e) { - this.log.errorAndThrow(`Could not parse "otherApps" capability: ${e.message}`); + this.log.errorAndThrow(`Could not parse "otherApps" capability: ${(e as Error).message}`); + throw new Error(); // unrechable } - otherApps = await B.all(otherApps - .map((app) => this.helpers.configureApp(app, [APK_EXTENSION, APKS_EXTENSION]))); - await helpers.installOtherApks(otherApps, this.adb, this.opts); + otherApps = await B.all( + otherApps.map((app) => this.helpers.configureApp(app, [APK_EXTENSION, APKS_EXTENSION])) + ); + await helpers.installOtherApks(otherApps, this.adb!, this.opts); } if (this.opts.app) { - if (this.opts.noReset && !(await this.adb.isAppInstalled(this.opts.appPackage)) - || !this.opts.noReset) { - if (!this.opts.noSign && !await this.adb.checkApkCert(this.opts.app, this.opts.appPackage, { - requireDefaultCert: false, - })) { - await helpers.signApp(this.adb, this.opts.app); + if ( + (this.opts.noReset && !(await this.adb!.isAppInstalled(this.opts.appPackage!))) || + !this.opts.noReset + ) { + if ( + !this.opts.noSign && + !(await this.adb!.checkApkCert(this.opts.app, this.opts.appPackage, { + requireDefaultCert: false, + })) + ) { + await helpers.signApp(this.adb!, this.opts.app); } if (!this.opts.skipUninstall) { - await this.adb.uninstallApk(this.opts.appPackage); + await this.adb!.uninstallApk(this.opts.appPackage!); } - await helpers.installApk(this.adb, this.opts); + await helpers.installApk(this.adb!, this.opts); } else { - this.log.debug('noReset has been requested and the app is already installed. Doing nothing'); + this.log.debug( + 'noReset has been requested and the app is already installed. Doing nothing' + ); } } else { if (this.opts.fullReset) { - this.log.errorAndThrow('Full reset requires an app capability, use fastReset if app is not provided'); + this.log.errorAndThrow( + 'Full reset requires an app capability, use fastReset if app is not provided' + ); } this.log.debug('No app capability. Assuming it is already on the device'); if (this.opts.fastReset && this.opts.appPackage) { - await helpers.resetApp(this.adb, this.opts); + await helpers.resetApp(this.adb!, this.opts); } } } - async ensureAppStarts () { + async ensureAppStarts() { // make sure we have an activity and package to wait for const appWaitPackage = this.opts.appWaitPackage || this.opts.appPackage; const appWaitActivity = this.opts.appWaitActivity || this.opts.appActivity; - - this.log.info(`Starting '${this.opts.appPackage}/${this.opts.appActivity} ` + - `and waiting for '${appWaitPackage}/${appWaitActivity}'`); + this.log.info( + `Starting '${this.opts.appPackage}/${this.opts.appActivity} ` + + `and waiting for '${appWaitPackage}/${appWaitActivity}'` + ); if (this.caps.androidCoverage) { - this.log.info(`androidCoverage is configured. ` + - ` Starting instrumentation of '${this.caps.androidCoverage}'...`); - await this.adb.androidCoverage(this.caps.androidCoverage, appWaitPackage, appWaitActivity); + this.log.info( + `androidCoverage is configured. ` + + ` Starting instrumentation of '${this.caps.androidCoverage}'...` + ); + await this.adb!.androidCoverage(this.caps.androidCoverage, appWaitPackage!, appWaitActivity!); return; } - if (this.opts.noReset && !this.opts.forceAppLaunch - && await this.adb.processExists(this.opts.appPackage)) { - this.log.info(`'${this.opts.appPackage}' is already running and noReset is enabled. ` + - `Set forceAppLaunch capability to true if the app must be forcefully restarted on session startup.`); + if ( + this.opts.noReset && + !this.opts.forceAppLaunch && + (await this.adb!.processExists(this.opts.appPackage!)) + ) { + this.log.info( + `'${this.opts.appPackage}' is already running and noReset is enabled. ` + + `Set forceAppLaunch capability to true if the app must be forcefully restarted on session startup.` + ); return; } - await this.adb.startApp({ - pkg: this.opts.appPackage, + await this.adb!.startApp({ + pkg: this.opts.appPackage!, activity: this.opts.appActivity, action: this.opts.intentAction || 'android.intent.action.MAIN', category: this.opts.intentCategory || 'android.intent.category.LAUNCHER', @@ -604,22 +737,26 @@ class AndroidUiautomator2Driver extends BaseDriver { }); } - async deleteSession () { + override async deleteSession() { this.log.debug('Deleting UiAutomator2 session'); - const screenRecordingStopTasks = [async () => { - if (!_.isEmpty(this._screenRecordingProperties)) { - await this.stopRecordingScreen(); - } - }, async () => { - if (await this.mobileIsMediaProjectionRecordingRunning()) { - await this.mobileStopMediaProjectionRecording(); - } - }, async () => { - if (!_.isEmpty(this._screenStreamingProps)) { - await this.mobileStopScreenStreaming(); - } - }]; + const screenRecordingStopTasks = [ + async () => { + if (!_.isEmpty(this._screenRecordingProperties)) { + await this.stopRecordingScreen(); + } + }, + async () => { + if (await this.mobileIsMediaProjectionRecordingRunning()) { + await this.mobileStopMediaProjectionRecording(); + } + }, + async () => { + if (!_.isEmpty(this._screenStreamingProps)) { + await this.mobileStopScreenStreaming(); + } + }, + ]; await androidHelpers.removeAllSessionWebSocketHandlers(this.server, this.sessionId); @@ -627,54 +764,65 @@ class AndroidUiautomator2Driver extends BaseDriver { try { await this.stopChromedriverProxies(); } catch (err) { - this.log.warn(`Unable to stop ChromeDriver proxies: ${err.message}`); + this.log.warn(`Unable to stop ChromeDriver proxies: ${(err as Error).message}`); } if (this.jwpProxyActive) { try { await this.uiautomator2.deleteSession(); } catch (err) { - this.log.warn(`Unable to proxy deleteSession to UiAutomator2: ${err.message}`); + this.log.warn(`Unable to proxy deleteSession to UiAutomator2: ${(err as Error).message}`); } } - this.uiautomator2 = null; + this.uiautomator2 = undefined; } this.jwpProxyActive = false; if (this.adb) { - await B.all(screenRecordingStopTasks.map((task) => { - (async () => { - try { - await task(); - } catch (ign) {} - })(); - })); + await B.all( + screenRecordingStopTasks.map((task) => { + (async () => { + try { + await task(); + } catch (ign) {} + })(); + }) + ); if (this.caps.androidCoverage) { this.log.info('Shutting down the adb process of instrumentation...'); await this.adb.endAndroidCoverage(); // Use this broadcast intent to notify it's time to dump coverage to file if (this.caps.androidCoverageEndIntent) { - this.log.info(`Sending intent broadcast '${this.caps.androidCoverageEndIntent}' at the end of instrumenting.`); + this.log.info( + `Sending intent broadcast '${this.caps.androidCoverageEndIntent}' at the end of instrumenting.` + ); await this.adb.broadcast(this.caps.androidCoverageEndIntent); } else { - this.log.warn('No androidCoverageEndIntent is configured in caps. Possibly you cannot get coverage file.'); + this.log.warn( + 'No androidCoverageEndIntent is configured in caps. Possibly you cannot get coverage file.' + ); } } if (this.opts.appPackage) { - if (!this.isChromeSession && ((!this.opts.dontStopAppOnReset && !this.opts.noReset) - || (this.opts.noReset && this.opts.shouldTerminateApp))) { + if ( + !this.isChromeSession && + ((!this.opts.dontStopAppOnReset && !this.opts.noReset) || + (this.opts.noReset && this.opts.shouldTerminateApp)) + ) { try { await this.adb.forceStop(this.opts.appPackage); } catch (err) { - this.log.warn(`Unable to force stop app: ${err.message}`); + this.log.warn(`Unable to force stop app: ${(err as Error).message}`); } } if (this.opts.fullReset && !this.opts.skipUninstall) { - this.log.debug(`Capability 'fullReset' set to 'true', Uninstalling '${this.opts.appPackage}'`); + this.log.debug( + `Capability 'fullReset' set to 'true', Uninstalling '${this.opts.appPackage}'` + ); try { await this.adb.uninstallApk(this.opts.appPackage); } catch (err) { - this.log.warn(`Unable to uninstall app: ${err.message}`); + this.log.warn(`Unable to uninstall app: ${(err as Error).message}`); } } } @@ -687,30 +835,32 @@ class AndroidUiautomator2Driver extends BaseDriver { try { await this.releaseSystemPort(); } catch (error) { - this.log.warn(`Unable to remove system port forward: ${error.message}`); + this.log.warn(`Unable to remove system port forward: ${(error as Error).message}`); // Ignore, this block will also be called when we fall in catch block // and before even port forward. } try { await this.releaseMjpegServerPort(); } catch (error) { - this.log.warn(`Unable to remove MJPEG server port forward: ${error.message}`); + this.log.warn(`Unable to remove MJPEG server port forward: ${(error as Error).message}`); // Ignore, this block will also be called when we fall in catch block // and before even port forward. } - if (await this.adb.getApiLevel() >= 28) { // Android P + if ((await this.adb.getApiLevel()) >= 28) { + // Android P this.log.info('Restoring hidden api policy to the device default configuration'); await this.adb.setDefaultHiddenApiPolicy(!!this.opts.ignoreHiddenApiPolicyError); } + // @ts-expect-error unknown option if (this.opts.reboot) { - let avdName = this.opts.avd.replace('@', ''); + const avdName = this.opts.avd!.replace('@', ''); this.log.debug(`Closing emulator '${avdName}'`); try { await this.adb.killEmulator(avdName); } catch (err) { - this.log.warn(`Unable to close emulator: ${err.message}`); + this.log.warn(`Unable to close emulator: ${(err as Error).message}`); } } } @@ -718,17 +868,18 @@ class AndroidUiautomator2Driver extends BaseDriver { this.log.info('Closing MJPEG stream'); this.mjpegStream.stop(); } - await super.deleteSession(); + await BaseDriver.prototype.deleteSession.call(this); } - async checkAppPresent () { + override async checkAppPresent() { this.log.debug('Checking whether app is actually present'); if (!(await fs.exists(this.opts.app))) { this.log.errorAndThrow(`Could not find app apk at '${this.opts.app}'`); + throw new Error(); // unreachable } } - async onSettingsUpdate () { + override async onSettingsUpdate() { // intentionally do nothing here, since commands.updateSettings proxies // settings to the uiauto2 server already } @@ -736,29 +887,28 @@ class AndroidUiautomator2Driver extends BaseDriver { // Need to override android-driver's version of this since we don't actually // have a bootstrap; instead we just restart adb and re-forward the UiAutomator2 // port - async wrapBootstrapDisconnect (wrapped) { + override async wrapBootstrapDisconnect(wrapped: () => Promise) { await wrapped(); - await this.adb.restart(); + this.adb!.restart(); await this.allocateSystemPort(); await this.allocateMjpegServerPort(); } - proxyActive (sessionId) { - super.proxyActive(sessionId); + override proxyActive(sessionId: string) { + BaseDriver.prototype.proxyActive.call(this, sessionId); // we always have an active proxy to the UiAutomator2 server return true; } - canProxy (sessionId) { - super.canProxy(sessionId); + override canProxy(sessionId: string) { + BaseDriver.prototype.canProxy.call(this, sessionId); // we can always proxy to the uiautomator2 server return true; } - getProxyAvoidList (sessionId) { - super.getProxyAvoidList(sessionId); + override getProxyAvoidList() { // we are maintaining two sets of NO_PROXY lists, one for chromedriver(CHROME_NO_PROXY) // and one for uiautomator2(NO_PROXY), based on current context will return related NO_PROXY list if (util.hasValue(this.chromedriver)) { @@ -768,26 +918,34 @@ class AndroidUiautomator2Driver extends BaseDriver { this.jwpProxyAvoid = NO_PROXY; } if (this.opts.nativeWebScreenshot) { - this.jwpProxyAvoid = [...this.jwpProxyAvoid, ['GET', new RegExp('^/session/[^/]+/screenshot')]]; + this.jwpProxyAvoid = [ + ...this.jwpProxyAvoid, + ['GET', new RegExp('^/session/[^/]+/screenshot')], + ]; } return this.jwpProxyAvoid; } - get isChromeSession () { + get isChromeSession() { return helpers.isChromeBrowser(this.opts.browserName); } -} -// first add the android-driver commands which we will fall back to -for (let [cmd, fn] of _.toPairs(androidCommands)) { - AndroidUiautomator2Driver.prototype[cmd] = fn; -} + async updateSettings(settings: Uiautomator2Settings) { + await this.settings.update(settings); + await this.uiautomator2!.jwproxy.command('/appium/settings', 'POST', {settings}); + } -// then overwrite with any uiautomator2-specific commands -for (let [cmd, fn] of _.toPairs(commands)) { - AndroidUiautomator2Driver.prototype[cmd] = fn; + async getSettings() { + const driverSettings = this.settings.getSettings(); + const serverSettings = (await this.uiautomator2!.jwproxy.command( + '/appium/settings', + 'GET' + )) as Partial; + return {...driverSettings, ...serverSettings}; + } } -export { AndroidUiautomator2Driver }; -export default AndroidUiautomator2Driver; +import './commands'; + +export {AndroidUiautomator2Driver}; diff --git a/lib/execute-method-map.ts b/lib/execute-method-map.ts new file mode 100644 index 000000000..458f45c29 --- /dev/null +++ b/lib/execute-method-map.ts @@ -0,0 +1,573 @@ +/** + * @privateRemarks This was created by hand from the type definitions in `lib/commands` here and in `appium-android-driver`. + * @module + */ + +export const executeMethodMap = { + 'mobile: shell': { + command: 'mobileShell', + params: { + required: ['command'], + optional: ['args', 'timeout', 'includeStderr'], + }, + }, + 'mobile: execEmuConsoleCommand': { + command: 'mobileExecEmuConsoleCommand', + params: { + required: ['command'], + optional: ['execTimeout', 'connTimeout', 'initTimeout'], + }, + }, + 'mobile: dragGesture': { + command: 'mobileDragGesture', + params: { + optional: ['elementId', 'startX', 'startY', 'endX', 'endY', 'speed'], + }, + }, + 'mobile: flingGesture': { + command: 'mobileFlingGesture', + params: { + required: ['direction'], + optional: ['elementId', 'left', 'top', 'width', 'height', 'speed'], + }, + }, + 'mobile: doubleClickGesture': { + command: 'mobileDoubleClickGesture', + params: { + optional: ['elementId', 'x', 'y'], + }, + }, + 'mobile: clickGesture': { + command: 'mobileClickGesture', + params: { + optional: ['elementId', 'x', 'y'], + }, + }, + 'mobile: longClickGesture': { + command: 'mobileLongClickGesture', + params: { + optional: ['elementId', 'x', 'y', 'duration'], + }, + }, + 'mobile: pinchCloseGesture': { + command: 'mobilePinchCloseGesture', + params: { + required: ['percent'], + optional: ['elementId', 'left', 'top', 'width', 'height', 'speed'], + }, + }, + 'mobile: pinchOpenGesture': { + command: 'mobilePinchOpenGesture', + params: { + required: ['percent'], + optional: ['elementId', 'left', 'top', 'width', 'height', 'speed'], + }, + }, + 'mobile: swipeGesture': { + command: 'mobileSwipeGesture', + params: { + required: ['direction', 'percent'], + optional: ['elementId', 'left', 'top', 'width', 'height', 'speed'], + }, + }, + 'mobile: scrollGesture': { + command: 'mobileScrollGesture', + params: { + required: ['direction', 'percent'], + optional: ['elementId', 'left', 'top', 'width', 'height', 'speed'], + }, + }, + 'mobile: scrollBackTo': { + command: 'mobileScrollBackTo', + params: { + required: ['elementId', 'elementToId'], + }, + }, + 'mobile: scroll': { + command: 'mobileScroll', + params: { + required: ['strategy', 'selector'], + optional: ['elementId', 'maxSwipes', 'element'], + }, + }, + 'mobile: viewportScreenshot': { + command: 'mobileViewportScreenshot', + }, + 'mobile: viewportRect': { + command: 'mobileViewPortRect', + }, + + 'mobile: deepLink': { + command: 'mobileDeepLink', + params: { + required: ['url', 'package'], + optional: ['waitForLaunch'], + }, + }, + + 'mobile: startLogsBroadcast': { + command: 'mobileStartLogsBroadcast', + }, + 'mobile: stopLogsBroadcast': { + command: 'mobileStopLogsBroadcast', + }, + + 'mobile: acceptAlert': { + command: 'mobileAcceptAlert', + params: { + optional: ['buttonLabel'], + }, + }, + 'mobile: dismissAlert': { + command: 'mobileDismissAlert', + params: { + optional: ['buttonLabel'], + }, + }, + + 'mobile: batteryInfo': { + command: 'mobileGetBatteryInfo', + }, + + 'mobile: deviceInfo': { + command: 'mobileGetDeviceInfo', + }, + + 'mobile: getDeviceTime': { + command: 'mobileGetDeviceTime', + params: { + optional: ['format'], + }, + }, + + 'mobile: changePermissions': { + command: 'mobileChangePermissions', + params: { + required: ['permissions'], + optional: ['appPackage', 'action', 'target'], + }, + }, + 'mobile: getPermissions': { + command: 'mobileGetPermissions', + params: { + optional: ['type', 'appPackage'], + }, + }, + + 'mobile: performEditorAction': { + command: 'mobilePerformEditorAction', + params: { + required: ['action'], + }, + }, + + 'mobile: startScreenStreaming': { + command: 'mobileStartScreenStreaming', + params: { + optional: [ + 'width', + 'height', + 'bitrate', + 'host', + 'pathname', + 'tcpPort', + 'port', + 'quality', + 'considerRotation', + 'logPipelineDetails', + ], + }, + }, + 'mobile: stopScreenStreaming': { + command: 'mobileStopScreenStreaming', + }, + + 'mobile: getNotifications': { + command: 'mobileGetNotifications', + }, + 'mobile: openNotifications': { + command: 'openNotifications', + }, + + 'mobile: listSms': { + command: 'mobileListSms', + params: { + optional: ['max'], + }, + }, + + 'mobile: type': { + command: 'mobileType', + params: { + required: ['text'], + }, + }, + 'mobile: replaceElementValue': { + command: 'mobileReplaceElementValue', + params: { + required: ['elementId', 'text'], + }, + }, + + 'mobile: pushFile': { + command: 'mobilePushFile', + params: { + required: ['payload', 'remotePath'], + }, + }, + 'mobile: pullFile': { + command: 'mobilePullFile', + params: { + required: ['remotePath'], + }, + }, + 'mobile: pullFolder': { + command: 'mobilePullFolder', + params: { + required: ['remotePath'], + }, + }, + 'mobile: deleteFile': { + command: 'mobileDeleteFile', + params: { + required: ['remotePath'], + }, + }, + + 'mobile: isAppInstalled': { + command: 'mobileIsAppInstalled', + params: { + required: ['appId'], + }, + }, + 'mobile: queryAppState': { + command: 'mobileQueryAppState', + params: { + required: ['appId'], + }, + }, + 'mobile: activateApp': { + command: 'mobileActivateApp', + params: { + required: ['appId'], + }, + }, + 'mobile: removeApp': { + command: 'mobileRemoveApp', + params: { + required: ['appId'], + optional: ['timeout', 'keepData'], + }, + }, + 'mobile: terminateApp': { + command: 'mobileTerminateApp', + params: { + required: ['appId'], + optional: ['timeout'], + }, + }, + 'mobile: installApp': { + command: 'mobileInstallApp', + params: { + required: ['appPath'], + optional: ['timeout', 'keepData'], + }, + }, + 'mobile: clearApp': { + command: 'mobileClearApp', + params: { + required: ['appId'], + }, + }, + 'mobile: backgroundApp': { + command: 'mobileBackgroundApp', + params: { + optional: ['seconds'], + }, + }, + 'mobile: getCurrentActivity': { + command: 'getCurrentActivity', + }, + 'mobile: getCurrentPackage': { + command: 'getCurrentPackage', + }, + + 'mobile: startActivity': { + command: 'mobileStartActivity', + params: { + optional: ['wait', 'stop', 'windowingMode', 'activityType', 'display'], + }, + }, + 'mobile: startService': { + command: 'mobileStartService', + params: { + optional: [ + 'user', + 'intent', + 'action', + 'package', + 'uri', + 'mimeType', + 'identifier', + 'component', + 'categories', + 'extras', + 'flags', + 'wait', + 'stop', + 'windowingMode', + 'activityType', + 'display', + ], + }, + }, + 'mobile: stopService': { + command: 'mobileStopService', + params: { + optional: [ + 'user', + 'intent', + 'action', + 'package', + 'uri', + 'mimeType', + 'identifier', + 'component', + 'categories', + 'extras', + 'flags', + ], + }, + }, + 'mobile: broadcast': { + command: 'mobileBroadcast', + params: { + optional: [ + 'user', + 'intent', + 'action', + 'package', + 'uri', + 'mimeType', + 'identifier', + 'component', + 'categories', + 'extras', + 'flags', + 'receiverPermission', + 'allowBackgroundActivityStarts', + ], + }, + }, + + 'mobile: getContexts': { + command: 'mobileGetContexts', + }, + + 'mobile: getAppStrings': { + command: 'mobileGetAppStrings', + params: { + optional: ['language'], + }, + }, + + 'mobile: installMultipleApks': { + command: 'mobileInstallMultipleApks', + params: { + required: ['apks'], + optional: ['options'], + }, + }, + + 'mobile: lock': { + command: 'mobileLock', + params: { + optional: ['seconds'], + }, + }, + 'mobile: unlock': { + command: 'mobileUnlock', + params: { + optional: ['key', 'type', 'strategy', 'timeoutMs'], + }, + }, + 'mobile: isLocked': { + command: 'isLocked', + }, + + 'mobile: refreshGpsCache': { + command: 'mobileRefreshGpsCache', + params: { + optional: ['timeoutMs'], + }, + }, + + 'mobile: startMediaProjectionRecording': { + command: 'mobileStartMediaProjectionRecording', + params: { + optional: ['resolution', 'maxDurationSec', 'priority', 'filename'], + }, + }, + 'mobile: isMediaProjectionRecordingRunning': { + command: 'mobileIsMediaProjectionRecordingRunning', + }, + 'mobile: stopMediaProjectionRecording': { + command: 'mobileStopMediaProjectionRecording', + params: { + optional: [ + 'remotePath', + 'user', + 'pass', + 'method', + 'headers', + 'fileFieldName', + 'formFields', + 'uploadTimeout', + ], + }, + }, + + 'mobile: getConnectivity': { + command: 'mobileGetConnectivity', + params: { + optional: ['services'], + }, + }, + 'mobile: setConnectivity': { + command: 'mobileSetConnectivity', + params: { + optional: ['wifi', 'data', 'airplaneMode'], + }, + }, + 'mobile: toggleGps': { + command: 'toggleLocationServices', + }, + 'mobile: isGpsEnabled': { + command: 'isLocationServicesEnabled', + }, + + 'mobile: hideKeyboard': { + command: 'hideKeyboard', + }, + 'mobile: isKeyboardShown': { + command: 'isKeyboardShown', + }, + + 'mobile: pressKey': { + command: 'mobilePressKey', + params: { + required: ['keycode'], + optional: ['metastate', 'flags', 'isLongPress'], + }, + }, + + 'mobile: getDisplayDensity': { + command: 'getDisplayDensity', + }, + 'mobile: getSystemBars': { + command: 'getSystemBars', + }, + + 'mobile: fingerprint': { + command: 'mobileFingerprint', + params: { + required: ['fingerprintId'], + }, + }, + 'mobile: sendSms': { + command: 'mobileSendSms', + params: { + required: ['phoneNumber', 'message'], + }, + }, + 'mobile: gsmCall': { + command: 'mobileGsmCall', + params: { + required: ['phoneNumber', 'action'], + }, + }, + 'mobile: gsmSignal': { + command: 'mobileGsmSignal', + params: { + required: ['strength'], + }, + }, + 'mobile: gsmVoice': { + command: 'mobileGsmVoice', + params: { + required: ['state'], + }, + }, + 'mobile: powerAc': { + command: 'mobilePowerAc', + params: { + required: ['state'], + }, + }, + 'mobile: powerCapacity': { + command: 'mobilePowerCapacity', + params: { + required: ['percent'], + }, + }, + 'mobile: networkSpeed': { + command: 'mobileNetworkSpeed', + params: { + required: ['speed'], + }, + }, + 'mobile: sensorSet': { + command: 'sensorSet', + params: { + required: ['sensorType', 'value'], + }, + }, + + 'mobile: getPerformanceData': { + command: 'mobileGetPerformanceData', + params: { + required: ['packageName', 'dataType'], + }, + }, + 'mobile: getPerformanceDataTypes': { + command: 'getPerformanceDataTypes', + }, + + 'mobile: statusBar': { + command: 'mobilePerformStatusBarCommand', + params: { + required: ['command'], + optional: ['component'], + }, + }, + + 'mobile: screenshots': { + command: 'mobileScreenshots', + params: { + optional: ['displayId'], + }, + }, + + 'mobile: scheduleAction': { + command: 'mobileScheduleAction', + params: { + optional: ['opts'], + }, + }, + + 'mobile: getActionHistory': { + command: 'mobileGetActionHistory', + params: { + optional: ['opts'], + }, + }, + + 'mobile: unscheduleAction': { + command: 'mobileUnscheduleAction', + params: { + optional: ['opts'], + }, + }, +} as const; + +export type Uiautomator2ExecuteMethodMap = typeof executeMethodMap; diff --git a/lib/method-map.js b/lib/method-map.js deleted file mode 100644 index da398c9c2..000000000 --- a/lib/method-map.js +++ /dev/null @@ -1,11 +0,0 @@ -import { AndroidDriver } from 'appium-android-driver'; - -export const newMethodMap = /** @type {const} */ ({ - ...AndroidDriver.newMethodMap, - '/session/:sessionId/appium/device/get_clipboard': { - POST: { - command: 'getClipboard', - payloadParams: { optional: ['contentType'] } - } - } -}); diff --git a/lib/method-map.ts b/lib/method-map.ts new file mode 100644 index 000000000..b6d70b5f6 --- /dev/null +++ b/lib/method-map.ts @@ -0,0 +1,11 @@ +import {AndroidDriver} from 'appium-android-driver'; + +export const newMethodMap = { + ...AndroidDriver.newMethodMap, + '/session/:sessionId/appium/device/get_clipboard': { + POST: { + command: 'getClipboard', + payloadParams: {optional: ['contentType']}, + }, + }, +} as const; diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 000000000..a15797400 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,59 @@ +import type {DriverCaps, DriverOpts, W3CDriverCaps} from '@appium/types'; +import type {AndroidSettings} from 'appium-android-driver'; +import type {EmptyObject} from 'type-fest'; +import type {RelativeRect} from './commands/types'; +import type {Uiautomator2Constraints} from './constraints'; + +export type Uiautomator2DriverOpts = DriverOpts; + +export type Uiautomator2DriverCaps = DriverCaps; + +export type W3CUiautomator2DriverCaps = W3CDriverCaps; + +export interface Uiautomator2DeviceInfo { + deviceApiLevel: number; + deviceScreenSize: string; + deviceScreenDensity: string; + deviceModel: string; + deviceManufacturer: string; + platformVersion: string; +} + +export interface Uiautomator2SessionInfo { + deviceName: string; + deviceUDID: string; +} + +export interface Uiautomator2DeviceDetails { + pixelRatio: string; + statBarHeight: number; + viewportRect: RelativeRect; +} + +export interface Uiautomator2ServerInfo { + platform: 'LINUX'; + webStorageEnabled: false; + takesScreenshot: true; + javascriptEnabled: true; + databaseEnabled: false; + networkConnectionEnabled: true; + locationContextEnabled: false; + warnings: EmptyObject; + desired: Uiautomator2DriverCaps; +} + +export interface Uiautomator2StartSessionOpts + extends Uiautomator2DriverCaps, + Uiautomator2ServerInfo {} + +export interface Uiautomator2SessionCaps + extends Uiautomator2ServerInfo, + Uiautomator2SessionInfo, + Uiautomator2DeviceInfo, + Uiautomator2DeviceDetails {} + +export type Uiautomator2CreateResult = [string, Uiautomator2SessionCaps]; + +export interface Uiautomator2Settings extends AndroidSettings { + allowInvisibleElements: boolean; +} diff --git a/lib/uiautomator2.js b/lib/uiautomator2.js index 45adf93d5..228429176 100644 --- a/lib/uiautomator2.js +++ b/lib/uiautomator2.js @@ -24,6 +24,9 @@ const INSTRUMENTATION_TARGET = `${SERVER_TEST_PACKAGE_ID}/androidx.test.runner.A const instrumentationLogger = logger.getLogger('Instrumentation'); class UIA2Proxy extends JWProxy { + /** @type {boolean} */ + didInstrumentationExit; + async proxyCommand (url, method, body = null) { if (this.didInstrumentationExit) { throw new errors.InvalidContextError( @@ -36,6 +39,21 @@ class UIA2Proxy extends JWProxy { } class UiAutomator2Server { + /** @type {string} */ + host; + + /** @type {number} */ + systemPort; + + /** @type {import('appium-adb').ADB} */ + adb; + + /** @type {boolean} */ + disableWindowAnimation; + + /** @type {boolean|undefined} */ + disableSuppressAccessibilityService; + constructor (log, opts = {}) { for (let req of REQD_PARAMS) { if (!opts || !util.hasValue(opts[req])) { @@ -192,6 +210,7 @@ class UiAutomator2Server { intervalMs: 1000, }); } catch (err) { + // @ts-ignore It is ok if the attribute does not exist this.log.error(`Unable to find instrumentation target '${INSTRUMENTATION_TARGET}': ${(pmError || {}).message}`); if (pmOutput) { this.log.debug('Available targets:'); @@ -271,10 +290,10 @@ class UiAutomator2Server { cmd.push('--no-window-animation'); } if (_.isBoolean(this.disableSuppressAccessibilityService)) { - cmd.push('-e', 'DISABLE_SUPPRESS_ACCESSIBILITY_SERVICES', this.disableSuppressAccessibilityService); + cmd.push('-e', 'DISABLE_SUPPRESS_ACCESSIBILITY_SERVICES', `${this.disableSuppressAccessibilityService}`); } // Disable Google analytics to prevent possible fatal exception - cmd.push('-e', 'disableAnalytics', true); + cmd.push('-e', 'disableAnalytics', 'true'); cmd.push(INSTRUMENTATION_TARGET); const instrumentationProcess = this.adb.createSubProcess(['shell', ...cmd]); instrumentationProcess.on('output', (stdout, stderr) => { diff --git a/lib/utils.js b/lib/utils.js index 3f677cc21..00e10f2ee 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,12 +4,12 @@ import { errors } from 'appium/driver'; /** * Assert the presence of particular keys in the given object * - * @template {Object} T + * @template {Record} T * @param {string|string[]} argNames one or more key names * @param {T} opts the object to check * @returns {T} the same given object */ -export function requireArgs (argNames, opts = {}) { +export function requireArgs (argNames, opts) { for (const argName of (_.isArray(argNames) ? argNames : [argNames])) { if (!_.has(opts, argName)) { throw new errors.InvalidArgumentError(`'${argName}' argument must be provided`); diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..fd1ca7892 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,10 @@ +INHERIT: ./node_modules/@appium/docutils/base-mkdocs.yml +docs_dir: docs +site_dir: site +site_name: appium-uiautomator2-driver +repo_url: git+https://github.com/appium/appium-uiautomator2-driver.git +repo_name: appium/appium-uiautomator2-driver +site_description: UiAutomator2 integration for Appium +nav: + - Reference: + - reference/commands/appium-uiautomator2-driver.md diff --git a/package.json b/package.json index 0705f175a..2fda6fdee 100644 --- a/package.json +++ b/package.json @@ -8,41 +8,19 @@ "android" ], "version": "2.29.11", + "bugs": { + "url": "https://github.com/appium/appium-uiautomator2-driver/issues" + }, "author": "Appium Contributors", "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/appium/appium-uiautomator2-driver.git" }, - "bugs": { - "url": "https://github.com/appium/appium-uiautomator2-driver/issues" - }, - "engines": { - "node": ">=14", - "npm": ">=8" - }, - "lint-staged": { - "*.js": [ - "eslint --fix" - ] - }, - "prettier": { - "bracketSpacing": false, - "printWidth": 100, - "singleQuote": true - }, - "appium": { - "driverName": "uiautomator2", - "automationName": "UiAutomator2", - "platformNames": [ - "Android" - ], - "mainClass": "AndroidUiautomator2Driver", - "scripts": { - "reset": "scripts/reset.js" - } - }, + "license": "Apache-2.0", + "author": "Appium Contributors", "main": "./build/index.js", + "types": "./build/index.d.ts", "bin": {}, "directories": { "lib": "lib" @@ -50,17 +28,44 @@ "files": [ "index.js", "lib", - "build/index.js", - "build/lib", + "build", "scripts", "CHANGELOG.md", "LICENSE", "npm-shrinkwrap.json" ], + "scripts": { + "build": "tsc -b", + "clean": "npm run build -- --clean", + "dev": "npm run build -- --watch", + "e2e-test:commands": "mocha --exit --timeout 10m \"./test/functional/commands\"", + "e2e-test:commands:find": "mocha --exit --timeout 10m \"./test/functional/commands/find\"", + "e2e-test:commands:general": "mocha --exit --timeout 10m \"./test/functional/commands/general\"", + "e2e-test:commands:keyboard": "mocha --exit --timeout 10m \"./test/functional/commands/keyboard\"", + "e2e-test:driver": "mocha --exit --timeout 10m \"./test/functional/driver-e2e-specs.js\"", + "lint": "eslint .", + "lint:commit": "commitlint", + "lint:fix": "npm run lint -- --fix", + "lint:staged": "lint-staged", + "prepare": "husky install; npm run rebuild", + "rebuild": "npm run clean; npm run build", + "reset": "node ./scripts/reset.js", + "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"" + }, + "lint-staged": { + "*.(js|ts)": [ + "eslint --fix", + "prettier --write" + ] + }, + "prettier": { + "bracketSpacing": false, + "printWidth": 100, + "singleQuote": true + }, "dependencies": { - "@babel/runtime": "^7.0.0", "appium-adb": "^9.14.12", - "appium-android-driver": "^5.14.10", + "appium-android-driver": "^6.0.0", "appium-chromedriver": "^5.6.5", "appium-uiautomator2-server": "^5.12.2", "asyncbox": "^2.3.1", @@ -70,60 +75,83 @@ "lodash": "^4.17.4", "portscanner": "^2.2.0", "source-map-support": "^0.x", - "teen_process": "^2.0.0" - }, - "scripts": { - "build": "rimraf build && babel --out-dir=build/lib lib && babel --out-dir=build index.js", - "reset": "node ./scripts/reset.js", - "dev": "npm run build -- --watch", - "lint": "eslint .", - "lint:fix": "npm run lint -- --fix", - "precommit-msg": "echo 'Pre-commit checks...' && exit 0", - "precommit-lint": "lint-staged", - "prepare": "npm run build", - "reinstall": "rm -rf node_modules && rm -rf package-lock.json && npm install", - "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"", - "e2e-test:driver": "mocha --exit --timeout 10m \"./test/functional/driver-e2e-specs.js\"", - "e2e-test:commands": "mocha --exit --timeout 10m \"./test/functional/commands\"", - "e2e-test:commands:find": "mocha --exit --timeout 10m \"./test/functional/commands/find\"", - "e2e-test:commands:general": "mocha --exit --timeout 10m \"./test/functional/commands/general\"", - "e2e-test:commands:keyboard": "mocha --exit --timeout 10m \"./test/functional/commands/keyboard\"" - }, - "pre-commit": [ - "precommit-msg", - "precommit-lint" - ], - "peerDependencies": { - "appium": "^2.0.0-beta.40" + "teen_process": "^2.0.0", + "type-fest": "^3.12.0" }, "devDependencies": { - "@appium/eslint-config-appium": "^8.0.0", + "@appium/docutils": "^0.4.4", + "@appium/eslint-config-appium": "^8.0.3", + "@appium/eslint-config-appium-ts": "^0.3.1", + "@appium/support": "^4.0.1", "@appium/test-support": "^3.0.0", - "@babel/cli": "^7.18.10", - "@babel/core": "^7.18.10", - "@babel/eslint-parser": "^7.18.9", - "@babel/plugin-transform-runtime": "^7.18.10", - "@babel/preset-env": "^7.18.10", - "@babel/register": "^7.18.9", + "@appium/tsconfig": "^0.3.0", + "@appium/types": "^0.13.0", + "@commitlint/cli": "^17.6.3", + "@commitlint/config-conventional": "^17.6.3", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", + "@types/bluebird": "^3.5.38", + "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.5", + "@types/lodash": "^4.14.194", + "@types/mocha": "^10.0.1", + "@types/node": "^20.2.3", + "@types/portscanner": "^2.1.1", + "@types/semver": "^7.5.0", + "@types/sinon": "^10.0.15", + "@types/sinon-chai": "^3.2.9", + "@types/source-map-support": "^0.5.6", + "@types/teen_process": "^2.0.0", + "@types/ws": "^8.5.4", + "@typescript-eslint/eslint-plugin": "^5.59.5", + "@typescript-eslint/parser": "^5.59.5", "@xmldom/xmldom": "^0.x", "android-apidemos": "^4.1.1", - "babel-plugin-source-map-support": "^2.2.0", - "chai": "^4.1.0", + "appium": "^2.0.0-rc.3", + "chai": "^4.1.2", "chai-as-promised": "^7.1.1", "conventional-changelog-conventionalcommits": "^7.0.1", - "eslint-config-prettier": "^8.5.0", + "eslint": "^8.40.0", + "eslint-config-prettier": "^8.8.0", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-mocha": "^10.1.0", + "eslint-plugin-promise": "^6.1.1", "fancy-log": "^2.0.0", + "husky": "^8.0.3", "lint-staged": "^14.0.0", "mocha": "^10.0.0", - "pre-commit": "^1.2.2", + "prettier": "^2.8.8", "rimraf": "^5.0.0", "semantic-release": "^21.1.0", "sharp": "^0.x", "sinon": "^15.0.0", + "sinon-chai": "^3.7.0", + "ts-node": "^10.9.1", + "typescript": "~5.0", "unzipper": "^0.x", "webdriverio": "^8.0.5", "xpath": "^0.x" + }, + "peerDependencies": { + "appium": "^2.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=8" + }, + "appium": { + "driverName": "uiautomator2", + "automationName": "UiAutomator2", + "platformNames": [ + "Android" + ], + "mainClass": "AndroidUiautomator2Driver", + "scripts": { + "reset": "scripts/reset.js" + } + }, + "typedoc": { + "entryPoint": "index.js" } } diff --git a/test/functional/commands/general/source-e2e-specs.js b/test/functional/commands/general/source-e2e-specs.js index 5734b51d0..eb5debb5c 100644 --- a/test/functional/commands/general/source-e2e-specs.js +++ b/test/functional/commands/general/source-e2e-specs.js @@ -1,10 +1,9 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { DOMParser } from '@xmldom/xmldom'; +import {DOMParser} from '@xmldom/xmldom'; import xpath from 'xpath'; -import { APIDEMOS_CAPS } from '../../desired'; -import { initSession, deleteSession } from '../../helpers/session'; - +import {APIDEMOS_CAPS} from '../../desired'; +import {initSession, deleteSession} from '../../helpers/session'; chai.should(); chai.use(chaiAsPromised); @@ -18,12 +17,12 @@ describe('apidemo - source', function () { await deleteSession(); }); - function assertSource (source) { + function assertSource(source) { source.should.exist; const dom = new DOMParser().parseFromString(source); const nodes = xpath.select('//hierarchy', dom); nodes.length.should.equal(1); - }; + } it('should return the page source', async function () { const source = await driver.getPageSource(); @@ -31,11 +30,11 @@ describe('apidemo - source', function () { }); it('should get less source when compression is enabled', async function () { const getSourceWithoutCompression = async () => { - await driver.updateSettings({'ignoreUnimportantViews': false}); + await driver.updateSettings({ignoreUnimportantViews: false}); return await driver.getPageSource(); }; const getSourceWithCompression = async () => { - await driver.updateSettings({'ignoreUnimportantViews': true}); + await driver.updateSettings({ignoreUnimportantViews: true}); return await driver.getPageSource(); }; const sourceWithoutCompression = await getSourceWithoutCompression(); diff --git a/test/functional/commands/viewport-e2e-specs.js b/test/functional/commands/viewport-e2e-specs.js index f9452a49b..bc78d900d 100644 --- a/test/functional/commands/viewport-e2e-specs.js +++ b/test/functional/commands/viewport-e2e-specs.js @@ -1,21 +1,24 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sharp from 'sharp'; -import { SCROLL_CAPS } from '../desired'; -import { initSession, deleteSession, attemptToDismissAlert } from '../helpers/session'; +import {SCROLL_CAPS} from '../desired'; +import {initSession, deleteSession, attemptToDismissAlert} from '../helpers/session'; chai.should(); chai.use(chaiAsPromised); -let driver; +const {expect} = chai; describe('testViewportCommands', function () { + /** @type {import('../../../lib/driver').AndroidUiautomator2Driver} */ + let driver; const caps = SCROLL_CAPS; before(async function () { driver = await initSession(caps); }); + after(async function () { if (driver) { await deleteSession(); @@ -28,20 +31,14 @@ describe('testViewportCommands', function () { it('should get device pixel ratio, status bar height, and viewport rect', async function () { const {viewportRect, statBarHeight, pixelRatio} = await driver.getSession(); - pixelRatio.should.exist; - pixelRatio.should.not.equal(0); - statBarHeight.should.exist; - statBarHeight.should.not.equal(0); - viewportRect.should.exist; - viewportRect.left.should.exist; - viewportRect.top.should.exist; - viewportRect.width.should.exist; - viewportRect.height.should.exist; + + expect(pixelRatio).not.to.be.empty; + expect(statBarHeight).to.be.greaterThan(0); + expect(viewportRect).to.have.keys(['left', 'top', 'width', 'height']); }); it('should get scrollable element', async function () { - let scrollableEl = await driver.$('//*[@scrollable="true"]'); - scrollableEl.should.exist; + await expect(driver.$('//*[@scrollable="true"]')).to.eventually.exist; }); it('should get content size from scrollable element found as uiobject', async function () { @@ -51,8 +48,8 @@ describe('testViewportCommands', function () { let scrollableEl = await driver.$('//*[@scrollable="true"]'); let contentSize = await scrollableEl.getAttribute('contentSize'); - contentSize.should.exist; - JSON.parse(contentSize).scrollableOffset.should.exist; + expect(contentSize).to.exist; + expect(JSON.parse(contentSize).scrollableOffset).to.exist; }); it('should get content size from scrollable element found as uiobject2', async function () { @@ -62,8 +59,8 @@ describe('testViewportCommands', function () { let scrollableEl = await driver.$('//android.widget.ScrollView'); let contentSize = await scrollableEl.getAttribute('contentSize'); - contentSize.should.exist; - JSON.parse(contentSize).scrollableOffset.should.exist; + expect(contentSize).to.exist; + expect(JSON.parse(contentSize).scrollableOffset).to.exist; }); it('should get first element from scrollable element', async function () { @@ -72,8 +69,7 @@ describe('testViewportCommands', function () { } let scrollableEl = await driver.$('//*[@scrollable="true"]'); - let element = await scrollableEl.$('/*[@firstVisible="true"]'); - element.should.exist; + expect(await scrollableEl.$('/*[@firstVisible="true"]')).to.eventually.exist; }); it('should get a cropped screenshot of the viewport without statusbar', async function () { diff --git a/test/functional/desired.js b/test/functional/desired.js index 077331418..2ab4ca70e 100644 --- a/test/functional/desired.js +++ b/test/functional/desired.js @@ -1,12 +1,13 @@ import _ from 'lodash'; import path from 'path'; +import {API_DEMOS_APK_PATH} from 'android-apidemos'; const uiautomator2ServerLaunchTimeout = process.env.CI ? 60000 : 20000; const uiautomator2ServerInstallTimeout = process.env.CI ? 120000 : 20000; const ADB_EXEC_TIMEOUT = process.env.CI ? 60000 : 20000; -function deepFreeze (object) { +function deepFreeze(object) { const propNames = Object.getOwnPropertyNames(object); for (const name of propNames) { const value = object[name]; @@ -17,7 +18,7 @@ function deepFreeze (object) { return Object.freeze(object); } -function amendCapabilities (baseCaps, ...newCaps) { +function amendCapabilities(baseCaps, ...newCaps) { return deepFreeze({ alwaysMatch: _.cloneDeep(Object.assign({}, baseCaps.alwaysMatch, ...newCaps)), firstMatch: [{}], @@ -34,11 +35,9 @@ const GENERIC_CAPS = deepFreeze({ 'appium:automationName': 'uiautomator2', 'appium:adbExecTimeout': ADB_EXEC_TIMEOUT, 'appium:ignoreHiddenApiPolicyError': true, - } + }, }); - -const { API_DEMOS_APK_PATH } = require('android-apidemos'); // http://www.impressive-artworx.de/tutorials/android/gps_tutorial_1.zip const gpsDemoApp = path.resolve(__dirname, 'assets', 'gpsDemo-debug.apk'); @@ -71,6 +70,11 @@ const SETTINGS_CAPS = amendCapabilities(GENERIC_CAPS, { }); export { - GENERIC_CAPS, APIDEMOS_CAPS, GPS_DEMO_CAPS, BROWSER_CAPS, SCROLL_CAPS, - SETTINGS_CAPS, amendCapabilities + GENERIC_CAPS, + APIDEMOS_CAPS, + GPS_DEMO_CAPS, + BROWSER_CAPS, + SCROLL_CAPS, + SETTINGS_CAPS, + amendCapabilities, }; diff --git a/test/unit/commands/general-specs.js b/test/unit/commands/general-specs.js index cbf969fbc..ebfe04ee8 100644 --- a/test/unit/commands/general-specs.js +++ b/test/unit/commands/general-specs.js @@ -1,75 +1,93 @@ +// @ts-check + import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; -import AndroidUiautomator2Driver from '../../../lib/driver'; +import {AndroidUiautomator2Driver} from '../../../lib/driver'; import ADB from 'appium-adb'; +import sinonChai from 'sinon-chai'; -let driver; -let sandbox = sinon.createSandbox(); -chai.should(); -chai.use(chaiAsPromised); +const {expect} = chai; +chai.use(chaiAsPromised).use(sinonChai); describe('General', function () { - describe('getWindowRect', function () { - beforeEach(function () { - driver = new AndroidUiautomator2Driver(); - }); - afterEach(function () { - sandbox.restore(); - }); + /** @type {AndroidUiautomator2Driver} */ + let driver; + /** @type {import('sinon').SinonSandbox} */ + let sandbox; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + driver = new AndroidUiautomator2Driver(); + }); + afterEach(function () { + sandbox.restore(); + }); + + describe('getWindowRect', function () { it('should get window size', async function () { - sandbox.stub(driver, 'getWindowSize') - .withArgs().returns({width: 300, height: 400}); + sandbox.stub(driver, 'getWindowSize').resolves({width: 300, height: 400}); const result = await driver.getWindowRect(); - result.width.should.be.equal(300); - result.height.should.be.equal(400); - result.x.should.be.equal(0); - result.y.should.be.equal(0); + expect(result).to.eql({ + width: 300, + height: 400, + x: 0, + y: 0, + }); }); - it('should raise error on non-existent mobile command', async function () { - await driver.executeMobile('fruta', {}).should.eventually.be.rejectedWith('Unknown mobile command "fruta"'); - }); - it('should accept sensorSet on emulator', async function () { - sandbox.stub(driver, 'isEmulator').returns(true); - let stub = sandbox.stub(driver, 'sensorSet'); - await driver.executeMobile('sensorSet', { sensorType: 'acceleration', value: '0:9.77631:0.812349' }); - stub.calledOnce.should.equal(true); - stub.calledWithExactly({ sensorType: 'acceleration', value: '0:9.77631:0.812349' }); + }); + + it('should raise error on non-existent mobile command', async function () { + await expect(driver.executeMobile('mobile: fruta', {})).to.be.rejectedWith( + /Unknown mobile command "mobile: fruta"/ + ); + }); + + describe('mobile: sensorSet', function () { + // note: this test does not depend on whether or not isEmulator returns + // true, because the "am I an emulator?" check happens in the sensorSet + // implementation, which is stubbed out. + it('should call sensorSet', async function () { + sandbox.stub(driver, 'sensorSet'); + await driver.executeMobile('mobile: sensorSet', { + sensorType: 'acceleration', + value: '0:9.77631:0.812349', + }); + expect(driver.sensorSet).to.have.been.calledOnceWithExactly({ + sensorType: 'acceleration', + value: '0:9.77631:0.812349', + }); }); }); - describe('mobileInstallMultipleApks', function () { - let adb = new ADB(); + describe('mobile: installMultipleApks', function () { + /** @type {ADB} */ + let adb; beforeEach(function () { + adb = new ADB(); driver = new AndroidUiautomator2Driver(); driver.adb = adb; - driver.helpers = { - configureApp: () => {} - }; - }); - afterEach(function () { - sandbox.restore(); + sandbox.stub(driver.helpers, 'configureApp').resolves('/path/to/test/apk.apk'); + sandbox.stub(adb, 'installMultipleApks'); }); it('should call mobileInstallMultipleApks', async function () { - sandbox.stub(driver.helpers, 'configureApp') - .returns(['/path/to/test/apk.apk']); - sandbox.stub(driver.adb, 'installMultipleApks') - .withArgs().returns(); - await driver.executeMobile('installMultipleApks', - {apks: ['/path/to/test/apk.apk']}); + await driver.executeMobile('mobile: installMultipleApks', {apks: ['/path/to/test/apk.apk']}); + expect(adb.installMultipleApks).to.have.been.calledOnceWith(['/path/to/test/apk.apk']); }); - it('should raise error if no apks were given', async function () { - await driver.executeMobile('installMultipleApks', {apks: []}) - .should.eventually.be.rejectedWith('No apks are given to install'); + it('should reject if no apks were given', async function () { + await expect( + driver.executeMobile('mobile: installMultipleApks', {apks: []}) + ).to.be.rejectedWith('No apks are given to install'); }); - it('should raise error if no apks were given', async function () { - await driver.executeMobile('installMultipleApks', {}) - .should.eventually.be.rejectedWith('No apks are given to install'); + it('should reject if no apks were given', async function () { + await expect(driver.executeMobile('mobile: installMultipleApks')).to.be.rejectedWith( + 'No apks are given to install' + ); }); }); }); diff --git a/test/unit/commands/touch-specs.js b/test/unit/commands/touch-specs.js index 1a216755b..a4bb584f1 100644 --- a/test/unit/commands/touch-specs.js +++ b/test/unit/commands/touch-specs.js @@ -1,9 +1,8 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import AndroidUiautomator2Driver from '../../../lib/driver'; +import {AndroidUiautomator2Driver} from '../../../lib/driver'; import ADB from 'appium-adb'; - chai.should(); chai.use(chaiAsPromised); @@ -21,7 +20,7 @@ describe('Touch', function () { {action: 'moveTo', options: {x: 50, y: 51}}, {action: 'wait', options: {ms: 5000}}, {action: 'moveTo', options: {x: -40, y: -41}}, - {action: 'release', options: {}} + {action: 'release', options: {}}, ]; let touchStates = await driver.parseTouch(actions, false); touchStates.length.should.equal(5); @@ -30,7 +29,7 @@ describe('Touch', function () { {action: 'moveTo', x: 50, y: 51}, {action: 'wait', x: 50, y: 51}, {action: 'moveTo', x: -40, y: -41}, - {action: 'release'} + {action: 'release'}, ]; let index = 0; for (let state of touchStates) { diff --git a/test/unit/driver-specs.js b/test/unit/driver-specs.js index 7885cafe5..e04591d32 100644 --- a/test/unit/driver-specs.js +++ b/test/unit/driver-specs.js @@ -1,17 +1,17 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import AndroidUiautomator2Driver from '../../lib/driver'; +import {AndroidUiautomator2Driver} from '../../lib/driver'; import sinon from 'sinon'; import path from 'path'; import B from 'bluebird'; -import { ADB } from 'appium-adb'; +import {ADB} from 'appium-adb'; chai.should(); chai.use(chaiAsPromised); let sandbox = sinon.createSandbox(); -function defaultStub (driver) { - sinon.stub(driver, 'fillDeviceDetails'); +function defaultStub(driver) { + sinon.stub(driver, 'getDeviceInfoFromUia2'); } describe('driver.js', function () { @@ -27,29 +27,27 @@ describe('driver.js', function () { it('should throw an error if app can not be found', async function () { let driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); - await driver.createSession(null, null, { - firstMatch: [{}], - alwaysMatch: { - 'appium:app': 'foo.apk' - } - }).should.be.rejectedWith('does not exist or is not accessible'); + await driver + .createSession(null, null, { + firstMatch: [{}], + alwaysMatch: { + 'appium:app': 'foo.apk', + }, + }) + .should.be.rejectedWith('does not exist or is not accessible'); }); it('should set sessionId', async function () { let driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); - sinon.mock(driver).expects('checkAppPresent') - .once() - .returns(B.resolve()); - sinon.mock(driver).expects('startUiAutomator2Session') - .once() - .returns(B.resolve()); + sinon.mock(driver).expects('checkAppPresent').once().returns(B.resolve()); + sinon.mock(driver).expects('startUiAutomator2Session').once().returns(B.resolve()); await driver.createSession(null, null, { firstMatch: [{}], alwaysMatch: { 'appium:cap': 'foo', browserName: 'chrome', - } + }, }); driver.sessionId.should.exist; driver.caps.cap.should.equal('foo'); @@ -58,15 +56,13 @@ describe('driver.js', function () { it('should set the default context', async function () { let driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); - sinon.mock(driver).expects('checkAppPresent') - .returns(B.resolve()); - sinon.mock(driver).expects('startUiAutomator2Session') - .returns(B.resolve()); + sinon.mock(driver).expects('checkAppPresent').returns(B.resolve()); + sinon.mock(driver).expects('startUiAutomator2Session').returns(B.resolve()); await driver.createSession(null, null, { firstMatch: [{}], alwaysMatch: { - browserName: 'chrome' - } + browserName: 'chrome', + }, }); driver.curContext.should.equal('NATIVE_APP'); }); @@ -77,14 +73,12 @@ describe('driver.js', function () { let driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); let app = path.resolve('.'); - sinon.mock(driver).expects('startUiAutomator2Session') - .returns(B.resolve()); - sinon.mock(driver.helpers).expects('configureApp') - .returns(app); + sinon.mock(driver).expects('startUiAutomator2Session').returns(B.resolve()); + sinon.mock(driver.helpers).expects('configureApp').returns(app); await driver.createSession(null, null, { firstMatch: [{}], - alwaysMatch: {'appium:app': app} + alwaysMatch: {'appium:app': app}, }); await driver.checkAppPresent(); // should not error @@ -98,16 +92,13 @@ describe('driver.js', function () { let driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); let app = path.resolve('asdfasdf'); - sinon.mock(driver).expects('checkAppPresent') - .returns(B.resolve()); - sinon.mock(driver).expects('startUiAutomator2Session') - .returns(B.resolve()); - sinon.mock(driver.helpers).expects('configureApp') - .returns(app); + sinon.mock(driver).expects('checkAppPresent').returns(B.resolve()); + sinon.mock(driver).expects('startUiAutomator2Session').returns(B.resolve()); + sinon.mock(driver.helpers).expects('configureApp').returns(app); await driver.createSession(null, null, { firstMatch: [{}], - alwaysMatch: {'appium:app': app} + alwaysMatch: {'appium:app': app}, }); driver.checkAppPresent.restore(); @@ -152,16 +143,13 @@ describe('driver.js', function () { }); describe('nativeWebScreenshot', function () { let proxyAvoidList; - let nativeWebScreenshotFilter = (item) => item[0] === 'GET' && item[1].test('/session/xxx/screenshot/'); + let nativeWebScreenshotFilter = (item) => + item[0] === 'GET' && item[1].test('/session/xxx/screenshot/'); beforeEach(function () { driver = new AndroidUiautomator2Driver({}, false); defaultStub(driver); - sinon.mock(driver).expects('checkAppPresent') - .once() - .returns(B.resolve()); - sinon.mock(driver).expects('startUiAutomator2Session') - .once() - .returns(B.resolve()); + sinon.mock(driver).expects('checkAppPresent').once().returns(B.resolve()); + sinon.mock(driver).expects('startUiAutomator2Session').once().returns(B.resolve()); }); describe('on webview mode', function () { @@ -175,8 +163,8 @@ describe('driver.js', function () { platformName: 'Android', 'appium:deviceName': 'device', browserName: 'chrome', - 'appium:nativeWebScreenshot': false - } + 'appium:nativeWebScreenshot': false, + }, }); proxyAvoidList = driver.getProxyAvoidList().filter(nativeWebScreenshotFilter); proxyAvoidList.should.be.empty; @@ -188,8 +176,8 @@ describe('driver.js', function () { platformName: 'Android', 'appium:deviceName': 'device', browserName: 'chrome', - 'appium:nativeWebScreenshot': true - } + 'appium:nativeWebScreenshot': true, + }, }); proxyAvoidList = driver.getProxyAvoidList().filter(nativeWebScreenshotFilter); proxyAvoidList.should.not.be.empty; @@ -205,8 +193,8 @@ describe('driver.js', function () { platformName: 'Android', 'appium:deviceName': 'device', browserName: 'chrome', - 'appium:nativeWebScreenshot': true - } + 'appium:nativeWebScreenshot': true, + }, }); proxyAvoidList = driver.getProxyAvoidList().filter(nativeWebScreenshotFilter); proxyAvoidList.should.not.be.empty; @@ -220,8 +208,8 @@ describe('driver.js', function () { platformName: 'Android', 'appium:deviceName': 'device', browserName: 'chrome', - 'appium:nativeWebScreenshot': false - } + 'appium:nativeWebScreenshot': false, + }, }); proxyAvoidList = driver.getProxyAvoidList().filter(nativeWebScreenshotFilter); proxyAvoidList.should.not.be.empty; @@ -251,7 +239,11 @@ describe('driver.js', function () { defaultStub(driver); driver.uiautomator2 = {jwproxy: {command: () => {}}}; let proxySpy = sinon.stub(driver.uiautomator2.jwproxy, 'command'); - await driver.doFindElementOrEls({strategy: 'xpath', selector: '/*[@firstVisible="true"]', context: 'foo'}); + await driver.doFindElementOrEls({ + strategy: 'xpath', + selector: '/*[@firstVisible="true"]', + context: 'foo', + }); proxySpy.firstCall.args.should.eql([`/appium/element/foo/first_visible`, 'GET', {}]); }); }); @@ -262,12 +254,20 @@ describe('driver.js', function () { defaultStub(driver); driver.uiautomator2 = {jwproxy: {command: () => {}}}; let proxySpy = sinon.stub(driver.uiautomator2.jwproxy, 'command'); - await driver.doFindElementOrEls({strategy: 'xpath', selector: '//*[@scrollable="true"]', context: 'foo'}); - proxySpy.firstCall.args.should.eql(['/element', 'POST', { + await driver.doFindElementOrEls({ + strategy: 'xpath', + selector: '//*[@scrollable="true"]', context: 'foo', - strategy: '-android uiautomator', - selector: 'new UiSelector().scrollable(true)', - }]); + }); + proxySpy.firstCall.args.should.eql([ + '/element', + 'POST', + { + context: 'foo', + strategy: '-android uiautomator', + selector: 'new UiSelector().scrollable(true)', + }, + ]); }); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..84b0b4a37 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@appium/tsconfig/tsconfig.json", + "compilerOptions": { + "outDir": "build", + "strict": false, + "checkJs": true, + "types": ["node", "mocha", "sinon-chai", "chai-as-promised"] + }, + "include": ["lib", "index.js"] +} diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 000000000..204d4f3ff --- /dev/null +++ b/typedoc.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "cleanOutputDir": true, + "entryPointStrategy": "packages", + "theme": "appium", + "entryPoints": ["."] +}