diff --git a/lib/commands/actions.js b/lib/commands/actions.js deleted file mode 100644 index 78ec747e..00000000 --- a/lib/commands/actions.js +++ /dev/null @@ -1,244 +0,0 @@ -// @ts-check - -import {util} from '@appium/support'; -import B from 'bluebird'; -import {AndroidHelpers} from '../helpers'; -import {requireArgs} from '../utils'; -import {mixin} from './mixins'; -import {errors} from 'appium/driver'; - -const dragStepsPerSec = 40; - -/** - * @type {import('./mixins').ActionsMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const ActionsMixin = { - async keyevent(keycode, metastate) { - // TODO deprecate keyevent; currently wd only implements keyevent - this.log.warn('keyevent will be deprecated use pressKeyCode'); - return await this.pressKeyCode(keycode, metastate); - }, - - async pressKeyCode(keycode, metastate) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async longPressKeyCode(keycode, metastate) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getOrientation() { - throw new errors.NotImplementedError('Not implemented'); - }, - - async setOrientation(orientation) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async fakeFlick(xSpeed, ySpeed) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async fakeFlickElement(elementId, xoffset, yoffset, speed) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async swipe(startX, startY, endX, endY, duration, touchCount, elId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async doSwipe(swipeOpts) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async pinchClose(startX, startY, endX, endY, duration, percent, steps, elId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async pinchOpen(startX, startY, endX, endY, duration, percent, steps, elId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async flick(element, xSpeed, ySpeed, xOffset, yOffset, speed) { - if (element) { - await this.fakeFlickElement(element, xOffset, yOffset, speed); - } else { - await this.fakeFlick(xSpeed, ySpeed); - } - }, - - async drag(startX, startY, endX, endY, duration, touchCount, elementId, destElId) { - let dragOpts = { - elementId, - destElId, - startX, - startY, - endX, - endY, - steps: Math.round(duration * dragStepsPerSec), - }; - return await this.doDrag(dragOpts); - }, - - async doDrag(dragOpts) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async mobileLock(opts = {}) { - const {seconds} = opts; - return await this.lock(seconds); - }, - - async lock(seconds) { - await this.adb.lock(); - if (Number.isNaN(seconds)) { - return; - } - - const floatSeconds = parseFloat(String(seconds)); - if (floatSeconds <= 0) { - return; - } - await B.delay(1000 * floatSeconds); - await this.unlock(); - }, - - async isLocked() { - return await this.adb.isScreenLocked(); - }, - - async unlock() { - return await AndroidHelpers.unlock(this, this.adb, this.caps); - }, - - async openNotifications() { - throw new errors.NotImplementedError('Not implemented'); - }, - - async setLocation(latitude, longitude) { - await this.adb.sendTelnetCommand(`geo fix ${longitude} ${latitude}`); - }, - - async fingerprint(fingerprintId) { - if (!this.isEmulator()) { - this.log.errorAndThrow('fingerprint method is only available for emulators'); - } - await this.adb.fingerprint(String(fingerprintId)); - }, - - async mobileFingerprint(opts) { - const {fingerprintId} = requireArgs('fingerprintId', opts); - await this.fingerprint(fingerprintId); - }, - - async sendSMS(phoneNumber, message) { - if (!this.isEmulator()) { - this.log.errorAndThrow('sendSMS method is only available for emulators'); - } - await this.adb.sendSMS(phoneNumber, message); - }, - - async mobileSendSms(opts) { - const {phoneNumber, message} = requireArgs(['phoneNumber', 'message'], opts); - await this.sendSMS(phoneNumber, message); - }, - - async gsmCall(phoneNumber, action) { - if (!this.isEmulator()) { - this.log.errorAndThrow('gsmCall method is only available for emulators'); - } - await this.adb.gsmCall(phoneNumber, /** @type {any} */(action)); - }, - - async mobileGsmCall(opts) { - const {phoneNumber, action} = requireArgs(['phoneNumber', 'action'], opts); - await this.gsmCall(phoneNumber, action); - }, - - async gsmSignal(signalStrengh) { - if (!this.isEmulator()) { - this.log.errorAndThrow('gsmSignal method is only available for emulators'); - } - await this.adb.gsmSignal(signalStrengh); - }, - - async mobileGsmSignal(opts) { - const {strength} = requireArgs('strength', opts); - await this.gsmSignal(strength); - }, - - async gsmVoice(state) { - if (!this.isEmulator()) { - this.log.errorAndThrow('gsmVoice method is only available for emulators'); - } - await this.adb.gsmVoice(state); - }, - - async mobileGsmVoice(opts) { - const {state} = requireArgs('state', opts); - await this.gsmVoice(state); - }, - - async powerAC(state) { - if (!this.isEmulator()) { - this.log.errorAndThrow('powerAC method is only available for emulators'); - } - await this.adb.powerAC(state); - }, - - async mobilePowerAc(opts) { - const {state} = requireArgs('state', opts); - await this.powerAC(state); - }, - - async powerCapacity(batteryPercent) { - if (!this.isEmulator()) { - this.log.errorAndThrow('powerCapacity method is only available for emulators'); - } - await this.adb.powerCapacity(batteryPercent); - }, - - async mobilePowerCapacity(opts) { - const {percent} = requireArgs('percent', opts); - await this.powerCapacity(percent); - }, - - async networkSpeed(networkSpeed) { - if (!this.isEmulator()) { - this.log.errorAndThrow('networkSpeed method is only available for emulators'); - } - await this.adb.networkSpeed(networkSpeed); - }, - - async mobileNetworkSpeed(opts) { - const {speed} = requireArgs('speed', opts); - await this.networkSpeed(speed); - }, - - async sensorSet(opts) { - const {sensorType, value} = opts; - if (!util.hasValue(sensorType)) { - this.log.errorAndThrow(`'sensorType' argument is required`); - } - if (!util.hasValue(value)) { - this.log.errorAndThrow(`'value' argument is required`); - } - if (!this.isEmulator()) { - this.log.errorAndThrow('sensorSet method is only available for emulators'); - } - await this.adb.sensorSet(sensorType, /** @type {any} */(value)); - }, - - async getScreenshot() { - throw new errors.NotImplementedError('Not implemented'); - }, -}; - -mixin(ActionsMixin); - -export default ActionsMixin; - -/** - * @typedef {import('appium-adb').ADB} ADB - */ diff --git a/lib/commands/alert.js b/lib/commands/alert.js deleted file mode 100644 index 1e0c4fb4..00000000 --- a/lib/commands/alert.js +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-check - -import {errors} from 'appium/driver'; -import {mixin} from './mixins'; - -/** - * @type {AlertMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const AlertMixin = { - getAlertText() { - throw new errors.NotYetImplementedError(); - }, - - setAlertText() { - throw new errors.NotYetImplementedError(); - }, - - postAcceptAlert() { - throw new errors.NotYetImplementedError(); - }, - - postDismissAlert() { - throw new errors.NotYetImplementedError(); - }, -}; - -mixin(AlertMixin); - -export default AlertMixin; - -/** - * @typedef {import('./mixins').AlertMixin} AlertMixin - */ diff --git a/lib/commands/app-management.js b/lib/commands/app-management.js index 4ab274f8..744ec664 100644 --- a/lib/commands/app-management.js +++ b/lib/commands/app-management.js @@ -1,176 +1,501 @@ -// @ts-check - import {util} from '@appium/support'; -import {waitForCondition} from 'asyncbox'; +import {waitForCondition, longSleep} from 'asyncbox'; import _ from 'lodash'; -import {APP_STATE} from '../helpers'; import {requireArgs} from '../utils'; -import {mixin} from './mixins'; +import {EOL} from 'node:os'; +import B from 'bluebird'; const APP_EXTENSIONS = ['.apk', '.apks']; const RESOLVER_ACTIVITY_NAME = 'android/com.android.internal.app.ResolverActivity'; +const PACKAGE_INSTALL_TIMEOUT_MS = 90000; +// These constants are in sync with +// https://developer.apple.com/documentation/xctest/xcuiapplicationstate/xcuiapplicationstaterunningbackground?language=objc +export const APP_STATE = /** @type {const} */ ({ + NOT_INSTALLED: 0, + NOT_RUNNING: 1, + RUNNING_IN_BACKGROUND: 3, + RUNNING_IN_FOREGROUND: 4, +}); /** - * @type {import('./mixins').AppManagementMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {AndroidDriver} + * @param {string} appId + * @returns {Promise} */ -const AppManagementMixin = { - async isAppInstalled(appId) { - return await this.adb.isAppInstalled(appId); - }, +export async function isAppInstalled(appId) { + return await this.adb.isAppInstalled(appId); +} - async mobileIsAppInstalled(opts) { - const {appId} = requireArgs('appId', opts); - return await this.isAppInstalled(appId); - }, +/** + * @this {AndroidDriver} + * @param {import('./types').IsAppInstalledOpts} opts + * @returns {Promise} + */ +export async function mobileIsAppInstalled(opts) { + const {appId} = requireArgs('appId', opts); + return await this.isAppInstalled(appId); +} - async queryAppState(appId) { - this.log.info(`Querying the state of '${appId}'`); - if (!(await this.adb.isAppInstalled(appId))) { - return APP_STATE.NOT_INSTALLED; +/** + * @this {AndroidDriver} + * @param {string} appId + * @returns {Promise} + */ +export async function queryAppState(appId) { + this.log.info(`Querying the state of '${appId}'`); + if (!(await this.adb.isAppInstalled(appId))) { + return APP_STATE.NOT_INSTALLED; + } + if (!(await this.adb.processExists(appId))) { + return APP_STATE.NOT_RUNNING; + } + const appIdRe = new RegExp(`\\b${_.escapeRegExp(appId)}/`); + for (const line of (await this.adb.dumpWindows()).split('\n')) { + if (appIdRe.test(line) && ['mCurrentFocus', 'mFocusedApp'].some((x) => line.includes(x))) { + return APP_STATE.RUNNING_IN_FOREGROUND; } - if (!(await this.adb.processExists(appId))) { - return APP_STATE.NOT_RUNNING; + } + return APP_STATE.RUNNING_IN_BACKGROUND; +} + +/** + * @this {AndroidDriver} + * @param {import('./types').QueryAppStateOpts} opts + * @returns {Promise} + */ +export async function mobileQueryAppState(opts) { + const {appId} = requireArgs('appId', opts); + return await this.queryAppState(appId); +} + +/** + * @this {AndroidDriver} + * @param {string} appId + * @returns {Promise} + */ +export async function activateApp(appId) { + this.log.debug(`Activating '${appId}'`); + const apiLevel = await this.adb.getApiLevel(); + // Fallback to Monkey in older APIs + if (apiLevel < 24) { + // The monkey command could raise an issue as https://stackoverflow.com/questions/44860475/how-to-use-the-monkey-command-with-an-android-system-that-doesnt-have-physical + // but '--pct-syskeys 0' could cause another background process issue. https://github.com/appium/appium/issues/16941#issuecomment-1129837285 + const cmd = ['monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']; + let output = ''; + try { + output = await this.adb.shell(cmd); + this.log.debug(`Command stdout: ${output}`); + } catch (e) { + this.log.errorAndThrow( + `Cannot activate '${appId}'. Original error: ${/** @type {Error} */ (e).message}`, + ); } - const appIdRe = new RegExp(`\\b${_.escapeRegExp(appId)}/`); - for (const line of (await this.adb.dumpWindows()).split('\n')) { - if (appIdRe.test(line) && ['mCurrentFocus', 'mFocusedApp'].some((x) => line.includes(x))) { - return APP_STATE.RUNNING_IN_FOREGROUND; - } + if (output.includes('monkey aborted')) { + this.log.errorAndThrow(`Cannot activate '${appId}'. Are you sure it is installed?`); } - return APP_STATE.RUNNING_IN_BACKGROUND; - }, - - async mobileQueryAppState(opts) { - const {appId} = requireArgs('appId', opts); - return await this.queryAppState(appId); - }, - - async activateApp(appId) { - this.log.debug(`Activating '${appId}'`); - const apiLevel = await this.adb.getApiLevel(); - // Fallback to Monkey in older APIs - if (apiLevel < 24) { - // The monkey command could raise an issue as https://stackoverflow.com/questions/44860475/how-to-use-the-monkey-command-with-an-android-system-that-doesnt-have-physical - // but '--pct-syskeys 0' could cause another background process issue. https://github.com/appium/appium/issues/16941#issuecomment-1129837285 - const cmd = ['monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']; - let output = ''; - try { - output = await this.adb.shell(cmd); - this.log.debug(`Command stdout: ${output}`); - } catch (e) { - this.log.errorAndThrow( - `Cannot activate '${appId}'. Original error: ${/** @type {Error} */ (e).message}` + return; + } + + let activityName = await this.adb.resolveLaunchableActivity(appId); + if (activityName === RESOLVER_ACTIVITY_NAME) { + // https://github.com/appium/appium/issues/17128 + this.log.debug( + `The launchable activity name of '${appId}' was resolved to '${activityName}'. ` + + `Switching the resolver to not use cmd`, + ); + activityName = await this.adb.resolveLaunchableActivity(appId, {preferCmd: false}); + } + + const stdout = await this.adb.shell([ + 'am', + apiLevel < 26 ? 'start' : 'start-activity', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_NEW_TASK + // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + '-f', + '0x10200000', + '-n', + activityName, + ]); + this.log.debug(stdout); + if (/^error:/im.test(stdout)) { + throw new Error(`Cannot activate '${appId}'. Original error: ${stdout}`); + } +} + +/** + * @this {AndroidDriver} + * @param {import('./types').ActivateAppOpts} opts + * @returns {Promise} + */ +export async function mobileActivateApp(opts) { + const {appId} = requireArgs('appId', opts); + return await this.activateApp(appId); +} + +/** + * @this {AndroidDriver} + * @param {string} appId + * @param {Omit} opts + * @returns {Promise} + */ +export async function removeApp(appId, opts = {}) { + return await this.adb.uninstallApk(appId, opts); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').RemoveAppOpts} opts + * @returns {Promise} + */ +export async function mobileRemoveApp(opts) { + const {appId} = requireArgs('appId', opts); + return await this.removeApp(appId, opts); +} + +/** + * @this {AndroidDriver} + * @param {string} appId + * @param {Omit} [options={}] + * @returns {Promise} + */ +export async function terminateApp(appId, options = {}) { + this.log.info(`Terminating '${appId}'`); + if (!(await this.adb.processExists(appId))) { + this.log.info(`The app '${appId}' is not running`); + return false; + } + await this.adb.forceStop(appId); + const timeout = + util.hasValue(options.timeout) && !Number.isNaN(options.timeout) + ? parseInt(String(options.timeout), 10) + : 500; + + if (timeout <= 0) { + this.log.info( + `'${appId}' has been terminated. Skip checking the application process state ` + + `since the timeout was set as ${timeout}ms`, + ); + return true; + } + + try { + await waitForCondition(async () => (await this.queryAppState(appId)) <= APP_STATE.NOT_RUNNING, { + waitMs: timeout, + intervalMs: 100, + }); + } catch (e) { + this.log.errorAndThrow(`'${appId}' is still running after ${timeout}ms timeout`); + } + this.log.info(`'${appId}' has been successfully terminated`); + return true; +} + +/** + * @this {AndroidDriver} + * @param {import('./types').TerminateAppOpts} opts + * @returns {Promise} + */ +export async function mobileTerminateApp(opts) { + const {appId} = requireArgs('appId', opts); + return await this.terminateApp(appId, opts); +} + +/** + * @this {AndroidDriver} + * @param {string} appPath + * @param {Omit} opts + * @returns {Promise} + */ +export async function installApp(appPath, opts) { + const localPath = await this.helpers.configureApp(appPath, APP_EXTENSIONS); + await this.adb.install(localPath, opts); +} + +/** + * @this {AndroidDriver} + * @param {import('./types').InstallAppOpts} opts + * @returns {Promise} + */ +export async function mobileInstallApp(opts) { + const {appPath} = requireArgs('appPath', opts); + return await this.installApp(appPath, opts); +} + +/** + * @this {AndroidDriver} + * @param {import('./types').ClearAppOpts} opts + * @returns {Promise} + */ +export async function mobileClearApp(opts) { + const {appId} = requireArgs('appId', opts); + await this.adb.clear(appId); +} + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function getCurrentActivity() { + return /** @type {string} */ ((await this.adb.getFocusedPackageAndActivity()).appActivity); +} + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function getCurrentPackage() { + return /** @type {string} */ ((await this.adb.getFocusedPackageAndActivity()).appPackage); +} + +/** + * @this {AndroidDriver} + * @param {number} seconds + * @returns {Promise} + */ +export async function background(seconds) { + if (seconds < 0) { + // if user passes in a negative seconds value, interpret that as the instruction + // to not bring the app back at all + await this.adb.goToHome(); + return true; + } + let {appPackage, appActivity} = await this.adb.getFocusedPackageAndActivity(); + await this.adb.goToHome(); + + // people can wait for a long time, so to be safe let's use the longSleep function and log + // progress periodically. + const sleepMs = seconds * 1000; + const thresholdMs = 30 * 1000; // use the spin-wait for anything over this threshold + // for our spin interval, use 1% of the total wait time, but nothing bigger than 30s + const intervalMs = _.min([30 * 1000, parseInt(String(sleepMs / 100), 10)]); + /** + * + * @param {{elapsedMs: number, progress: number}} param0 + */ + const progressCb = ({elapsedMs, progress}) => { + const waitSecs = (elapsedMs / 1000).toFixed(0); + const progressPct = (progress * 100).toFixed(2); + this.log.debug(`Waited ${waitSecs}s so far (${progressPct}%)`); + }; + await longSleep(sleepMs, {thresholdMs, intervalMs, progressCb}); + + /** @type {import('appium-adb').StartAppOptions} */ + let args; + if (this._cachedActivityArgs && this._cachedActivityArgs[`${appPackage}/${appActivity}`]) { + // the activity was started with `startActivity`, so use those args to restart + args = this._cachedActivityArgs[`${appPackage}/${appActivity}`]; + } else { + try { + this.log.debug(`Activating app '${appPackage}' in order to restore it`); + await this.activateApp(/** @type {string} */ (appPackage)); + return true; + } catch (ign) {} + args = + (appPackage === this.opts.appPackage && appActivity === this.opts.appActivity) || + (appPackage === this.opts.appWaitPackage && + (this.opts.appWaitActivity || '').split(',').includes(String(appActivity))) + ? { + // the activity is the original session activity, so use the original args + pkg: /** @type {string} */ (this.opts.appPackage), + activity: this.opts.appActivity ?? undefined, + action: this.opts.intentAction, + category: this.opts.intentCategory, + flags: this.opts.intentFlags, + waitPkg: this.opts.appWaitPackage ?? undefined, + waitActivity: this.opts.appWaitActivity ?? undefined, + waitForLaunch: this.opts.appWaitForLaunch, + waitDuration: this.opts.appWaitDuration, + optionalIntentArguments: this.opts.optionalIntentArguments, + stopApp: false, + user: this.opts.userProfile, + } + : { + // the activity was started some other way, so use defaults + pkg: /** @type {string} */ (appPackage), + activity: appActivity ?? undefined, + waitPkg: appPackage ?? undefined, + waitActivity: appActivity ?? undefined, + stopApp: false, + }; + } + args = /** @type {import('appium-adb').StartAppOptions} */ ( + _.pickBy(args, (value) => !_.isUndefined(value)) + ); + this.log.debug(`Bringing application back to foreground with arguments: ${JSON.stringify(args)}`); + return await this.adb.startApp(args); +} + +/** + * @this {AndroidDriver} + * @param {import('../driver').AndroidDriverOpts?} [opts=null] + * @returns {Promise} + */ +export async function resetApp(opts = null) { + const { + app, + appPackage, + fastReset, + fullReset, + androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, + autoGrantPermissions, + allowTestPackages, + } = opts ?? this.opts; + + if (!appPackage) { + throw new Error("'appPackage' option is required"); + } + + const isInstalled = await this.adb.isAppInstalled(appPackage); + + if (isInstalled) { + try { + await this.adb.forceStop(appPackage); + } catch (ign) {} + // fullReset has priority over fastReset + if (!fullReset && fastReset) { + const output = await this.adb.clear(appPackage); + if (_.isString(output) && output.toLowerCase().includes('failed')) { + throw new Error( + `Cannot clear the application data of '${appPackage}'. Original error: ${output}`, ); } - if (output.includes('monkey aborted')) { - this.log.errorAndThrow(`Cannot activate '${appId}'. Are you sure it is installed?`); + // executing `shell pm clear` resets previously assigned application permissions as well + if (autoGrantPermissions) { + try { + await this.adb.grantAllPermissions(appPackage); + } catch (error) { + this.log.error(`Unable to grant permissions requested. Original error: ${error.message}`); + } } - return; - } - - let activityName = await this.adb.resolveLaunchableActivity(appId); - if (activityName === RESOLVER_ACTIVITY_NAME) { - // https://github.com/appium/appium/issues/17128 this.log.debug( - `The launchable activity name of '${appId}' was resolved to '${activityName}'. ` + - `Switching the resolver to not use cmd` + `Performed fast reset on the installed '${appPackage}' application (stop and clear)`, ); - activityName = await this.adb.resolveLaunchableActivity(appId, {preferCmd: false}); + return; } + } - const stdout = await this.adb.shell([ - 'am', - apiLevel < 26 ? 'start' : 'start-activity', - '-a', - 'android.intent.action.MAIN', - '-c', - 'android.intent.category.LAUNCHER', - // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_NEW_TASK - // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_RESET_TASK_IF_NEEDED - '-f', - '0x10200000', - '-n', - activityName, - ]); - this.log.debug(stdout); - if (/^error:/im.test(stdout)) { - throw new Error(`Cannot activate '${appId}'. Original error: ${stdout}`); - } - }, - - async mobileActivateApp(opts) { - const {appId} = requireArgs('appId', opts); - return await this.activateApp(appId); - }, - - async removeApp(appId, opts = {}) { - return await this.adb.uninstallApk(appId, opts); - }, - - async mobileRemoveApp(opts) { - const {appId} = requireArgs('appId', opts); - return await this.removeApp(appId, opts); - }, - - async terminateApp(appId, options = {}) { - this.log.info(`Terminating '${appId}'`); - if (!(await this.adb.processExists(appId))) { - this.log.info(`The app '${appId}' is not running`); - return false; - } - await this.adb.forceStop(appId); - const timeout = - util.hasValue(options.timeout) && !Number.isNaN(options.timeout) - ? parseInt(String(options.timeout), 10) - : 500; - - if (timeout <= 0) { - this.log.info( - `'${appId}' has been terminated. Skip checking the application process state ` + - `since the timeout was set as ${timeout}ms` - ); - return true; - } + if (!app) { + throw new Error( + `Either provide 'app' option to install '${appPackage}' or ` + + `consider setting 'noReset' to 'true' if '${appPackage}' is supposed to be preinstalled.`, + ); + } - try { - await waitForCondition( - async () => (await this.queryAppState(appId)) <= APP_STATE.NOT_RUNNING, - {waitMs: timeout, intervalMs: 100} - ); - } catch (e) { - this.log.errorAndThrow(`'${appId}' is still running after ${timeout}ms timeout`); - } - this.log.info(`'${appId}' has been successfully terminated`); - return true; - }, + this.log.debug(`Running full reset on '${appPackage}' (reinstall)`); + if (isInstalled) { + await this.adb.uninstallApk(appPackage); + } + await this.adb.install(app, { + grantPermissions: autoGrantPermissions, + timeout: androidInstallTimeout, + allowTestPackages, + }); +} + +export async function installApk(opts = null) { + const { + app, + appPackage, + fastReset, + fullReset, + androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, + autoGrantPermissions, + allowTestPackages, + enforceAppInstall, + } = opts ?? this.opts; + + if (!app || !appPackage) { + throw new Error("'app' and 'appPackage' options are required"); + } - async mobileTerminateApp(opts) { - const {appId} = requireArgs('appId', opts); - return await this.terminateApp(appId, opts); - }, + if (fullReset) { + await this.resetApp(opts); + return; + } - async installApp(appPath, opts) { - const localPath = await this.helpers.configureApp(appPath, APP_EXTENSIONS); - await this.adb.install(localPath, opts); - }, + const {appState, wasUninstalled} = await this.adb.installOrUpgrade(app, appPackage, { + grantPermissions: autoGrantPermissions, + timeout: androidInstallTimeout, + allowTestPackages, + enforceCurrentBuild: enforceAppInstall, + }); - async mobileInstallApp(opts) { - const {appPath} = requireArgs('appPath', opts); - return await this.installApp(appPath, opts); - }, + // There is no need to reset the newly installed app + const isInstalledOverExistingApp = + !wasUninstalled && appState !== this.adb.APP_INSTALL_STATE.NOT_INSTALLED; + if (fastReset && isInstalledOverExistingApp) { + this.log.info(`Performing fast reset on '${appPackage}'`); + await this.resetApp(opts); + } +} - async mobileClearApp(opts) { - const {appId} = requireArgs('appId', opts); - await this.adb.clear(appId); - }, -}; +/** + * @this {AndroidDriver} + * @param {string[]} otherApps + * @param {import('../driver').AndroidDriverOpts?} [opts=null] + * @returns {Promise} + */ +export async function installOtherApks(otherApps, opts = null) { + const { + androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, + autoGrantPermissions, + allowTestPackages, + } = opts ?? this.opts; -mixin(AppManagementMixin); + // Install all of the APK's asynchronously + await B.all( + otherApps.map((otherApp) => { + this.log.debug(`Installing app: ${otherApp}`); + return this.adb.installOrUpgrade(otherApp, undefined, { + grantPermissions: autoGrantPermissions, + timeout: androidInstallTimeout, + allowTestPackages, + }); + }), + ); +} -export default AppManagementMixin; +/** + * @this {AndroidDriver} + * @param {string[]} appPackages + * @param {string[]} [filterPackages=[]] + * @returns {Promise} + */ +export async function uninstallOtherPackages(appPackages, filterPackages = []) { + if (appPackages.includes('*')) { + this.log.debug('Uninstall third party packages'); + appPackages = await getThirdPartyPackages.bind(this)(filterPackages); + } + + this.log.debug(`Uninstalling packages: ${appPackages}`); + await B.all(appPackages.map((appPackage) => this.adb.uninstallApk(appPackage))); +} + +/** + * @this {AndroidDriver} + * @param {string[]} [filterPackages=[]] + * @returns {Promise} + */ +export async function getThirdPartyPackages(filterPackages = []) { + try { + const packagesString = await this.adb.shell(['pm', 'list', 'packages', '-3']); + const appPackagesArray = packagesString + .trim() + .replace(/package:/g, '') + .split(EOL); + this.log.debug(`'${appPackagesArray}' filtered with '${filterPackages}'`); + return _.difference(appPackagesArray, filterPackages); + } catch (err) { + this.log.warn(`Unable to get packages with 'adb shell pm list packages -3': ${err.message}`); + return []; + } +} /** - * @typedef {import('appium-adb').ADB} ADB + * @typedef {import('../driver').AndroidDriver} AndroidDriver */ diff --git a/lib/commands/appearance.js b/lib/commands/appearance.js index dc8a2e87..2abeeaff 100644 --- a/lib/commands/appearance.js +++ b/lib/commands/appearance.js @@ -1,43 +1,36 @@ -import {mixin} from './mixins'; import {requireArgs} from '../utils'; const RESPONSE_PATTERN = /:\s+(\w+)/; /** - * @type {import('./mixins').AppearanceMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * Set the Ui appearance. + * + * @since Android 10 + * @this {import('../driver').AndroidDriver} + * @property {import('./types').SetUiModeOpts} + * @returns {Promise} */ -const AppearanceMixin = { - /** - * Set the Ui appearance. - * - * @since Android 10 - */ - async mobileSetUiMode(opts) { - const {mode, value} = requireArgs(['mode', 'value'], opts); - await this.adb.shell(['cmd', 'uimode', mode, value]); - }, +export async function mobileSetUiMode(opts) { + const {mode, value} = requireArgs(['mode', 'value'], opts); + await this.adb.shell(['cmd', 'uimode', mode, value]); +} - /** - * Get the Ui appearance. - * - * @since Android 10 - * @returns {Promise} The actual state for the queried UI mode, - * for example 'yes' or 'no' - */ - async mobileGetUiMode(opts) { - const {mode} = requireArgs(['mode'], opts); - const response = await this.adb.shell(['cmd', 'uimode', mode]); - // response looks like 'Night mode: no' - const match = RESPONSE_PATTERN.exec(response); - if (!match) { - throw new Error(`Cannot parse the command response: ${response}`); - } - return match[1]; - }, - -}; - -export default AppearanceMixin; - -mixin(AppearanceMixin); +/** + * Get the Ui appearance. + * + * @since Android 10 + * @this {import('../driver').AndroidDriver} + * @property {import('./types').GetUiModeOpts} + * @returns {Promise} The actual state for the queried UI mode, + * for example 'yes' or 'no' + */ +export async function mobileGetUiMode(opts) { + const {mode} = requireArgs(['mode'], opts); + const response = await this.adb.shell(['cmd', 'uimode', mode]); + // response looks like 'Night mode: no' + const match = RESPONSE_PATTERN.exec(response); + if (!match) { + throw new Error(`Cannot parse the command response: ${response}`); + } + return match[1]; +} diff --git a/lib/commands/context.js b/lib/commands/context.js deleted file mode 100644 index ce7d1db1..00000000 --- a/lib/commands/context.js +++ /dev/null @@ -1,507 +0,0 @@ -/* eslint-disable require-await */ -// @ts-check -import {util} from '@appium/support'; -import Chromedriver from 'appium-chromedriver'; -import {errors} from 'appium/driver'; -import _ from 'lodash'; -import { - APP_STATE, - CHROMIUM_WIN, - KNOWN_CHROME_PACKAGE_NAMES, - NATIVE_WIN, - WEBVIEW_BASE, - WEBVIEW_WIN, - WebviewHelpers, -} from '../helpers'; -import {mixin} from './mixins'; -import net from 'node:net'; -import {findAPortNotInUse} from 'portscanner'; - -const CHROMEDRIVER_AUTODOWNLOAD_FEATURE = 'chromedriver_autodownload'; - -/** - * @returns {Promise} - */ -async function getFreePort() { - return await new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.listen(0, () => { - const address = srv.address(); - let port; - if (_.has(address, 'port')) { - // @ts-ignore The above condition covers possible errors - port = address.port; - } else { - reject(new Error('Cannot determine any free port number')); - } - srv.close(() => resolve(port)); - }); - }); -} - -/** - * @type {import('./mixins').ContextMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const ContextMixin = { - /* ------------------------------- - * Actual MJSONWP command handlers - * ------------------------------- */ - async getCurrentContext() { - // if the current context is `null`, indicating no context - // explicitly set, it is the default context - return this.curContext || this.defaultContextName(); - }, - - async getContexts() { - const webviewsMapping = await WebviewHelpers.getWebViewsMapping(this.adb, this.opts); - return this.assignContexts(webviewsMapping); - }, - - async setContext(name) { - if (!util.hasValue(name)) { - name = this.defaultContextName(); - } else if (name === WEBVIEW_WIN) { - // handle setContext "WEBVIEW" - name = this.defaultWebviewName(); - } - // if we're already in the context we want, do nothing - if (name === this.curContext) { - return; - } - - const webviewsMapping = await WebviewHelpers.getWebViewsMapping(this.adb, this.opts); - const contexts = this.assignContexts(webviewsMapping); - // if the context we want doesn't exist, fail - if (!_.includes(contexts, name)) { - throw new errors.NoSuchContextError(); - } - - await this.switchContext(name, webviewsMapping); - this.curContext = name; - }, - - async mobileGetContexts(opts = {}) { - const _opts = { - androidDeviceSocket: this.opts.androidDeviceSocket, - ensureWebviewsHavePages: true, - webviewDevtoolsPort: this.opts.webviewDevtoolsPort, - enableWebviewDetailsCollection: true, - waitForWebviewMs: opts.waitForWebviewMs || 0, - }; - return await WebviewHelpers.getWebViewsMapping(/** @type {ADB} */ (this.adb), _opts); - }, - - assignContexts(webviewsMapping) { - const opts = Object.assign({isChromeSession: this.isChromeSession}, this.opts); - const webviews = WebviewHelpers.parseWebviewNames(webviewsMapping, opts); - this.contexts = [NATIVE_WIN, ...webviews]; - this.log.debug(`Available contexts: ${JSON.stringify(this.contexts)}`); - return this.contexts; - }, - - async switchContext(name, webviewsMapping) { - // We have some options when it comes to webviews. If we want a - // Chromedriver webview, we can only control one at a time. - if (this.isChromedriverContext(name)) { - // start proxying commands directly to chromedriver - await this.startChromedriverProxy(name, webviewsMapping); - } else if (this.isChromedriverContext(this.curContext)) { - // if we're moving to a non-chromedriver webview, and our current context - // _is_ a chromedriver webview, if caps recreateChromeDriverSessions is set - // to true then kill chromedriver session using stopChromedriverProxies or - // else simply suspend proxying to the latter - if (this.opts.recreateChromeDriverSessions) { - this.log.debug('recreateChromeDriverSessions set to true; killing existing chromedrivers'); - await this.stopChromedriverProxies(); - } else { - this.suspendChromedriverProxy(); - } - } else { - throw new Error(`Didn't know how to handle switching to context '${name}'`); - } - }, - - /* --------------------------------- - * On-object context-related helpers - * --------------------------------- */ - - // The reason this is a function and not just a constant is that both android- - // driver and selendroid-driver use this logic, and each one returns - // a different default context name - defaultContextName() { - return NATIVE_WIN; - }, - - defaultWebviewName() { - return WEBVIEW_BASE + (this.opts.autoWebviewName || this.opts.appPackage); - }, - - isWebContext() { - return this.curContext !== null && this.curContext !== NATIVE_WIN; - }, - - // Turn on proxying to an existing Chromedriver session or a new one - async startChromedriverProxy(context, webviewsMapping) { - this.log.debug(`Connecting to chrome-backed webview context '${context}'`); - - let cd; - if (this.sessionChromedrivers[context]) { - // in the case where we've already set up a chromedriver for a context, - // we want to reconnect to it, not create a whole new one - this.log.debug(`Found existing Chromedriver for context '${context}'. Using it.`); - cd = this.sessionChromedrivers[context]; - await this.setupExistingChromedriver(this.log, cd); - } else { - // XXX: this suppresses errors about putting arbitrary stuff on opts - const opts = /** @type {any} */ (_.cloneDeep(this.opts)); - opts.chromeUseRunningApp = true; - - // if requested, tell chromedriver to attach to the android package we have - // associated with the context name, rather than the package of the AUT. - // And turn this on by default for chrome--if chrome pops up with a webview - // and someone wants to switch to it, we should let chromedriver connect to - // chrome rather than staying stuck on the AUT - if (opts.extractChromeAndroidPackageFromContextName || context === `${WEBVIEW_BASE}chrome`) { - let androidPackage = context.match(`${WEBVIEW_BASE}(.+)`); - if (androidPackage && androidPackage.length > 0) { - opts.chromeAndroidPackage = androidPackage[1]; - } - if (!opts.extractChromeAndroidPackageFromContextName) { - if ( - _.has(this.opts, 'enableWebviewDetailsCollection') && - !this.opts.enableWebviewDetailsCollection - ) { - // When enableWebviewDetailsCollection capability is explicitly disabled, try to identify - // chromeAndroidPackage based on contexts, known chrome variant packages and queryAppState result - // since webviewsMapping does not have info object - const contexts = webviewsMapping.map((wm) => wm.webviewName); - for (const knownPackage of KNOWN_CHROME_PACKAGE_NAMES) { - if (_.includes(contexts, `${WEBVIEW_BASE}${knownPackage}`)) { - continue; - } - const appState = await this.queryAppState(knownPackage); - if ( - _.includes( - [APP_STATE.RUNNING_IN_BACKGROUND, APP_STATE.RUNNING_IN_FOREGROUND], - appState, - ) - ) { - opts.chromeAndroidPackage = knownPackage; - this.log.debug( - `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + - `for context '${context}' by querying states of Chrome app packages`, - ); - break; - } - } - } else { - for (const wm of webviewsMapping) { - if (wm.webviewName === context && _.has(wm?.info, 'Android-Package')) { - // XXX: should be a type guard here - opts.chromeAndroidPackage = - /** @type {NonNullable} */ (wm.info)[ - 'Android-Package' - ]; - this.log.debug( - `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + - `for context '${context}' by CDP`, - ); - break; - } - } - } - } - } - - cd = await this.setupNewChromedriver( - opts, - /** @type {string} */ (this.adb.curDeviceId), - this.adb, - context, - ); - // bind our stop/exit handler, passing in context so we know which - // one stopped unexpectedly - cd.on(Chromedriver.EVENT_CHANGED, (msg) => { - if (msg.state === Chromedriver.STATE_STOPPED) { - this.onChromedriverStop(context); - } - }); - // save the chromedriver object under the context - this.sessionChromedrivers[context] = cd; - } - // hook up the local variables so we can proxy this biz - this.chromedriver = cd; - this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); - this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( - this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) - ); - this.jwpProxyActive = true; - }, - - // Stop proxying to any Chromedriver - suspendChromedriverProxy() { - this.chromedriver = undefined; - this.proxyReqRes = undefined; - this.proxyCommand = undefined; - this.jwpProxyActive = false; - }, - - // Handle an out-of-band Chromedriver stop event - async onChromedriverStop(context) { - this.log.warn(`Chromedriver for context ${context} stopped unexpectedly`); - if (context === this.curContext) { - // we exited unexpectedly while automating the current context and so want - // to shut down the session and respond with an error - let err = new Error('Chromedriver quit unexpectedly during session'); - await this.startUnexpectedShutdown(err); - } else { - // if a Chromedriver in the non-active context barfs, we don't really - // care, we'll just make a new one next time we need the context. - this.log.warn( - "Chromedriver quit unexpectedly, but it wasn't the active " + 'context, ignoring', - ); - delete this.sessionChromedrivers[context]; - } - }, - - // Intentionally stop all the chromedrivers currently active, and ignore - // their exit events - async stopChromedriverProxies() { - this.suspendChromedriverProxy(); // make sure we turn off the proxy flag - for (let context of _.keys(this.sessionChromedrivers)) { - let cd = this.sessionChromedrivers[context]; - this.log.debug(`Stopping chromedriver for context ${context}`); - // stop listening for the stopped state event - cd.removeAllListeners(Chromedriver.EVENT_CHANGED); - try { - await cd.stop(); - } catch (err) { - this.log.warn(`Error stopping Chromedriver: ${/** @type {Error} */ (err).message}`); - } - delete this.sessionChromedrivers[context]; - } - }, - - isChromedriverContext(viewName) { - return _.includes(viewName, WEBVIEW_WIN) || viewName === CHROMIUM_WIN; - }, - - shouldDismissChromeWelcome() { - return ( - !!this.opts.chromeOptions && - _.isArray(this.opts.chromeOptions.args) && - this.opts.chromeOptions.args.includes('--no-first-run') - ); - }, - - async dismissChromeWelcome() { - this.log.info('Trying to dismiss Chrome welcome'); - let activity = await this.getCurrentActivity(); - if (activity !== 'org.chromium.chrome.browser.firstrun.FirstRunActivity') { - this.log.info('Chrome welcome dialog never showed up! Continuing'); - return; - } - let el = await this.findElOrEls('id', 'com.android.chrome:id/terms_accept', false); - await this.click(/** @type {string} */ (el.ELEMENT)); - try { - let el = await this.findElOrEls('id', 'com.android.chrome:id/negative_button', false); - await this.click(/** @type {string} */ (el.ELEMENT)); - } catch (e) { - // DO NOTHING, THIS DEVICE DIDNT LAUNCH THE SIGNIN DIALOG - // IT MUST BE A NON GMS DEVICE - this.log.warn( - `This device did not show Chrome SignIn dialog, ${/** @type {Error} */ (e).message}`, - ); - } - }, - - async startChromeSession() { - this.log.info('Starting a chrome-based browser session'); - // XXX: this suppresses errors about putting arbitrary stuff on opts - const opts = /** @type {any} */ (_.cloneDeep(this.opts)); - - const knownPackages = [ - 'org.chromium.chrome.shell', - 'com.android.chrome', - 'com.chrome.beta', - 'org.chromium.chrome', - 'org.chromium.webview_shell', - ]; - - if (_.includes(knownPackages, this.opts.appPackage)) { - opts.chromeBundleId = this.opts.appPackage; - } else { - opts.chromeAndroidActivity = this.opts.appActivity; - } - this.chromedriver = await this.setupNewChromedriver( - opts, - /** @type {string} */ (this.adb.curDeviceId), - this.adb, - ); - this.chromedriver.on(Chromedriver.EVENT_CHANGED, (msg) => { - if (msg.state === Chromedriver.STATE_STOPPED) { - this.onChromedriverStop(CHROMIUM_WIN); - } - }); - - // Now that we have a Chrome session, we ensure that the context is - // appropriately set and that this chromedriver is added to the list - // of session chromedrivers so we can switch back and forth - this.curContext = CHROMIUM_WIN; - this.sessionChromedrivers[CHROMIUM_WIN] = this.chromedriver; - this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); - this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( - this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) - ); - this.jwpProxyActive = true; - - if (this.shouldDismissChromeWelcome()) { - // dismiss Chrome welcome dialog - await this.dismissChromeWelcome(); - } - }, - - /* -------------------------- - * Internal library functions - * -------------------------- */ - - async setupExistingChromedriver(log, chromedriver) { - // check the status by sending a simple window-based command to ChromeDriver - // if there is an error, we want to recreate the ChromeDriver session - if (!(await chromedriver.hasWorkingWebview())) { - log.debug('ChromeDriver is not associated with a window. ' + 'Re-initializing the session.'); - await chromedriver.restart(); - } - return chromedriver; - }, - - async getChromedriverPort(portSpec, log) { - // if the user didn't give us any specific information about chromedriver - // port ranges, just find any free port - if (!portSpec) { - const port = await getFreePort(); - log?.debug(`A port was not given, using random free port: ${port}`); - return port; - } - - // otherwise find the free port based on a list or range provided by the user - log?.debug(`Finding a free port for chromedriver using spec ${JSON.stringify(portSpec)}`); - let foundPort = null; - for (const potentialPort of portSpec) { - /** @type {number} */ - let port; - /** @type {number} */ - let stopPort; - if (_.isArray(potentialPort)) { - [port, stopPort] = potentialPort.map((p) => parseInt(String(p), 10)); - } else { - port = parseInt(String(potentialPort), 10); // ensure we have a number and not a string - stopPort = port; - } - log?.debug(`Checking port range ${port}:${stopPort}`); - try { - foundPort = await findAPortNotInUse(port, stopPort); - break; - } catch (e) { - log?.debug(`Nothing in port range ${port}:${stopPort} was available`); - } - } - - if (foundPort === null) { - throw new Error( - `Could not find a free port for chromedriver using ` + - `chromedriverPorts spec ${JSON.stringify(portSpec)}`, - ); - } - - log?.debug(`Using free port ${foundPort} for chromedriver`); - return foundPort; - }, - - isChromedriverAutodownloadEnabled() { - if (this.isFeatureEnabled(CHROMEDRIVER_AUTODOWNLOAD_FEATURE)) { - return true; - } - this?.log?.debug( - `Automated Chromedriver download is disabled. ` + - `Use '${CHROMEDRIVER_AUTODOWNLOAD_FEATURE}' server feature to enable it`, - ); - return false; - }, - - async setupNewChromedriver(opts, curDeviceId, adb, context) { - // @ts-ignore TODO: Remove the legacy - if (opts.chromeDriverPort) { - this?.log?.warn( - `The 'chromeDriverPort' capability is deprecated. Please use 'chromedriverPort' instead`, - ); - // @ts-ignore TODO: Remove the legacy - opts.chromedriverPort = opts.chromeDriverPort; - } - - if (opts.chromedriverPort) { - this?.log?.debug(`Using user-specified port ${opts.chromedriverPort} for chromedriver`); - } else { - // if a single port wasn't given, we'll look for a free one - opts.chromedriverPort = await this.getChromedriverPort(opts.chromedriverPorts, this?.log); - } - - const details = context ? WebviewHelpers.getWebviewDetails(adb, context) : undefined; - if (!_.isEmpty(details)) { - this?.log?.debug( - 'Passing web view details to the Chromedriver constructor: ' + - JSON.stringify(details, null, 2), - ); - } - - const chromedriver = new Chromedriver({ - port: String(opts.chromedriverPort), - executable: opts.chromedriverExecutable, - // eslint-disable-next-line object-shorthand - adb: /** @type {any} */ (adb), - cmdArgs: /** @type {string[]} */ (opts.chromedriverArgs), - verbose: !!opts.showChromedriverLog, - executableDir: opts.chromedriverExecutableDir, - mappingPath: opts.chromedriverChromeMappingFile, - // @ts-expect-error arbitrary value on opts? - bundleId: opts.chromeBundleId, - useSystemExecutable: opts.chromedriverUseSystemExecutable, - disableBuildCheck: opts.chromedriverDisableBuildCheck, - // @ts-expect-error FIXME: chromedriver typing are probably too strict - details, - isAutodownloadEnabled: this?.isChromedriverAutodownloadEnabled?.(), - }); - - // make sure there are chromeOptions - opts.chromeOptions = opts.chromeOptions || {}; - // try out any prefixed chromeOptions, - // and strip the prefix - for (const opt of _.keys(opts)) { - if (opt.endsWith(':chromeOptions')) { - this?.log?.warn( - `Merging '${opt}' into 'chromeOptions'. This may cause unexpected behavior`, - ); - _.merge(opts.chromeOptions, opts[opt]); - } - } - - const caps = /** @type {any} */ ( - WebviewHelpers.createChromedriverCaps(opts, curDeviceId, details) - ); - this?.log?.debug( - `Before starting chromedriver, androidPackage is '${caps.chromeOptions.androidPackage}'`, - ); - await chromedriver.start(caps); - return chromedriver; - }, -}; - -mixin(ContextMixin); - -export default ContextMixin; -export const setupNewChromedriver = ContextMixin.setupNewChromedriver; - -/** - * @typedef {import('appium-adb').ADB} ADB - */ diff --git a/lib/commands/context/cache.js b/lib/commands/context/cache.js new file mode 100644 index 00000000..a2b08b85 --- /dev/null +++ b/lib/commands/context/cache.js @@ -0,0 +1,29 @@ +import {LRUCache} from 'lru-cache'; + +/** @type {LRUCache} */ +export const WEBVIEWS_DETAILS_CACHE = new LRUCache({ + max: 100, + updateAgeOnGet: true, +}); + +/** + * + * @param {import('appium-adb').ADB} adb + * @param {string} webview + * @returns {string} + */ +export function toDetailsCacheKey(adb, webview) { + return `${adb?.curDeviceId}:${webview}`; +} + +/** + * Retrieves web view details previously cached by `getWebviews` call + * + * @param {import('appium-adb').ADB} adb + * @param {string} webview + * @returns {import('../types').WebViewDetails | undefined} + */ +export function getWebviewDetails(adb, webview) { + const key = toDetailsCacheKey(adb, webview); + return WEBVIEWS_DETAILS_CACHE.get(key); +} diff --git a/lib/commands/context/exports.js b/lib/commands/context/exports.js new file mode 100644 index 00000000..0d3e50b2 --- /dev/null +++ b/lib/commands/context/exports.js @@ -0,0 +1,379 @@ +/* eslint-disable require-await */ +import {util} from '@appium/support'; +import Chromedriver from 'appium-chromedriver'; +import {errors} from 'appium/driver'; +import _ from 'lodash'; +import { + CHROMIUM_WIN, + KNOWN_CHROME_PACKAGE_NAMES, + NATIVE_WIN, + WEBVIEW_BASE, + WEBVIEW_WIN, + dismissChromeWelcome, + getWebViewsMapping, + parseWebviewNames, + setupExistingChromedriver, + setupNewChromedriver, + shouldDismissChromeWelcome, +} from './helpers'; +import {APP_STATE} from '../app-management'; + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getCurrentContext() { + // if the current context is `null`, indicating no context + // explicitly set, it is the default context + return this.curContext || this.defaultContextName(); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getContexts() { + const webviewsMapping = await getWebViewsMapping.bind(this)(this.opts); + return this.assignContexts(webviewsMapping); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {string?} name + * @returns {Promise} + */ +export async function setContext(name) { + if (!util.hasValue(name)) { + name = this.defaultContextName(); + } else if (name === WEBVIEW_WIN) { + // handle setContext "WEBVIEW" + name = this.defaultWebviewName(); + } + // if we're already in the context we want, do nothing + if (name === this.curContext) { + return; + } + + const webviewsMapping = await getWebViewsMapping.bind(this)(this.opts); + const contexts = this.assignContexts(webviewsMapping); + // if the context we want doesn't exist, fail + if (!_.includes(contexts, name)) { + throw new errors.NoSuchContextError(); + } + + await this.switchContext(name, webviewsMapping); + this.curContext = name; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {any} [opts={}] + * @returns {Promise} + */ +export async function mobileGetContexts(opts = {}) { + const _opts = { + androidDeviceSocket: this.opts.androidDeviceSocket, + ensureWebviewsHavePages: true, + webviewDevtoolsPort: this.opts.webviewDevtoolsPort, + enableWebviewDetailsCollection: true, + waitForWebviewMs: opts.waitForWebviewMs || 0, + }; + return await getWebViewsMapping.bind(this)(_opts); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').WebviewsMapping[]} webviewsMapping + * @returns {string[]} + */ +export function assignContexts(webviewsMapping) { + const opts = Object.assign({isChromeSession: this.isChromeSession}, this.opts); + const webviews = parseWebviewNames.bind(this)(webviewsMapping, opts); + this.contexts = [NATIVE_WIN, ...webviews]; + this.log.debug(`Available contexts: ${JSON.stringify(this.contexts)}`); + return this.contexts; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {string} name + * @param {import('../types').WebviewsMapping[]} webviewsMapping + * @returns {Promise} + */ +export async function switchContext(name, webviewsMapping) { + // We have some options when it comes to webviews. If we want a + // Chromedriver webview, we can only control one at a time. + if (this.isChromedriverContext(name)) { + // start proxying commands directly to chromedriver + await this.startChromedriverProxy(name, webviewsMapping); + } else if (this.isChromedriverContext(this.curContext)) { + // if we're moving to a non-chromedriver webview, and our current context + // _is_ a chromedriver webview, if caps recreateChromeDriverSessions is set + // to true then kill chromedriver session using stopChromedriverProxies or + // else simply suspend proxying to the latter + if (this.opts.recreateChromeDriverSessions) { + this.log.debug('recreateChromeDriverSessions set to true; killing existing chromedrivers'); + await this.stopChromedriverProxies(); + } else { + this.suspendChromedriverProxy(); + } + } else { + throw new Error(`Didn't know how to handle switching to context '${name}'`); + } +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {string} + */ +export function defaultContextName() { + return NATIVE_WIN; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {string} + */ +export function defaultWebviewName() { + return WEBVIEW_BASE + (this.opts.autoWebviewName || this.opts.appPackage); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {boolean} + */ +export function isWebContext() { + return this.curContext !== null && this.curContext !== NATIVE_WIN; +} + +/** + * Turn on proxying to an existing Chromedriver session or a new one + * + * @this {import('../../driver').AndroidDriver} + * @param {string} context + * @param {import('../types').WebviewsMapping[]} webviewsMapping + * @returns {Promise} + */ +export async function startChromedriverProxy(context, webviewsMapping) { + this.log.debug(`Connecting to chrome-backed webview context '${context}'`); + + let cd; + if (this.sessionChromedrivers[context]) { + // in the case where we've already set up a chromedriver for a context, + // we want to reconnect to it, not create a whole new one + this.log.debug(`Found existing Chromedriver for context '${context}'. Using it.`); + cd = this.sessionChromedrivers[context]; + await setupExistingChromedriver.bind(this)(cd); + } else { + // XXX: this suppresses errors about putting arbitrary stuff on opts + const opts = /** @type {any} */ (_.cloneDeep(this.opts)); + opts.chromeUseRunningApp = true; + + // if requested, tell chromedriver to attach to the android package we have + // associated with the context name, rather than the package of the AUT. + // And turn this on by default for chrome--if chrome pops up with a webview + // and someone wants to switch to it, we should let chromedriver connect to + // chrome rather than staying stuck on the AUT + if (opts.extractChromeAndroidPackageFromContextName || context === `${WEBVIEW_BASE}chrome`) { + let androidPackage = context.match(`${WEBVIEW_BASE}(.+)`); + if (androidPackage && androidPackage.length > 0) { + opts.chromeAndroidPackage = androidPackage[1]; + } + if (!opts.extractChromeAndroidPackageFromContextName) { + if ( + _.has(this.opts, 'enableWebviewDetailsCollection') && + !this.opts.enableWebviewDetailsCollection + ) { + // When enableWebviewDetailsCollection capability is explicitly disabled, try to identify + // chromeAndroidPackage based on contexts, known chrome variant packages and queryAppState result + // since webviewsMapping does not have info object + const contexts = webviewsMapping.map((wm) => wm.webviewName); + for (const knownPackage of KNOWN_CHROME_PACKAGE_NAMES) { + if (_.includes(contexts, `${WEBVIEW_BASE}${knownPackage}`)) { + continue; + } + const appState = await this.queryAppState(knownPackage); + if ( + _.includes( + [APP_STATE.RUNNING_IN_BACKGROUND, APP_STATE.RUNNING_IN_FOREGROUND], + appState, + ) + ) { + opts.chromeAndroidPackage = knownPackage; + this.log.debug( + `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + + `for context '${context}' by querying states of Chrome app packages`, + ); + break; + } + } + } else { + for (const wm of webviewsMapping) { + if (wm.webviewName === context && _.has(wm?.info, 'Android-Package')) { + // XXX: should be a type guard here + opts.chromeAndroidPackage = + /** @type {NonNullable} */ (wm.info)[ + 'Android-Package' + ]; + this.log.debug( + `Identified chromeAndroidPackage as '${opts.chromeAndroidPackage}' ` + + `for context '${context}' by CDP`, + ); + break; + } + } + } + } + } + + cd = await setupNewChromedriver.bind(this)( + opts, + /** @type {string} */ (this.adb.curDeviceId), + context, + ); + // bind our stop/exit handler, passing in context so we know which + // one stopped unexpectedly + cd.on(Chromedriver.EVENT_CHANGED, (msg) => { + if (msg.state === Chromedriver.STATE_STOPPED) { + this.onChromedriverStop(context); + } + }); + // save the chromedriver object under the context + this.sessionChromedrivers[context] = cd; + } + // hook up the local variables so we can proxy this biz + this.chromedriver = cd; + // @ts-ignore chromedriver is defined + this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); + this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( + // @ts-ignore chromedriver is defined + this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) + ); + this.jwpProxyActive = true; +} + +/** + * Stop proxying to any Chromedriver + * + * @this {import('../../driver').AndroidDriver} + * @returns {void} + */ +export function suspendChromedriverProxy() { + this.chromedriver = undefined; + this.proxyReqRes = undefined; + this.proxyCommand = undefined; + this.jwpProxyActive = false; +} + +/** + * Handle an out-of-band Chromedriver stop event + * + * @this {import('../../driver').AndroidDriver} + * @param {string} context + * @returns {Promise} + */ +export async function onChromedriverStop(context) { + this.log.warn(`Chromedriver for context ${context} stopped unexpectedly`); + if (context === this.curContext) { + // we exited unexpectedly while automating the current context and so want + // to shut down the session and respond with an error + let err = new Error('Chromedriver quit unexpectedly during session'); + await this.startUnexpectedShutdown(err); + } else { + // if a Chromedriver in the non-active context barfs, we don't really + // care, we'll just make a new one next time we need the context. + this.log.warn( + "Chromedriver quit unexpectedly, but it wasn't the active " + 'context, ignoring', + ); + delete this.sessionChromedrivers[context]; + } +} + +/** + * Intentionally stop all the chromedrivers currently active, and ignore + * their exit events + * + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function stopChromedriverProxies() { + this.suspendChromedriverProxy(); // make sure we turn off the proxy flag + for (let context of _.keys(this.sessionChromedrivers)) { + let cd = this.sessionChromedrivers[context]; + this.log.debug(`Stopping chromedriver for context ${context}`); + // stop listening for the stopped state event + cd.removeAllListeners(Chromedriver.EVENT_CHANGED); + try { + await cd.stop(); + } catch (err) { + this.log.warn(`Error stopping Chromedriver: ${/** @type {Error} */ (err).message}`); + } + delete this.sessionChromedrivers[context]; + } +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {string} viewName + * @returns {boolean} + */ +export function isChromedriverContext(viewName) { + return _.includes(viewName, WEBVIEW_WIN) || viewName === CHROMIUM_WIN; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function startChromeSession() { + this.log.info('Starting a chrome-based browser session'); + // XXX: this suppresses errors about putting arbitrary stuff on opts + const opts = /** @type {any} */ (_.cloneDeep(this.opts)); + + const knownPackages = [ + 'org.chromium.chrome.shell', + 'com.android.chrome', + 'com.chrome.beta', + 'org.chromium.chrome', + 'org.chromium.webview_shell', + ]; + + if (_.includes(knownPackages, this.opts.appPackage)) { + opts.chromeBundleId = this.opts.appPackage; + } else { + opts.chromeAndroidActivity = this.opts.appActivity; + } + this.chromedriver = await setupNewChromedriver.bind(this)( + opts, + /** @type {string} */ (this.adb.curDeviceId), + ); + // @ts-ignore chromedriver is defined + this.chromedriver.on(Chromedriver.EVENT_CHANGED, (msg) => { + if (msg.state === Chromedriver.STATE_STOPPED) { + this.onChromedriverStop(CHROMIUM_WIN); + } + }); + + // Now that we have a Chrome session, we ensure that the context is + // appropriately set and that this chromedriver is added to the list + // of session chromedrivers so we can switch back and forth + this.curContext = CHROMIUM_WIN; + // @ts-ignore chromedriver is defined + this.sessionChromedrivers[CHROMIUM_WIN] = this.chromedriver; + // @ts-ignore chromedriver should be defined + this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); + this.proxyCommand = /** @type {import('@appium/types').ExternalDriver['proxyCommand']} */ ( + // @ts-ignore chromedriver is defined + this.chromedriver.jwproxy.command.bind(this.chromedriver.jwproxy) + ); + this.jwpProxyActive = true; + + if (shouldDismissChromeWelcome.bind(this)()) { + // dismiss Chrome welcome dialog + await dismissChromeWelcome.bind(this)(); + } +} + +/** + * @typedef {import('appium-adb').ADB} ADB + */ diff --git a/lib/commands/context/helpers.js b/lib/commands/context/helpers.js new file mode 100644 index 00000000..366edf02 --- /dev/null +++ b/lib/commands/context/helpers.js @@ -0,0 +1,802 @@ +import {util, timing} from '@appium/support'; +import _ from 'lodash'; +import axios from 'axios'; +import net from 'node:net'; +import {findAPortNotInUse} from 'portscanner'; +import {sleep} from 'asyncbox'; +import B from 'bluebird'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import Chromedriver from 'appium-chromedriver'; +import {toDetailsCacheKey, getWebviewDetails, WEBVIEWS_DETAILS_CACHE} from './cache'; + +// https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc +export const CHROME_BROWSER_PACKAGE_ACTIVITY = /** @type {const} */ ({ + chrome: { + pkg: 'com.android.chrome', + activity: 'com.google.android.apps.chrome.Main', + }, + chromium: { + pkg: 'org.chromium.chrome.shell', + activity: '.ChromeShellActivity', + }, + chromebeta: { + pkg: 'com.chrome.beta', + activity: 'com.google.android.apps.chrome.Main', + }, + browser: { + pkg: 'com.android.browser', + activity: 'com.android.browser.BrowserActivity', + }, + 'chromium-browser': { + pkg: 'org.chromium.chrome', + activity: 'com.google.android.apps.chrome.Main', + }, + 'chromium-webview': { + pkg: 'org.chromium.webview_shell', + activity: 'org.chromium.webview_shell.WebViewBrowserActivity', + }, + default: { + pkg: 'com.android.chrome', + activity: 'com.google.android.apps.chrome.Main', + }, +}); +export const CHROME_PACKAGE_NAME = 'com.android.chrome'; +export const KNOWN_CHROME_PACKAGE_NAMES = [ + CHROME_PACKAGE_NAME, + 'com.chrome.beta', + 'com.chrome.dev', + 'com.chrome.canary', +]; +const CHROMEDRIVER_AUTODOWNLOAD_FEATURE = 'chromedriver_autodownload'; +const CROSSWALK_SOCKET_PATTERN = /@([\w.]+)_devtools_remote\b/; +const CHROMIUM_DEVTOOLS_SOCKET = 'chrome_devtools_remote'; +export const NATIVE_WIN = 'NATIVE_APP'; +export const WEBVIEW_WIN = 'WEBVIEW'; +export const CHROMIUM_WIN = 'CHROMIUM'; +export const WEBVIEW_BASE = `${WEBVIEW_WIN}_`; +export const DEVTOOLS_SOCKET_PATTERN = /@[\w.]+_devtools_remote_?([\w.]+_)?(\d+)?\b/; +const WEBVIEW_PID_PATTERN = new RegExp(`^${WEBVIEW_BASE}(\\d+)`); +const WEBVIEW_PKG_PATTERN = new RegExp(`^${WEBVIEW_BASE}([^\\d\\s][\\w.]*)`); +const WEBVIEW_WAIT_INTERVAL_MS = 200; +const CDP_REQ_TIMEOUT = 2000; // ms +const DEVTOOLS_PORTS_RANGE = [10900, 11000]; +const DEVTOOLS_PORT_ALLOCATION_GUARD = util.getLockFileGuard( + path.resolve(os.tmpdir(), 'android_devtools_port_guard'), + {timeout: 7, tryRecovery: true}, +); + +/** + * @returns {Promise} + */ +async function getFreePort() { + return await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, () => { + const address = srv.address(); + let port; + if (_.has(address, 'port')) { + // @ts-ignore The above condition covers possible errors + port = address.port; + } else { + reject(new Error('Cannot determine any free port number')); + } + srv.close(() => resolve(port)); + }); + }); +} + +/** + * https://chromedevtools.github.io/devtools-protocol/ + * + * @param {string} host + * @param {number} port + * @param {string} endpoint + * @returns {Promise} + */ +async function cdpGetRequest(host, port, endpoint) { + return (await axios({ + url: `http://${host}:${port}${endpoint}`, + timeout: CDP_REQ_TIMEOUT, + // We need to set this from Node.js v19 onwards. + // Otherwise, in situation with multiple webviews, + // the preceding webview pages will be incorrectly retrieved as the current ones. + // https://nodejs.org/en/blog/announcements/v19-release-announce#https11-keepalive-by-default + httpAgent: new http.Agent({keepAlive: false}), + })).data; +} + +/** + * @param {string} host + * @param {number} port + * @returns {Promise} + */ +async function cdpList(host, port) { + return cdpGetRequest(host, port, '/json/list'); +} + +/** + * @param {string} host + * @param {number} port + * @returns {Promise} + */ +async function cdpInfo(host, port) { + return cdpGetRequest(host, port, '/json/version'); +} + +/** + * + * @param {string} browser + * @returns {import('type-fest').ValueOf} + */ +export function getChromePkg(browser) { + return ( + CHROME_BROWSER_PACKAGE_ACTIVITY[browser.toLowerCase()] || + CHROME_BROWSER_PACKAGE_ACTIVITY.default + ); +} + +/** + * Create Chromedriver capabilities based on the provided + * Appium capabilities + * + * @this {import('../../driver').AndroidDriver} + * @param {any} opts + * @param {string} deviceId + * @param {import('../types').WebViewDetails | null} [webViewDetails] + * @returns {import('@appium/types').StringRecord} + */ +function createChromedriverCaps(opts, deviceId, webViewDetails) { + const caps = {chromeOptions: {}}; + + const androidPackage = + opts.chromeOptions?.androidPackage || + opts.appPackage || + webViewDetails?.info?.['Android-Package']; + if (androidPackage) { + // chromedriver raises an invalid argument error when androidPackage is 'null' + + caps.chromeOptions.androidPackage = androidPackage; + } + if (_.isBoolean(opts.chromeUseRunningApp)) { + caps.chromeOptions.androidUseRunningApp = opts.chromeUseRunningApp; + } + if (opts.chromeAndroidPackage) { + caps.chromeOptions.androidPackage = opts.chromeAndroidPackage; + } + if (opts.chromeAndroidActivity) { + caps.chromeOptions.androidActivity = opts.chromeAndroidActivity; + } + if (opts.chromeAndroidProcess) { + caps.chromeOptions.androidProcess = opts.chromeAndroidProcess; + } else if (webViewDetails?.process?.name && webViewDetails?.process?.id) { + caps.chromeOptions.androidProcess = webViewDetails.process.name; + } + if (_.toLower(opts.browserName) === 'chromium-webview') { + caps.chromeOptions.androidActivity = opts.appActivity; + } + if (opts.pageLoadStrategy) { + caps.pageLoadStrategy = opts.pageLoadStrategy; + } + const isChrome = _.toLower(caps.chromeOptions.androidPackage) === 'chrome'; + if (_.includes(KNOWN_CHROME_PACKAGE_NAMES, caps.chromeOptions.androidPackage) || isChrome) { + // if we have extracted package from context name, it could come in as bare + // "chrome", and so we should make sure the details are correct, including + // not using an activity or process id + if (isChrome) { + caps.chromeOptions.androidPackage = CHROME_PACKAGE_NAME; + } + delete caps.chromeOptions.androidActivity; + delete caps.chromeOptions.androidProcess; + } + // add device id from adb + caps.chromeOptions.androidDeviceSerial = deviceId; + + if (_.isPlainObject(opts.loggingPrefs) || _.isPlainObject(opts.chromeLoggingPrefs)) { + if (opts.loggingPrefs) { + this.log.warn( + `The 'loggingPrefs' cap is deprecated; use the 'chromeLoggingPrefs' cap instead`, + ); + } + caps.loggingPrefs = opts.chromeLoggingPrefs || opts.loggingPrefs; + } + if (opts.enablePerformanceLogging) { + this.log.warn( + `The 'enablePerformanceLogging' cap is deprecated; simply use ` + + `the 'chromeLoggingPrefs' cap instead, with a 'performance' key set to 'ALL'`, + ); + const newPref = {performance: 'ALL'}; + // don't overwrite other logging prefs that have been sent in if they exist + caps.loggingPrefs = caps.loggingPrefs ? Object.assign({}, caps.loggingPrefs, newPref) : newPref; + } + + if (opts.chromeOptions?.Arguments) { + // merge `Arguments` and `args` + opts.chromeOptions.args = [...(opts.chromeOptions.args || []), ...opts.chromeOptions.Arguments]; + delete opts.chromeOptions.Arguments; + } + + this.log.debug( + 'Precalculated Chromedriver capabilities: ' + JSON.stringify(caps.chromeOptions, null, 2), + ); + + /** @type {string[]} */ + const protectedCapNames = []; + for (const [opt, val] of _.toPairs(opts.chromeOptions)) { + if (_.isUndefined(caps.chromeOptions[opt])) { + caps.chromeOptions[opt] = val; + } else { + protectedCapNames.push(opt); + } + } + if (!_.isEmpty(protectedCapNames)) { + this.log.info( + 'The following Chromedriver capabilities cannot be overridden ' + + 'by the provided chromeOptions:', + ); + for (const optName of protectedCapNames) { + this.log.info(` ${optName} (${JSON.stringify(opts.chromeOptions[optName])})`); + } + } + + return caps; +} + +/** + * Parse webview names for getContexts + * + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').WebviewsMapping[]} webviewsMapping + * @param {import('../types').GetWebviewsOpts} options + * @returns {string[]} + */ +export function parseWebviewNames( + webviewsMapping, + {ensureWebviewsHavePages = true, isChromeSession = false} = {}, +) { + if (isChromeSession) { + return [CHROMIUM_WIN]; + } + + /** @type {string[]} */ + const result = []; + for (const {webview, pages, proc, webviewName} of webviewsMapping) { + if (ensureWebviewsHavePages && !pages?.length) { + this.log.info( + `Skipping the webview '${webview}' at '${proc}' ` + + `since it has reported having zero pages`, + ); + continue; + } + if (webviewName) { + result.push(webviewName); + } + } + this.log.debug( + `Found ${util.pluralize('webview', result.length, true)}: ${JSON.stringify(result)}`, + ); + return result; +} + +/** + * Allocates a local port for devtools communication + * + * @this {import('../../driver').AndroidDriver} + * @param {string} socketName - The remote Unix socket name + * @param {number?} [webviewDevtoolsPort=null] - The local port number or null to apply + * autodetection + * @returns {Promise<[string, number]>} The host name and the port number to connect to if the + * remote socket has been forwarded successfully + * @throws {Error} If there was an error while allocating the local port + */ +async function allocateDevtoolsChannel(socketName, webviewDevtoolsPort = null) { + // socket names come with '@', but this should not be a part of the abstract + // remote port, so remove it + const remotePort = socketName.replace(/^@/, ''); + let [startPort, endPort] = DEVTOOLS_PORTS_RANGE; + if (webviewDevtoolsPort) { + endPort = webviewDevtoolsPort + (endPort - startPort); + startPort = webviewDevtoolsPort; + } + this.log.debug( + `Forwarding remote port ${remotePort} to a local ` + `port in range ${startPort}..${endPort}`, + ); + if (!webviewDevtoolsPort) { + this.log.debug( + `You could use the 'webviewDevtoolsPort' capability to customize ` + + `the starting port number`, + ); + } + const port = await DEVTOOLS_PORT_ALLOCATION_GUARD(async () => { + let localPort; + try { + localPort = await findAPortNotInUse(startPort, endPort); + } catch (e) { + throw new Error( + `Cannot find any free port to forward the Devtools socket ` + + `in range ${startPort}..${endPort}. You could set the starting port number ` + + `manually by providing the 'webviewDevtoolsPort' capability`, + ); + } + await this.adb.adbExec(['forward', `tcp:${localPort}`, `localabstract:${remotePort}`]); + return localPort; + }); + return [this.adb.adbHost ?? '127.0.0.1', port]; +} + +/** + * This is a wrapper for Chrome Debugger Protocol data collection. + * No error is thrown if CDP request fails - in such case no data will be + * recorded into the corresponding `webviewsMapping` item. + * + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').WebviewProps[]} webviewsMapping The current webviews mapping + * !!! Each item of this array gets mutated (`info`/`pages` properties get added + * based on the provided `opts`) if the requested details have been + * successfully retrieved for it !!! + * @param {import('../types').DetailCollectionOptions} [opts={}] If both `ensureWebviewsHavePages` and + * `enableWebviewDetailsCollection` properties are falsy then no details collection + * is performed + * @returns {Promise} + */ +async function collectWebviewsDetails(webviewsMapping, opts = {}) { + if (_.isEmpty(webviewsMapping)) { + return; + } + + const { + webviewDevtoolsPort = null, + ensureWebviewsHavePages = null, + enableWebviewDetailsCollection = null, + } = opts; + + if (!ensureWebviewsHavePages) { + this.log.info( + `Not checking whether webviews have active pages; use the ` + + `'ensureWebviewsHavePages' cap to turn this check on`, + ); + } + + if (!enableWebviewDetailsCollection) { + this.log.info( + `Not collecting web view details. Details collection might help ` + + `to make Chromedriver initialization more precise. Use the 'enableWebviewDetailsCollection' ` + + `cap to turn it on`, + ); + } + + if (!ensureWebviewsHavePages && !enableWebviewDetailsCollection) { + return; + } + + // Connect to each devtools socket and retrieve web view details + this.log.debug( + `Collecting CDP data of ${util.pluralize('webview', webviewsMapping.length, true)}`, + ); + const detailCollectors = []; + for (const item of webviewsMapping) { + detailCollectors.push( + (async () => { + let port; + let host; + try { + [host, port] = await allocateDevtoolsChannel.bind(this)(item.proc, webviewDevtoolsPort); + if (enableWebviewDetailsCollection) { + item.info = await cdpInfo(host, port); + } + if (ensureWebviewsHavePages) { + item.pages = await cdpList(host, port); + } + } catch (e) { + this.log.debug(e); + } finally { + if (port) { + try { + await this.adb.removePortForward(port); + } catch (e) { + this.log.debug(e); + } + } + } + })(), + ); + } + await B.all(detailCollectors); + this.log.debug(`CDP data collection completed`); +} + +/** + * Get a list of available webviews mapping by introspecting processes with adb, + * where webviews are listed. It's possible to pass in a 'deviceSocket' arg, which + * limits the webview possibilities to the one running on the Chromium devtools + * socket we're interested in (see note on webviewsFromProcs). We can also + * direct this method to verify whether a particular webview process actually + * has any pages (if a process exists but no pages are found, Chromedriver will + * not actually be able to connect to it, so this serves as a guard for that + * strange failure mode). The strategy for checking whether any pages are + * active involves sending a request to the remote debug server on the device, + * hence it is also possible to specify the port on the host machine which + * should be used for this communication. + * + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GetWebviewsOpts} [opts={}] + * @returns {Promise} + */ +export async function getWebViewsMapping({ + androidDeviceSocket = null, + ensureWebviewsHavePages = true, + webviewDevtoolsPort = null, + enableWebviewDetailsCollection = true, + waitForWebviewMs = 0, +} = {}) { + this.log.debug(`Getting a list of available webviews`); + + if (!_.isNumber(waitForWebviewMs)) { + waitForWebviewMs = parseInt(`${waitForWebviewMs}`, 10) || 0; + } + + /** @type {import('../types').WebviewsMapping[]} */ + let webviewsMapping; + const timer = new timing.Timer().start(); + do { + webviewsMapping = await webviewsFromProcs.bind(this)(androidDeviceSocket); + + if (webviewsMapping.length > 0) { + break; + } + + this.log.debug(`No webviews found in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); + await sleep(WEBVIEW_WAIT_INTERVAL_MS); + } while (timer.getDuration().asMilliSeconds < waitForWebviewMs); + + await collectWebviewsDetails.bind(this)(webviewsMapping, { + ensureWebviewsHavePages, + enableWebviewDetailsCollection, + webviewDevtoolsPort, + }); + + for (const webviewMapping of webviewsMapping) { + const {webview, info} = webviewMapping; + webviewMapping.webviewName = null; + + let wvName = webview; + /** @type {{name: string; id: string | null} | undefined} */ + let process; + if (!androidDeviceSocket) { + const pkgMatch = WEBVIEW_PKG_PATTERN.exec(webview); + try { + // web view name could either be suffixed with PID or the package name + // package names could not start with a digit + const pkg = pkgMatch ? pkgMatch[1] : await procFromWebview.bind(this)(webview); + wvName = `${WEBVIEW_BASE}${pkg}`; + const pidMatch = WEBVIEW_PID_PATTERN.exec(webview); + process = { + name: pkg, + id: pidMatch ? pidMatch[1] : null, + }; + } catch (e) { + this.log.debug(e.stack); + this.log.warn(e.message); + continue; + } + } + + webviewMapping.webviewName = wvName; + const key = toDetailsCacheKey(this.adb, wvName); + if (info || process) { + WEBVIEWS_DETAILS_CACHE.set(key, {info, process}); + } else if (WEBVIEWS_DETAILS_CACHE.has(key)) { + WEBVIEWS_DETAILS_CACHE.delete(key); + } + } + return webviewsMapping; +} + +/** + * Take a webview name like WEBVIEW_4296 and use 'adb shell ps' to figure out + * which app package is associated with that webview. One of the reasons we + * want to do this is to make sure we're listing webviews for the actual AUT, + * not some other running app + * + * @this {import('../../driver').AndroidDriver} + * @param {string} webview + * @returns {Promise} + */ +async function procFromWebview(webview) { + const pidMatch = WEBVIEW_PID_PATTERN.exec(webview); + if (!pidMatch) { + throw new Error(`Could not find PID for webview '${webview}'`); + } + + const pid = pidMatch[1]; + this.log.debug(`${webview} mapped to pid ${pid}`); + this.log.debug(`Getting process name for webview '${webview}'`); + const pkg = await this.adb.getNameByPid(pid); + this.log.debug(`Got process name: '${pkg}'`); + return pkg; +} + +/** + * This function gets a list of android system processes and returns ones + * that look like webviews + * See https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc + * for more details + * + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} a list of matching webview socket names (including the leading '@') + */ +async function getPotentialWebviewProcs() { + const out = await this.adb.shell(['cat', '/proc/net/unix']); + /** @type {string[]} */ + const names = []; + /** @type {string[]} */ + const allMatches = []; + for (const line of out.split('\n')) { + // Num RefCount Protocol Flags Type St Inode Path + const [, , , flags, , st, , sockPath] = line.trim().split(/\s+/); + if (!sockPath) { + continue; + } + if (sockPath.startsWith('@')) { + allMatches.push(line.trim()); + } + if (flags !== '00010000' || st !== '01') { + continue; + } + if (!DEVTOOLS_SOCKET_PATTERN.test(sockPath)) { + continue; + } + + names.push(sockPath); + } + if (_.isEmpty(names)) { + this.log.debug('Found no active devtools sockets'); + if (!_.isEmpty(allMatches)) { + this.log.debug(`Other sockets are: ${JSON.stringify(allMatches, null, 2)}`); + } + } else { + this.log.debug( + `Parsed ${names.length} active devtools ${util.pluralize('socket', names.length, false)}: ` + + JSON.stringify(names), + ); + } + // sometimes the webview process shows up multiple times per app + return _.uniq(names); +} + +/** + * This function retrieves a list of system processes that look like webviews, + * and returns them along with the webview context name appropriate for it. + * If we pass in a deviceSocket, we only attempt to find webviews which match + * that socket name (this is for apps which embed Chromium, which isn't the + * same as chrome-backed webviews). + * + * @this {import('../../driver').AndroidDriver} + * @param {string?} [deviceSocket=null] - the explictly-named device socket to use + * @returns {Promise} + */ +async function webviewsFromProcs(deviceSocket = null) { + const socketNames = await getPotentialWebviewProcs.bind(this)(); + /** @type {{proc: string; webview: string}[]} */ + const webviews = []; + for (const socketName of socketNames) { + if (deviceSocket === CHROMIUM_DEVTOOLS_SOCKET && socketName === `@${deviceSocket}`) { + webviews.push({ + proc: socketName, + webview: CHROMIUM_WIN, + }); + continue; + } + + const socketNameMatch = DEVTOOLS_SOCKET_PATTERN.exec(socketName); + if (!socketNameMatch) { + continue; + } + const matchedSocketName = socketNameMatch[2]; + const crosswalkMatch = CROSSWALK_SOCKET_PATTERN.exec(socketName); + if (!matchedSocketName && !crosswalkMatch) { + continue; + } + + if ((deviceSocket && socketName === `@${deviceSocket}`) || !deviceSocket) { + webviews.push({ + proc: socketName, + webview: matchedSocketName + ? `${WEBVIEW_BASE}${matchedSocketName}` + : // @ts-expect-error: XXX crosswalkMatch can absolutely be null + `${WEBVIEW_BASE}${crosswalkMatch[1]}`, + }); + } + } + return webviews; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').PortSpec} [portSpec] + * @returns {Promise} + */ +async function getChromedriverPort(portSpec) { + // if the user didn't give us any specific information about chromedriver + // port ranges, just find any free port + if (!portSpec) { + const port = await getFreePort(); + this.log.debug(`A port was not given, using random free port: ${port}`); + return port; + } + + // otherwise find the free port based on a list or range provided by the user + this.log.debug(`Finding a free port for chromedriver using spec ${JSON.stringify(portSpec)}`); + let foundPort = null; + for (const potentialPort of portSpec) { + /** @type {number} */ + let port; + /** @type {number} */ + let stopPort; + if (Array.isArray(potentialPort)) { + [port, stopPort] = potentialPort.map((p) => parseInt(String(p), 10)); + } else { + port = parseInt(String(potentialPort), 10); // ensure we have a number and not a string + stopPort = port; + } + this.log.debug(`Checking port range ${port}:${stopPort}`); + try { + foundPort = await findAPortNotInUse(port, stopPort); + break; + } catch (e) { + this.log.debug(`Nothing in port range ${port}:${stopPort} was available`); + } + } + + if (foundPort === null) { + throw new Error( + `Could not find a free port for chromedriver using ` + + `chromedriverPorts spec ${JSON.stringify(portSpec)}`, + ); + } + + this.log.debug(`Using free port ${foundPort} for chromedriver`); + return foundPort; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {boolean} + */ +function isChromedriverAutodownloadEnabled() { + if (this.isFeatureEnabled(CHROMEDRIVER_AUTODOWNLOAD_FEATURE)) { + return true; + } + this.log.debug( + `Automated Chromedriver download is disabled. ` + + `Use '${CHROMEDRIVER_AUTODOWNLOAD_FEATURE}' server feature to enable it`, + ); + return false; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../../driver').AndroidDriverOpts} opts + * @param {string} curDeviceId + * @param {string} [context] + * @returns {Promise} + */ +export async function setupNewChromedriver(opts, curDeviceId, context) { + // @ts-ignore TODO: Remove the legacy + if (opts.chromeDriverPort) { + this.log.warn( + `The 'chromeDriverPort' capability is deprecated. Please use 'chromedriverPort' instead`, + ); + // @ts-ignore TODO: Remove the legacy + opts.chromedriverPort = opts.chromeDriverPort; + } + + if (opts.chromedriverPort) { + this.log.debug(`Using user-specified port ${opts.chromedriverPort} for chromedriver`); + } else { + // if a single port wasn't given, we'll look for a free one + opts.chromedriverPort = await getChromedriverPort.bind(this)(opts.chromedriverPorts); + } + + const details = context ? getWebviewDetails(this.adb, context) : undefined; + if (!_.isEmpty(details)) { + this.log.debug( + 'Passing web view details to the Chromedriver constructor: ' + + JSON.stringify(details, null, 2), + ); + } + + const chromedriver = new Chromedriver({ + port: String(opts.chromedriverPort), + executable: opts.chromedriverExecutable, + // eslint-disable-next-line object-shorthand + adb: /** @type {any} */ (this.adb), + cmdArgs: /** @type {string[]} */ (opts.chromedriverArgs), + verbose: !!opts.showChromedriverLog, + executableDir: opts.chromedriverExecutableDir, + mappingPath: opts.chromedriverChromeMappingFile, + // @ts-ignore this property exists + bundleId: opts.chromeBundleId, + useSystemExecutable: opts.chromedriverUseSystemExecutable, + disableBuildCheck: opts.chromedriverDisableBuildCheck, + // @ts-ignore this is ok + details, + isAutodownloadEnabled: isChromedriverAutodownloadEnabled.bind(this)(), + }); + + // make sure there are chromeOptions + opts.chromeOptions = opts.chromeOptions || {}; + // try out any prefixed chromeOptions, + // and strip the prefix + for (const opt of _.keys(opts)) { + if (opt.endsWith(':chromeOptions')) { + this?.log?.warn(`Merging '${opt}' into 'chromeOptions'. This may cause unexpected behavior`); + _.merge(opts.chromeOptions, opts[opt]); + } + } + + const caps = /** @type {any} */ (createChromedriverCaps.bind(this)(opts, curDeviceId, details)); + this.log.debug( + `Before starting chromedriver, androidPackage is '${caps.chromeOptions.androidPackage}'`, + ); + await chromedriver.start(caps); + return chromedriver; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @template {Chromedriver} T + * @param {T} chromedriver + * @returns {Promise} + */ +export async function setupExistingChromedriver(chromedriver) { + // check the status by sending a simple window-based command to ChromeDriver + // if there is an error, we want to recreate the ChromeDriver session + if (!(await chromedriver.hasWorkingWebview())) { + this.log.debug('ChromeDriver is not associated with a window. Re-initializing the session.'); + await chromedriver.restart(); + } + return chromedriver; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {boolean} + */ +export function shouldDismissChromeWelcome() { + return ( + !!this.opts.chromeOptions && + _.isArray(this.opts.chromeOptions.args) && + this.opts.chromeOptions.args.includes('--no-first-run') + ); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function dismissChromeWelcome() { + this.log.info('Trying to dismiss Chrome welcome'); + let activity = await this.getCurrentActivity(); + if (activity !== 'org.chromium.chrome.browser.firstrun.FirstRunActivity') { + this.log.info('Chrome welcome dialog never showed up! Continuing'); + return; + } + let el = await this.findElOrEls('id', 'com.android.chrome:id/terms_accept', false); + await this.click(/** @type {string} */ (el.ELEMENT)); + try { + let el = await this.findElOrEls('id', 'com.android.chrome:id/negative_button', false); + await this.click(/** @type {string} */ (el.ELEMENT)); + } catch (e) { + // DO NOTHING, THIS DEVICE DIDNT LAUNCH THE SIGNIN DIALOG + // IT MUST BE A NON GMS DEVICE + this.log.warn( + `This device did not show Chrome SignIn dialog, ${/** @type {Error} */ (e).message}`, + ); + } +} + +/** + * @typedef {import('appium-adb').ADB} ADB + */ diff --git a/lib/commands/device/common.js b/lib/commands/device/common.js new file mode 100644 index 00000000..8c9dc974 --- /dev/null +++ b/lib/commands/device/common.js @@ -0,0 +1,264 @@ +import semver from 'semver'; +import _ from 'lodash'; +import {resetMockLocation, setMockLocationApp} from '../geolocation'; +import {SETTINGS_HELPER_ID} from 'io.appium.settings'; +import {hideKeyboardCompletely, initUnicodeKeyboard} from '../keyboard'; +import { + createBaseADB, + prepareEmulator, + validatePackageActivityNames, + pushSettingsApp, +} from './utils'; + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function getDeviceInfoFromCaps() { + // we can create a throwaway ADB instance here, so there is no dependency + // on instantiating on earlier (at this point, we have no udid) + // we can only use this ADB object for commands that would not be confused + // if multiple devices are connected + /** @type {import('appium-adb').ADB} */ + const adb = this.adb ?? (await createBaseADB(this.opts)); + let udid = this.opts.udid; + let emPort; + + // a specific avd name was given. try to initialize with that + if (this.opts?.avd) { + await prepareEmulator.bind(this)(); + udid = adb.curDeviceId; + emPort = adb.emulatorPort; + } else { + // no avd given. lets try whatever's plugged in devices/emulators + this.log.info('Retrieving device list'); + const devices = await adb.getDevicesWithRetry(); + + // udid was given, lets try to init with that device + if (udid) { + if (!_.includes(_.map(devices, 'udid'), udid)) { + throw this.log.errorAndThrow(`Device ${udid} was not in the list of connected devices`); + } + emPort = adb.getPortFromEmulatorString(udid); + } else if (this.opts.platformVersion) { + this.opts.platformVersion = `${this.opts.platformVersion}`.trim(); + + // a platform version was given. lets try to find a device with the same os + const platformVersion = semver.coerce(this.opts.platformVersion) || this.opts.platformVersion; + this.log.info(`Looking for a device with Android '${platformVersion}'`); + + // in case we fail to find something, give the user a useful log that has + // the device udids and os versions so they know what's available + const availDevices = []; + let partialMatchCandidate; + // first try started devices/emulators + for (const device of devices) { + // direct adb calls to the specific device + adb.setDeviceId(device.udid); + /** @type {string} */ + const rawDeviceOS = await adb.getPlatformVersion(); + // The device OS could either be a number, like `6.0` + // or an abbreviation, like `R` + availDevices.push(`${device.udid} (${rawDeviceOS})`); + const deviceOS = semver.coerce(rawDeviceOS) || rawDeviceOS; + if (!deviceOS) { + continue; + } + + const semverPV = platformVersion; + const semverDO = deviceOS; + + const bothVersionsCanBeCoerced = semver.valid(deviceOS) && semver.valid(platformVersion); + const bothVersionsAreStrings = _.isString(deviceOS) && _.isString(platformVersion); + if ( + (bothVersionsCanBeCoerced && + /** @type {semver.SemVer} */ (semverDO).version === + /** @type {semver.SemVer} */ (semverPV).version) || + (bothVersionsAreStrings && _.toLower(deviceOS) === _.toLower(platformVersion)) + ) { + // Got an exact match - proceed immediately + udid = device.udid; + break; + } else if (!bothVersionsCanBeCoerced) { + // There is no point to check for partial match if either of version numbers is not coercible + continue; + } + + if ( + ((!_.includes(this.opts.platformVersion, '.') && + /** @type {semver.SemVer} */ (semverPV).major === + /** @type {semver.SemVer} */ (semverDO).major) || + (/** @type {semver.SemVer} */ (semverPV).major === + /** @type {semver.SemVer} */ (semverDO).major && + /** @type {semver.SemVer} */ (semverPV).minor === + /** @type {semver.SemVer} */ (semverDO).minor)) && + // Got a partial match - make sure we consider the most recent + // device version available on the host system + ((partialMatchCandidate && semver.gt(deviceOS, _.values(partialMatchCandidate)[0])) || + !partialMatchCandidate) + ) { + partialMatchCandidate = {[device.udid]: deviceOS}; + } + } + if (!udid && partialMatchCandidate) { + udid = _.keys(partialMatchCandidate)[0]; + adb.setDeviceId(udid); + } + + if (!udid) { + // we couldn't find anything! quit + throw this.log.errorAndThrow( + `Unable to find an active device or emulator ` + + `with OS ${this.opts.platformVersion}. The following are available: ` + + availDevices.join(', '), + ); + } + + emPort = adb.getPortFromEmulatorString(udid); + } else { + // a udid was not given, grab the first device we see + udid = devices[0].udid; + emPort = adb.getPortFromEmulatorString(udid); + } + } + + this.log.info(`Using device: ${udid}`); + return {udid: String(udid), emPort: emPort ?? false}; +} + +/** + * @this {AndroidDriver} + * @property {(AndroidDriverOpts & {emPort?: number})?} [opts=null] + * @returns {Promise} + */ +export async function createADB(opts = null) { + // @ts-expect-error do not put arbitrary properties on opts + const {udid, emPort} = opts ?? {}; + const adb = await createBaseADB(opts); + adb.setDeviceId(udid ?? ''); + if (emPort) { + adb.setEmulatorPort(emPort); + } + return adb; +} + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function getLaunchInfo() { + if (!this.opts.app) { + this.log.warn('No app sent in, not parsing package/activity'); + return; + } + let {appPackage, appActivity, appWaitPackage, appWaitActivity} = this.opts; + const {app} = this.opts; + + validatePackageActivityNames.bind(this)(); + + if (appPackage && appActivity) { + return; + } + + this.log.debug('Parsing package and activity from app manifest'); + const {apkPackage, apkActivity} = await this.adb.packageAndLaunchActivityFromManifest(app); + if (apkPackage && !appPackage) { + appPackage = apkPackage; + } + if (!appWaitPackage) { + appWaitPackage = appPackage; + } + if (apkActivity && !appActivity) { + appActivity = apkActivity; + } + if (!appWaitActivity) { + appWaitActivity = appActivity; + } + this.log.debug(`Parsed package and activity are: ${apkPackage}/${apkActivity}`); + return {appPackage, appWaitPackage, appActivity, appWaitActivity}; +} + +/** + * @this {AndroidDriver} + * @returns + */ +export async function initDevice() { + const { + skipDeviceInitialization, + locale, + language, + localeScript, + unicodeKeyboard, + hideKeyboard, + disableWindowAnimation, + skipUnlock, + mockLocationApp, + skipLogcatCapture, + logcatFormat, + logcatFilterSpecs, + } = this.opts; + + if (skipDeviceInitialization) { + this.log.info(`'skipDeviceInitialization' is set. Skipping device initialization.`); + } else { + if (this.isEmulator()) { + // Check if the device wake up only for an emulator. + // It takes 1 second or so even when the device is already awake in a real device. + await this.adb.waitForDevice(); + } + // pushSettingsApp required before calling ensureDeviceLocale for API Level 24+ + + // Some feature such as location/wifi are not necessary for all users, + // but they require the settings app. So, try to configure it while Appium + // does not throw error even if they fail. + const shouldThrowError = Boolean( + language || + locale || + localeScript || + unicodeKeyboard || + hideKeyboard || + disableWindowAnimation || + !skipUnlock, + ); + await pushSettingsApp.bind(this)(shouldThrowError); + } + + if (!this.isEmulator()) { + if (mockLocationApp || _.isUndefined(mockLocationApp)) { + await setMockLocationApp.bind(this)(mockLocationApp || SETTINGS_HELPER_ID); + } else { + await resetMockLocation.bind(this)(); + } + } + + if (language && locale) { + await this.ensureDeviceLocale(language, locale, localeScript); + } + + if (skipLogcatCapture) { + this.log.info(`'skipLogcatCapture' is set. Skipping starting logcat capture.`); + } else { + await this.adb.startLogcat({ + format: logcatFormat, + filterSpecs: logcatFilterSpecs, + }); + } + + if (hideKeyboard) { + await hideKeyboardCompletely.bind(this)(); + } else if (hideKeyboard === false) { + await this.adb.shell(['ime', 'reset']); + } + + if (unicodeKeyboard) { + this.log.warn( + `The 'unicodeKeyboard' capability has been deprecated and will be removed. ` + + `Set the 'hideKeyboard' capability to 'true' in order to make the on-screen keyboard invisible.`, + ); + return await initUnicodeKeyboard.bind(this)(); + } +} + +/** + * @typedef {import('../../driver').AndroidDriver} AndroidDriver + */ diff --git a/lib/commands/device/emulator-actions.js b/lib/commands/device/emulator-actions.js new file mode 100644 index 00000000..97067276 --- /dev/null +++ b/lib/commands/device/emulator-actions.js @@ -0,0 +1,194 @@ +import {util} from '@appium/support'; +import {requireArgs} from '../../utils'; +import {requireEmulator} from './utils'; + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {string|number} fingerprintId + * @returns {Promise} + */ +export async function fingerprint(fingerprintId) { + requireEmulator.bind(this)('fingerprint is only available for emulators'); + await this.adb.fingerprint(String(fingerprintId)); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').FingerprintOpts} opts + * @returns {Promise} + */ +export async function mobileFingerprint(opts) { + const {fingerprintId} = requireArgs('fingerprintId', opts); + await this.fingerprint(fingerprintId); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {string} phoneNumber + * @param {string} message + * @returns {Promise} + */ +export async function sendSMS(phoneNumber, message) { + requireEmulator.bind(this)('sendSMS is only available for emulators'); + await this.adb.sendSMS(phoneNumber, message); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').SendSMSOpts} opts + * @returns {Promise} + */ +export async function mobileSendSms(opts) { + const {phoneNumber, message} = requireArgs(['phoneNumber', 'message'], opts); + await this.sendSMS(phoneNumber, message); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {string} phoneNumber + * @param {string} action + * @returns {Promise} + */ +export async function gsmCall(phoneNumber, action) { + requireEmulator.bind(this)('gsmCall is only available for emulators'); + await this.adb.gsmCall(phoneNumber, /** @type {any} */ (action)); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GsmCallOpts} opts + * @returns {Promise} + */ +export async function mobileGsmCall(opts) { + const {phoneNumber, action} = requireArgs(['phoneNumber', 'action'], opts); + await this.gsmCall(phoneNumber, action); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GsmSignalStrength} signalStrengh + * @returns {Promise} + */ +export async function gsmSignal(signalStrengh) { + requireEmulator.bind(this)('gsmSignal is only available for emulators'); + await this.adb.gsmSignal(signalStrengh); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GsmSignalStrengthOpts} opts + * @returns {Promise} + */ +export async function mobileGsmSignal(opts) { + const {strength} = requireArgs('strength', opts); + await this.gsmSignal(strength); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GsmVoiceState} state + * @returns {Promise} + */ +export async function gsmVoice(state) { + requireEmulator.bind(this)('gsmVoice is only available for emulators'); + await this.adb.gsmVoice(state); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').GsmVoiceOpts} opts + * @returns {Promise} + */ +export async function mobileGsmVoice(opts) { + const {state} = requireArgs('state', opts); + await this.gsmVoice(state); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').PowerACState} state + * @returns {Promise} + */ +export async function powerAC(state) { + requireEmulator.bind(this)('powerAC is only available for emulators'); + await this.adb.powerAC(state); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').PowerACOpts} opts + * @returns {Promise} + */ +export async function mobilePowerAc(opts) { + const {state} = requireArgs('state', opts); + await this.powerAC(state); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {number} batteryPercent + * @returns {Promise} + */ +export async function powerCapacity(batteryPercent) { + requireEmulator.bind(this)('powerCapacity is only available for emulators'); + await this.adb.powerCapacity(batteryPercent); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').PowerCapacityOpts} opts + * @return {Promise} + */ +export async function mobilePowerCapacity(opts) { + const {percent} = requireArgs('percent', opts); + await this.powerCapacity(percent); +} + +/** + * @deprecated Use mobile: extension + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').NetworkSpeed} networkSpeed + * @returns {Promise} + */ +export async function networkSpeed(networkSpeed) { + requireEmulator.bind(this)('networkSpeed is only available for emulators'); + await this.adb.networkSpeed(networkSpeed); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').NetworkSpeedOpts} opts + * @returns {Promise} + */ +export async function mobileNetworkSpeed(opts) { + const {speed} = requireArgs('speed', opts); + await this.networkSpeed(speed); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').SensorSetOpts} opts + * @returns {Promise} + */ +export async function sensorSet(opts) { + requireEmulator.bind(this)('sensorSet is only available for emulators'); + const {sensorType, value} = opts; + if (!util.hasValue(sensorType)) { + this.log.errorAndThrow(`'sensorType' argument is required`); + } + if (!util.hasValue(value)) { + this.log.errorAndThrow(`'value' argument is required`); + } + await this.adb.sensorSet(sensorType, /** @type {any} */ (value)); +} + +/** + * @typedef {import('appium-adb').ADB} ADB + */ diff --git a/lib/commands/device/emulator-console.js b/lib/commands/device/emulator-console.js new file mode 100644 index 00000000..79d0d085 --- /dev/null +++ b/lib/commands/device/emulator-console.js @@ -0,0 +1,24 @@ +import {errors} from 'appium/driver'; + +const EMU_CONSOLE_FEATURE = 'emulator_console'; + +/** + * @this {import('../../driver').AndroidDriver} + * @param {import('../types').ExecOptions} opts + * @returns {Promise} + */ +export async function mobileExecEmuConsoleCommand(opts) { + this.ensureFeatureEnabled(EMU_CONSOLE_FEATURE); + + const {command, execTimeout, connTimeout, initTimeout} = opts; + + if (!command) { + throw new errors.InvalidArgumentError(`The 'command' argument is mandatory`); + } + + return await /** @type {import('appium-adb').ADB} */ (this.adb).execEmuConsoleCommand(command, { + execTimeout, + connTimeout, + initTimeout, + }); +} diff --git a/lib/commands/device/utils.js b/lib/commands/device/utils.js new file mode 100644 index 00000000..811716d7 --- /dev/null +++ b/lib/commands/device/utils.js @@ -0,0 +1,285 @@ +import _ from 'lodash'; +import {util} from '@appium/support'; +import ADB from 'appium-adb'; +import {retryInterval} from 'asyncbox'; +import { + path as SETTINGS_APK_PATH, + SETTINGS_HELPER_ID, + UNICODE_IME, + EMPTY_IME, +} from 'io.appium.settings'; +import B from 'bluebird'; + +const HELPER_APP_INSTALL_RETRIES = 3; +const HELPER_APP_INSTALL_RETRY_DELAY_MS = 5000; + +/** + * @this {import('../../driver').AndroidDriver} + * @param {string} errMsg + */ +export function requireEmulator(errMsg) { + if (!this.isEmulator()) { + this.log.errorAndThrow(errMsg); + } +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {void} + */ +export function validatePackageActivityNames() { + for (const key of ['appPackage', 'appActivity', 'appWaitPackage', 'appWaitActivity']) { + const name = this.opts[key]; + if (!name) { + continue; + } + + const match = /([^\w.*,])+/.exec(String(name)); + if (!match) { + continue; + } + + this.log.warn( + `Capability '${key}' is expected to only include latin letters, digits, underscore, dot, comma and asterisk characters.`, + ); + this.log.warn( + `Current value '${name}' has non-matching character at index ${match.index}: '${String( + name, + ).substring(0, match.index + 1)}'`, + ); + } +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {string} networkSpeed + * @returns {string} + */ +export function ensureNetworkSpeed(networkSpeed) { + if (networkSpeed.toUpperCase() in this.adb.NETWORK_SPEED) { + return networkSpeed; + } + this.log.warn( + `Wrong network speed param '${networkSpeed}', using default: ${this.adb.NETWORK_SPEED.FULL}. ` + + `Supported values: ${_.values(this.adb.NETWORK_SPEED)}`, + ); + return this.adb.NETWORK_SPEED.FULL; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {string[]} + */ +export function prepareAvdArgs() { + const {networkSpeed, isHeadless, avdArgs} = this.opts; + const result = []; + if (avdArgs) { + if (_.isArray(avdArgs)) { + result.push(...avdArgs); + } else { + result.push(...util.shellParse(`${avdArgs}`)); + } + } + if (networkSpeed) { + result.push('-netspeed', ensureNetworkSpeed.bind(this)(networkSpeed)); + } + if (isHeadless) { + result.push('-no-window'); + } + return result; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function prepareEmulator() { + const { + avd, + avdEnv: env, + language, + locale: country, + avdLaunchTimeout: launchTimeout, + avdReadyTimeout: readyTimeout, + } = this.opts; + if (!avd) { + throw new Error('Cannot launch AVD without AVD name'); + } + + const avdName = avd.replace('@', ''); + let isEmulatorRunning = true; + try { + await this.adb.getRunningAVDWithRetry(avdName, 5000); + } catch (e) { + this.log.debug(`Emulator '${avdName}' is not running: ${e.message}`); + isEmulatorRunning = false; + } + const args = prepareAvdArgs.bind(this)(); + if (isEmulatorRunning) { + if (args.includes('-wipe-data')) { + this.log.debug(`Killing '${avdName}' because it needs to be wiped at start.`); + await this.adb.killEmulator(avdName); + } else { + this.log.debug('Not launching AVD because it is already running.'); + return; + } + } + await this.adb.launchAVD(avd, { + args, + env, + language, + country, + launchTimeout, + readyTimeout, + }); +} + +/** + * @param {import('../../driver').AndroidDriverOpts?} [opts=null] + * @returns {Promise} + */ +export async function createBaseADB(opts = null) { + // filter out any unwanted options sent in + // this list should be updated as ADB takes more arguments + const { + adbPort, + suppressKillServer, + remoteAdbHost, + clearDeviceLogsOnStart, + adbExecTimeout, + useKeystore, + keystorePath, + keystorePassword, + keyAlias, + keyPassword, + remoteAppsCacheLimit, + buildToolsVersion, + allowOfflineDevices, + allowDelayAdb, + } = opts ?? {}; + return await ADB.createADB({ + adbPort, + suppressKillServer, + remoteAdbHost, + clearDeviceLogsOnStart, + adbExecTimeout, + useKeystore, + keystorePath, + keystorePassword, + keyAlias, + keyPassword, + remoteAppsCacheLimit, + buildToolsVersion, + allowOfflineDevices, + allowDelayAdb, + }); +} + +/** + * @this {import('../../driver').AndroidDriver} + * @param {boolean} throwIfError + * @returns {Promise} + */ +export async function pushSettingsApp(throwIfError) { + this.log.debug('Pushing settings apk to the device...'); + + try { + // Sometimes adb push or adb instal take more time than expected to install an app + // e.g. https://github.com/appium/io.appium.settings/issues/40#issuecomment-476593174 + await retryInterval( + HELPER_APP_INSTALL_RETRIES, + HELPER_APP_INSTALL_RETRY_DELAY_MS, + async () => + await this.adb.installOrUpgrade(SETTINGS_APK_PATH, SETTINGS_HELPER_ID, { + grantPermissions: true, + }), + ); + } catch (err) { + if (throwIfError) { + throw err; + } + + this.log.warn( + `Ignored error while installing '${SETTINGS_APK_PATH}': ` + + `'${err.message}'. Features that rely on this helper ` + + 'require the apk such as toggle WiFi and getting location ' + + 'will raise an error if you try to use them.', + ); + } + + // Reinstall would stop the settings helper process anyway, so + // there is no need to continue if the application is still running + if (await this.settingsApp.isRunningInForeground()) { + this.log.debug( + `${SETTINGS_HELPER_ID} is already running. ` + `There is no need to reset its permissions.`, + ); + return; + } + + const fixSettingsAppPermissionsForLegacyApis = async () => { + if ((await this.adb.getApiLevel()) > 23) { + return; + } + + // Android 6- devices should have granted permissions + // https://github.com/appium/appium/pull/11640#issuecomment-438260477 + const perms = ['SET_ANIMATION_SCALE', 'CHANGE_CONFIGURATION', 'ACCESS_FINE_LOCATION']; + this.log.info(`Granting permissions ${perms} to '${SETTINGS_HELPER_ID}'`); + await this.adb.grantPermissions( + SETTINGS_HELPER_ID, + perms.map((x) => `android.permission.${x}`), + ); + }; + + try { + await B.all([ + this.settingsApp.adjustNotificationsPermissions(), + this.settingsApp.adjustMediaProjectionServicePermissions(), + fixSettingsAppPermissionsForLegacyApis(), + ]); + } catch (e) { + this.log.debug(e.stack); + } + + // launch io.appium.settings app due to settings failing to be set + // if the app is not launched prior to start the session on android 7+ + // see https://github.com/appium/appium/issues/8957 + try { + await this.settingsApp.requireRunning({ + timeout: this.isEmulator() ? 30000 : 5000, + }); + } catch (err) { + this.log.debug(err.stack); + if (throwIfError) { + throw err; + } + } +} + +/** + * @deprecated + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function initUnicodeKeyboard() { + this.log.debug('Enabling Unicode keyboard support'); + + // get the default IME so we can return back to it later if we want + const defaultIME = await this.adb.defaultIME(); + + this.log.debug(`Unsetting previous IME ${defaultIME}`); + this.log.debug(`Setting IME to '${UNICODE_IME}'`); + await this.adb.enableIME(UNICODE_IME); + await this.adb.setIME(UNICODE_IME); + return defaultIME; +} + +/** + * @this {import('../../driver').AndroidDriver} + * @returns {Promise} + */ +export async function hideKeyboardCompletely() { + this.log.debug(`Hiding the on-screen keyboard by setting IME to '${EMPTY_IME}'`); + await this.adb.enableIME(EMPTY_IME); + await this.adb.setIME(EMPTY_IME); +} diff --git a/lib/commands/deviceidle.js b/lib/commands/deviceidle.js index fa3c6cb7..e0cffabb 100644 --- a/lib/commands/deviceidle.js +++ b/lib/commands/deviceidle.js @@ -1,56 +1,43 @@ import {errors} from 'appium/driver'; import _ from 'lodash'; -import {mixin} from './mixins'; const SUPPORTED_ACTIONS = ['whitelistAdd', 'whitelistRemove']; /** - * @type {import('./mixins').DeviceidleMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * This is a wrapper to 'adb shell dumpsys deviceidle' interface. + * Read https://www.protechtraining.com/blog/post/diving-into-android-m-doze-875 + * for more details. + * + * @param {import('./types').DeviceidleOpts} opts + * @returns {Promise} */ -const DeviceidleMixin = { - /** - * This is a wrapper to 'adb shell dumpsys deviceidle' interface. - * Read https://www.protechtraining.com/blog/post/diving-into-android-m-doze-875 - * for more details. - * - * @param {import('./types').DeviceidleOpts} opts - */ - async mobileDeviceidle(opts) { - const { - action, - packages, - } = opts; +export async function mobileDeviceidle(opts) { + const {action, packages} = opts; - if (!(_.isString(packages) || _.isArray(packages))) { - throw new errors.InvalidArgumentError(`packages argument must be a string or an array`); - } + if (!(_.isString(packages) || _.isArray(packages))) { + throw new errors.InvalidArgumentError(`packages argument must be a string or an array`); + } - /** @type {string[]} */ - const packagesArr = _.isArray(packages) ? packages : [packages]; - /** @type {string[]} */ - const commonArgs = ['dumpsys', 'deviceidle', 'whitelist']; - /** @type {(x: string) => string[]} */ - let argsGenerator; - switch (action) { - case SUPPORTED_ACTIONS[0]: - argsGenerator = (pkg) => [...commonArgs, `+${pkg}`]; - break; - case SUPPORTED_ACTIONS[1]: - argsGenerator = (pkg) => [...commonArgs, `-${pkg}`]; - break; - default: - throw new errors.InvalidArgumentError( - `action must be one of ${JSON.stringify(SUPPORTED_ACTIONS)}. Got '${action}' instead` - ); - } - await (this.adb).shellChunks(argsGenerator, packagesArr); - }, -}; - -mixin(DeviceidleMixin); - -export default DeviceidleMixin; + /** @type {string[]} */ + const packagesArr = _.isArray(packages) ? packages : [packages]; + /** @type {string[]} */ + const commonArgs = ['dumpsys', 'deviceidle', 'whitelist']; + /** @type {(x: string) => string[]} */ + let argsGenerator; + switch (action) { + case SUPPORTED_ACTIONS[0]: + argsGenerator = (pkg) => [...commonArgs, `+${pkg}`]; + break; + case SUPPORTED_ACTIONS[1]: + argsGenerator = (pkg) => [...commonArgs, `-${pkg}`]; + break; + default: + throw new errors.InvalidArgumentError( + `action must be one of ${JSON.stringify(SUPPORTED_ACTIONS)}. Got '${action}' instead`, + ); + } + await this.adb.shellChunks(argsGenerator, packagesArr); +} /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/element.js b/lib/commands/element.js index 9b8ace5a..e86412ef 100644 --- a/lib/commands/element.js +++ b/lib/commands/element.js @@ -1,151 +1,158 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import {mixin} from './mixins'; -import {retryInterval} from 'asyncbox'; import {errors} from 'appium/driver'; /** - * @type {import('./mixins').ElementMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @param {string} attribute + * @param {string} elementId + * @returns {Promise} */ -const ElementMixin = { - async getAttribute(attribute, elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getName(elementId) { - return await this.getAttribute('className', elementId); - }, - - async elementDisplayed(elementId) { - return (await this.getAttribute('displayed', elementId)) === 'true'; - }, - - async elementEnabled(elementId) { - return (await this.getAttribute('enabled', elementId)) === 'true'; - }, - - async elementSelected(elementId) { - return (await this.getAttribute('selected', elementId)) === 'true'; - }, - - async setElementValue(keys, elementId, replace = false) { - const text = keys instanceof Array ? keys.join('') : keys; - return await this.doSetElementValue({ - elementId, - text: String(text), - replace, - }); - }, - - /** - * Reason for isolating doSetElementValue from setElementValue is for reusing setElementValue - * across android-drivers (like appium-uiautomator2-driver) and to avoid code duplication. - * Other android-drivers (like appium-uiautomator2-driver) need to override doSetElementValue - * to facilitate setElementValue. - */ - async doSetElementValue(params) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async setValue(keys, elementId) { - return await this.setElementValue(keys, elementId, false); - }, - - async replaceValue(keys, elementId) { - return await this.setElementValue(keys, elementId, true); - }, - - async setValueImmediate(keys, elementId) { - let text = keys; - if (keys instanceof Array) { - text = keys.join(''); - } - - // first, make sure we are focused on the element - await this.click(elementId); - - // then send through adb - await this.adb.inputText(/** @type {string} */ (text)); - }, - - async getText(elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async clear(elementId) { - let text = (await this.getText(elementId)) || ''; - let length = text.length; - if (length === 0) { - // if length is zero there are two possibilities: - // 1. there is nothing in the text field - // 2. it is a password field - // since there is little overhead to the adb call, delete 100 elements - // if we get zero, just in case it is #2 - length = 100; - } - await this.click(elementId); - this.log.debug(`Sending up to ${length} clear characters to device`); - await retryInterval(5, 500, async () => { - let remainingLength = length; - while (remainingLength > 0) { - let lengthToSend = remainingLength < 50 ? remainingLength : 50; - this.log.debug(`Sending ${lengthToSend} clear characters to device`); - await this.adb.clearTextField(lengthToSend); - remainingLength -= lengthToSend; - } - }); - }, - - async click(elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getLocation(elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getLocationInView(elementId) { - return await this.getLocation(elementId); - }, - - async getSize(elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getElementRect(elementId) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async touchLongClick(elementId, x, y, duration) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async touchDown(elementId, x, y) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async touchUp(elementId, x, y) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async touchMove(elementId, x, y) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async complexTap(tapCount, touchCount, duration, x, y) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async tap(elementId = null, x = null, y = null, count = 1) { - throw new errors.NotImplementedError('Not implemented'); - }, -}; - -mixin(ElementMixin); - -export default ElementMixin; +export async function getAttribute(attribute, elementId) { + throw new errors.NotImplementedError('Not implemented'); +} /** - * @typedef {import('appium-adb').ADB} ADB + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} */ +export async function click(elementId) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function getText(elementId) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function getLocation(elementId) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function getSize(elementId) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function getName(elementId) { + return /** @type {string} */ (await this.getAttribute('className', elementId)); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function elementDisplayed(elementId) { + return (await this.getAttribute('displayed', elementId)) === 'true'; +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function elementEnabled(elementId) { + return (await this.getAttribute('enabled', elementId)) === 'true'; +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function elementSelected(elementId) { + return (await this.getAttribute('selected', elementId)) === 'true'; +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|string[]} keys + * @param {string} elementId + * @param {boolean} [replace=false] + * @returns {Promise} + */ +export async function setElementValue(keys, elementId, replace = false) { + const text = keys instanceof Array ? keys.join('') : keys; + return await this.doSetElementValue({ + elementId, + text: String(text), + replace, + }); +} + +/** + * Reason for isolating doSetElementValue from setElementValue is for reusing setElementValue + * across android-drivers (like appium-uiautomator2-driver) and to avoid code duplication. + * Other android-drivers (like appium-uiautomator2-driver) need to override doSetElementValue + * to facilitate setElementValue. + * + * @this {import('../driver').AndroidDriver} + * @param {import('./types').DoSetElementValueOpts} params + * @returns {Promise} + */ +export async function doSetElementValue(params) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|string[]} keys + * @param {string} elementId + * @returns {Promise} + */ +export async function setValue(keys, elementId) { + return await this.setElementValue(keys, elementId, false); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|string[]} keys + * @param {string} elementId + * @returns {Promise} + */ +export async function replaceValue(keys, elementId) { + return await this.setElementValue(keys, elementId, true); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|string[]} keys + * @param {string} elementId + * @returns {Promise} + */ +export async function setValueImmediate(keys, elementId) { + const text = Array.isArray(keys) ? keys.join('') : keys; + // first, make sure we are focused on the element + await this.click(elementId); + // then send through adb + await this.adb.inputText(/** @type {string} */ (text)); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @returns {Promise} + */ +export async function getLocationInView(elementId) { + return await this.getLocation(elementId); +} diff --git a/lib/commands/emu-console.js b/lib/commands/emu-console.js deleted file mode 100644 index adb800c2..00000000 --- a/lib/commands/emu-console.js +++ /dev/null @@ -1,31 +0,0 @@ -// @ts-check - -import {mixin} from './mixins'; -import {errors} from 'appium/driver'; - -const EMU_CONSOLE_FEATURE = 'emulator_console'; -/** - * @type {import('./mixins').EmulatorConsoleMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const EmulatorConsoleMixin = { - async mobileExecEmuConsoleCommand(opts) { - this.ensureFeatureEnabled(EMU_CONSOLE_FEATURE); - - const {command, execTimeout, connTimeout, initTimeout} = opts; - - if (!command) { - throw new errors.InvalidArgumentError(`The 'command' argument is mandatory`); - } - - return await /** @type {import('appium-adb').ADB} */ (this.adb).execEmuConsoleCommand(command, { - execTimeout, - connTimeout, - initTimeout, - }); - }, -}; - -mixin(EmulatorConsoleMixin); - -export default EmulatorConsoleMixin; diff --git a/lib/commands/execute.js b/lib/commands/execute.js index 7b9d0f80..8df474be 100644 --- a/lib/commands/execute.js +++ b/lib/commands/execute.js @@ -1,115 +1,114 @@ -// @ts-check - import _ from 'lodash'; import {errors, PROTOCOLS} from 'appium/driver'; -import {mixin} from './mixins'; /** - * @type {import('./mixins').ExecuteMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @param {string} script + * @param {any[]} [args] + * @returns {Promise} */ -const ExecuteMixin = { - async execute(script, args) { - if (script.match(/^mobile:/)) { - this.log.info(`Executing native command '${script}'`); - script = script.replace(/^mobile:/, '').trim(); - return await this.executeMobile( - script, - _.isArray(args) ? /** @type {import('@appium/types').StringRecord} */ (args[0]) : args - ); - } - 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', { +export async function execute(script, args) { + if (script.match(/^mobile:/)) { + this.log.info(`Executing native command '${script}'`); + script = script.replace(/^mobile:/, '').trim(); + return await this.executeMobile( script, - args, - }); - }, - - async executeMobile(mobileCommand, opts = {}) { - const mobileCommandsMapping = { - shell: 'mobileShell', - - execEmuConsoleCommand: 'mobileExecEmuConsoleCommand', + _.isArray(args) ? /** @type {import('@appium/types').StringRecord} */ (args[0]) : args, + ); + } + 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, + }); +} - startLogsBroadcast: 'mobileStartLogsBroadcast', - stopLogsBroadcast: 'mobileStopLogsBroadcast', +/** + * @this {import('../driver').AndroidDriver} + * @param {string} mobileCommand + * @param {import('@appium/types').StringRecord} [opts={}] + * @returns {Promise} + */ +export async function executeMobile(mobileCommand, opts = {}) { + const mobileCommandsMapping = { + shell: 'mobileShell', - changePermissions: 'mobileChangePermissions', - getPermissions: 'mobileGetPermissions', + execEmuConsoleCommand: 'mobileExecEmuConsoleCommand', - performEditorAction: 'mobilePerformEditorAction', + startLogsBroadcast: 'mobileStartLogsBroadcast', + stopLogsBroadcast: 'mobileStopLogsBroadcast', - sensorSet: 'sensorSet', + changePermissions: 'mobileChangePermissions', + getPermissions: 'mobileGetPermissions', - getDeviceTime: 'mobileGetDeviceTime', + performEditorAction: 'mobilePerformEditorAction', - startScreenStreaming: 'mobileStartScreenStreaming', - stopScreenStreaming: 'mobileStopScreenStreaming', + sensorSet: 'sensorSet', - getNotifications: 'mobileGetNotifications', + getDeviceTime: 'mobileGetDeviceTime', - listSms: 'mobileListSms', + startScreenStreaming: 'mobileStartScreenStreaming', + stopScreenStreaming: 'mobileStopScreenStreaming', - pushFile: 'mobilePushFile', - pullFile: 'mobilePullFile', - pullFolder: 'mobilePullFolder', - deleteFile: 'mobileDeleteFile', + getNotifications: 'mobileGetNotifications', - isAppInstalled: 'mobileIsAppInstalled', - queryAppState: 'mobileQueryAppState', - activateApp: 'mobileActivateApp', - removeApp: 'mobileRemoveApp', - terminateApp: 'mobileTerminateApp', - installApp: 'mobileInstallApp', - clearApp: 'mobileClearApp', + listSms: 'mobileListSms', - startService: 'mobileStartService', - stopService: 'mobileStopService', - startActivity: 'mobileStartActivity', - broadcast: 'mobileBroadcast', + pushFile: 'mobilePushFile', + pullFile: 'mobilePullFile', + pullFolder: 'mobilePullFolder', + deleteFile: 'mobileDeleteFile', - getContexts: 'mobileGetContexts', + isAppInstalled: 'mobileIsAppInstalled', + queryAppState: 'mobileQueryAppState', + activateApp: 'mobileActivateApp', + removeApp: 'mobileRemoveApp', + terminateApp: 'mobileTerminateApp', + installApp: 'mobileInstallApp', + clearApp: 'mobileClearApp', - lock: 'mobileLock', - unlock: 'mobileUnlock', + startService: 'mobileStartService', + stopService: 'mobileStopService', + startActivity: 'mobileStartActivity', + broadcast: 'mobileBroadcast', - refreshGpsCache: 'mobileRefreshGpsCache', + getContexts: 'mobileGetContexts', - startMediaProjectionRecording: 'mobileStartMediaProjectionRecording', - isMediaProjectionRecordingRunning: 'mobileIsMediaProjectionRecordingRunning', - stopMediaProjectionRecording: 'mobileStopMediaProjectionRecording', + lock: 'mobileLock', + unlock: 'mobileUnlock', - getConnectivity: 'mobileGetConnectivity', - setConnectivity: 'mobileSetConnectivity', + refreshGpsCache: 'mobileRefreshGpsCache', - hideKeyboard: 'hideKeyboard', - isKeyboardShown: 'isKeyboardShown', + startMediaProjectionRecording: 'mobileStartMediaProjectionRecording', + isMediaProjectionRecordingRunning: 'mobileIsMediaProjectionRecordingRunning', + stopMediaProjectionRecording: 'mobileStopMediaProjectionRecording', - deviceidle: 'mobileDeviceidle', + getConnectivity: 'mobileGetConnectivity', + setConnectivity: 'mobileSetConnectivity', - setUiMode: 'mobileSetUiMode', - getUiMode: 'mobileGetUiMode', - }; + hideKeyboard: 'hideKeyboard', + isKeyboardShown: 'isKeyboardShown', - if (!_.has(mobileCommandsMapping, mobileCommand)) { - throw new errors.UnknownCommandError( - `Unknown mobile command "${mobileCommand}". ` + - `Only ${_.keys(mobileCommandsMapping)} commands are supported.` - ); - } - return await this[mobileCommandsMapping[mobileCommand]](opts); - }, -}; + deviceidle: 'mobileDeviceidle', -mixin(ExecuteMixin); + setUiMode: 'mobileSetUiMode', + getUiMode: 'mobileGetUiMode', + }; -export default ExecuteMixin; + if (!_.has(mobileCommandsMapping, mobileCommand)) { + throw new errors.UnknownCommandError( + `Unknown mobile command "${mobileCommand}". ` + + `Only ${_.keys(mobileCommandsMapping)} commands are supported.`, + ); + } + return await this[mobileCommandsMapping[mobileCommand]](opts); +} diff --git a/lib/commands/file-actions.js b/lib/commands/file-actions.js index 4caf1a7e..415f1119 100644 --- a/lib/commands/file-actions.js +++ b/lib/commands/file-actions.js @@ -1,17 +1,260 @@ -// @ts-check - import _ from 'lodash'; import {fs, util, zip, tempDir} from '@appium/support'; import path from 'path'; import {errors} from 'appium/driver'; import {requireArgs} from '../utils'; -import {mixin} from './mixins'; const CONTAINER_PATH_MARKER = '@'; // https://regex101.com/r/PLdB0G/2 const CONTAINER_PATH_PATTERN = new RegExp(`^${CONTAINER_PATH_MARKER}([^/]+)/(.+)`); const ANDROID_MEDIA_RESCAN_INTENT = 'android.intent.action.MEDIA_SCANNER_SCAN_FILE'; +/** + * @this {import('../driver').AndroidDriver} + * @param {string} remotePath + * @returns {Promise} + */ +export async function pullFile(remotePath) { + if (remotePath.endsWith('/')) { + throw new errors.InvalidArgumentError( + `It is expected that remote path points to a file and not to a folder. ` + + `'${remotePath}' is given instead`, + ); + } + let tmpDestination = null; + if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { + const [packageId, pathInContainer] = parseContainerPath(remotePath); + this.log.debug( + `Parsed package identifier '${packageId}' from '${remotePath}'. Will get the data from '${pathInContainer}'`, + ); + tmpDestination = `/data/local/tmp/${path.posix.basename(pathInContainer)}`; + try { + await this.adb.shell(['run-as', packageId, `chmod 777 '${escapePath(pathInContainer)}'`]); + await this.adb.shell([ + 'run-as', + packageId, + `cp -f '${escapePath(pathInContainer)}' '${escapePath(tmpDestination)}'`, + ]); + } catch (e) { + this.log.errorAndThrow( + `Cannot access the container of '${packageId}' application. ` + + `Is the application installed and has 'debuggable' build option set to true? ` + + `Original error: ${/** @type {Error} */ (e).message}`, + ); + } + } + const localFile = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); + try { + await this.adb.pull(tmpDestination || remotePath, localFile); + return (await util.toInMemoryBase64(localFile)).toString(); + } finally { + if (await fs.exists(localFile)) { + await fs.unlink(localFile); + } + if (tmpDestination) { + await this.adb.shell(['rm', '-f', tmpDestination]); + } + } +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').PullFileOpts} opts + * @returns {Promise} + */ +export async function mobilePullFile(opts) { + const {remotePath} = requireArgs('remotePath', opts); + return await this.pullFile(remotePath); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} remotePath + * @param {string} base64Data + * @returns {Promise} + */ +export async function pushFile(remotePath, base64Data) { + if (remotePath.endsWith('/')) { + throw new errors.InvalidArgumentError( + `It is expected that remote path points to a file and not to a folder. ` + + `'${remotePath}' is given instead`, + ); + } + const localFile = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); + if (_.isArray(base64Data)) { + // some clients (ahem) java, send a byte array encoding utf8 characters + // instead of a string, which would be infinitely better! + base64Data = Buffer.from(base64Data).toString('utf8'); + } + const content = Buffer.from(base64Data, 'base64'); + let tmpDestination = null; + try { + await fs.writeFile(localFile, content.toString('binary'), 'binary'); + if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { + const [packageId, pathInContainer] = parseContainerPath(remotePath); + this.log.debug( + `Parsed package identifier '${packageId}' from '${remotePath}'. ` + + `Will put the data into '${pathInContainer}'`, + ); + tmpDestination = `/data/local/tmp/${path.posix.basename(pathInContainer)}`; + try { + await this.adb.shell([ + 'run-as', + packageId, + `mkdir -p '${escapePath(path.posix.dirname(pathInContainer))}'`, + ]); + await this.adb.shell(['run-as', packageId, `touch '${escapePath(pathInContainer)}'`]); + await this.adb.shell(['run-as', packageId, `chmod 777 '${escapePath(pathInContainer)}'`]); + await this.adb.push(localFile, tmpDestination); + await this.adb.shell([ + 'run-as', + packageId, + `cp -f '${escapePath(tmpDestination)}' '${escapePath(pathInContainer)}'`, + ]); + } catch (e) { + this.log.errorAndThrow( + `Cannot access the container of '${packageId}' application. ` + + `Is the application installed and has 'debuggable' build option set to true? ` + + `Original error: ${/** @type {Error} */ (e).message}`, + ); + } + } else { + // adb push creates folders and overwrites existing files. + await this.adb.push(localFile, remotePath); + + // if we have pushed a file, it might be a media file, so ensure that + // apps know about it + await scanMedia.bind(this)(remotePath); + } + } finally { + if (await fs.exists(localFile)) { + await fs.unlink(localFile); + } + if (tmpDestination) { + await this.adb.shell(['rm', '-f', tmpDestination]); + } + } +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').PushFileOpts} opts + * @returns {Promise} + */ +export async function mobilePushFile(opts) { + const {remotePath, payload} = requireArgs(['remotePath', 'payload'], opts); + return await this.pushFile(remotePath, payload); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} remotePath + * @returns {Promise} + */ +export async function pullFolder(remotePath) { + const tmpRoot = await tempDir.openDir(); + try { + await this.adb.pull(remotePath, tmpRoot); + return ( + await zip.toInMemoryZip(tmpRoot, { + encodeToBase64: true, + }) + ).toString(); + } finally { + await fs.rimraf(tmpRoot); + } +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').PullFolderOpts} opts + * @returns {Promise} + */ +export async function mobilePullFolder(opts) { + const {remotePath} = requireArgs('remotePath', opts); + return await this.pullFolder(remotePath); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').DeleteFileOpts} opts + * @returns {Promise} + */ +export async function mobileDeleteFile(opts) { + const {remotePath} = requireArgs('remotePath', opts); + if (remotePath.endsWith('/')) { + throw new errors.InvalidArgumentError( + `It is expected that remote path points to a folder and not to a file. ` + + `'${remotePath}' is given instead`, + ); + } + return await deleteFileOrFolder.call(this, this.adb, remotePath); +} + +/** + * Deletes the given folder or file from the remote device + * + * @param {ADB} adb + * @param {string} remotePath The full path to the remote folder + * or file (folder names must end with a single slash) + * @throws {Error} If the provided remote path is invalid or + * the package content cannot be accessed + * @returns {Promise} `true` if the remote item has been successfully deleted. + * If the remote path is valid, but the remote path does not exist + * this function return `false`. + * @this {import('../driver').AndroidDriver} + */ +async function deleteFileOrFolder(adb, remotePath) { + const {isDir, isPresent, isFile} = createFSTests(adb); + let dstPath = remotePath; + /** @type {string|undefined} */ + let pkgId; + if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { + const [packageId, pathInContainer] = parseContainerPath(remotePath); + this.log.debug(`Parsed package identifier '${packageId}' from '${remotePath}'`); + dstPath = pathInContainer; + pkgId = packageId; + } + + if (pkgId) { + try { + await adb.shell(['run-as', pkgId, 'ls']); + } catch (e) { + this.log.errorAndThrow( + `Cannot access the container of '${pkgId}' application. ` + + `Is the application installed and has 'debuggable' build option set to true? ` + + `Original error: ${/** @type {Error} */ (e).message}`, + ); + } + } + + if (!(await isPresent(dstPath, pkgId))) { + this.log.info(`The item at '${dstPath}' does not exist. Perhaps, already deleted?`); + return false; + } + + const expectsFile = !remotePath.endsWith('/'); + if (expectsFile && !(await isFile(dstPath, pkgId))) { + this.log.errorAndThrow(`The item at '${dstPath}' is not a file`); + } else if (!expectsFile && !(await isDir(dstPath, pkgId))) { + this.log.errorAndThrow(`The item at '${dstPath}' is not a folder`); + } + + if (pkgId) { + await adb.shell(['run-as', pkgId, `rm -f${expectsFile ? '' : 'r'} '${escapePath(dstPath)}'`]); + } else { + await adb.shell(['rm', `-f${expectsFile ? '' : 'r'}`, dstPath]); + } + if (await isPresent(dstPath, pkgId)) { + this.log.errorAndThrow( + `The item at '${dstPath}' still exists after being deleted. ` + `Is it writable?`, + ); + } + return true; +} + +// #region Internal helpers + /** * Parses the actual destination path from the given value * @@ -26,7 +269,7 @@ function parseContainerPath(remotePath) { if (!match) { throw new Error( `It is expected that package identifier is separated from the relative path with a single slash. ` + - `'${remotePath}' is given instead` + `'${remotePath}' is given instead`, ); } return [match[1], path.posix.resolve(`/data/data/${match[1]}`, match[2])]; @@ -62,7 +305,7 @@ async function scanMedia(remotePath) { this.log.warn( `Ignoring an unexpected error upon media scanning of '${remotePath}': ${ err.stderr ?? err.message - }` + }`, ); } } @@ -78,158 +321,6 @@ function escapePath(p) { return p.replace(/'/g, `\\'`); } -/** - * @type {import('./mixins').FileActionsMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const FileActionsMixin = { - async pullFile(remotePath) { - if (remotePath.endsWith('/')) { - throw new errors.InvalidArgumentError( - `It is expected that remote path points to a file and not to a folder. ` + - `'${remotePath}' is given instead` - ); - } - let tmpDestination = null; - if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { - const [packageId, pathInContainer] = parseContainerPath(remotePath); - this.log.debug( - `Parsed package identifier '${packageId}' from '${remotePath}'. Will get the data from '${pathInContainer}'` - ); - tmpDestination = `/data/local/tmp/${path.posix.basename(pathInContainer)}`; - try { - await this.adb.shell(['run-as', packageId, `chmod 777 '${escapePath(pathInContainer)}'`]); - await this.adb.shell([ - 'run-as', - packageId, - `cp -f '${escapePath(pathInContainer)}' '${escapePath(tmpDestination)}'`, - ]); - } catch (e) { - this.log.errorAndThrow( - `Cannot access the container of '${packageId}' application. ` + - `Is the application installed and has 'debuggable' build option set to true? ` + - `Original error: ${/** @type {Error} */ (e).message}` - ); - } - } - const localFile = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); - try { - await this.adb.pull(tmpDestination || remotePath, localFile); - return (await util.toInMemoryBase64(localFile)).toString(); - } finally { - if (await fs.exists(localFile)) { - await fs.unlink(localFile); - } - if (tmpDestination) { - await this.adb.shell(['rm', '-f', tmpDestination]); - } - } - }, - - async mobilePullFile(opts) { - const {remotePath} = requireArgs('remotePath', opts); - return await this.pullFile(remotePath); - }, - - async pushFile(remotePath, base64Data) { - if (remotePath.endsWith('/')) { - throw new errors.InvalidArgumentError( - `It is expected that remote path points to a file and not to a folder. ` + - `'${remotePath}' is given instead` - ); - } - const localFile = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); - if (_.isArray(base64Data)) { - // some clients (ahem) java, send a byte array encoding utf8 characters - // instead of a string, which would be infinitely better! - base64Data = Buffer.from(base64Data).toString('utf8'); - } - const content = Buffer.from(base64Data, 'base64'); - let tmpDestination = null; - try { - await fs.writeFile(localFile, content.toString('binary'), 'binary'); - if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { - const [packageId, pathInContainer] = parseContainerPath(remotePath); - this.log.debug( - `Parsed package identifier '${packageId}' from '${remotePath}'. ` + - `Will put the data into '${pathInContainer}'` - ); - tmpDestination = `/data/local/tmp/${path.posix.basename(pathInContainer)}`; - try { - await this.adb.shell([ - 'run-as', - packageId, - `mkdir -p '${escapePath(path.posix.dirname(pathInContainer))}'`, - ]); - await this.adb.shell(['run-as', packageId, `touch '${escapePath(pathInContainer)}'`]); - await this.adb.shell(['run-as', packageId, `chmod 777 '${escapePath(pathInContainer)}'`]); - await this.adb.push(localFile, tmpDestination); - await this.adb.shell([ - 'run-as', - packageId, - `cp -f '${escapePath(tmpDestination)}' '${escapePath(pathInContainer)}'`, - ]); - } catch (e) { - this.log.errorAndThrow( - `Cannot access the container of '${packageId}' application. ` + - `Is the application installed and has 'debuggable' build option set to true? ` + - `Original error: ${/** @type {Error} */ (e).message}` - ); - } - } else { - // adb push creates folders and overwrites existing files. - await this.adb.push(localFile, remotePath); - - // if we have pushed a file, it might be a media file, so ensure that - // apps know about it - await scanMedia.bind(this)(remotePath); - } - } finally { - if (await fs.exists(localFile)) { - await fs.unlink(localFile); - } - if (tmpDestination) { - await this.adb.shell(['rm', '-f', tmpDestination]); - } - } - }, - - async mobilePushFile(opts) { - const {remotePath, payload} = requireArgs(['remotePath', 'payload'], opts); - return await this.pushFile(remotePath, payload); - }, - - async pullFolder(remotePath) { - const tmpRoot = await tempDir.openDir(); - try { - await this.adb.pull(remotePath, tmpRoot); - return ( - await zip.toInMemoryZip(tmpRoot, { - encodeToBase64: true, - }) - ).toString(); - } finally { - await fs.rimraf(tmpRoot); - } - }, - - async mobilePullFolder(opts) { - const {remotePath} = requireArgs('remotePath', opts); - return await this.pullFolder(remotePath); - }, - - async mobileDeleteFile(opts) { - const {remotePath} = requireArgs('remotePath', opts); - if (remotePath.endsWith('/')) { - throw new errors.InvalidArgumentError( - `It is expected that remote path points to a folder and not to a file. ` + - `'${remotePath}' is given instead` - ); - } - return await deleteFileOrFolder.call(this, this.adb, remotePath); - }, -}; - /** * Factory providing filesystem test functions using ADB * @param {ADB} adb @@ -272,72 +363,8 @@ function createFSTests(adb) { return {isFile, isDir, isPresent}; } -/** - * Deletes the given folder or file from the remote device - * - * @param {ADB} adb - * @param {string} remotePath The full path to the remote folder - * or file (folder names must end with a single slash) - * @throws {Error} If the provided remote path is invalid or - * the package content cannot be accessed - * @returns {Promise} `true` if the remote item has been successfully deleted. - * If the remote path is valid, but the remote path does not exist - * this function return `false`. - * @this {import('../driver').AndroidDriver} - */ -async function deleteFileOrFolder(adb, remotePath) { - const {isDir, isPresent, isFile} = createFSTests(adb); - let dstPath = remotePath; - /** @type {string|undefined} */ - let pkgId; - if (remotePath.startsWith(CONTAINER_PATH_MARKER)) { - const [packageId, pathInContainer] = parseContainerPath(remotePath); - this.log.debug(`Parsed package identifier '${packageId}' from '${remotePath}'`); - dstPath = pathInContainer; - pkgId = packageId; - } - - if (pkgId) { - try { - await adb.shell(['run-as', pkgId, 'ls']); - } catch (e) { - this.log.errorAndThrow( - `Cannot access the container of '${pkgId}' application. ` + - `Is the application installed and has 'debuggable' build option set to true? ` + - `Original error: ${/** @type {Error} */ (e).message}` - ); - } - } - - if (!(await isPresent(dstPath, pkgId))) { - this.log.info(`The item at '${dstPath}' does not exist. Perhaps, already deleted?`); - return false; - } - - const expectsFile = !remotePath.endsWith('/'); - if (expectsFile && !(await isFile(dstPath, pkgId))) { - this.log.errorAndThrow(`The item at '${dstPath}' is not a file`); - } else if (!expectsFile && !(await isDir(dstPath, pkgId))) { - this.log.errorAndThrow(`The item at '${dstPath}' is not a folder`); - } - - if (pkgId) { - await adb.shell(['run-as', pkgId, `rm -f${expectsFile ? '' : 'r'} '${escapePath(dstPath)}'`]); - } else { - await adb.shell(['rm', `-f${expectsFile ? '' : 'r'}`, dstPath]); - } - if (await isPresent(dstPath, pkgId)) { - this.log.errorAndThrow( - `The item at '${dstPath}' still exists after being deleted. ` + `Is it writable?` - ); - } - return true; -} - -mixin(FileActionsMixin); +// #endregion /** * @typedef {import('appium-adb').ADB} ADB */ - -export default FileActionsMixin; diff --git a/lib/commands/find.ts b/lib/commands/find.ts index 2ae29c1e..94cb47ba 100644 --- a/lib/commands/find.ts +++ b/lib/commands/find.ts @@ -1,35 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ /** - * @privateRemarks This file needed to be converted to TS because the overload of `findElOrEls` is seemingly impossible to express in JS since the value of `this` cannot be bound via a type assertion. * @module */ import _ from 'lodash'; -import {mixin, type FindMixin} from './mixins'; import {errors, isErrorType} from 'appium/driver'; import type {AndroidDriver} from '../driver'; import type {Element} from '@appium/types'; import type {FindElementOpts} from './types'; -async function findElOrEls( +export async function findElOrEls( this: AndroidDriver, strategy: string, selector: string, mult: true, - context?: string + context?: string, ): Promise; -async function findElOrEls( +export async function findElOrEls( this: AndroidDriver, strategy: string, selector: string, mult: false, - context?: string + context?: string, ): Promise; -async function findElOrEls( +export async function findElOrEls( this: AndroidDriver, strategy: string, selector: string, mult: boolean, - context = '' + context = '', ) { if (!selector) { throw new Error('Must provide a selector when finding elements'); @@ -89,14 +88,9 @@ async function findElOrEls( return element as Element; } -const FindMixin: FindMixin & ThisType = { - async doFindElementOrEls(params) { - throw new errors.NotImplementedError('Not implemented'); - }, - - findElOrEls, -}; - -mixin(FindMixin); - -export default FindMixin; +export async function doFindElementOrEls( + this: AndroidDriver, + params: FindElementOpts, +): Promise { + throw new errors.NotImplementedError('Not implemented'); +} diff --git a/lib/commands/general.js b/lib/commands/general.js deleted file mode 100644 index a7430719..00000000 --- a/lib/commands/general.js +++ /dev/null @@ -1,343 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import {util} from '@appium/support'; -import {longSleep} from 'asyncbox'; -import _ from 'lodash'; -import moment from 'moment'; -import androidHelpers from '../helpers/android'; -import {requireArgs} from '../utils'; -import {mixin} from './mixins'; -import {errors} from 'appium/driver'; - -const MOMENT_FORMAT_ISO8601 = 'YYYY-MM-DDTHH:mm:ssZ'; - -/** - * @type {import('./mixins').GeneralMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const GeneralMixin = { - _cachedActivityArgs: {}, - async keys(keys) { - // Protocol sends an array; rethink approach - keys = _.isArray(keys) ? keys.join('') : keys; - await this.doSendKeys({ - text: keys, - replace: false, - }); - }, - - async doSendKeys(params) { - throw new errors.NotImplementedError('Not implemented'); - }, - - async getDeviceTime(format = MOMENT_FORMAT_ISO8601) { - this.log.debug( - 'Attempting to capture android device date and time. ' + `The format specifier is '${format}'` - ); - const deviceTimestamp = ( - await this.adb.shell(['date', '+%Y-%m-%dT%T%z']) - ).trim(); - this.log.debug(`Got device timestamp: ${deviceTimestamp}`); - const parsedTimestamp = moment.utc(deviceTimestamp, 'YYYY-MM-DDTHH:mm:ssZZ'); - if (!parsedTimestamp.isValid()) { - this.log.warn('Cannot parse the returned timestamp. Returning as is'); - return deviceTimestamp; - } - // @ts-expect-error private API - return parsedTimestamp.utcOffset(parsedTimestamp._tzm || 0).format(format); - }, - - async mobileGetDeviceTime(opts = {}) { - return await this.getDeviceTime(opts.format); - }, - - async getPageSource() { - throw new errors.NotImplementedError('Not implemented'); - }, - - async back() { - throw new errors.NotImplementedError('Not implemented'); - }, - - async openSettingsActivity(setting) { - let {appPackage, appActivity} = await this.adb.getFocusedPackageAndActivity(); - await this.adb.shell(['am', 'start', '-a', `android.settings.${setting}`]); - await this.adb.waitForNotActivity( - /** @type {string} */ (appPackage), - /** @type {string} */ (appActivity), - 5000 - ); - }, - - async getWindowSize() { - throw new errors.NotImplementedError('Not implemented'); - }, - - // For W3C - async getWindowRect() { - const {width, height} = await this.getWindowSize(); - return { - width, - height, - x: 0, - y: 0, - }; - }, - - /** - * @returns {Promise} - */ - async getCurrentActivity() { - return /** @type {string} */ ((await this.adb.getFocusedPackageAndActivity()).appActivity); - }, - - /** - * @returns {Promise} - */ - async getCurrentPackage() { - return /** @type {string} */ ((await this.adb.getFocusedPackageAndActivity()).appPackage); - }, - - async background(seconds) { - if (seconds < 0) { - // if user passes in a negative seconds value, interpret that as the instruction - // to not bring the app back at all - await this.adb.goToHome(); - return true; - } - let {appPackage, appActivity} = await this.adb.getFocusedPackageAndActivity(); - await this.adb.goToHome(); - - // people can wait for a long time, so to be safe let's use the longSleep function and log - // progress periodically. - const sleepMs = seconds * 1000; - const thresholdMs = 30 * 1000; // use the spin-wait for anything over this threshold - // for our spin interval, use 1% of the total wait time, but nothing bigger than 30s - const intervalMs = _.min([30 * 1000, parseInt(String(sleepMs / 100), 10)]); - /** - * - * @param {{elapsedMs: number, progress: number}} param0 - */ - const progressCb = ({elapsedMs, progress}) => { - const waitSecs = (elapsedMs / 1000).toFixed(0); - const progressPct = (progress * 100).toFixed(2); - this.log.debug(`Waited ${waitSecs}s so far (${progressPct}%)`); - }; - await longSleep(sleepMs, {thresholdMs, intervalMs, progressCb}); - - /** @type {import('appium-adb').StartAppOptions} */ - let args; - if (this._cachedActivityArgs && this._cachedActivityArgs[`${appPackage}/${appActivity}`]) { - // the activity was started with `startActivity`, so use those args to restart - args = this._cachedActivityArgs[`${appPackage}/${appActivity}`]; - } else { - try { - this.log.debug(`Activating app '${appPackage}' in order to restore it`); - await this.activateApp(/** @type {string} */ (appPackage)); - return true; - } catch (ign) {} - args = - (appPackage === this.opts.appPackage && appActivity === this.opts.appActivity) || - (appPackage === this.opts.appWaitPackage && - (this.opts.appWaitActivity || '').split(',').includes(String(appActivity))) - ? { - // the activity is the original session activity, so use the original args - pkg: /** @type {string} */ (this.opts.appPackage), - activity: this.opts.appActivity ?? undefined, - action: this.opts.intentAction, - category: this.opts.intentCategory, - flags: this.opts.intentFlags, - waitPkg: this.opts.appWaitPackage ?? undefined, - waitActivity: this.opts.appWaitActivity ?? undefined, - waitForLaunch: this.opts.appWaitForLaunch, - waitDuration: this.opts.appWaitDuration, - optionalIntentArguments: this.opts.optionalIntentArguments, - stopApp: false, - user: this.opts.userProfile, - } - : { - // the activity was started some other way, so use defaults - pkg: /** @type {string} */ (appPackage), - activity: appActivity ?? undefined, - waitPkg: appPackage ?? undefined, - waitActivity: appActivity ?? undefined, - stopApp: false, - }; - } - args = /** @type {import('appium-adb').StartAppOptions} */ ( - _.pickBy(args, (value) => !_.isUndefined(value)) - ); - this.log.debug( - `Bringing application back to foreground with arguments: ${JSON.stringify(args)}` - ); - return await this.adb.startApp(args); - }, - - async getStrings(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 = (mapping) => { - /** @type {StringRecord} */ - const result = {}; - for (const [key, value] of _.toPairs(mapping)) { - result[key] = _.isString(value) ? value : JSON.stringify(value); - } - return result; - }; - - if (this.apkStrings[language]) { - // Return cached strings - return preprocessStringsMap(this.apkStrings[language]); - } - - this.apkStrings[language] = await androidHelpers.pushStrings(language, this.adb, this.opts); - - return preprocessStringsMap(this.apkStrings[language]); - }, - - async launchApp() { - throw new errors.NotImplementedError('Not implemented'); - }, - - async startActivity( - appPackage, - appActivity, - appWaitPackage, - appWaitActivity, - intentAction, - intentCategory, - intentFlags, - optionalIntentArguments, - dontStopAppOnReset - ) { - this.log.debug(`Starting package '${appPackage}' and activity '${appActivity}'`); - - // dontStopAppOnReset is both an argument here, and a desired capability - // if the argument is set, use it, otherwise use the cap - if (!util.hasValue(dontStopAppOnReset)) { - dontStopAppOnReset = !!this.opts.dontStopAppOnReset; - } - - /** @type {import('appium-adb').StartAppOptions} */ - let args = { - pkg: appPackage, - activity: appActivity, - waitPkg: appWaitPackage || appPackage, - waitActivity: appWaitActivity || appActivity, - action: intentAction, - category: intentCategory, - flags: intentFlags, - optionalIntentArguments, - stopApp: !dontStopAppOnReset, - }; - this._cachedActivityArgs = this._cachedActivityArgs || {}; - this._cachedActivityArgs[`${args.waitPkg}/${args.waitActivity}`] = args; - await this.adb.startApp(args); - }, - - async reset() { - await androidHelpers.resetApp( - this.adb, - Object.assign({}, this.opts, {fastReset: true}) - ); - // reset context since we don't know what kind on context we will end up after app launch. - await this.setContext(); - return this.isChromeSession ? this.startChromeSession() : this.startAUT(); - }, - - async startAUT() { - await this.adb.startApp({ - pkg: /** @type {string} */ (this.opts.appPackage), - activity: this.opts.appActivity, - action: this.opts.intentAction, - category: this.opts.intentCategory, - flags: this.opts.intentFlags, - waitPkg: this.opts.appWaitPackage, - waitActivity: this.opts.appWaitActivity, - waitForLaunch: this.opts.appWaitForLaunch, - waitDuration: this.opts.appWaitDuration, - optionalIntentArguments: this.opts.optionalIntentArguments, - stopApp: !this.opts.dontStopAppOnReset, - user: this.opts.userProfile, - }); - }, - - // we override setUrl to take an android URI which can be used for deep-linking - // inside an app, similar to starting an intent - async setUrl(uri) { - await this.adb.startUri(uri, /** @type {string} */ (this.opts.appPackage)); - }, - - // closing app using force stop - async closeApp() { - await this.adb.forceStop(/** @type {string} */ (this.opts.appPackage)); - // reset context since we don't know what kind on context we will end up after app launch. - await this.setContext(); - }, - - async getDisplayDensity() { - // first try the property for devices - let out = await this.adb.shell(['getprop', 'ro.sf.lcd_density']); - if (out) { - let val = parseInt(out, 10); - // if the value is NaN, try getting the emulator property - if (!isNaN(val)) { - return val; - } - this.log.debug(`Parsed density value was NaN: "${out}"`); - } - // fallback to trying property for emulators - out = await this.adb.shell(['getprop', 'qemu.sf.lcd_density']); - if (out) { - let val = parseInt(out, 10); - if (!isNaN(val)) { - return val; - } - this.log.debug(`Parsed density value was NaN: "${out}"`); - } - // couldn't get anything, so error out - throw this.log.errorAndThrow('Failed to get display density property.'); - }, - - async mobilePerformEditorAction(opts) { - const {action} = requireArgs('action', opts); - await this.settingsApp.performEditorAction(action); - }, - - async mobileGetNotifications() { - return await this.settingsApp.getNotifications(); - }, - - async mobileListSms(opts) { - return await this.settingsApp.getSmsList(opts); - }, - - async mobileUnlock(opts = {}) { - const {key, type, strategy, timeoutMs} = opts; - if (!key && !type) { - await this.unlock(); - } else { - // @ts-expect-error XXX: these caps should be defined in the constraints!! - await androidHelpers.unlock(this, this.adb, { - unlockKey: key, - unlockType: type, - unlockStrategy: strategy, - unlockSuccessTimeout: timeoutMs, - }); - } - }, -}; - -mixin(GeneralMixin); - -export default GeneralMixin; - -/** - * @typedef {import('appium-adb').ADB} ADB - * @typedef {import('@appium/types').StringRecord} StringRecord - */ diff --git a/lib/commands/geolocation.js b/lib/commands/geolocation.js new file mode 100644 index 00000000..7ee73374 --- /dev/null +++ b/lib/commands/geolocation.js @@ -0,0 +1,179 @@ +import _ from 'lodash'; +import {fs, tempDir} from '@appium/support'; +import path from 'node:path'; +import B from 'bluebird'; +import {SETTINGS_HELPER_ID} from 'io.appium.settings'; +import {getThirdPartyPackages} from './app-management'; + +// The value close to zero, but not zero, is needed +// to trick JSON generation and send a float value instead of an integer, +// This allows strictly-typed clients, like Java, to properly +// parse it. Otherwise float 0.0 is always represented as integer 0 in JS. +// The value must not be greater than DBL_EPSILON (https://opensource.apple.com/source/Libc/Libc-498/include/float.h) +const GEO_EPSILON = Number.MIN_VALUE; +const MOCK_APP_IDS_STORE = '/data/local/tmp/mock_apps.json'; + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('@appium/types').Location} location + * @returns {Promise} + */ +export async function setGeoLocation(location) { + await this.settingsApp.setGeoLocation(location, this.isEmulator()); + try { + return await this.getGeoLocation(); + } catch (e) { + this.log.warn( + `Could not get the current geolocation info: ${/** @type {Error} */ (e).message}`, + ); + this.log.warn(`Returning the default zero'ed values`); + return { + latitude: GEO_EPSILON, + longitude: GEO_EPSILON, + altitude: GEO_EPSILON, + }; + } +} + +/** + * Sends an async request to refresh the GPS cache. + * + * This feature only works if the device under test has Google Play Services + * installed. In case the vanilla LocationManager is used the device API level + * must be at version 30 (Android R) or higher. + * + * @this {import('../driver').AndroidDriver} + * @param {import('./types').GpsCacheRefreshOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileRefreshGpsCache(opts = {}) { + const {timeoutMs} = opts; + await this.settingsApp.refreshGeoLocationCache(timeoutMs); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getGeoLocation() { + const {latitude, longitude, altitude} = await this.settingsApp.getGeoLocation(); + return { + latitude: parseFloat(String(latitude)) || GEO_EPSILON, + longitude: parseFloat(String(longitude)) || GEO_EPSILON, + altitude: parseFloat(String(altitude)) || GEO_EPSILON, + }; +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function isLocationServicesEnabled() { + return (await this.adb.getLocationProviders()).includes('gps'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function toggleLocationServices() { + this.log.info('Toggling location services'); + const isGpsEnabled = await this.isLocationServicesEnabled(); + this.log.debug( + `Current GPS state: ${isGpsEnabled}. ` + + `The service is going to be ${isGpsEnabled ? 'disabled' : 'enabled'}`, + ); + await this.adb.toggleGPSLocationProvider(!isGpsEnabled); +} + +// #region Internal helpers + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} appId + * @returns {Promise} + */ +export async function setMockLocationApp(appId) { + try { + if ((await this.adb.getApiLevel()) < 23) { + await this.adb.shell(['settings', 'put', 'secure', 'mock_location', '1']); + } else { + await this.adb.shell(['appops', 'set', appId, 'android:mock_location', 'allow']); + } + } catch (err) { + this.log.warn(`Unable to set mock location for app '${appId}': ${err.message}`); + return; + } + try { + /** @type {string[]} */ + let pkgIds = []; + if (await this.adb.fileExists(MOCK_APP_IDS_STORE)) { + try { + pkgIds = JSON.parse(await this.adb.shell(['cat', MOCK_APP_IDS_STORE])); + } catch (ign) {} + } + if (pkgIds.includes(appId)) { + return; + } + pkgIds.push(appId); + const tmpRoot = await tempDir.openDir(); + const srcPath = path.posix.join(tmpRoot, path.posix.basename(MOCK_APP_IDS_STORE)); + try { + await fs.writeFile(srcPath, JSON.stringify(pkgIds), 'utf8'); + await this.adb.push(srcPath, MOCK_APP_IDS_STORE); + } finally { + await fs.rimraf(tmpRoot); + } + } catch (e) { + this.log.warn(`Unable to persist mock location app id '${appId}': ${e.message}`); + } +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function resetMockLocation() { + try { + if ((await this.adb.getApiLevel()) < 23) { + await this.adb.shell(['settings', 'put', 'secure', 'mock_location', '0']); + return; + } + + const thirdPartyPkgIdsPromise = getThirdPartyPackages.bind(this)(); + let pkgIds = []; + if (await this.adb.fileExists(MOCK_APP_IDS_STORE)) { + try { + pkgIds = JSON.parse(await this.adb.shell(['cat', MOCK_APP_IDS_STORE])); + } catch (ign) {} + } + const thirdPartyPkgIds = await thirdPartyPkgIdsPromise; + // Only include currently installed packages + const resultPkgs = _.intersection(pkgIds, thirdPartyPkgIds); + if (_.size(resultPkgs) <= 1) { + await this.adb.shell([ + 'appops', + 'set', + resultPkgs[0] ?? SETTINGS_HELPER_ID, + 'android:mock_location', + 'deny', + ]); + return; + } + + this.log.debug(`Resetting mock_location permission for the following apps: ${resultPkgs}`); + await B.all( + resultPkgs.map((pkgId) => + (async () => { + try { + await this.adb.shell(['appops', 'set', pkgId, 'android:mock_location', 'deny']); + } catch (ign) {} + })(), + ), + ); + } catch (err) { + this.log.warn(`Unable to reset mock location: ${err.message}`); + } +} + +// #endregion Internal helpers diff --git a/lib/commands/ime.js b/lib/commands/ime.js index 8cd4dd34..9e67ed74 100644 --- a/lib/commands/ime.js +++ b/lib/commands/ime.js @@ -1,56 +1,64 @@ -// @ts-check - -import {mixin} from './mixins'; import {errors} from 'appium/driver'; /** - * @type {import('./mixins').IMEMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @returns {Promise} */ -const IMEMixin = { - async isIMEActivated() { - // eslint-disable-line require-await - // IME is always activated on Android devices - return true; - }, - - async availableIMEEngines() { - this.log.debug('Retrieving available IMEs'); - let engines = await this.adb.availableIMEs(); - this.log.debug(`Engines: ${JSON.stringify(engines)}`); - return engines; - }, - - async getActiveIMEEngine() { - this.log.debug('Retrieving current default IME'); - return String(await this.adb.defaultIME()); - }, +export async function isIMEActivated() { + // eslint-disable-line require-await + // IME is always activated on Android devices + return true; +} - async activateIMEEngine(imeId) { - this.log.debug(`Attempting to activate IME ${imeId}`); - let availableEngines = await this.adb.availableIMEs(); - if (availableEngines.indexOf(imeId) === -1) { - this.log.debug('IME not found, failing'); - throw new errors.IMENotAvailableError(); - } - this.log.debug('Found installed IME, attempting to activate'); - await this.adb.enableIME(imeId); - await this.adb.setIME(imeId); - }, +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function availableIMEEngines() { + this.log.debug('Retrieving available IMEs'); + let engines = await this.adb.availableIMEs(); + this.log.debug(`Engines: ${JSON.stringify(engines)}`); + return engines; +} - async deactivateIMEEngine() { - let currentEngine = await this.getActiveIMEEngine(); - // XXX: this allowed 'null' to be passed into `adb.shell` - if (currentEngine) { - this.log.debug(`Attempting to deactivate ${currentEngine}`); - await this.adb.disableIME(currentEngine); - } - }, -}; +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getActiveIMEEngine() { + this.log.debug('Retrieving current default IME'); + return String(await this.adb.defaultIME()); +} -export default IMEMixin; +/** + * @this {import('../driver').AndroidDriver} + * @param {string} imeId + * @returns {Promise} + */ +export async function activateIMEEngine(imeId) { + this.log.debug(`Attempting to activate IME ${imeId}`); + let availableEngines = await this.adb.availableIMEs(); + if (availableEngines.indexOf(imeId) === -1) { + this.log.debug('IME not found, failing'); + throw new errors.IMENotAvailableError(); + } + this.log.debug('Found installed IME, attempting to activate'); + await this.adb.enableIME(imeId); + await this.adb.setIME(imeId); +} -mixin(IMEMixin); +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function deactivateIMEEngine() { + let currentEngine = await this.getActiveIMEEngine(); + // XXX: this allowed 'null' to be passed into `adb.shell` + if (currentEngine) { + this.log.debug(`Attempting to deactivate ${currentEngine}`); + await this.adb.disableIME(currentEngine); + } +} /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/index.ts b/lib/commands/index.ts deleted file mode 100644 index 471ac58b..00000000 --- a/lib/commands/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import actionCmds from './actions'; -import alertCmds from './alert'; -import appManagementCmds from './app-management'; -import contextCmds from './context'; -import elementCmds from './element'; -import emuConsoleCmds from './emu-console'; -import executeCmds from './execute'; -import fileActionsCmds from './file-actions'; -import findCmds from './find'; -import generalCmds from './general'; -import imeCmds from './ime'; -import intentCmds from './intent'; -import keyboardCmds from './keyboard'; -import logCmds from './log'; -import mediaProjectionCmds from './media-projection'; -import networkCmds from './network'; -import performanceCmds from './performance'; -import permissionsCmds from './permissions'; -import recordscreenCmds from './recordscreen'; -import shellCmds from './shell'; -import screenStreamCmds from './streamscreen'; -import systemBarsCmds from './system-bars'; -import touchCmds from './touch'; - -const commands = { - findCmds, - generalCmds, - alertCmds, - elementCmds, - contextCmds, - actionCmds, - touchCmds, - imeCmds, - networkCmds, - recordscreenCmds, - intentCmds, - screenStreamCmds, - performanceCmds, - executeCmds, - shellCmds, - emuConsoleCmds, - systemBarsCmds, - appManagementCmds, - fileActionsCmds, - logCmds, - mediaProjectionCmds, - permissionsCmds, - keyboardCmds, - // add other command types here -}; - -export {commands}; - -export type * from './mixins'; diff --git a/lib/commands/intent.js b/lib/commands/intent.js index 26acb16b..54f546c2 100644 --- a/lib/commands/intent.js +++ b/lib/commands/intent.js @@ -1,8 +1,6 @@ -// @ts-check - -import {mixin} from './mixins'; import _ from 'lodash'; import {errors} from 'appium/driver'; +import {util} from '@appium/support'; const NO_VALUE_ARG_TYPE = 'sn'; const SUPPORTED_EXTRA_TYPES = [ @@ -25,6 +23,150 @@ const SUPPORTED_EXTRA_TYPES = [ ]; const API_LEVEL_ANDROID_8 = 26; +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} appPackage + * @param {string} appActivity + * @param {string} [appWaitPackage] + * @param {string} [appWaitActivity] + * @param {string} [intentAction] + * @param {string} [intentCategory] + * @param {string} [intentFlags] + * @param {string} [optionalIntentArguments] + * @param {boolean} [dontStopAppOnReset] + * @returns {Promise} + */ +export async function startActivity( + appPackage, + appActivity, + appWaitPackage, + appWaitActivity, + intentAction, + intentCategory, + intentFlags, + optionalIntentArguments, + dontStopAppOnReset, +) { + this.log.debug(`Starting package '${appPackage}' and activity '${appActivity}'`); + + // dontStopAppOnReset is both an argument here, and a desired capability + // if the argument is set, use it, otherwise use the cap + if (!util.hasValue(dontStopAppOnReset)) { + dontStopAppOnReset = !!this.opts.dontStopAppOnReset; + } + + /** @type {import('appium-adb').StartAppOptions} */ + let args = { + pkg: appPackage, + activity: appActivity, + waitPkg: appWaitPackage || appPackage, + waitActivity: appWaitActivity || appActivity, + action: intentAction, + category: intentCategory, + flags: intentFlags, + optionalIntentArguments, + stopApp: !dontStopAppOnReset, + }; + this._cachedActivityArgs = this._cachedActivityArgs || {}; + this._cachedActivityArgs[`${args.waitPkg}/${args.waitActivity}`] = args; + await this.adb.startApp(args); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StartActivityOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileStartActivity(opts = {}) { + const {user, wait, stop, windowingMode, activityType, display} = opts; + const cmd = [ + 'am', + (await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8 ? 'start' : 'start-activity', + ]; + if (!_.isNil(user)) { + cmd.push('--user', String(user)); + } + if (wait) { + cmd.push('-W'); + } + if (stop) { + cmd.push('-S'); + } + if (!_.isNil(windowingMode)) { + cmd.push('--windowingMode', String(windowingMode)); + } + if (!_.isNil(activityType)) { + cmd.push('--activityType', String(activityType)); + } + if (!_.isNil(display)) { + cmd.push('--display', String(display)); + } + cmd.push(...parseIntentSpec(opts)); + return await this.adb.shell(cmd); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').BroadcastOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileBroadcast(opts = {}) { + const {user, receiverPermission, allowBackgroundActivityStarts} = opts; + const cmd = ['am', 'broadcast']; + if (!_.isNil(user)) { + cmd.push('--user', String(user)); + } + if (receiverPermission) { + cmd.push('--receiver-permission', receiverPermission); + } + if (allowBackgroundActivityStarts) { + cmd.push('--allow-background-activity-starts'); + } + cmd.push(...parseIntentSpec(opts)); + return await this.adb.shell(cmd); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StartServiceOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileStartService(opts = {}) { + const {user, foreground} = opts; + const cmd = ['am']; + if ((await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8) { + cmd.push('startservice'); + } else { + cmd.push(foreground ? 'start-foreground-service' : 'start-service'); + } + if (!_.isNil(user)) { + cmd.push('--user', String(user)); + } + cmd.push(...parseIntentSpec(opts)); + return await this.adb.shell(cmd); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StopServiceOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileStopService(opts = {}) { + const {user} = opts; + const cmd = [ + 'am', + (await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8 ? 'stopservice' : 'stop-service', + ]; + if (!_.isNil(user)) { + cmd.push('--user', String(user)); + } + cmd.push(...parseIntentSpec(opts)); + return await this.adb.shell(cmd); +} + +// #region Internal helpers + /** * * @param {import('./types').IntentOpts} opts @@ -73,12 +215,12 @@ function parseIntentSpec(opts = {}) { if (!_.includes(SUPPORTED_EXTRA_TYPES, type)) { throw new errors.InvalidArgumentError( `Extra argument type '${type}' is not known. ` + - `Supported intent argument types are: ${SUPPORTED_EXTRA_TYPES}` + `Supported intent argument types are: ${SUPPORTED_EXTRA_TYPES}`, ); } if (_.isEmpty(key) || (_.isString(key) && _.trim(key) === '')) { throw new errors.InvalidArgumentError( - `Extra argument's key in '${JSON.stringify(item)}' must be a valid string identifier` + `Extra argument's key in '${JSON.stringify(item)}' must be a valid string identifier`, ); } if (type === NO_VALUE_ARG_TYPE) { @@ -86,7 +228,7 @@ function parseIntentSpec(opts = {}) { } else if (_.isUndefined(value)) { throw new errors.InvalidArgumentError( `Intent argument type '${type}' in '${JSON.stringify(item)}' requires a ` + - `valid value to be provided` + `valid value to be provided`, ); } else { resultArgs.push(`--e${type}`, key, value); @@ -99,91 +241,7 @@ function parseIntentSpec(opts = {}) { return resultArgs; } -/** - * @type {import('./mixins').ActivityMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const ActivityMixin = { - async mobileStartActivity(opts = {}) { - const {user, wait, stop, windowingMode, activityType, display} = opts; - const cmd = [ - 'am', - (await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8 - ? 'start' - : 'start-activity', - ]; - if (!_.isNil(user)) { - cmd.push('--user', String(user)); - } - if (wait) { - cmd.push('-W'); - } - if (stop) { - cmd.push('-S'); - } - if (!_.isNil(windowingMode)) { - cmd.push('--windowingMode', String(windowingMode)); - } - if (!_.isNil(activityType)) { - cmd.push('--activityType', String(activityType)); - } - if (!_.isNil(display)) { - cmd.push('--display', String(display)); - } - cmd.push(...parseIntentSpec(opts)); - return await this.adb.shell(cmd); - }, - - async mobileBroadcast(opts = {}) { - const {user, receiverPermission, allowBackgroundActivityStarts} = opts; - const cmd = ['am', 'broadcast']; - if (!_.isNil(user)) { - cmd.push('--user', String(user)); - } - if (receiverPermission) { - cmd.push('--receiver-permission', receiverPermission); - } - if (allowBackgroundActivityStarts) { - cmd.push('--allow-background-activity-starts'); - } - cmd.push(...parseIntentSpec(opts)); - return await this.adb.shell(cmd); - }, - - async mobileStartService(opts = {}) { - const {user, foreground} = opts; - const cmd = ['am']; - if ((await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8) { - cmd.push('startservice'); - } else { - cmd.push(foreground ? 'start-foreground-service' : 'start-service'); - } - if (!_.isNil(user)) { - cmd.push('--user', String(user)); - } - cmd.push(...parseIntentSpec(opts)); - return await this.adb.shell(cmd); - }, - - async mobileStopService(opts = {}) { - const {user} = opts; - const cmd = [ - 'am', - (await this.adb.getApiLevel()) < API_LEVEL_ANDROID_8 - ? 'stopservice' - : 'stop-service', - ]; - if (!_.isNil(user)) { - cmd.push('--user', String(user)); - } - cmd.push(...parseIntentSpec(opts)); - return await this.adb.shell(cmd); - }, -}; - -mixin(ActivityMixin); - -export default ActivityMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/keyboard.js b/lib/commands/keyboard.js index bcec8bb4..fe291b54 100644 --- a/lib/commands/keyboard.js +++ b/lib/commands/keyboard.js @@ -1,24 +1,121 @@ -// @ts-check +/* eslint-disable @typescript-eslint/no-unused-vars */ +import _ from 'lodash'; +import {errors} from 'appium/driver'; +import {requireArgs} from '../utils'; +import {UNICODE_IME, EMPTY_IME} from 'io.appium.settings'; -import {mixin} from './mixins'; +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function hideKeyboard() { + return await /** @type {import('appium-adb').ADB} */ (this.adb).hideKeyboard(); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function isKeyboardShown() { + const {isKeyboardShown} = await /** @type {import('appium-adb').ADB} */ ( + this.adb + ).isSoftKeyboardPresent(); + return isKeyboardShown; +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|string[]} keys + * @returns {Promise} + */ +export async function keys(keys) { + // Protocol sends an array; rethink approach + keys = _.isArray(keys) ? keys.join('') : keys; + await this.doSendKeys({ + text: keys, + replace: false, + }); +} /** - * @type {import('./mixins').KeyboardMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @param {import('./types').SendKeysOpts} params + * @returns {Promise} */ -const KeyboardMixin = { - async hideKeyboard() { - return await /** @type {import('appium-adb').ADB} */ (this.adb).hideKeyboard(); - }, +export async function doSendKeys(params) { + throw new errors.NotImplementedError('Not implemented'); +} - async isKeyboardShown() { - const {isKeyboardShown} = await /** @type {import('appium-adb').ADB} */ ( - this.adb - ).isSoftKeyboardPresent(); - return isKeyboardShown; - }, -}; +/** + * @this {import('../driver').AndroidDriver} + * @param {string|number} keycode + * @param {number} [metastate] + * @returns {Promise} + */ +export async function keyevent(keycode, metastate) { + // TODO deprecate keyevent; currently wd only implements keyevent + this.log.warn('keyevent will be deprecated use pressKeyCode'); + return await this.pressKeyCode(keycode, metastate); +} -mixin(KeyboardMixin); +/** + * @this {import('../driver').AndroidDriver} + * @param {string|number} keycode + * @param {number} [metastate] + * @returns {Promise} + */ +export async function pressKeyCode(keycode, metastate) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string|number} keycode + * @param {number} [metastate] + * @returns {Promise} + */ +export async function longPressKeyCode(keycode, metastate) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').PerformEditorActionOpts} opts + * @returns {Promise} + */ +export async function mobilePerformEditorAction(opts) { + const {action} = requireArgs('action', opts); + await this.settingsApp.performEditorAction(action); +} + +// #region Internal Helpers + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function initUnicodeKeyboard() { + this.log.debug('Enabling Unicode keyboard support'); + + // get the default IME so we can return back to it later if we want + const defaultIME = await this.adb.defaultIME(); + + this.log.debug(`Unsetting previous IME ${defaultIME}`); + this.log.debug(`Setting IME to '${UNICODE_IME}'`); + await this.adb.enableIME(UNICODE_IME); + await this.adb.setIME(UNICODE_IME); + return defaultIME; +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function hideKeyboardCompletely() { + this.log.debug(`Hiding the on-screen keyboard by setting IME to '${EMPTY_IME}'`); + await this.adb.enableIME(EMPTY_IME); + await this.adb.setIME(EMPTY_IME); +} -export default KeyboardMixin; +// #endregion diff --git a/lib/commands/lock/exports.js b/lib/commands/lock/exports.js new file mode 100644 index 00000000..219e468d --- /dev/null +++ b/lib/commands/lock/exports.js @@ -0,0 +1,139 @@ +import B from 'bluebird'; +import { + validateUnlockCapabilities, + FINGERPRINT_UNLOCK, + fastUnlock, + PIN_UNLOCK, + pinUnlock, + PIN_UNLOCK_KEY_EVENT, + pinUnlockWithKeyEvent, + PASSWORD_UNLOCK, + passwordUnlock, + PATTERN_UNLOCK, + patternUnlock, + fingerprintUnlock, + toCredentialType, + verifyUnlock, +} from './helpers'; +import _ from 'lodash'; + + +/** + * @this {AndroidDriver} + * @param {import('../types').LockOpts} opts + * @returns {Promise} + */ +export async function mobileLock(opts = {}) { + const {seconds} = opts; + return await this.lock(seconds); +} + +/** + * @this {AndroidDriver} + * @param {number} [seconds] + * @returns {Promise} + */ +export async function lock(seconds) { + await this.adb.lock(); + if (Number.isNaN(seconds)) { + return; + } + + const floatSeconds = parseFloat(String(seconds)); + if (floatSeconds <= 0) { + return; + } + await B.delay(1000 * floatSeconds); + await this.unlock(); +} + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function isLocked() { + return await this.adb.isScreenLocked(); +} + +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function unlock() { + await unlockWithOptions.bind(this)(); +} + +/** + * @this {AndroidDriver} + * @param {import('../types').UnlockOptions} [opts={}] + * @returns {Promise} + */ +export async function mobileUnlock(opts = {}) { + const {key, type, strategy, timeoutMs} = opts; + if (!key && !type) { + await this.unlock(); + } else { + await unlockWithOptions.bind(this)({ + unlockKey: key, + unlockType: type, + unlockStrategy: strategy, + unlockSuccessTimeout: timeoutMs, + }); + } +} + +// #region Internal Helpers + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps?} [caps=null] + * @returns {Promise} + */ +export async function unlockWithOptions(caps = null) { + if (!(await this.adb.isScreenLocked())) { + this.log.info('Screen already unlocked, doing nothing'); + return; + } + + const capabilities = caps ?? this.opts; + this.log.debug('Screen is locked, trying to unlock'); + if (!capabilities.unlockType && !capabilities.unlockKey) { + this.log.info( + `Neither 'unlockType' nor 'unlockKey' capability is provided. ` + + `Assuming the device is locked with a simple lock screen.`, + ); + await this.adb.dismissKeyguard(); + return; + } + + const {unlockType, unlockKey, unlockStrategy, unlockSuccessTimeout} = + validateUnlockCapabilities(capabilities); + if ( + unlockKey && + unlockType !== FINGERPRINT_UNLOCK && + (_.isNil(unlockStrategy) || _.toLower(unlockStrategy) === 'locksettings') && + (await this.adb.isLockManagementSupported()) + ) { + await fastUnlock.bind(this)({ + credential: unlockKey, + credentialType: toCredentialType(/** @type {import('../types').UnlockType} */ (unlockType)), + }); + } else { + const unlockMethod = { + [PIN_UNLOCK]: pinUnlock, + [PIN_UNLOCK_KEY_EVENT]: pinUnlockWithKeyEvent, + [PASSWORD_UNLOCK]: passwordUnlock, + [PATTERN_UNLOCK]: patternUnlock, + [FINGERPRINT_UNLOCK]: fingerprintUnlock, + }[unlockType]; + await unlockMethod.bind(this)(capabilities); + } + await verifyUnlock.bind(this)(unlockSuccessTimeout); +} + +// #endregion + +/** + * @typedef {import('@appium/types').Capabilities} AndroidDriverCaps + * @typedef {import('../../driver').AndroidDriver} AndroidDriver + */ diff --git a/lib/commands/lock/helpers.js b/lib/commands/lock/helpers.js new file mode 100644 index 00000000..de174ccb --- /dev/null +++ b/lib/commands/lock/helpers.js @@ -0,0 +1,379 @@ +import {util} from '@appium/support'; +import {sleep, waitForCondition} from 'asyncbox'; +import _ from 'lodash'; + +export const PIN_UNLOCK = 'pin'; +export const PIN_UNLOCK_KEY_EVENT = 'pinWithKeyEvent'; +export const PASSWORD_UNLOCK = 'password'; +export const PATTERN_UNLOCK = 'pattern'; +export const FINGERPRINT_UNLOCK = 'fingerprint'; +const UNLOCK_TYPES = /** @type {const} */ ([ + PIN_UNLOCK, + PIN_UNLOCK_KEY_EVENT, + PASSWORD_UNLOCK, + PATTERN_UNLOCK, + FINGERPRINT_UNLOCK, +]); +export const KEYCODE_NUMPAD_ENTER = 66; +export const UNLOCK_WAIT_TIME = 100; +export const INPUT_KEYS_WAIT_TIME = 100; +const NUMBER_ZERO_KEYCODE = 7; + +/** + * + * @param {any} value + * @returns {value is string} + */ +function isNonEmptyString(value) { + return typeof value === 'string' && value !== ''; +} + +/** + * Wait for the display to be unlocked. + * Some devices automatically accept typed 'pin' and 'password' code + * without pressing the Enter key. But some devices need it. + * This method waits a few seconds first for such automatic acceptance case. + * If the device is still locked, then this method will try to send + * the enter key code. + * + * @param {import('appium-adb').ADB} adb The instance of ADB + */ +async function waitForUnlock(adb) { + await sleep(UNLOCK_WAIT_TIME); + if (!(await adb.isScreenLocked())) { + return; + } + + await adb.keyevent(KEYCODE_NUMPAD_ENTER); + await sleep(UNLOCK_WAIT_TIME); +} + +/** + * + * @param {import('../types').UnlockType} unlockType + * @returns {string} + */ +export function toCredentialType(unlockType) { + const result = { + [PIN_UNLOCK]: 'pin', + [PIN_UNLOCK_KEY_EVENT]: 'pin', + [PASSWORD_UNLOCK]: 'password', + [PATTERN_UNLOCK]: 'pattern', + }[unlockType]; + if (result) { + return result; + } + throw new Error(`Unlock type '${unlockType}' is not known`); +} + +/** + * @template {AndroidDriverCaps} T + * @param {T} caps + * @returns {T} + */ +export function validateUnlockCapabilities(caps) { + const {unlockKey, unlockType} = caps ?? {}; + if (!isNonEmptyString(unlockType)) { + throw new Error('A non-empty unlock key value must be provided'); + } + + if ([PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, FINGERPRINT_UNLOCK].includes(unlockType)) { + if (!/^[0-9]+$/.test(_.trim(unlockKey))) { + throw new Error(`Unlock key value '${unlockKey}' must only consist of digits`); + } + } else if (unlockType === PATTERN_UNLOCK) { + if (!/^[1-9]{2,9}$/.test(_.trim(unlockKey))) { + throw new Error( + `Unlock key value '${unlockKey}' must only include from two to nine digits in range 1..9`, + ); + } + if (/([1-9]).*?\1/.test(_.trim(unlockKey))) { + throw new Error( + `Unlock key value '${unlockKey}' must define a valid pattern where repeats are not allowed`, + ); + } + } else if (unlockType === PASSWORD_UNLOCK) { + // Dont trim password key, you can use blank spaces in your android password + // ¯\_(ツ)_/¯ + if (!/.{4,}/g.test(String(unlockKey))) { + throw new Error( + `The minimum allowed length of unlock key value '${unlockKey}' is 4 characters`, + ); + } + } else { + throw new Error( + `Invalid unlock type '${unlockType}'. ` + + `Only the following unlock types are supported: ${UNLOCK_TYPES}`, + ); + } + return caps; +} + +/** + * @this {AndroidDriver} + * @param {import('../types').FastUnlockOptions} opts + */ +export async function fastUnlock(opts) { + const {credential, credentialType} = opts; + this.log.info(`Unlocking the device via ADB using ${credentialType} credential '${credential}'`); + const wasLockEnabled = await this.adb.isLockEnabled(); + if (wasLockEnabled) { + await this.adb.clearLockCredential(credential); + // not sure why, but the device's screen still remains locked + // if a preliminary wake up cycle has not been performed + await this.adb.cycleWakeUp(); + } else { + this.log.info('No active lock has been detected. Proceeding to the keyguard dismissal'); + } + try { + await this.adb.dismissKeyguard(); + } finally { + if (wasLockEnabled) { + await this.adb.setLockCredential(credentialType, credential); + } + } +} + +/** + * + * @param {string} key + * @returns {string} + */ +export function encodePassword(key) { + return `${key}`.replace(/\s/gi, '%s'); +} + +/** + * + * @param {string} key + * @returns {string[]} + */ +export function stringKeyToArr(key) { + return `${key}`.trim().replace(/\s+/g, '').split(/\s*/); +} + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps} capabilities + * @returns {Promise} + */ +export async function fingerprintUnlock(capabilities) { + if ((await this.adb.getApiLevel()) < 23) { + throw new Error('Fingerprint unlock only works for Android 6+ emulators'); + } + await this.adb.fingerprint(String(capabilities.unlockKey)); + await sleep(UNLOCK_WAIT_TIME); +} + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps} capabilities + * @returns {Promise} + */ +export async function pinUnlock(capabilities) { + this.log.info(`Trying to unlock device using pin ${capabilities.unlockKey}`); + await this.adb.dismissKeyguard(); + const keys = stringKeyToArr(String(capabilities.unlockKey)); + if ((await this.adb.getApiLevel()) >= 21) { + const els = await this.findElOrEls('id', 'com.android.systemui:id/digit_text', true); + if (_.isEmpty(els)) { + // fallback to pin with key event + return await pinUnlockWithKeyEvent.bind(this)(capabilities); + } + const pins = {}; + for (const el of els) { + const text = await this.getAttribute('text', util.unwrapElement(el)); + pins[text] = el; + } + for (const pin of keys) { + const el = pins[pin]; + await this.click(util.unwrapElement(el)); + } + } else { + for (const pin of keys) { + const el = await this.findElOrEls('id', `com.android.keyguard:id/key${pin}`, false); + if (el === null) { + // fallback to pin with key event + return await pinUnlockWithKeyEvent.bind(this)(capabilities); + } + await this.click(util.unwrapElement(el)); + } + } + await waitForUnlock(this.adb); +} + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps} capabilities + * @returns {Promise} + */ +export async function pinUnlockWithKeyEvent(capabilities) { + this.log.info(`Trying to unlock device using pin with keycode ${capabilities.unlockKey}`); + await this.adb.dismissKeyguard(); + const keys = stringKeyToArr(String(capabilities.unlockKey)); + + // Some device does not have system key ids like 'com.android.keyguard:id/key' + // Then, sending keyevents are more reliable to unlock the screen. + for (const pin of keys) { + // 'pin' is number (0-9) in string. + // Number '0' is keycode '7'. number '9' is keycode '16'. + await this.adb.shell(['input', 'keyevent', String(parseInt(pin, 10) + NUMBER_ZERO_KEYCODE)]); + } + await waitForUnlock(this.adb); +} + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps} capabilities + * @returns {Promise} + */ +export async function passwordUnlock(capabilities) { + const {unlockKey} = capabilities; + this.log.info(`Trying to unlock device using password ${unlockKey}`); + await this.adb.dismissKeyguard(); + // Replace blank spaces with %s + const key = encodePassword(String(unlockKey)); + // Why adb ? It was less flaky + await this.adb.shell(['input', 'text', key]); + // Why sleeps ? Avoid some flakyness waiting for the input to receive the keys + await sleep(INPUT_KEYS_WAIT_TIME); + await this.adb.shell(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]); + // Waits a bit for the device to be unlocked + await waitForUnlock(this.adb); +} + +/** + * + * @param {number} key + * @param {import('@appium/types').Position} initPos + * @param {number} piece + * @returns {import('@appium/types').Position} + */ +export function getPatternKeyPosition(key, initPos, piece) { + /* + How the math works: + We have 9 buttons divided in 3 columns and 3 rows inside the lockPatternView, + every button has a position on the screen corresponding to the lockPatternView since + it is the parent view right at the middle of each column or row. + */ + const cols = 3; + const pins = 9; + const xPos = (key, x, piece) => Math.round(x + (key % cols || cols) * piece - piece / 2); + const yPos = (key, y, piece) => + Math.round(y + (Math.ceil((key % pins || pins) / cols) * piece - piece / 2)); + return { + x: xPos(key, initPos.x, piece), + y: yPos(key, initPos.y, piece), + }; +} + +/** + * @param {string[]|number[]} keys + * @param {import('@appium/types').Position} initPos + * @param {number} piece + * @returns {import('../types').TouchAction[]} + */ +export function getPatternActions(keys, initPos, piece) { + /** @type {import('../types').TouchAction[]} */ + const actions = []; + /** @type {number[]} */ + const intKeys = keys.map((key) => (_.isString(key) ? _.parseInt(key) : key)); + /** @type {import('@appium/types').Position|undefined} */ + let lastPos; + for (const key of intKeys) { + const keyPos = getPatternKeyPosition(key, initPos, piece); + if (!lastPos) { + actions.push({action: 'press', options: {element: undefined, x: keyPos.x, y: keyPos.y}}); + lastPos = keyPos; + continue; + } + const moveTo = {x: 0, y: 0}; + const diffX = keyPos.x - lastPos.x; + if (diffX > 0) { + moveTo.x = piece; + if (Math.abs(diffX) > piece) { + moveTo.x += piece; + } + } else if (diffX < 0) { + moveTo.x = -1 * piece; + if (Math.abs(diffX) > piece) { + moveTo.x -= piece; + } + } + const diffY = keyPos.y - lastPos.y; + if (diffY > 0) { + moveTo.y = piece; + if (Math.abs(diffY) > piece) { + moveTo.y += piece; + } + } else if (diffY < 0) { + moveTo.y = -1 * piece; + if (Math.abs(diffY) > piece) { + moveTo.y -= piece; + } + } + actions.push({ + action: 'moveTo', + // @ts-ignore lastPos should be defined + options: {element: undefined, x: moveTo.x + lastPos.x, y: moveTo.y + lastPos.y}, + }); + lastPos = keyPos; + } + actions.push({action: 'release'}); + return actions; +} + +/** + * @this {AndroidDriver} + * @param {number?} [timeoutMs=null] + */ +export async function verifyUnlock(timeoutMs = null) { + try { + await waitForCondition(async () => !(await this.adb.isScreenLocked()), { + waitMs: timeoutMs ?? 2000, + intervalMs: 500, + }); + } catch (ign) { + throw new Error('The device has failed to be unlocked'); + } + this.log.info('The device has been successfully unlocked'); +} + +/** + * @this {AndroidDriver} + * @param {AndroidDriverCaps} capabilities + */ +export async function patternUnlock(capabilities) { + const {unlockKey} = capabilities; + this.log.info(`Trying to unlock device using pattern ${unlockKey}`); + await this.adb.dismissKeyguard(); + const keys = stringKeyToArr(String(unlockKey)); + /* We set the device pattern buttons as number of a regular phone + * | • • • | | 1 2 3 | + * | • • • | --> | 4 5 6 | + * | • • • | | 7 8 9 | + + The pattern view buttons are not seeing by the uiautomator since they are + included inside a FrameLayout, so we are going to try clicking on the buttons + using the parent view bounds and math. + */ + const apiLevel = await this.adb.getApiLevel(); + const el = await this.findElOrEls( + 'id', + `com.android.${apiLevel >= 21 ? 'systemui' : 'keyguard'}:id/lockPatternView`, + false, + ); + const initPos = await this.getLocation(util.unwrapElement(el)); + const size = await this.getSize(util.unwrapElement(el)); + // Get actions to perform + const actions = getPatternActions(keys, initPos, size.width / 3); + // Perform gesture + await this.performTouch(actions); + // Waits a bit for the device to be unlocked + await sleep(UNLOCK_WAIT_TIME); +} + +/** + * @typedef {import('@appium/types').Capabilities} AndroidDriverCaps + * @typedef {import('../../driver').AndroidDriver} AndroidDriver + */ diff --git a/lib/commands/log.js b/lib/commands/log.js index 071cf6b1..e752109c 100644 --- a/lib/commands/log.js +++ b/lib/commands/log.js @@ -1,14 +1,179 @@ -// @ts-check - import {DEFAULT_WS_PATHNAME_PREFIX, BaseDriver} from 'appium/driver'; import _ from 'lodash'; import os from 'node:os'; import WebSocket from 'ws'; -import log from '../logger'; -import {mixin} from './mixins'; const GET_SERVER_LOGS_FEATURE = 'get_server_logs'; +export const supportedLogTypes = { + logcat: { + description: 'Logs for Android applications on real device and emulators via ADB', + /** + * + * @param {import('../driver').AndroidDriver} self + * @returns + */ + getter: (self) => /** @type {ADB} */ (self.adb).getLogcatLogs(), + }, + bugreport: { + description: `'adb bugreport' output for advanced issues diagnostic`, + /** + * + * @param {import('../driver').AndroidDriver} self + * @returns + */ + getter: async (self) => { + const output = await /** @type {ADB} */ (self.adb).bugreport(); + const timestamp = Date.now(); + return output.split(os.EOL).map((x) => toLogRecord(timestamp, 'ALL', x)); + }, + }, + server: { + description: 'Appium server logs', + /** + * + * @param {import('../driver').AndroidDriver} self + * @returns + */ + getter: (self) => { + self.ensureFeatureEnabled(GET_SERVER_LOGS_FEATURE); + const timestamp = Date.now(); + return self.log + .unwrap() + .record.map((x) => + toLogRecord( + timestamp, + 'ALL', + _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}`, + ), + ); + }, + }, +}; + +/** + * Starts Android logcat broadcast websocket on the same host and port + * where Appium server is running at `/ws/session/:sessionId:/appium/logcat` endpoint. The method + * will return immediately if the web socket is already listening. + * + * Each connected websocket listener will receive logcat log lines + * as soon as they are visible to Appium. + * + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileStartLogsBroadcast() { + const server = /** @type {import('@appium/types').AppiumServer} */ (this.server); + const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); + if (!_.isEmpty(await server.getWebSocketHandlers(pathname))) { + this.log.debug(`The logcat broadcasting web socket server is already listening at ${pathname}`); + return; + } + + this.log.info( + `Starting logcat broadcasting on web socket server ` + + `${JSON.stringify(server.address())} to ${pathname}`, + ); + // https://github.com/websockets/ws/blob/master/doc/ws.md + const wss = new WebSocket.Server({ + noServer: true, + }); + wss.on('connection', (ws, req) => { + if (req) { + const remoteIp = _.isEmpty(req.headers['x-forwarded-for']) + ? req.connection?.remoteAddress + : req.headers['x-forwarded-for']; + this.log.debug(`Established a new logcat listener web socket connection from ${remoteIp}`); + } else { + this.log.debug('Established a new logcat listener web socket connection'); + } + + if (_.isEmpty(this._logcatWebsocketListener)) { + this._logcatWebsocketListener = (logRecord) => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(logRecord.message); + } + }; + } + this.adb.setLogcatListener(this._logcatWebsocketListener); + + ws.on('close', (code, reason) => { + if (!_.isEmpty(this._logcatWebsocketListener)) { + try { + this.adb.removeLogcatListener(this._logcatWebsocketListener); + } catch (ign) {} + this._logcatWebsocketListener = undefined; + } + + let closeMsg = 'Logcat listener web socket is closed.'; + if (!_.isEmpty(code)) { + closeMsg += ` Code: ${code}.`; + } + if (!_.isEmpty(reason)) { + closeMsg += ` Reason: ${reason.toString()}.`; + } + this.log.debug(closeMsg); + }); + }); + await server.addWebSocketHandler(pathname, /** @type {import('@appium/types').WSServer} */ (wss)); +} + +/** + * Stops the previously started logcat broadcasting wesocket server. + * This method will return immediately if no server is running. + * + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileStopLogsBroadcast() { + const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); + const server = /** @type {import('@appium/types').AppiumServer} */ (this.server); + if (_.isEmpty(await server.getWebSocketHandlers(pathname))) { + return; + } + + this.log.debug( + `Stopping logcat broadcasting on web socket server ` + + `${JSON.stringify(server.address())} to ${pathname}`, + ); + await server.removeWebSocketHandler(pathname); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getLogTypes() { + // XXX why doesn't `super` work here? + const nativeLogTypes = await BaseDriver.prototype.getLogTypes.call(this); + if (this.isWebContext()) { + const webLogTypes = /** @type {string[]} */ ( + await /** @type {import('appium-chromedriver').Chromedriver} */ ( + this.chromedriver + ).jwproxy.command('/log/types', 'GET') + ); + return [...nativeLogTypes, ...webLogTypes]; + } + return nativeLogTypes; +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} logType + * @returns {Promise} + */ +export async function getLog(logType) { + if (this.isWebContext() && !_.keys(this.supportedLogTypes).includes(logType)) { + return await /** @type {import('appium-chromedriver').Chromedriver} */ ( + this.chromedriver + ).jwproxy.command('/log', 'POST', {type: logType}); + } + // XXX why doesn't `super` work here? + return await BaseDriver.prototype.getLog.call(this, logType); +} + +// #region Internal helpers + /** * @param {string} sessionId * @returns {string} @@ -30,169 +195,8 @@ function toLogRecord(timestamp, level, message) { message, }; } -/** - * @type {import('./mixins').LogMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const LogMixin = { - supportedLogTypes: { - logcat: { - description: 'Logs for Android applications on real device and emulators via ADB', - /** - * - * @param {import('../driver').AndroidDriver} self - * @returns - */ - getter: (self) => /** @type {ADB} */ (self.adb).getLogcatLogs(), - }, - bugreport: { - description: `'adb bugreport' output for advanced issues diagnostic`, - /** - * - * @param {import('../driver').AndroidDriver} self - * @returns - */ - getter: async (self) => { - const output = await /** @type {ADB} */ (self.adb).bugreport(); - const timestamp = Date.now(); - return output.split(os.EOL).map((x) => toLogRecord(timestamp, 'ALL', x)); - }, - }, - server: { - description: 'Appium server logs', - /** - * - * @param {import('../driver').AndroidDriver} self - * @returns - */ - getter: (self) => { - self.ensureFeatureEnabled(GET_SERVER_LOGS_FEATURE); - const timestamp = Date.now(); - return log - .unwrap() - .record.map((x) => - toLogRecord( - timestamp, - 'ALL', - _.isEmpty(x.prefix) ? x.message : `[${x.prefix}] ${x.message}` - ) - ); - }, - }, - }, - - /** - * Starts Android logcat broadcast websocket on the same host and port - * where Appium server is running at `/ws/session/:sessionId:/appium/logcat` endpoint. The method - * will return immediately if the web socket is already listening. - * - * Each connected websocket listener will receive logcat log lines - * as soon as they are visible to Appium. - */ - async mobileStartLogsBroadcast() { - const server = /** @type {import('@appium/types').AppiumServer} */ (this.server); - const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); - if (!_.isEmpty(await server.getWebSocketHandlers(pathname))) { - log.debug(`The logcat broadcasting web socket server is already listening at ${pathname}`); - return; - } - - log.info( - `Starting logcat broadcasting on web socket server ` + - `${JSON.stringify(server.address())} to ${pathname}` - ); - // https://github.com/websockets/ws/blob/master/doc/ws.md - const wss = new WebSocket.Server({ - noServer: true, - }); - wss.on('connection', (ws, req) => { - if (req) { - const remoteIp = _.isEmpty(req.headers['x-forwarded-for']) - ? req.connection?.remoteAddress - : req.headers['x-forwarded-for']; - log.debug(`Established a new logcat listener web socket connection from ${remoteIp}`); - } else { - log.debug('Established a new logcat listener web socket connection'); - } - - if (_.isEmpty(this._logcatWebsocketListener)) { - this._logcatWebsocketListener = (logRecord) => { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(logRecord.message); - } - }; - } - this.adb.setLogcatListener(this._logcatWebsocketListener); - - ws.on('close', (code, reason) => { - if (!_.isEmpty(this._logcatWebsocketListener)) { - try { - this.adb.removeLogcatListener(this._logcatWebsocketListener); - } catch (ign) {} - this._logcatWebsocketListener = undefined; - } - - let closeMsg = 'Logcat listener web socket is closed.'; - if (!_.isEmpty(code)) { - closeMsg += ` Code: ${code}.`; - } - if (!_.isEmpty(reason)) { - closeMsg += ` Reason: ${reason.toString()}.`; - } - log.debug(closeMsg); - }); - }); - await server.addWebSocketHandler( - pathname, - /** @type {import('@appium/types').WSServer} */ (wss) - ); - }, - /** - * Stops the previously started logcat broadcasting wesocket server. - * This method will return immediately if no server is running. - */ - async mobileStopLogsBroadcast() { - const pathname = WEBSOCKET_ENDPOINT(/** @type {string} */ (this.sessionId)); - const server = /** @type {import('@appium/types').AppiumServer} */ (this.server); - if (_.isEmpty(await server.getWebSocketHandlers(pathname))) { - return; - } - - log.debug( - `Stopping logcat broadcasting on web socket server ` + - `${JSON.stringify(server.address())} to ${pathname}` - ); - await server.removeWebSocketHandler(pathname); - }, - - async getLogTypes() { - // XXX why doesn't `super` work here? - const nativeLogTypes = await BaseDriver.prototype.getLogTypes.call(this); - if (this.isWebContext()) { - const webLogTypes = /** @type {string[]} */ ( - await /** @type {import('appium-chromedriver').Chromedriver} */ ( - this.chromedriver - ).jwproxy.command('/log/types', 'GET') - ); - return [...nativeLogTypes, ...webLogTypes]; - } - return nativeLogTypes; - }, - - async getLog(logType) { - if (this.isWebContext() && !_.keys(this.supportedLogTypes).includes(logType)) { - return await /** @type {import('appium-chromedriver').Chromedriver} */ ( - this.chromedriver - ).jwproxy.command('/log', 'POST', {type: logType}); - } - // XXX why doesn't `super` work here? - return await BaseDriver.prototype.getLog.call(this, logType); - }, -}; - -mixin(LogMixin); -export default LogMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/media-projection.js b/lib/commands/media-projection.js index bdf0e72e..030b6bb0 100644 --- a/lib/commands/media-projection.js +++ b/lib/commands/media-projection.js @@ -2,13 +2,85 @@ import {fs, net, util} from '@appium/support'; import _ from 'lodash'; import moment from 'moment'; import path from 'node:path'; -import {mixin} from './mixins'; // https://github.com/appium/io.appium.settings#internal-audio--video-recording const DEFAULT_EXT = '.mp4'; const MIN_API_LEVEL = 29; const DEFAULT_FILENAME_FORMAT = 'YYYY-MM-DDTHH-mm-ss'; +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StartMediaProjectionRecordingOpts} [options={}] + * @returns {Promise} + */ +export async function mobileStartMediaProjectionRecording(options = {}) { + await verifyMediaProjectionRecordingIsSupported(this.adb); + + const {resolution, priority, maxDurationSec, filename} = options; + const recorder = this.settingsApp.makeMediaProjectionRecorder(); + const fname = adjustMediaExtension(filename || moment().format(DEFAULT_FILENAME_FORMAT)); + const didStart = await recorder.start({ + resolution, + priority, + maxDurationSec, + filename: fname, + }); + if (didStart) { + this.log.info(`A new media projection recording '${fname}' has been successfully started`); + } else { + this.log.info( + 'Another media projection recording is already in progress. There is nothing to start', + ); + } + return didStart; +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileIsMediaProjectionRecordingRunning() { + await verifyMediaProjectionRecordingIsSupported(this.adb); + + const recorder = this.settingsApp.makeMediaProjectionRecorder(); + return await recorder.isRunning(); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StopMediaProjectionRecordingOpts} [options={}] + * @returns {Promise} + */ +export async function mobileStopMediaProjectionRecording(options = {}) { + await verifyMediaProjectionRecordingIsSupported(this.adb); + + const recorder = this.settingsApp.makeMediaProjectionRecorder(); + if (await recorder.stop()) { + this.log.info('Successfully stopped a media projection recording. Pulling the recorded media'); + } else { + this.log.info('Media projection recording is not running. There is nothing to stop'); + } + const recentRecordingPath = await recorder.pullRecent(); + if (!recentRecordingPath) { + throw new Error(`No recent media projection recording have been found. Did you start any?`); + } + + const {remotePath} = options; + if (_.isEmpty(remotePath)) { + const {size} = await fs.stat(recentRecordingPath); + this.log.debug( + `The size of the resulting media projection recording is ${util.toReadableSizeString(size)}`, + ); + } + try { + return await uploadRecordedMedia(recentRecordingPath, remotePath, options); + } finally { + await fs.rimraf(path.dirname(recentRecordingPath)); + } +} + +// #region Internal helpers + /** * * @param {string} localFile @@ -65,79 +137,12 @@ async function verifyMediaProjectionRecordingIsSupported(adb) { if (apiLevel < MIN_API_LEVEL) { throw new Error( `Media projection-based recording is not available on API Level ${apiLevel}. ` + - `Minimum required API Level is ${MIN_API_LEVEL}.` + `Minimum required API Level is ${MIN_API_LEVEL}.`, ); } } -/** - * @type {import('./mixins').MediaProjectionMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const MediaProjectionMixin = { - async mobileStartMediaProjectionRecording(options = {}) { - await verifyMediaProjectionRecordingIsSupported(this.adb); - - const {resolution, priority, maxDurationSec, filename} = options; - const recorder = this.settingsApp.makeMediaProjectionRecorder(); - const fname = adjustMediaExtension(filename || moment().format(DEFAULT_FILENAME_FORMAT)); - const didStart = await recorder.start({ - resolution, - priority, - maxDurationSec, - filename: fname, - }); - if (didStart) { - this.log.info(`A new media projection recording '${fname}' has been successfully started`); - } else { - this.log.info( - 'Another media projection recording is already in progress. There is nothing to start' - ); - } - return didStart; - }, - - async mobileIsMediaProjectionRecordingRunning() { - await verifyMediaProjectionRecordingIsSupported(this.adb); - - const recorder = this.settingsApp.makeMediaProjectionRecorder(); - return await recorder.isRunning(); - }, - - async mobileStopMediaProjectionRecording(options = {}) { - await verifyMediaProjectionRecordingIsSupported(this.adb); - - const recorder = this.settingsApp.makeMediaProjectionRecorder(); - if (await recorder.stop()) { - this.log.info( - 'Successfully stopped a media projection recording. Pulling the recorded media' - ); - } else { - this.log.info('Media projection recording is not running. There is nothing to stop'); - } - const recentRecordingPath = await recorder.pullRecent(); - if (!recentRecordingPath) { - throw new Error(`No recent media projection recording have been found. Did you start any?`); - } - - const {remotePath} = options; - if (_.isEmpty(remotePath)) { - const {size} = await fs.stat(recentRecordingPath); - this.log.debug( - `The size of the resulting media projection recording is ${util.toReadableSizeString(size)}` - ); - } - try { - return await uploadRecordedMedia(recentRecordingPath, remotePath, options); - } finally { - await fs.rimraf(path.dirname(recentRecordingPath)); - } - }, -}; - -mixin(MediaProjectionMixin); - -export default MediaProjectionMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/memory.js b/lib/commands/memory.js index f4b2b4a0..3cad34b8 100644 --- a/lib/commands/memory.js +++ b/lib/commands/memory.js @@ -1,38 +1,26 @@ import {errors} from 'appium/driver'; -import {mixin} from './mixins'; /** - * @type {import('./mixins').MemoryMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * Simulates the onTrimMemory() event for the given package. + * Read https://developer.android.com/topic/performance/memory + * for more details. + * + * @this {import('../driver').AndroidDriver} + * @param {import('./types').SendTrimMemoryOpts} opts + * @returns {Promise} */ -const MemoryMixin = { - /** - * Simulates the onTrimMemory() event for the given package. - * Read https://developer.android.com/topic/performance/memory - * for more details. - * - * @param {import('./types').SendTrimMemoryOpts} opts - */ - async mobileSendTrimMemory(opts) { - const { - pkg, - level, - } = opts; +export async function mobileSendTrimMemory(opts) { + const {pkg, level} = opts; - if (!pkg) { - throw new errors.InvalidArgumentError(`The 'pkg' argument must be provided`); - } - if (!level) { - throw new errors.InvalidArgumentError(`The 'level' argument must be provided`); - } + if (!pkg) { + throw new errors.InvalidArgumentError(`The 'pkg' argument must be provided`); + } + if (!level) { + throw new errors.InvalidArgumentError(`The 'level' argument must be provided`); + } - await this.adb.shell(['am', 'send-trim-memory', pkg, level]); - }, -}; - -mixin(MemoryMixin); - -export default MemoryMixin; + await this.adb.shell(['am', 'send-trim-memory', pkg, level]); +} /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/misc.js b/lib/commands/misc.js new file mode 100644 index 00000000..9e84009c --- /dev/null +++ b/lib/commands/misc.js @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import _ from 'lodash'; +import {errors} from 'appium/driver'; + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getWindowSize() { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getWindowRect() { + const {width, height} = await this.getWindowSize(); + return { + width, + height, + x: 0, + y: 0, + }; +} + +/** + * we override setUrl to take an android URI which can be used for deep-linking + * inside an app, similar to starting an intent + * + * @this {import('../driver').AndroidDriver} + * @param {string} uri + * @returns {Promise} + */ +export async function setUrl(uri) { + await this.adb.startUri(uri, /** @type {string} */ (this.opts.appPackage)); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getDisplayDensity() { + // first try the property for devices + let out = await this.adb.shell(['getprop', 'ro.sf.lcd_density']); + if (out) { + let val = parseInt(out, 10); + // if the value is NaN, try getting the emulator property + if (!isNaN(val)) { + return val; + } + this.log.debug(`Parsed density value was NaN: "${out}"`); + } + // fallback to trying property for emulators + out = await this.adb.shell(['getprop', 'qemu.sf.lcd_density']); + if (out) { + let val = parseInt(out, 10); + if (!isNaN(val)) { + return val; + } + this.log.debug(`Parsed density value was NaN: "${out}"`); + } + // couldn't get anything, so error out + throw this.log.errorAndThrow('Failed to get display density property.'); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileGetNotifications() { + return await this.settingsApp.getNotifications(); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileListSms(opts) { + return await this.settingsApp.getSmsList(opts); +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function openNotifications() { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @typedef {import('appium-adb').ADB} ADB + * @typedef {import('@appium/types').StringRecord} StringRecord + */ diff --git a/lib/commands/mixins.ts b/lib/commands/mixins.ts deleted file mode 100644 index 5d709c67..00000000 --- a/lib/commands/mixins.ts +++ /dev/null @@ -1,976 +0,0 @@ -import type { - AppiumLogger, - Element, - ExternalDriver, - LogDefRecord, - Orientation, - Position, - Rect, - Size, - StringRecord, - Location, -} from '@appium/types'; -import type { - ADB, - InstallOptions, - LogcatListener, - UninstallOptions, -} from 'appium-adb'; -import type Chromedriver from 'appium-chromedriver'; -import {AndroidDriverOpts, AndroidDriver} from '../driver'; -import type * as types from './types'; - -export interface ActionsMixin { - keyevent(keycode: string | number, metastate?: number): Promise; - pressKeyCode(keycode: string | number, metastate?: number, flags?: any): Promise; - longPressKeyCode(keycode: string | number, metastate?: number, flags?: any): Promise; - getOrientation(): Promise; - setOrientation(orientation: Orientation): Promise; - fakeFlick(xSpeed: number, ySpeed: number): Promise; - fakeFlickElement( - elementId: string, - xoffset: number, - yoffset: number, - speed: number - ): Promise; - swipe( - startX: number | 'null', - startY: number | 'null', - endX: number, - endY: number, - duration: number, - touchCount: number, - elId: string - ): Promise; - doSwipe(opts: types.SwipeOpts): Promise; - pinchClose( - startX: number, - startY: number, - endX: number, - endY: number, - duration: number, - percent: number, - steps: number, - elId: string - ): Promise; - pinchOpen( - startX: number, - startY: number, - endX: number, - endY: number, - duration: number, - percent: number, - steps: number, - elId: string - ): Promise; - flick( - element: string, - xSpeed: number, - ySpeed: number, - xOffset: number, - yOffset: number, - speed: number - ): Promise; - drag( - startX: number, - startY: number, - endX: number, - endY: number, - duration: number, - touchCount: number, - elementId?: string | number, - destElId?: string | number - ): Promise; - doDrag(opts: types.DragOpts): Promise; - lock(seconds?: number): Promise; - /** - * Lock the device (and optionally unlock it after a certain amount of time). - * @throws {Error} if lock or unlock operation fails - */ - mobileLock(opts: types.LockOpts): Promise; - unlock(): Promise; - isLocked(): Promise; - openNotifications(): Promise; - setLocation(latitude: number, longitude: number): Promise; - /** - * @group Emulator Only - */ - fingerprint(fingerprintId: string | number): Promise; - /** - * Emulate fingerprint on Android Emulator. - * Only works on API 23+ - * @group Emulator Only - */ - mobileFingerprint(opts: types.FingerprintOpts): Promise; - /** - * @group Emulator Only - */ - sendSMS(phoneNumber: string, message: string): Promise; - - /** - * Emulate sending an SMS to the given phone number. - * Only works on emulators. - * - * @group Emulator Only - */ - mobileSendSms(opts: types.SendSMSOpts): Promise; - - /** - * @group Emulator Only - */ - gsmCall(phoneNumber: string, action: string): Promise; - - /** - * Emulate a GSM call to the given phone number. - * Only works on emulators. - * - * @group Emulator Only - */ - mobileGsmCall(opts: types.GsmCallOpts): Promise; - - /** - * @group Emulator Only - */ - gsmSignal(signalStrength: types.GsmSignalStrength): Promise; - /** - * Emulate GSM signal strength change event. - * Only works on emulators. - * - * @group Emulator Only - */ - mobileGsmSignal(opts: types.GsmSignalStrengthOpts): Promise; - /** - * @group Emulator Only - */ - gsmVoice(state: types.GsmVoiceState): Promise; - - /** - * Emulate GSM voice state change event. - * Only works on emulators. - */ - mobileGsmVoice(opts: types.GsmVoiceOpts): Promise; - /** - * @group Emulator Only - */ - powerAC(state: types.PowerACState): Promise; - /** - * Emulate AC power state change. - * Only works on emulators. - * - * @group Emulator Only - */ - mobilePowerAc(opts: types.PowerACOpts): Promise; - - /** - * @group Emulator Only - */ - powerCapacity(percent: number): Promise; - - /** - * Emulate power capacity change. - * Only works on emulators. - * - * @group Emulator Only - */ - mobilePowerCapacity(opts: types.PowerCapacityOpts): Promise; - - /** - * @group Emulator Only - */ - networkSpeed(networkSpeed: types.NetworkSpeed): Promise; - - /** - * Emulate different network connection speed modes. - Only works on emulators. - * - * @group Emulator Only - */ - mobileNetworkSpeed(opts: types.NetworkSpeedOpts): Promise; - - /** - * Emulate sensors values on the connected emulator. - * @group Emulator Only - * @throws {Error} - If sensorType is not defined - * @throws {Error} - If value for the sensor is not defined - * @throws {Error} - If deviceType is not an emulator - */ - sensorSet(opts: types.SensorSetOpts): Promise; - - getScreenshot(): Promise; -} - -export type AlertMixin = Required< - Pick ->; - -export interface AppManagementMixin { - /** - * Installs the given application to the device under test - * @throws {Error} if the given apk does not exist or is not reachable - */ - installApp(appId: string, opts?: Omit): Promise; - /** - * Terminates the app if it is running. - * - * If the given timeout was lower or equal to zero, it returns true after - * terminating the app without checking the app state. - * @throws {Error} if the app has not been terminated within the given timeout. - */ - terminateApp(appId: string, opts?: Omit): Promise; - /** - * Remove the corresponding application if is installed. - * - * The call is ignored if the app is not installed. - * - * @returns `true` if the package was found on the device and - * successfully uninstalled. - */ - removeApp(appId: string, opts: Omit): Promise; - /** - * Activates the given application or launches it if necessary. - * - * The action literally simulates clicking the corresponding application - * icon on the dashboard. - * - * @throws {Error} If the app cannot be activated - */ - activateApp(appId: string): Promise; - /** - * Queries the current state of the app. - * @returns The corresponding constant, which describes the current application state. - */ - queryAppState(appId: string): Promise; - /** - * Determine whether an app is installed - */ - isAppInstalled(appId: string): Promise; - /** - * Installs the given application to the device under test - * @throws {Error} if the given apk does not exist or is not reachable - */ - mobileInstallApp(opts: types.InstallAppOpts): Promise; - /** - * Terminates the app if it is running. - * - * If the given timeout was lower or equal to zero, it returns true after - * terminating the app without checking the app state. - * @throws {Error} if the app has not been terminated within the given timeout. - */ - mobileTerminateApp(opts: types.TerminateAppOpts): Promise; - /** - * Remove the corresponding application if is installed. - * - * The call is ignored if the app is not installed. - * - * @returns `true` if the package was found on the device and - * successfully uninstalled. - */ - mobileRemoveApp(opts: types.RemoveAppOpts): Promise; - /** - * - * Activates the given application or launches it if necessary. - * - * The action literally simulates clicking the corresponding application - * icon on the dashboard. - * - * @throws {Error} If the app cannot be activated - */ - mobileActivateApp(opts: types.ActivateAppOpts): Promise; - /** - * Queries the current state of the app. - * @returns The corresponding constant, which describes the current application state. - */ - mobileQueryAppState(opts: types.QueryAppStateOpts): Promise; - /** - * Determine whether an app is installed - */ - mobileIsAppInstalled(opts: types.IsAppInstalledOpts): Promise; - /** - * Deletes all data associated with a package. - * - * @throws {Error} If cleaning of the app data fails - */ - mobileClearApp(opts: types.ClearAppOpts): Promise; -} - -export interface ContextMixin { - getCurrentContext(): Promise; - getContexts(): Promise; - setContext(name?: string): Promise; - defaultContextName(): string; - defaultWebviewName(): string; - assignContexts(mappings: types.WebviewsMapping[]): string[]; - /** - * Returns a webviewsMapping based on CDP endpoints - */ - mobileGetContexts(): Promise; - - switchContext(name: string, mappings: types.WebviewsMapping[]): Promise; - - isWebContext(): boolean; - - startChromedriverProxy(context: string, mappings: types.WebviewsMapping[]): Promise; - onChromedriverStop(context: string): Promise; - - isChromedriverContext(viewName: string): boolean; - shouldDismissChromeWelcome(): boolean; - dismissChromeWelcome(): Promise; - startChromeSession(): Promise; - /** - * @internal - */ - setupExistingChromedriver(log: AppiumLogger, chromedriver: Chromedriver): Promise; - /** - * Find a free port to have Chromedriver listen on. - * - * @param portSpec - List of ports. - * @param log Logger instance - * @internal - * @returns free port - */ - getChromedriverPort(portSpec?: types.PortSpec, log?: AppiumLogger): Promise; - - /** - * @internal - */ - isChromedriverAutodownloadEnabled(): boolean; - - /** - * @internal - * @param opts - * @param curDeviceId - * @param adb - * @param context - */ - setupNewChromedriver( - opts: AndroidDriverOpts, - curDeviceId: string, - adb: ADB, - context?: string - ): Promise; - - suspendChromedriverProxy(): void; - - stopChromedriverProxies(): Promise; -} - -export interface ElementMixin { - getAttribute(attribute: string, elementId: string): Promise; - getName(elementId: string): Promise; - elementDisplayed(elementId: string): Promise; - elementEnabled(elementId: string): Promise; - elementSelected(elementId: string): Promise; - setElementValue(keys: string | string[], elementId: string, replace?: boolean): Promise; - doSetElementValue(opts: types.DoSetElementValueOpts): Promise; - setValue(keys: string | string[], elementId: string): Promise; - replaceValue(keys: string | string[], elementId: string): Promise; - setValueImmediate(keys: string | string[], elementId: string): Promise; - getText(elementId: string): Promise; - clear(elementId: string): Promise; - - click(elementId: string): Promise; - getLocation(elementId: string): Promise; - getLocationInView(elementId: string): Promise; - - getSize(elementId: string): Promise; - getElementRect(elementId: string): Promise; - - touchLongClick(elementId: string, x: number, y: number, duration: number): Promise; - touchDown(elementId: string, x: number, y: number): Promise; - touchUp(elementId: string, x: number, y: number): Promise; - touchMove(elementId: string, x: number, y: number): Promise; - complexTap( - tapCount: number, - touchCount: number, - duration: number, - x: number, - y: number - ): Promise; - tap( - elementId?: string | null, - x?: number | null, - y?: number | null, - count?: number - ): Promise; -} - -export interface EmulatorConsoleMixin { - /** - * Executes a command through emulator telnet console interface and returns its output. - * The `emulator_console` server feature must be enabled in order to use this method. - * - * @returns The command output - * @throws {Error} If there was an error while connecting to the Telnet console - * or if the given command returned non-OK response - */ - mobileExecEmuConsoleCommand(opts: types.ExecOptions): Promise; -} - -export interface ExecuteMixin { - execute(script: string, args?: unknown[]): Promise; - executeMobile(mobileCommand: string, opts?: StringRecord): Promise; -} - -export interface FileActionsMixin { - /** - * Pulls a remote file from the device. - * - * It is required that a package has debugging flag enabled in order to access its files. - * - * @param remotePath The full path to the remote file or a specially formatted path, which points to an item inside app bundle - * @returns Base64 encoded content of the pulled file - * @throws {Error} If the pull operation failed - */ - pullFile(remotePath: string): Promise; - - /** - * Pulls a remote file from the device. - * - * @param opts - * @returns The same as {@linkcode pullFile} - */ - mobilePullFile(opts: types.PullFileOpts): Promise; - /** - * Pushes the given data to a file on the remote device - * - * It is required that a package has debugging flag enabled in order to access - * its files. - * - * After a file is pushed, it gets automatically scanned for possible media - * occurrences. The file is added to the media library if the scan succeeds. - * - * @param remotePath The full path to the remote file or a file - * inside a package bundle - * @param base64Data Base64 encoded data to be written to the remote - * file. The remote file will be silently overridden if it already exists. - * @throws {Error} If there was an error while pushing the data - */ - pushFile(remotePath: string, base64Data: string): Promise; - - /** - * Pushes the given data to a file on the remote device. - */ - mobilePushFile(opts: types.PushFileOpts): Promise; - /** - * Pulls the whole folder from the remote device - * - * @param remotePath The full path to a folder on the remote device or a folder inside an application bundle - * @returns Base64-encoded and zipped content of the folder - * @throws {Error} If there was a failure while getting the folder content - */ - pullFolder(remotePath: string): Promise; - - /** - * Pulls the whole folder from the device under test. - * - * @returns The same as {@linkcode pullFolder} - */ - mobilePullFolder(opts: types.PullFolderOpts): Promise; - - /** - * Deletes a file on the remote device - * - * @returns `true` if the remote file has been successfully deleted. If the - * path to a remote file is valid, but the file itself does not exist then - * `false` is returned. - * @throws {Error} If the argument is invalid or there was an error while - * deleting the file - */ - mobileDeleteFile(opts: types.DeleteFileOpts): Promise; -} - -export interface FindMixin { - /** - * @remarks The reason for isolating `doFindElementOrEls` from {@linkcode findElOrEls} is for reusing `findElOrEls` - * across android-drivers (like `appium-uiautomator2-driver`) to avoid code duplication. - * Other android-drivers (like `appium-uiautomator2-driver`) need to override `doFindElementOrEls` - * to facilitate `findElOrEls`. - */ - doFindElementOrEls(opts: types.FindElementOpts): Promise; - - /** - * Find an element or elements - * @param strategy locator strategy - * @param selector actual selector for finding an element - * @param mult multiple elements or just one? - * @param context finding an element from the root context? or starting from another element - */ - findElOrEls(strategy: string, selector: string, mult: true, context?: any): Promise; - findElOrEls(strategy: string, selector: string, mult: false, context?: any): Promise; -} - -export interface GeneralMixin { - keys(keys: string | string[]): Promise; - doSendKeys(opts: types.SendKeysOpts): Promise; - /** - * Retrieves the current device's timestamp. - * - * @param format - The set of format specifiers. Read {@link https://momentjs.com/docs/} to get the full list of supported format specifiers. The default format is `YYYY-MM-DDTHH:mm:ssZ`, which complies to ISO-8601 - * @return Formatted datetime string or the raw command output if formatting fails - */ - getDeviceTime(format?: string): Promise; - /** - * Retrieves the current device time - * - * @return Formatted datetime string or the raw command output if formatting fails - */ - mobileGetDeviceTime(opts: types.DeviceTimeOpts): Promise; - - getPageSource(): Promise; - - openSettingsActivity(setting: string): Promise; - - getWindowSize(): Promise; - - back(): Promise; - - getWindowRect(): Promise; - getCurrentActivity(): Promise; - - getCurrentPackage(): Promise; - - background(seconds: number): Promise; - - getStrings(language?: string | null): Promise; - - launchApp(): Promise; - - startActivity( - appPackage: string, - appActivity?: string, - appWaitPackage?: string, - appWaitActivity?: string, - intentAction?: string, - intentCategory?: string, - intentFlags?: string, - optionalIntentArguments?: string, - dontStopAppOnReset?: boolean - ): Promise; - - _cachedActivityArgs: StringRecord; - - reset(): Promise; - - startAUT(): Promise; - - setUrl(uri: string): Promise; - - closeApp(): Promise; - - getDisplayDensity(): Promise; - - mobilePerformEditorAction(opts: types.PerformEditorActionOpts): Promise; - /** - * Retrieves the list of recent system notifications. - * - * @returns See the documentation on `io.appium.settings -> getNotifications` for more details - */ - mobileGetNotifications(): Promise; - - /** - * Retrieves the list of recent SMS messages with their properties. - * @returns See the documentation on `io.appium.settings -> getSmsList` for more details - */ - mobileListSms(opts: types.ListSmsOpts): Promise; - - /** - * Unlocks the device if it is locked. Noop if the device's screen is not locked. - * - * @throws {Error} if unlock operation fails or the provided arguments are not valid - */ - mobileUnlock(opts: types.UnlockOptions): Promise; -} - -export interface IMEMixin { - isIMEActivated: () => Promise; - availableIMEEngines: () => Promise; - getActiveIMEEngine: () => Promise; - activateIMEEngine: (imeId: string) => Promise; - deactivateIMEEngine: () => Promise; -} - -export interface AppearanceMixin { - mobileSetUiMode: (opts: types.GetUiModeOpts) => Promise; - mobileGetUiMode: (opts: types.SetUiModeOpts) => Promise; -} - -export interface ActivityMixin { - /** - * Starts the given activity intent. - * - * @param opts - * @returns The command output - * @throws {Error} If there was a failure while starting the activity - * or required options are missing - */ - mobileStartActivity(opts?: types.StartActivityOpts): Promise; - /** - * Send a broadcast intent. - * - * @returns The command output - * @throws {Error} If there was a failure while starting the activity - * or required options are missing - */ - mobileBroadcast(opts?: types.BroadcastOpts): Promise; - /** - * Starts the given service intent. - * - * @returns The command output - * @throws {Error} If there was a failure while starting the service - * or required options are missing - */ - mobileStartService(opts?: types.StartServiceOpts): Promise; - /** - * Stops the given service intent. - * - * @returns The command output - * @throws {Error} If there was a failure while stopping the service - * or required options are missing - */ - mobileStopService(opts?: types.StopServiceOpts): Promise; -} - -export interface KeyboardMixin { - hideKeyboard(): Promise; - isKeyboardShown(): Promise; -} - -export interface LogMixin { - supportedLogTypes: Readonly; - mobileStartLogsBroadcast(): Promise; - mobileStopLogsBroadcast(): Promise; - getLogTypes(): Promise; - getLog(logType: string): Promise; - _logcatWebsocketListener?: LogcatListener; -} - -export interface MediaProjectionMixin { - /** - * Record the display of a real devices running Android 10 (API level 29) and higher. - * The screen activity is recorded to a MPEG-4 file. Audio is also recorded by default - * (only for apps that allow it in their manifests). - * If another recording has been already started then the command will exit silently. - * The previously recorded video file is deleted when a new recording session is started. - * Recording continues it is stopped explicitly or until the timeout happens. - * - * @param opts Available options. - * @returns `true` if a new recording has successfully started. - * @throws {Error} If recording has failed to start or is not supported on the device under test. - */ - mobileStartMediaProjectionRecording( - opts?: types.StartMediaProjectionRecordingOpts - ): Promise; - /** - * Checks if a media projection-based recording is currently running. - * - * @returns `true` if a recording is in progress. - * @throws {Error} If a recording is not supported on the device under test. - */ - mobileIsMediaProjectionRecordingRunning(): Promise; - /** - * Stop a media projection-based recording. - * If no recording has been started before then an error is thrown. - * If the recording has been already finished before this API has been called - * then the most recent recorded file is returned. - * - * @param opts Available options. - * @returns Base64-encoded content of the recorded media file if 'remotePath' - * parameter is falsy or an empty string. - * @throws {Error} If there was an error while stopping a recording, - * fetching the content of the remote media file, - * or if a recording is not supported on the device under test. - */ - mobileStopMediaProjectionRecording( - opts?: types.StopMediaProjectionRecordingOpts - ): Promise; -} - -export interface NetworkMixin { - getNetworkConnection(): Promise; - /** - * decoupling to override the behaviour in other drivers like UiAutomator2. - */ - isWifiOn(): Promise; - /** - * Set the connectivity state for different services - * - * @throws {Error} If none of known properties were provided or there was an error - * while changing connectivity states - */ - mobileSetConnectivity(opts?: types.SetConnectivityOpts): Promise; - /** - * Retrieves the connectivity properties from the device under test - * - * @param opts If no service names are provided then the connectivity state is - * returned for all of them. - */ - mobileGetConnectivity(opts?: types.GetConnectivityOpts): Promise; - setNetworkConnection(type: number): Promise; - /** - * decoupling to override behaviour in other drivers like UiAutomator2. - */ - setWifiState(state: boolean): Promise; - setDataState(state: boolean): Promise; - toggleData(): Promise; - toggleWiFi(): Promise; - toggleFlightMode(): Promise; - setGeoLocation(location: Location): Promise; - getGeoLocation(): Promise; - /** - * Sends an async request to refresh the GPS cache. - * - * This feature only works if the device under test has Google Play Services - * installed. In case the vanilla LocationManager is used the device API level - * must be at version 30 (Android R) or higher. - * - */ - mobileRefreshGpsCache(opts: types.GpsCacheRefreshOpts): Promise; - /** - * Checks if GPS is enabled - * - * @returns True if yes - */ - isLocationServicesEnabled(): Promise; - /** - * Toggles GPS state - */ - toggleLocationServices(): Promise; -} - -export interface PerformanceMixin { - getPerformanceDataTypes(): Promise; - - /** - * @returns The information type of the system state which is supported to read as like cpu, memory, network traffic, and battery. - * input - (packageName) the package name of the application - * (dataType) the type of system state which wants to read. It should be one of the keys of the SUPPORTED_PERFORMANCE_DATA_TYPES - * (dataReadTimeout) the number of attempts to read - * output - table of the performance data, The first line of the table represents the type of data. The remaining lines represent the values of the data. - * - * in case of battery info : [[power], [23]] - * in case of memory info : [[totalPrivateDirty, nativePrivateDirty, dalvikPrivateDirty, eglPrivateDirty, glPrivateDirty, totalPss, - * nativePss, dalvikPss, eglPss, glPss, nativeHeapAllocatedSize, nativeHeapSize], [18360, 8296, 6132, null, null, 42588, 8406, 7024, null, null, 26519, 10344]] - * in case of network info : [[bucketStart, activeTime, rxBytes, rxPackets, txBytes, txPackets, operations, bucketDuration,], - * [1478091600000, null, 1099075, 610947, 928, 114362, 769, 0, 3600000], [1478095200000, null, 1306300, 405997, 509, 46359, 370, 0, 3600000]] - * in case of network info : [[st, activeTime, rb, rp, tb, tp, op, bucketDuration], [1478088000, null, null, 32115296, 34291, 2956805, 25705, 0, 3600], - * [1478091600, null, null, 2714683, 11821, 1420564, 12650, 0, 3600], [1478095200, null, null, 10079213, 19962, 2487705, 20015, 0, 3600], - * [1478098800, null, null, 4444433, 10227, 1430356, 10493, 0, 3600]] - * in case of cpu info : [[user, kernel], [0.9, 1.3]] - * - * @privateRemarks XXX: type the result - */ - getPerformanceData( - packageName: string, - dataType: types.PerformanceDataType, - retries?: number - ): Promise; - /** - * Retrieves performance data about the given Android subsystem. - * The data is parsed from the output of the dumpsys utility. - * - * @returns The output depends on the selected subsystem. - * It is orginized into a table, where the first row represent column names - * and the following rows represent the sampled data for each column. - * Example output for different data types: - * - batteryinfo: [[power], [23]] - * - memory info: [[totalPrivateDirty, nativePrivateDirty, dalvikPrivateDirty, eglPrivateDirty, glPrivateDirty, totalPss, - * nativePss, dalvikPss, eglPss, glPss, nativeHeapAllocatedSize, nativeHeapSize], [18360, 8296, 6132, null, null, 42588, 8406, 7024, null, null, 26519, 10344]] - * - networkinfo: [[bucketStart, activeTime, rxBytes, rxPackets, txBytes, txPackets, operations, bucketDuration,], - * [1478091600000, null, 1099075, 610947, 928, 114362, 769, 0, 3600000], [1478095200000, null, 1306300, 405997, 509, 46359, 370, 0, 3600000]] - * - * [[st, activeTime, rb, rp, tb, tp, op, bucketDuration], [1478088000, null, null, 32115296, 34291, 2956805, 25705, 0, 3600], - * [1478091600, null, null, 2714683, 11821, 1420564, 12650, 0, 3600], [1478095200, null, null, 10079213, 19962, 2487705, 20015, 0, 3600], - * [1478098800, null, null, 4444433, 10227, 1430356, 10493, 0, 3600]] - * - cpuinfo: [[user, kernel], [0.9, 1.3]] - */ - mobileGetPerformanceData(opts: types.PerformanceDataOpts): Promise; -} - -export interface PermissionsMixin { - /** - * Changes package permissions in runtime. - * - * @param opts - Available options mapping. - * @throws {Error} if there was a failure while changing permissions - */ - mobileChangePermissions(opts: types.ChangePermissionsOpts): Promise; - /** - * Gets runtime permissions list for the given application package. - * - * opts - Available options mapping. - * @returns The list of retrieved permissions for the given type - * (can also be empty). - * @throws {Error} if there was an error while getting permissions. - */ - mobileGetPermissions(opts: types.GetPermissionsOpts): Promise; -} - -export interface RecordScreenMixin { - /** - * @privateRemarks FIXME: type this properly - */ - _screenRecordingProperties?: StringRecord; - /** - * Record the display of a real devices running Android 4.4 (API level 19) and - * higher. - * - * Emulators are supported since API level 27 (Android P). It records screen - * activity to an MPEG-4 file. Audio is not recorded with the video file. If - * screen recording has been already started then the command will stop it - * forcefully and start a new one. The previously recorded video file will be - * deleted. - * - * @param opts - The available options. - * @returns Base64-encoded content of the recorded media file if any screen - * recording is currently running or an empty string. - * @throws {Error} If screen recording has failed to start or is not supported - * on the device under test. - */ - startRecordingScreen(opts?: types.StartScreenRecordingOpts): Promise; - /** - * Stop recording the screen. - * - * If no screen recording has been started before then the method returns an - * empty string. - * - * @param opts - The available options. - * @returns Base64-encoded content of the recorded media file if `remotePath` - * option is falsy or an empty string. - * @throws {Error} If there was an error while getting the name of a media - * file or the file content cannot be uploaded to the remote location or - * screen recording is not supported on the device under test. - */ - stopRecordingScreen(opts?: types.StopScreenRecordingOpts): Promise; -} - -export interface ShellMixin { - mobileShell(opts?: types.ShellOpts): Promise; -} - -export interface StreamScreenMixin { - _screenStreamingProps?: StringRecord; - /** - * Starts device screen broadcast by creating MJPEG server. Multiple calls to - * this method have no effect unless the previous streaming session is stopped. - * This method only works if the `adb_screen_streaming` feature is enabled on - * the server side. - * - * @param opts - The available options. - * @throws {Error} If screen streaming has failed to start or is not - * supported on the host system or the corresponding server feature is not - * enabled. - */ - mobileStartScreenStreaming(opts?: types.StartScreenStreamingOpts): Promise; - - /** - * Stop screen streaming. - * - * If no screen streaming server has been started then nothing is done. - */ - mobileStopScreenStreaming(): Promise; -} - -export interface SystemBarsMixin { - getSystemBars(): Promise; - /** - * Performs commands on the system status bar. - * - * A thin wrapper over `adb shell cmd statusbar` CLI. Works on Android Oreo and newer. - * - * @returns The actual output of the downstream console command. - */ - mobilePerformStatusBarCommand(opts?: types.StatusBarCommandOpts): Promise; -} - -export interface TouchMixin { - /** - * @privateRemarks the shape of opts is dependent on the value of action, and - * this can be further narrowed to avoid all of the type assertions below. - */ - doTouchAction(action: types.TouchActionKind, opts?: types.TouchActionOpts): Promise; - - /** - * @privateRemarks drag is *not* press-move-release, so we need to translate. - * drag works fine for scroll, as well - */ - doTouchDrag(gestures: types.TouchDragAction): Promise; - - /** - * @privateRemarks Release gesture needs element or co-ordinates to release it - * from that position or else release gesture is performed from center of the - * screen, so to fix it This method sets co-ordinates/element to release - * gesture if it has no options set already. - */ - fixRelease(gestures: types.TouchAction[]): Promise; - - /** - * Performs a single gesture - */ - performGesture(gesture: types.TouchAction): Promise; - - getSwipeOptions(gestures: types.SwipeAction, touchCount?: number): Promise; - - performTouch(gestures: types.TouchAction[]): Promise; - parseTouch(gestures: types.TouchAction[], mult?: boolean): Promise; - performMultiAction(actions: types.TouchAction[], elementId: string): Promise; - /** - * @privateRemarks Reason for isolating `doPerformMultiAction` from - * {@link performMultiAction} is for reusing `performMultiAction` across android-drivers - * (like `appium-uiautomator2-driver`) and to avoid code duplication. Other - * android-drivers (like `appium-uiautomator2-driver`) need to override - * `doPerformMultiAction` to facilitate `performMultiAction`. - */ - doPerformMultiAction(elementId: string, states: types.TouchState[]): Promise; -} - -export interface DeviceidleMixin { - mobileDeviceidle(opts: types.DeviceidleOpts): Promise; -} - -export interface MemoryMixin { - mobileSendTrimMemory(opts: types.SendTrimMemoryOpts): Promise; -} - -declare module '../driver' { - interface AndroidDriver - extends ActionsMixin, - AlertMixin, - AppManagementMixin, - ContextMixin, - ElementMixin, - EmulatorConsoleMixin, - ExecuteMixin, - FileActionsMixin, - FindMixin, - GeneralMixin, - IMEMixin, - AppearanceMixin, - ActivityMixin, - KeyboardMixin, - LogMixin, - MediaProjectionMixin, - NetworkMixin, - PerformanceMixin, - PermissionsMixin, - RecordScreenMixin, - ShellMixin, - StreamScreenMixin, - SystemBarsMixin, - DeviceidleMixin, - MemoryMixin, - TouchMixin {} -} - -/** - * This function assigns a mixin `T` to the `AndroidDriver` class' prototype. - * - * While each mixin has its own interface which is (in isolation) unrelated to - * `AndroidDriver`, the constraint on this generic type `T` is that it must be a - * partial of `AndroidDriver`'s interface. This enforces that it does not - * conflict with the existing interface of `AndroidDriver`. In that way, you - * can think of it as a type guard. - * @param mixin Mixin implementation - */ -export function mixin>(mixin: T): void { - Object.assign(AndroidDriver.prototype, mixin); -} diff --git a/lib/commands/network.js b/lib/commands/network.js index d096e9a1..bb6c766b 100644 --- a/lib/commands/network.js +++ b/lib/commands/network.js @@ -1,4 +1,3 @@ -import {mixin} from './mixins'; import _ from 'lodash'; import {errors} from 'appium/driver'; import {util} from 'appium/support'; @@ -7,12 +6,6 @@ import B from 'bluebird'; const AIRPLANE_MODE_MASK = 0b001; const WIFI_MASK = 0b010; const DATA_MASK = 0b100; -// The value close to zero, but not zero, is needed -// to trick JSON generation and send a float value instead of an integer, -// This allows strictly-typed clients, like Java, to properly -// parse it. Otherwise float 0.0 is always represented as integer 0 in JS. -// The value must not be greater than DBL_EPSILON (https://opensource.apple.com/source/Libc/Libc-498/include/float.h) -const GEO_EPSILON = Number.MIN_VALUE; const WIFI_KEY_NAME = 'wifi'; const DATA_KEY_NAME = 'data'; const AIRPLANE_MODE_KEY_NAME = 'airplaneMode'; @@ -23,239 +16,232 @@ const SUPPORTED_SERVICE_NAMES = /** @type {const} */ ([ ]); /** - * @type {import('./mixins').NetworkMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @returns {Promise} */ -const NetworkMixin = { - async getNetworkConnection() { - this.log.info('Getting network connection'); - let airplaneModeOn = await this.adb.isAirplaneModeOn(); - let connection = airplaneModeOn ? AIRPLANE_MODE_MASK : 0; +export async function getNetworkConnection() { + this.log.info('Getting network connection'); + let airplaneModeOn = await this.adb.isAirplaneModeOn(); + let connection = airplaneModeOn ? AIRPLANE_MODE_MASK : 0; + + // no need to check anything else if we are in airplane mode + if (!airplaneModeOn) { + let wifiOn = await this.isWifiOn(); + connection |= wifiOn ? WIFI_MASK : 0; + let dataOn = await this.adb.isDataOn(); + connection |= dataOn ? DATA_MASK : 0; + } + + return connection; +} - // no need to check anything else if we are in airplane mode - if (!airplaneModeOn) { - let wifiOn = await this.isWifiOn(); - connection |= wifiOn ? WIFI_MASK : 0; - let dataOn = await this.adb.isDataOn(); - connection |= dataOn ? DATA_MASK : 0; - } - - return connection; - }, - - async isWifiOn() { - return await this.adb.isWifiOn(); - }, - - async mobileSetConnectivity(opts = {}) { - const {wifi, data, airplaneMode} = opts; - if (_.every([wifi, data, airplaneMode], _.isUndefined)) { - throw new errors.InvalidArgumentError( - `Either one of ${JSON.stringify(SUPPORTED_SERVICE_NAMES)} options must be provided` - ); - } - - const currentState = await this.mobileGetConnectivity({ - services: /** @type {import('./types').ServiceType[]} */ ([ - ...(_.isUndefined(wifi) ? [] : [WIFI_KEY_NAME]), - ...(_.isUndefined(data) ? [] : [DATA_KEY_NAME]), - ...(_.isUndefined(airplaneMode) ? [] : [AIRPLANE_MODE_KEY_NAME]), - ]), - }); - /** @type {(Promise|(() => Promise))[]} */ - const setters = []; - if (!_.isUndefined(wifi) && currentState.wifi !== Boolean(wifi)) { - setters.push(this.setWifiState(wifi)); - } - if (!_.isUndefined(data) && currentState.data !== Boolean(data)) { - setters.push(this.setDataState(data)); - } - if (!_.isUndefined(airplaneMode) && currentState.airplaneMode !== Boolean(airplaneMode)) { - setters.push(async () => { - await this.adb.setAirplaneMode(airplaneMode); - if ((await this.adb.getApiLevel()) < 30) { - await this.adb.broadcastAirplaneMode(airplaneMode); - } - }); - } - if (!_.isEmpty(setters)) { - await B.all(setters); - } - }, - - async mobileGetConnectivity(opts = {}) { - let {services = SUPPORTED_SERVICE_NAMES} = opts; - const svcs = _.castArray(services); - const unsupportedServices = _.difference(services, SUPPORTED_SERVICE_NAMES); - if (!_.isEmpty(unsupportedServices)) { - throw new errors.InvalidArgumentError( - `${util.pluralize( - 'Service name', - unsupportedServices.length, - false - )} ${unsupportedServices} ` + - `${ - unsupportedServices.length === 1 ? 'is' : 'are' - } not known. Only the following services are ` + - `suported: ${SUPPORTED_SERVICE_NAMES}` - ); - } - - const statePromises = { - wifi: B.resolve(svcs.includes(WIFI_KEY_NAME) ? this.adb.isWifiOn() : undefined), - data: B.resolve(svcs.includes(DATA_KEY_NAME) ? this.adb.isDataOn() : undefined), - airplaneMode: B.resolve( - svcs.includes(AIRPLANE_MODE_KEY_NAME) ? this.adb.isAirplaneModeOn() : undefined - ), - }; - await B.all(_.values(statePromises)); - return { - wifi: Boolean(statePromises.wifi.value()), - data: Boolean(statePromises.data.value()), - airplaneMode: Boolean(statePromises.airplaneMode.value()), - }; - }, - - async setNetworkConnection(type) { - this.log.info('Setting network connection'); - // decode the input - const shouldEnableAirplaneMode = (type & AIRPLANE_MODE_MASK) !== 0; - const shouldEnableWifi = (type & WIFI_MASK) !== 0; - const shouldEnableDataConnection = (type & DATA_MASK) !== 0; - - const currentState = await this.getNetworkConnection(); - const isAirplaneModeEnabled = (currentState & AIRPLANE_MODE_MASK) !== 0; - const isWiFiEnabled = (currentState & WIFI_MASK) !== 0; - const isDataEnabled = (currentState & DATA_MASK) !== 0; +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function isWifiOn() { + return await this.adb.isWifiOn(); +} - if (shouldEnableAirplaneMode !== isAirplaneModeEnabled) { - await this.adb.setAirplaneMode(shouldEnableAirplaneMode); +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @param {import('./types').SetConnectivityOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileSetConnectivity(opts = {}) { + const {wifi, data, airplaneMode} = opts; + if (_.every([wifi, data, airplaneMode], _.isUndefined)) { + throw new errors.InvalidArgumentError( + `Either one of ${JSON.stringify(SUPPORTED_SERVICE_NAMES)} options must be provided`, + ); + } + + const currentState = await this.mobileGetConnectivity({ + services: /** @type {import('./types').ServiceType[]} */ ([ + ...(_.isUndefined(wifi) ? [] : [WIFI_KEY_NAME]), + ...(_.isUndefined(data) ? [] : [DATA_KEY_NAME]), + ...(_.isUndefined(airplaneMode) ? [] : [AIRPLANE_MODE_KEY_NAME]), + ]), + }); + /** @type {(Promise|(() => Promise))[]} */ + const setters = []; + if (!_.isUndefined(wifi) && currentState.wifi !== Boolean(wifi)) { + setters.push(this.setWifiState(wifi)); + } + if (!_.isUndefined(data) && currentState.data !== Boolean(data)) { + setters.push(this.setDataState(data)); + } + if (!_.isUndefined(airplaneMode) && currentState.airplaneMode !== Boolean(airplaneMode)) { + setters.push(async () => { + await this.adb.setAirplaneMode(airplaneMode); if ((await this.adb.getApiLevel()) < 30) { - await this.adb.broadcastAirplaneMode(shouldEnableAirplaneMode); + await this.adb.broadcastAirplaneMode(airplaneMode); } - } else { - this.log.info( - `Not changing airplane mode, since it is already ${ - shouldEnableAirplaneMode ? 'enabled' : 'disabled' - }` - ); - } - - if (shouldEnableWifi === isWiFiEnabled && shouldEnableDataConnection === isDataEnabled) { - this.log.info( - 'Not changing data connection/Wi-Fi states, since they are already set to expected values' - ); - if (await this.adb.isAirplaneModeOn()) { - return AIRPLANE_MODE_MASK | currentState; - } - return ~AIRPLANE_MODE_MASK & currentState; - } - - if (shouldEnableWifi !== isWiFiEnabled) { - await this.setWifiState(shouldEnableWifi); - } else { - this.log.info( - `Not changing Wi-Fi state, since it is already ` + - `${shouldEnableWifi ? 'enabled' : 'disabled'}` - ); - } - - if (shouldEnableAirplaneMode) { - this.log.info('Not changing data connection state, because airplane mode is enabled'); - } else if (shouldEnableDataConnection === isDataEnabled) { - this.log.info( - `Not changing data connection state, since it is already ` + - `${shouldEnableDataConnection ? 'enabled' : 'disabled'}` - ); - } else { - await this.setDataState(shouldEnableDataConnection); - } - - return await this.getNetworkConnection(); - }, - - async setWifiState(isOn) { - await this.settingsApp.setWifiState(isOn, this.isEmulator()); - }, - - async setDataState(isOn) { - await this.settingsApp.setDataState(isOn, this.isEmulator()); - }, - - async toggleData() { - const isOn = await this.adb.isDataOn(); - this.log.info(`Turning network data ${!isOn ? 'on' : 'off'}`); - await this.setDataState(!isOn); - }, + }); + } + if (!_.isEmpty(setters)) { + await B.all(setters); + } +} - async toggleWiFi() { - const isOn = await this.adb.isWifiOn(); - this.log.info(`Turning WiFi ${!isOn ? 'on' : 'off'}`); - await this.setWifiState(!isOn); - }, +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').GetConnectivityOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileGetConnectivity(opts = {}) { + let {services = SUPPORTED_SERVICE_NAMES} = opts; + const svcs = _.castArray(services); + const unsupportedServices = _.difference(services, SUPPORTED_SERVICE_NAMES); + if (!_.isEmpty(unsupportedServices)) { + throw new errors.InvalidArgumentError( + `${util.pluralize( + 'Service name', + unsupportedServices.length, + false, + )} ${unsupportedServices} ` + + `${ + unsupportedServices.length === 1 ? 'is' : 'are' + } not known. Only the following services are ` + + `suported: ${SUPPORTED_SERVICE_NAMES}`, + ); + } + + const statePromises = { + wifi: B.resolve(svcs.includes(WIFI_KEY_NAME) ? this.adb.isWifiOn() : undefined), + data: B.resolve(svcs.includes(DATA_KEY_NAME) ? this.adb.isDataOn() : undefined), + airplaneMode: B.resolve( + svcs.includes(AIRPLANE_MODE_KEY_NAME) ? this.adb.isAirplaneModeOn() : undefined, + ), + }; + await B.all(_.values(statePromises)); + return { + wifi: Boolean(statePromises.wifi.value()), + data: Boolean(statePromises.data.value()), + airplaneMode: Boolean(statePromises.airplaneMode.value()), + }; +} - async toggleFlightMode() { - /* - * TODO: Implement isRealDevice(). This method fails on - * real devices, it should throw a NotYetImplementedError - */ - let flightMode = !(await this.adb.isAirplaneModeOn()); - this.log.info(`Turning flight mode ${flightMode ? 'on' : 'off'}`); - await this.adb.setAirplaneMode(flightMode); +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @param {number} type + * @returns {Promise} + */ +export async function setNetworkConnection(type) { + this.log.info('Setting network connection'); + // decode the input + const shouldEnableAirplaneMode = (type & AIRPLANE_MODE_MASK) !== 0; + const shouldEnableWifi = (type & WIFI_MASK) !== 0; + const shouldEnableDataConnection = (type & DATA_MASK) !== 0; + + const currentState = await this.getNetworkConnection(); + const isAirplaneModeEnabled = (currentState & AIRPLANE_MODE_MASK) !== 0; + const isWiFiEnabled = (currentState & WIFI_MASK) !== 0; + const isDataEnabled = (currentState & DATA_MASK) !== 0; + + if (shouldEnableAirplaneMode !== isAirplaneModeEnabled) { + await this.adb.setAirplaneMode(shouldEnableAirplaneMode); if ((await this.adb.getApiLevel()) < 30) { - await this.adb.broadcastAirplaneMode(flightMode); + await this.adb.broadcastAirplaneMode(shouldEnableAirplaneMode); } - }, + } else { + this.log.info( + `Not changing airplane mode, since it is already ${ + shouldEnableAirplaneMode ? 'enabled' : 'disabled' + }`, + ); + } - async setGeoLocation(location) { - await this.settingsApp.setGeoLocation(location, this.isEmulator()); - try { - return await this.getGeoLocation(); - } catch (e) { - this.log.warn( - `Could not get the current geolocation info: ${/** @type {Error} */ (e).message}` - ); - this.log.warn(`Returning the default zero'ed values`); - return { - latitude: GEO_EPSILON, - longitude: GEO_EPSILON, - altitude: GEO_EPSILON, - }; + if (shouldEnableWifi === isWiFiEnabled && shouldEnableDataConnection === isDataEnabled) { + this.log.info( + 'Not changing data connection/Wi-Fi states, since they are already set to expected values', + ); + if (await this.adb.isAirplaneModeOn()) { + return AIRPLANE_MODE_MASK | currentState; } - }, + return ~AIRPLANE_MODE_MASK & currentState; + } + + if (shouldEnableWifi !== isWiFiEnabled) { + await this.setWifiState(shouldEnableWifi); + } else { + this.log.info( + `Not changing Wi-Fi state, since it is already ` + + `${shouldEnableWifi ? 'enabled' : 'disabled'}`, + ); + } + + if (shouldEnableAirplaneMode) { + this.log.info('Not changing data connection state, because airplane mode is enabled'); + } else if (shouldEnableDataConnection === isDataEnabled) { + this.log.info( + `Not changing data connection state, since it is already ` + + `${shouldEnableDataConnection ? 'enabled' : 'disabled'}`, + ); + } else { + await this.setDataState(shouldEnableDataConnection); + } - async mobileRefreshGpsCache(opts = {}) { - const {timeoutMs} = opts; - await this.settingsApp.refreshGeoLocationCache(timeoutMs); - }, + return await this.getNetworkConnection(); +} - async getGeoLocation() { - const {latitude, longitude, altitude} = await this.settingsApp.getGeoLocation(); - return { - latitude: parseFloat(String(latitude)) || GEO_EPSILON, - longitude: parseFloat(String(longitude)) || GEO_EPSILON, - altitude: parseFloat(String(altitude)) || GEO_EPSILON, - }; - }, +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @param {boolean} isOn + * @returns {Promise} + */ +export async function setWifiState(isOn) { + await this.settingsApp.setWifiState(isOn, this.isEmulator()); +} - async isLocationServicesEnabled() { - return (await this.adb.getLocationProviders()).includes('gps'); - }, +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @param {boolean} isOn + * @returns {Promise} + */ +export async function setDataState(isOn) { + await this.settingsApp.setDataState(isOn, this.isEmulator()); +} - async toggleLocationServices() { - this.log.info('Toggling location services'); - const isGpsEnabled = await this.isLocationServicesEnabled(); - this.log.debug( - `Current GPS state: ${isGpsEnabled}. ` + - `The service is going to be ${isGpsEnabled ? 'disabled' : 'enabled'}` - ); - await this.adb.toggleGPSLocationProvider(!isGpsEnabled); - }, -}; +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function toggleData() { + const isOn = await this.adb.isDataOn(); + this.log.info(`Turning network data ${!isOn ? 'on' : 'off'}`); + await this.setDataState(!isOn); +} -mixin(NetworkMixin); +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function toggleWiFi() { + const isOn = await this.adb.isWifiOn(); + this.log.info(`Turning WiFi ${!isOn ? 'on' : 'off'}`); + await this.setWifiState(!isOn); +} -export default NetworkMixin; +/** + * @since Android 12 (only real devices, emulators work in all APIs) + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function toggleFlightMode() { + let flightMode = !(await this.adb.isAirplaneModeOn()); + this.log.info(`Turning flight mode ${flightMode ? 'on' : 'off'}`); + await this.adb.setAirplaneMode(flightMode); + if ((await this.adb.getApiLevel()) < 30) { + await this.adb.broadcastAirplaneMode(flightMode); + } +} /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/performance.js b/lib/commands/performance.js index e0cd5190..f438eae4 100644 --- a/lib/commands/performance.js +++ b/lib/commands/performance.js @@ -2,9 +2,8 @@ import {retryInterval} from 'asyncbox'; import _ from 'lodash'; import {requireArgs} from '../utils'; -import {mixin} from './mixins'; -const NETWORK_KEYS = [ +export const NETWORK_KEYS = [ [ 'bucketStart', 'activeTime', @@ -17,9 +16,9 @@ const NETWORK_KEYS = [ ], ['st', 'activeTime', 'rb', 'rp', 'tb', 'tp', 'op', 'bucketDuration'], ]; -const CPU_KEYS = /** @type {const} */ (['user', 'kernel']); -const BATTERY_KEYS = ['power']; -const MEMORY_KEYS = [ +export const CPU_KEYS = /** @type {const} */ (['user', 'kernel']); +export const BATTERY_KEYS = ['power']; +export const MEMORY_KEYS = [ 'totalPrivateDirty', 'nativePrivateDirty', 'dalvikPrivateDirty', @@ -36,7 +35,7 @@ const MEMORY_KEYS = [ 'dalvikRss', 'totalRss', ]; -const SUPPORTED_PERFORMANCE_DATA_TYPES = Object.freeze({ +export const SUPPORTED_PERFORMANCE_DATA_TYPES = Object.freeze({ cpuinfo: 'the amount of cpu by user and kernel process - cpu information for applications on real devices and simulators', memoryinfo: @@ -46,7 +45,7 @@ const SUPPORTED_PERFORMANCE_DATA_TYPES = Object.freeze({ networkinfo: 'the network statistics - network rx/tx information for applications on real devices and simulators', }); -const MEMINFO_TITLES = Object.freeze({ +export const MEMINFO_TITLES = Object.freeze({ NATIVE: 'Native', DALVIK: 'Dalvik', EGL: 'EGL', @@ -57,6 +56,81 @@ const MEMINFO_TITLES = Object.freeze({ }); const RETRY_PAUSE_MS = 1000; +/** + * @this {AndroidDriver} + * @returns {Promise} + */ +export async function getPerformanceDataTypes() { + return /** @type {import('./types').PerformanceDataType[]} */ ( + _.keys(SUPPORTED_PERFORMANCE_DATA_TYPES) + ); +} + +/** + * @this {AndroidDriver} + * @param {string} packageName + * @param {string} dataType + * @param {number} [retries=2] + * @returns {Promise} + */ +export async function getPerformanceData(packageName, dataType, retries = 2) { + let result; + switch (_.toLower(dataType)) { + case 'batteryinfo': + result = await getBatteryInfo.call(this, retries); + break; + case 'cpuinfo': + result = await getCPUInfo.call(this, packageName, retries); + break; + case 'memoryinfo': + result = await getMemoryInfo.call(this, packageName, retries); + break; + case 'networkinfo': + result = await getNetworkTrafficInfo.call(this, retries); + break; + default: + throw new Error( + `No performance data of type '${dataType}' found. ` + + `Only the following values are supported: ${JSON.stringify( + SUPPORTED_PERFORMANCE_DATA_TYPES, + [' '], + 2, + )}`, + ); + } + return /** @type {any[][]} */ (result); +} + +/** + * Retrieves performance data about the given Android subsystem. + * The data is parsed from the output of the dumpsys utility. + * + * The output depends on the selected subsystem. + * It is orginized into a table, where the first row represent column names + * and the following rows represent the sampled data for each column. + * Example output for different data types: + * - batteryinfo: [[power], [23]] + * - memory info: [[totalPrivateDirty, nativePrivateDirty, dalvikPrivateDirty, eglPrivateDirty, glPrivateDirty, totalPss, + * nativePss, dalvikPss, eglPss, glPss, nativeHeapAllocatedSize, nativeHeapSize], [18360, 8296, 6132, null, null, 42588, 8406, 7024, null, null, 26519, 10344]] + * - networkinfo: [[bucketStart, activeTime, rxBytes, rxPackets, txBytes, txPackets, operations, bucketDuration,], + * [1478091600000, null, 1099075, 610947, 928, 114362, 769, 0, 3600000], [1478095200000, null, 1306300, 405997, 509, 46359, 370, 0, 3600000]] + * + * [[st, activeTime, rb, rp, tb, tp, op, bucketDuration], [1478088000, null, null, 32115296, 34291, 2956805, 25705, 0, 3600], + * [1478091600, null, null, 2714683, 11821, 1420564, 12650, 0, 3600], [1478095200, null, null, 10079213, 19962, 2487705, 20015, 0, 3600], + * [1478098800, null, null, 4444433, 10227, 1430356, 10493, 0, 3600]] + * - cpuinfo: [[user, kernel], [0.9, 1.3]] + * + * @this {AndroidDriver} + * @param {import('./types').PerformanceDataOpts} opts + * @returns {Promise} + */ +export async function mobileGetPerformanceData(opts) { + const {packageName, dataType} = requireArgs(['packageName', 'dataType'], opts); + return await this.getPerformanceData(packageName, dataType); +} + +// #region Internal helpers + /** * API level between 18 and 30 * ['', '', , , , , , , ] @@ -151,7 +225,7 @@ function parseMeminfoForApiAbove29(entries, valDict) { * @param {string} packageName * @param {number} retries */ -async function getMemoryInfo(packageName, retries = 2) { +export async function getMemoryInfo(packageName, retries = 2) { return await retryInterval(retries, RETRY_PAUSE_MS, async () => { const cmd = [ 'dumpsys', @@ -193,7 +267,7 @@ async function getMemoryInfo(packageName, retries = 2) { * @this {AndroidDriver} * @param {number} retries */ -async function getNetworkTrafficInfo(retries = 2) { +export async function getNetworkTrafficInfo(retries = 2) { return await retryInterval(retries, RETRY_PAUSE_MS, async () => { let returnValue = []; let bucketDuration, bucketStart, activeTime, rxBytes, rxPackets, txBytes, txPackets, operations; @@ -376,7 +450,7 @@ async function getNetworkTrafficInfo(retries = 2) { * '14.3' is usage by the user (%), '28.2' is usage by the kernel (%) * @throws {Error} If it failed to parse the result of dumpsys, or no package name exists. */ -async function getCPUInfo(packageName, retries = 2) { +export async function getCPUInfo(packageName, retries = 2) { // TODO: figure out why this is // sometimes, the function of 'adb.shell' fails. when I tested this function on the target of 'Galaxy Note5', // adb.shell(dumpsys cpuinfo) returns cpu datas for other application packages, but I can't find the data for packageName. @@ -401,13 +475,13 @@ async function getCPUInfo(packageName, retries = 2) { // +0% 2209/io.appium.android.apis: 0.1% user + 0.2% kernel / faults: 70 minor const usagesPattern = new RegExp( `^.+\\/${_.escapeRegExp(packageName)}:\\D+([\\d.]+)%\\s+user\\s+\\+\\s+([\\d.]+)%\\s+kernel`, - 'm' + 'm', ); const match = usagesPattern.exec(output); if (!match) { this.log.debug(output); throw new Error( - `Unable to parse cpu usage data for '${packageName}'. Check the server log for more details` + `Unable to parse cpu usage data for '${packageName}'. Check the server log for more details`, ); } const user = /** @type {string} */ (match[1]); @@ -420,7 +494,7 @@ async function getCPUInfo(packageName, retries = 2) { * @this {AndroidDriver} * @param {number} retries */ -async function getBatteryInfo(retries = 2) { +export async function getBatteryInfo(retries = 2) { return await retryInterval(retries, RETRY_PAUSE_MS, async () => { let cmd = ['dumpsys', 'battery', '|', 'grep', 'level']; let data = await this.adb.shell(cmd); @@ -436,66 +510,7 @@ async function getBatteryInfo(retries = 2) { }); } -/** - * @type {import('./mixins').PerformanceMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const PerformanceMixin = { - async getPerformanceDataTypes() { - return /** @type {import('./types').PerformanceDataType[]} */ ( - _.keys(SUPPORTED_PERFORMANCE_DATA_TYPES) - ); - }, - - async getPerformanceData(packageName, dataType, retries = 2) { - let result; - switch (_.toLower(dataType)) { - case 'batteryinfo': - result = await getBatteryInfo.call(this, retries); - break; - case 'cpuinfo': - result = await getCPUInfo.call(this, packageName, retries); - break; - case 'memoryinfo': - result = await getMemoryInfo.call(this, packageName, retries); - break; - case 'networkinfo': - result = await getNetworkTrafficInfo.call(this, retries); - break; - default: - throw new Error( - `No performance data of type '${dataType}' found. ` + - `Only the following values are supported: ${JSON.stringify( - SUPPORTED_PERFORMANCE_DATA_TYPES, - [' '], - 2 - )}` - ); - } - return /** @type {any[][]} */ (result); - }, - - async mobileGetPerformanceData(opts) { - const {packageName, dataType} = requireArgs(['packageName', 'dataType'], opts); - return await this.getPerformanceData(packageName, dataType); - }, -}; - -mixin(PerformanceMixin); - -export { - BATTERY_KEYS, - CPU_KEYS, - MEMORY_KEYS, - NETWORK_KEYS, - SUPPORTED_PERFORMANCE_DATA_TYPES, - getBatteryInfo, - getCPUInfo, - getMemoryInfo, - getNetworkTrafficInfo, -}; - -export default PerformanceMixin; +// #endregion /** * @typedef {import('../driver').AndroidDriver} AndroidDriver diff --git a/lib/commands/permissions.js b/lib/commands/permissions.js index faf6bbad..2bf1bd10 100644 --- a/lib/commands/permissions.js +++ b/lib/commands/permissions.js @@ -1,10 +1,7 @@ -// @ts-check - import {errors} from 'appium/driver'; import B from 'bluebird'; import _ from 'lodash'; import {ADB_SHELL_FEATURE} from '../utils'; -import {mixin} from './mixins'; const ALL_PERMISSIONS_MAGIC = 'all'; const PM_ACTION = Object.freeze({ @@ -27,23 +24,92 @@ const PERMISSIONS_TYPE = Object.freeze({ REQUESTED: 'requested', }); +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').ChangePermissionsOpts} opts + * @returns {Promise} + */ +export async function mobileChangePermissions(opts) { + const { + permissions, + appPackage = this.opts.appPackage, + action = _.toLower(opts.target) === PERMISSION_TARGET.APPOPS + ? APPOPS_ACTION.ALLOW + : PM_ACTION.GRANT, + target = PERMISSION_TARGET.PM, + } = opts; + if (_.isNil(permissions)) { + throw new errors.InvalidArgumentError(`'permissions' argument is required`); + } + if (_.isEmpty(permissions)) { + throw new errors.InvalidArgumentError(`'permissions' argument must not be empty`); + } + + switch (_.toLower(target)) { + case PERMISSION_TARGET.PM: + return await changePermissionsViaPm.bind(this)(permissions, appPackage, _.toLower(action)); + case PERMISSION_TARGET.APPOPS: + this.ensureFeatureEnabled(ADB_SHELL_FEATURE); + return await changePermissionsViaAppops.bind(this)( + permissions, + appPackage, + _.toLower(action), + ); + default: + throw new errors.InvalidArgumentError( + `'target' argument must be one of: ${_.values(PERMISSION_TARGET)}`, + ); + } +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').GetPermissionsOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileGetPermissions(opts = {}) { + const {type = PERMISSIONS_TYPE.REQUESTED, appPackage = this.opts.appPackage} = opts; + /** + * @type {(pkg: string) => Promise} + */ + let actionFunc; + switch (_.toLower(type)) { + case PERMISSIONS_TYPE.REQUESTED: + actionFunc = (pkg) => this.adb.getReqPermissions(pkg); + break; + case PERMISSIONS_TYPE.GRANTED: + actionFunc = (pkg) => this.adb.getGrantedPermissions(pkg); + break; + case PERMISSIONS_TYPE.DENIED: + actionFunc = (pkg) => this.adb.getDeniedPermissions(pkg); + break; + default: + throw new errors.InvalidArgumentError( + `Unknown permissions type '${type}'. ` + + `Only ${JSON.stringify(_.values(PERMISSIONS_TYPE))} types are supported`, + ); + } + return await actionFunc(/** @type {string} */ (appPackage)); +} + +// #region Internal helpers + /** * @this {AndroidDriver} - * @param {*} permissions - * @param {*} appPackage - * @param {*} action - * @todo FIXME: type this + * @param {string|string[]} permissions + * @param {string} appPackage + * @param {import('type-fest').ValueOf} action */ async function changePermissionsViaPm(permissions, appPackage, action) { if (!_.values(PM_ACTION).includes(action)) { throw new errors.InvalidArgumentError( `Unknown action '${action}'. ` + - `Only ${JSON.stringify(_.values(PM_ACTION))} actions are supported` + `Only ${JSON.stringify(_.values(PM_ACTION))} actions are supported`, ); } let affectedPermissions = _.isArray(permissions) ? permissions : [permissions]; - if (_.toLower(permissions) === ALL_PERMISSIONS_MAGIC) { + if (_.isString(permissions) && _.toLower(permissions) === ALL_PERMISSIONS_MAGIC) { const dumpsys = await this.adb.shell(['dumpsys', 'package', appPackage]); const grantedPermissions = await this.adb.getGrantedPermissions(appPackage, dumpsys); if (action === PM_ACTION.GRANT) { @@ -66,99 +132,32 @@ async function changePermissionsViaPm(permissions, appPackage, action) { } /** * @this {AndroidDriver} - * @param {*} permissions - * @param {*} appPackage - * @param {*} action - * @todo FIXME: type this + * @param {string|string[]} permissions + * @param {string} appPackage + * @param {import('type-fest').ValueOf} action */ async function changePermissionsViaAppops(permissions, appPackage, action) { if (!_.values(APPOPS_ACTION).includes(action)) { throw new errors.InvalidArgumentError( `Unknown action '${action}'. ` + - `Only ${JSON.stringify(_.values(APPOPS_ACTION))} actions are supported` + `Only ${JSON.stringify(_.values(APPOPS_ACTION))} actions are supported`, ); } - if (_.toLower(permissions) === ALL_PERMISSIONS_MAGIC) { + if (_.isString(permissions) && _.toLower(permissions) === ALL_PERMISSIONS_MAGIC) { throw new errors.InvalidArgumentError( `'${ALL_PERMISSIONS_MAGIC}' permission is only supported for ` + `'${PERMISSION_TARGET.PM}' target. ` + - `Check AppOpsManager.java from Android platform sources to get the full list of supported AppOps permissions` + `Check AppOpsManager.java from Android platform sources to get the full list of supported AppOps permissions`, ); } const promises = (_.isArray(permissions) ? permissions : [permissions]).map((permission) => - this.adb.shell(['appops', 'set', appPackage, permission, action]) + this.adb.shell(['appops', 'set', appPackage, permission, action]), ); await B.all(promises); } -/** - * @type {import('./mixins').PermissionsMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const PermissionsMixin = { - async mobileChangePermissions(opts) { - const { - permissions, - appPackage = this.opts.appPackage, - action = _.toLower(opts.target) === PERMISSION_TARGET.APPOPS - ? APPOPS_ACTION.ALLOW - : PM_ACTION.GRANT, - target = PERMISSION_TARGET.PM, - } = opts; - if (_.isNil(permissions)) { - throw new errors.InvalidArgumentError(`'permissions' argument is required`); - } - if (_.isEmpty(permissions)) { - throw new errors.InvalidArgumentError(`'permissions' argument must not be empty`); - } - - switch (_.toLower(target)) { - case PERMISSION_TARGET.PM: - return await changePermissionsViaPm.bind(this)(permissions, appPackage, _.toLower(action)); - case PERMISSION_TARGET.APPOPS: - this.ensureFeatureEnabled(ADB_SHELL_FEATURE); - return await changePermissionsViaAppops.bind(this)( - permissions, - appPackage, - _.toLower(action) - ); - default: - throw new errors.InvalidArgumentError( - `'target' argument must be one of: ${_.values(PERMISSION_TARGET)}` - ); - } - }, - - async mobileGetPermissions(opts = {}) { - const {type = PERMISSIONS_TYPE.REQUESTED, appPackage = this.opts.appPackage} = opts; - /** - * @type {(pkg: string) => Promise} - */ - let actionFunc; - switch (_.toLower(type)) { - case PERMISSIONS_TYPE.REQUESTED: - actionFunc = (pkg) => this.adb.getReqPermissions(pkg); - break; - case PERMISSIONS_TYPE.GRANTED: - actionFunc = (pkg) => this.adb.getGrantedPermissions(pkg); - break; - case PERMISSIONS_TYPE.DENIED: - actionFunc = (pkg) => this.adb.getDeniedPermissions(pkg); - break; - default: - throw new errors.InvalidArgumentError( - `Unknown permissions type '${type}'. ` + - `Only ${JSON.stringify(_.values(PERMISSIONS_TYPE))} types are supported` - ); - } - return await actionFunc(/** @type {string} */ (appPackage)); - }, -}; - -mixin(PermissionsMixin); - -export default PermissionsMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/recordscreen.js b/lib/commands/recordscreen.js index 4c861507..f14d421e 100644 --- a/lib/commands/recordscreen.js +++ b/lib/commands/recordscreen.js @@ -1,11 +1,8 @@ -// @ts-check - import {fs, net, system, tempDir, timing, util} from '@appium/support'; import {waitForCondition} from 'asyncbox'; import _ from 'lodash'; import path from 'path'; import {exec} from 'teen_process'; -import {mixin} from './mixins'; const RETRY_PAUSE = 300; const RETRY_TIMEOUT = 5000; @@ -18,6 +15,155 @@ const DEFAULT_EXT = '.mp4'; const MIN_EMULATOR_API_LEVEL = 27; const FFMPEG_BINARY = `ffmpeg${system.isWindows() ? '.exe' : ''}`; +/** + * + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StartScreenRecordingOpts} [options={}] + * @returns {Promise} + */ +export async function startRecordingScreen(options = {}) { + await verifyScreenRecordIsSupported(this.adb, this.isEmulator()); + + let result = ''; + const { + videoSize, + timeLimit = DEFAULT_RECORDING_TIME_SEC, + bugReport, + bitRate, + forceRestart, + } = options; + if (!forceRestart) { + result = await this.stopRecordingScreen(options); + } + + if (await terminateBackgroundScreenRecording(this.adb, true)) { + this.log.warn( + `There were some ${SCREENRECORD_BINARY} process leftovers running ` + + `in the background. Make sure you stop screen recording each time after it is started, ` + + `otherwise the recorded media might quickly exceed all the free space on the device under test.`, + ); + } + + if (!_.isEmpty(this._screenRecordingProperties)) { + // XXX: this doesn't need to be done in serial, does it? + for (const record of this._screenRecordingProperties.records || []) { + await this.adb.rimraf(record); + } + this._screenRecordingProperties = undefined; + } + + const timeout = parseFloat(String(timeLimit)); + if (isNaN(timeout) || timeout > MAX_TIME_SEC || timeout <= 0) { + throw new Error( + `The timeLimit value must be in range [1, ${MAX_TIME_SEC}] seconds. ` + + `The value of '${timeLimit}' has been passed instead.`, + ); + } + + this._screenRecordingProperties = { + timer: new timing.Timer().start(), + videoSize, + timeLimit, + currentTimeLimit: timeLimit, + bitRate, + bugReport, + records: [], + recordingProcess: null, + stopped: false, + }; + await scheduleScreenRecord.bind(this)(this._screenRecordingProperties); + return result; +} + +/** + * + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StopScreenRecordingOpts} [options={}] + * @returns {Promise} + */ +export async function stopRecordingScreen(options = {}) { + await verifyScreenRecordIsSupported(this.adb, this.isEmulator()); + + if (!_.isEmpty(this._screenRecordingProperties)) { + this._screenRecordingProperties.stopped = true; + } + + try { + await terminateBackgroundScreenRecording(this.adb, false); + } catch (err) { + this.log.warn(/** @type {Error} */ (err).message); + if (!_.isEmpty(this._screenRecordingProperties)) { + this.log.warn('The resulting video might be corrupted'); + } + } + + if (_.isEmpty(this._screenRecordingProperties)) { + this.log.info( + `Screen recording has not been previously started by Appium. There is nothing to stop`, + ); + return ''; + } + + if ( + this._screenRecordingProperties.recordingProcess && + this._screenRecordingProperties.recordingProcess.isRunning + ) { + try { + await this._screenRecordingProperties.recordingProcess.stop( + 'SIGINT', + PROCESS_SHUTDOWN_TIMEOUT, + ); + } catch (e) { + this.log.errorAndThrow( + `Unable to stop screen recording within ${PROCESS_SHUTDOWN_TIMEOUT}ms`, + ); + } + this._screenRecordingProperties.recordingProcess = null; + } + + if (_.isEmpty(this._screenRecordingProperties.records)) { + this.log.errorAndThrow( + `No screen recordings have been stored on the device so far. ` + + `Are you sure the ${SCREENRECORD_BINARY} utility works as expected?`, + ); + } + + const tmpRoot = await tempDir.openDir(); + try { + const localRecords = []; + for (const pathOnDevice of this._screenRecordingProperties.records) { + const relativePath = path.resolve(tmpRoot, path.posix.basename(pathOnDevice)); + localRecords.push(relativePath); + await this.adb.pull(pathOnDevice, relativePath); + await this.adb.rimraf(pathOnDevice); + } + let resultFilePath = /** @type {string} */ (_.last(localRecords)); + if (localRecords.length > 1) { + this.log.info(`Got ${localRecords.length} screen recordings. Trying to merge them`); + try { + resultFilePath = await mergeScreenRecords.bind(this)(localRecords); + } catch (e) { + this.log.warn( + `Cannot merge the recorded files. The most recent screen recording is going to be returned as the result. ` + + `Original error: ${/** @type {Error} */ (e).message}`, + ); + } + } + if (_.isEmpty(options.remotePath)) { + const {size} = await fs.stat(resultFilePath); + this.log.debug( + `The size of the resulting screen recording is ${util.toReadableSizeString(size)}`, + ); + } + return await uploadRecordedMedia(resultFilePath, options.remotePath, options); + } finally { + await fs.rimraf(tmpRoot); + this._screenRecordingProperties = undefined; + } +} + +// #region Internal helpers + /** * * @param {string} localFile @@ -56,24 +202,22 @@ async function verifyScreenRecordIsSupported(adb, isEmulator) { const apiLevel = await adb.getApiLevel(); if (isEmulator && apiLevel < MIN_EMULATOR_API_LEVEL) { throw new Error( - `Screen recording does not work on emulators running Android API level less than ${MIN_EMULATOR_API_LEVEL}` + `Screen recording does not work on emulators running Android API level less than ${MIN_EMULATOR_API_LEVEL}`, ); } if (apiLevel < 19) { throw new Error( - `Screen recording not available on API Level ${apiLevel}. Minimum API Level is 19.` + `Screen recording not available on API Level ${apiLevel}. Minimum API Level is 19.`, ); } } /** - * - * @param {ADB} adb + * @this {import('../driver').AndroidDriver} * @param {import('@appium/types').StringRecord} recordingProperties - * @param {import('@appium/types').AppiumLogger} [log] * @returns {Promise} */ -async function scheduleScreenRecord(adb, recordingProperties, log) { +async function scheduleScreenRecord(recordingProperties) { if (recordingProperties.stopped) { return; } @@ -88,7 +232,7 @@ async function scheduleScreenRecord(adb, recordingProperties, log) { } } const pathOnDevice = `/sdcard/${util.uuidV4().substring(0, 8)}${DEFAULT_EXT}`; - const recordingProc = adb.screenrecord(pathOnDevice, { + const recordingProc = this.adb.screenrecord(pathOnDevice, { videoSize, bitRate, timeLimit: currentTimeLimit, @@ -100,10 +244,10 @@ async function scheduleScreenRecord(adb, recordingProperties, log) { return; } const currentDuration = timer.getDuration().asSeconds.toFixed(0); - log?.debug(`The overall screen recording duration is ${currentDuration}s so far`); + this.log.debug(`The overall screen recording duration is ${currentDuration}s so far`); const timeLimitInt = parseInt(timeLimit, 10); if (isNaN(timeLimitInt) || currentDuration >= timeLimitInt) { - log?.debug('There is no need to start the next recording chunk'); + this.log.debug('There is no need to start the next recording chunk'); return; } @@ -112,15 +256,15 @@ async function scheduleScreenRecord(adb, recordingProperties, log) { recordingProperties.currentTimeLimit < MAX_RECORDING_TIME_SEC ? recordingProperties.currentTimeLimit : MAX_RECORDING_TIME_SEC; - log?.debug( + this.log.debug( `Starting the next ${chunkDuration}s-chunk ` + - `of screen recording in order to achieve ${timeLimitInt}s total duration` + `of screen recording in order to achieve ${timeLimitInt}s total duration`, ); (async () => { try { - await scheduleScreenRecord(adb, recordingProperties, log); + await scheduleScreenRecord.bind(this)(recordingProperties); } catch (e) { - log?.error(/** @type {Error} */ (e).stack); + this.log.error(/** @type {Error} */ (e).stack); recordingProperties.stopped = true; } })(); @@ -128,14 +272,14 @@ async function scheduleScreenRecord(adb, recordingProperties, log) { await recordingProc.start(0); try { - await waitForCondition(async () => await adb.fileExists(pathOnDevice), { + await waitForCondition(async () => await this.adb.fileExists(pathOnDevice), { waitMs: RETRY_TIMEOUT, intervalMs: RETRY_PAUSE, }); } catch (e) { throw new Error( `The expected screen record file '${pathOnDevice}' does not exist after ${RETRY_TIMEOUT}ms. ` + - `Is ${SCREENRECORD_BINARY} utility available and operational on the device under test?` + `Is ${SCREENRECORD_BINARY} utility available and operational on the device under test?`, ); } @@ -145,29 +289,29 @@ async function scheduleScreenRecord(adb, recordingProperties, log) { /** * + * @this {import('../driver').AndroidDriver} * @param {string[]} mediaFiles - * @param {import('@appium/types').AppiumLogger} [log] * @returns {Promise} */ -async function mergeScreenRecords(mediaFiles, log) { +async function mergeScreenRecords(mediaFiles) { try { await fs.which(FFMPEG_BINARY); } catch (e) { throw new Error( - `${FFMPEG_BINARY} utility is not available in PATH. Please install it from https://www.ffmpeg.org/` + `${FFMPEG_BINARY} utility is not available in PATH. Please install it from https://www.ffmpeg.org/`, ); } const configContent = mediaFiles.map((x) => `file '${x}'`).join('\n'); const configFile = path.resolve(path.dirname(mediaFiles[0]), 'config.txt'); await fs.writeFile(configFile, configContent, 'utf8'); - log?.debug(`Generated ffmpeg merging config '${configFile}' with items:\n${configContent}`); + this.log.debug(`Generated ffmpeg merging config '${configFile}' with items:\n${configContent}`); const result = path.resolve( path.dirname(mediaFiles[0]), - `merge_${Math.floor(+new Date())}${DEFAULT_EXT}` + `merge_${Math.floor(+new Date())}${DEFAULT_EXT}`, ); const args = ['-safe', '0', '-f', 'concat', '-i', configFile, '-c', 'copy', result]; - log?.info( - `Initiating screen records merging using the command '${FFMPEG_BINARY} ${args.join(' ')}'` + this.log.info( + `Initiating screen records merging using the command '${FFMPEG_BINARY} ${args.join(' ')}'`, ); await exec(FFMPEG_BINARY, args); return result; @@ -194,155 +338,12 @@ async function terminateBackgroundScreenRecording(adb, force = true) { return true; } catch (err) { throw new Error( - `Unable to stop the background screen recording: ${/** @type {Error} */ (err).message}` + `Unable to stop the background screen recording: ${/** @type {Error} */ (err).message}`, ); } } -/** - * @type {import('./mixins').RecordScreenMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const RecordScreenMixin = { - async startRecordingScreen(options = {}) { - await verifyScreenRecordIsSupported(this.adb, this.isEmulator()); - - let result = ''; - const { - videoSize, - timeLimit = DEFAULT_RECORDING_TIME_SEC, - bugReport, - bitRate, - forceRestart, - } = options; - if (!forceRestart) { - result = await this.stopRecordingScreen(options); - } - - if (await terminateBackgroundScreenRecording(this.adb, true)) { - this.log.warn( - `There were some ${SCREENRECORD_BINARY} process leftovers running ` + - `in the background. Make sure you stop screen recording each time after it is started, ` + - `otherwise the recorded media might quickly exceed all the free space on the device under test.` - ); - } - - if (!_.isEmpty(this._screenRecordingProperties)) { - // XXX: this doesn't need to be done in serial, does it? - for (const record of this._screenRecordingProperties.records || []) { - await this.adb.rimraf(record); - } - this._screenRecordingProperties = undefined; - } - - const timeout = parseFloat(String(timeLimit)); - if (isNaN(timeout) || timeout > MAX_TIME_SEC || timeout <= 0) { - throw new Error( - `The timeLimit value must be in range [1, ${MAX_TIME_SEC}] seconds. ` + - `The value of '${timeLimit}' has been passed instead.` - ); - } - - this._screenRecordingProperties = { - timer: new timing.Timer().start(), - videoSize, - timeLimit, - currentTimeLimit: timeLimit, - bitRate, - bugReport, - records: [], - recordingProcess: null, - stopped: false, - }; - await scheduleScreenRecord(this.adb, this._screenRecordingProperties, this.log); - return result; - }, - - async stopRecordingScreen(options = {}) { - await verifyScreenRecordIsSupported(this.adb, this.isEmulator()); - - if (!_.isEmpty(this._screenRecordingProperties)) { - this._screenRecordingProperties.stopped = true; - } - - try { - await terminateBackgroundScreenRecording(this.adb, false); - } catch (err) { - this.log.warn(/** @type {Error} */ (err).message); - if (!_.isEmpty(this._screenRecordingProperties)) { - this.log.warn('The resulting video might be corrupted'); - } - } - - if (_.isEmpty(this._screenRecordingProperties)) { - this.log.info( - `Screen recording has not been previously started by Appium. There is nothing to stop` - ); - return ''; - } - - if ( - this._screenRecordingProperties.recordingProcess && - this._screenRecordingProperties.recordingProcess.isRunning - ) { - try { - await this._screenRecordingProperties.recordingProcess.stop( - 'SIGINT', - PROCESS_SHUTDOWN_TIMEOUT - ); - } catch (e) { - this.log.errorAndThrow( - `Unable to stop screen recording within ${PROCESS_SHUTDOWN_TIMEOUT}ms` - ); - } - this._screenRecordingProperties.recordingProcess = null; - } - - if (_.isEmpty(this._screenRecordingProperties.records)) { - this.log.errorAndThrow( - `No screen recordings have been stored on the device so far. ` + - `Are you sure the ${SCREENRECORD_BINARY} utility works as expected?` - ); - } - - const tmpRoot = await tempDir.openDir(); - try { - const localRecords = []; - for (const pathOnDevice of this._screenRecordingProperties.records) { - const relativePath = path.resolve(tmpRoot, path.posix.basename(pathOnDevice)); - localRecords.push(relativePath); - await this.adb.pull(pathOnDevice, relativePath); - await this.adb.rimraf(pathOnDevice); - } - let resultFilePath = /** @type {string} */ (_.last(localRecords)); - if (localRecords.length > 1) { - this.log.info(`Got ${localRecords.length} screen recordings. Trying to merge them`); - try { - resultFilePath = await mergeScreenRecords(localRecords, this.log); - } catch (e) { - this.log.warn( - `Cannot merge the recorded files. The most recent screen recording is going to be returned as the result. ` + - `Original error: ${/** @type {Error} */ (e).message}` - ); - } - } - if (_.isEmpty(options.remotePath)) { - const {size} = await fs.stat(resultFilePath); - this.log.debug( - `The size of the resulting screen recording is ${util.toReadableSizeString(size)}` - ); - } - return await uploadRecordedMedia(resultFilePath, options.remotePath, options); - } finally { - await fs.rimraf(tmpRoot); - this._screenRecordingProperties = undefined; - } - }, -}; - -mixin(RecordScreenMixin); - -export default RecordScreenMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/resources.js b/lib/commands/resources.js new file mode 100644 index 00000000..a8e1c390 --- /dev/null +++ b/lib/commands/resources.js @@ -0,0 +1,96 @@ +import path from 'node:path'; +import _ from 'lodash'; +import {fs, util} from '@appium/support'; + +/** + * @this {import('../driver').AndroidDriver} + * @param {string?} [language=null] + * @returns {Promise}} + */ +export async function getStrings(language = null) { + 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 {import('@appium/types').StringRecord} mapping */ + const preprocessStringsMap = (mapping) => { + /** @type {import('@appium/types').StringRecord} */ + const result = {}; + for (const [key, value] of _.toPairs(mapping)) { + result[key] = _.isString(value) ? value : JSON.stringify(value); + } + return result; + }; + + if (!this.apkStrings[language]) { + this.apkStrings[language] = await extractStringsFromResources.bind(this)(language); + } + const mapping = JSON.parse(await fs.readFile(this.apkStrings[language], 'utf-8')); + return preprocessStringsMap(mapping); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} language + * @param {string} country + * @param {string} [script] + * @returns {Promise}} + */ +export async function ensureDeviceLocale(language, country, script) { + await this.settingsApp.setDeviceLocale(language, country, script); + + if (!(await this.adb.ensureCurrentLocale(language, country, script))) { + const message = script + ? `language: ${language}, country: ${country} and script: ${script}` + : `language: ${language} and country: ${country}`; + throw new Error(`Failed to set ${message}`); + } +} + +// #region Internal helpers + +/** + * @this {import('../driver').AndroidDriver} + * @param {string?} [language] + * @param {import('../driver').AndroidDriverOpts?} [opts=null] + * @returns {Promise}; + */ +async function extractStringsFromResources(language, opts = null) { + const caps = opts ?? this.opts; + + /** @type {string|undefined} */ + let app; + try { + app = + caps.app || + (caps.appPackage && caps.tmpDir && (await this.adb.pullApk(caps.appPackage, caps.tmpDir))); + } catch (err) { + throw new Error( + `Failed to pull an apk from '${caps.appPackage}' to '${caps.tmpDir}'. Original error: ${err.message}`, + ); + } + + if (!app || !(await fs.exists(app))) { + throw new Error(`Could not extract app strings, no app or package specified`); + } + + const stringsTmpDir = path.resolve(String(caps.tmpDir), util.uuidV4()); + try { + this.log.debug( + `Extracting strings from '${app}' for the language '${language || 'default'}' into '${stringsTmpDir}'`, + ); + const {localPath} = await this.adb.extractStringsFromApk(app, language ?? null, stringsTmpDir); + return localPath; + } catch (err) { + throw new Error(`Could not extract app strings. Original error: ${err.message}`); + } +} + +// #endregion + +/** + * @typedef {import('appium-adb').ADB} ADB + */ diff --git a/lib/commands/shell.js b/lib/commands/shell.js index 68a4803a..8c3fa0f9 100644 --- a/lib/commands/shell.js +++ b/lib/commands/shell.js @@ -1,56 +1,42 @@ -// @ts-check - import {util} from '@appium/support'; import {errors} from 'appium/driver'; import _ from 'lodash'; import {exec} from 'teen_process'; import {ADB_SHELL_FEATURE} from '../utils'; -import {mixin} from './mixins'; /** - * @type {import('./mixins').ShellMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @this {import('../driver').AndroidDriver} + * @param {import('./types').ShellOpts} [opts={}] + * @returns {Promise}; */ -const ShellMixin = { - async mobileShell(opts) { - this.ensureFeatureEnabled(ADB_SHELL_FEATURE); - const { - command, - args = /** @type {string[]} */ ([]), - timeout = 20000, - includeStderr, - } = opts ?? {}; +export async function mobileShell(opts) { + this.ensureFeatureEnabled(ADB_SHELL_FEATURE); + const {command, args = /** @type {string[]} */ ([]), timeout = 20000, includeStderr} = opts ?? {}; - if (!_.isString(command)) { - throw new errors.InvalidArgumentError(`The 'command' argument is mandatory`); - } + if (!_.isString(command)) { + throw new errors.InvalidArgumentError(`The 'command' argument is mandatory`); + } - const adbArgs = [...this.adb.executable.defaultArgs, 'shell', command, ..._.castArray(args)]; - this.log.debug(`Running '${this.adb.executable.path} ${util.quote(adbArgs)}'`); - try { - const {stdout, stderr} = await exec(this.adb.executable.path, adbArgs, {timeout}); - if (includeStderr) { - return { - stdout, - stderr, - }; - } - return stdout; - } catch (e) { - const err = /** @type {import('teen_process').ExecError} */ (e); - this.log.errorAndThrow( - `Cannot execute the '${command}' shell command. ` + - `Original error: ${err.message}. ` + - `StdOut: ${err.stdout}. StdErr: ${err.stderr}` - ); - throw new Error(); // unreachable; for TS + const adbArgs = [...this.adb.executable.defaultArgs, 'shell', command, ..._.castArray(args)]; + this.log.debug(`Running '${this.adb.executable.path} ${util.quote(adbArgs)}'`); + try { + const {stdout, stderr} = await exec(this.adb.executable.path, adbArgs, {timeout}); + if (includeStderr) { + return { + stdout, + stderr, + }; } - }, -}; - -mixin(ShellMixin); - -export default ShellMixin; + return stdout; + } catch (e) { + const err = /** @type {import('teen_process').ExecError} */ (e); + throw this.log.errorAndThrow( + `Cannot execute the '${command}' shell command. ` + + `Original error: ${err.message}. ` + + `StdOut: ${err.stdout}. StdErr: ${err.stderr}`, + ); + } +} /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/streamscreen.js b/lib/commands/streamscreen.js index a8141dbc..0d5d21fd 100644 --- a/lib/commands/streamscreen.js +++ b/lib/commands/streamscreen.js @@ -10,7 +10,6 @@ import net from 'node:net'; import url from 'node:url'; import {checkPortStatus} from 'portscanner'; import {SubProcess, exec} from 'teen_process'; -import {mixin} from './mixins'; const RECORDING_INTERVAL_SEC = 5; const STREAMING_STARTUP_TIMEOUT_MS = 5000; @@ -34,6 +33,201 @@ const BOUNDARY_STRING = '--2ae9746887f170b8cf7c271047ce314c'; const ADB_SCREEN_STREAMING_FEATURE = 'adb_screen_streaming'; +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StartScreenStreamingOpts} [options={}] + * @returns {Promise} + */ +export async function mobileStartScreenStreaming(options = {}) { + this.ensureFeatureEnabled(ADB_SCREEN_STREAMING_FEATURE); + + const { + width, + height, + bitRate, + host = DEFAULT_HOST, + port = DEFAULT_PORT, + pathname, + tcpPort = DEFAULT_PORT + 1, + quality = DEFAULT_QUALITY, + considerRotation = false, + logPipelineDetails = false, + } = options; + if (_.isUndefined(this._screenStreamingProps)) { + await verifyStreamingRequirements(this.adb); + } + if (!_.isEmpty(this._screenStreamingProps)) { + this.log.info( + `The screen streaming session is already running. ` + + `Stop it first in order to start a new one.`, + ); + return; + } + if ((await checkPortStatus(port, host)) === 'open') { + this.log.info( + `The port #${port} at ${host} is busy. ` + `Assuming the screen streaming is already running`, + ); + return; + } + if ((await checkPortStatus(tcpPort, TCP_HOST)) === 'open') { + this.log.errorAndThrow( + `The port #${tcpPort} at ${TCP_HOST} is busy. ` + + `Make sure there are no leftovers from previous sessions.`, + ); + } + this._screenStreamingProps = undefined; + + const deviceInfo = await getDeviceInfo(this.adb, this.log); + const deviceStreamingProc = await initDeviceStreamingProc(this.adb, this.log, deviceInfo, { + width, + height, + bitRate, + }); + let gstreamerPipeline; + try { + gstreamerPipeline = await initGstreamerPipeline(deviceStreamingProc, deviceInfo, this.log, { + width, + height, + quality, + tcpPort, + considerRotation, + logPipelineDetails, + }); + } catch (e) { + if (deviceStreamingProc.kill(0)) { + deviceStreamingProc.kill(); + } + throw e; + } + + /** @type {import('node:net').Socket|undefined} */ + let mjpegSocket; + /** @type {import('node:http').Server|undefined} */ + let mjpegServer; + try { + await new B((resolve, reject) => { + mjpegSocket = net.createConnection(tcpPort, TCP_HOST, () => { + this.log.info(`Successfully connected to MJPEG stream at tcp://${TCP_HOST}:${tcpPort}`); + mjpegServer = http.createServer((req, res) => { + const remoteAddress = extractRemoteAddress(req); + const currentPathname = url.parse(String(req.url)).pathname; + this.log.info( + `Got an incoming screen broadcasting request from ${remoteAddress} ` + + `(${req.headers['user-agent'] || 'User Agent unknown'}) at ${currentPathname}`, + ); + + if (pathname && currentPathname !== pathname) { + this.log.info( + 'Rejecting the broadcast request since it does not match the given pathname', + ); + res.writeHead(404, { + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.write(`'${currentPathname}' did not match any known endpoints`); + res.end(); + return; + } + + this.log.info('Starting MJPEG broadcast'); + res.writeHead(200, { + 'Cache-Control': + 'no-store, no-cache, must-revalidate, pre-check=0, post-check=0, max-age=0', + Pragma: 'no-cache', + Connection: 'close', + 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY_STRING}`, + }); + + /** @type {import('node:net').Socket} */ (mjpegSocket).pipe(res); + }); + mjpegServer.on('error', (e) => { + this.log.warn(e); + reject(e); + }); + mjpegServer.on('close', () => { + this.log.debug(`MJPEG server at http://${host}:${port} has been closed`); + }); + mjpegServer.on('listening', () => { + this.log.info(`Successfully started MJPEG server at http://${host}:${port}`); + resolve(); + }); + mjpegServer.listen(port, host); + }); + mjpegSocket.on('error', (e) => { + this.log.error(e); + reject(e); + }); + }).timeout( + STREAMING_STARTUP_TIMEOUT_MS, + `Cannot connect to the streaming server within ${STREAMING_STARTUP_TIMEOUT_MS}ms`, + ); + } catch (e) { + if (deviceStreamingProc.kill(0)) { + deviceStreamingProc.kill(); + } + if (gstreamerPipeline.isRunning) { + await gstreamerPipeline.stop(); + } + if (mjpegSocket) { + mjpegSocket.destroy(); + } + if (mjpegServer && mjpegServer.listening) { + mjpegServer.close(); + } + throw e; + } + + this._screenStreamingProps = { + deviceStreamingProc, + gstreamerPipeline, + mjpegSocket, + mjpegServer, + }; +} + +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function mobileStopScreenStreaming() { + if (_.isEmpty(this._screenStreamingProps)) { + if (!_.isUndefined(this._screenStreamingProps)) { + this.log.debug(`Screen streaming is not running. There is nothing to stop`); + } + return; + } + + const {deviceStreamingProc, gstreamerPipeline, mjpegSocket, mjpegServer} = + this._screenStreamingProps; + + try { + mjpegSocket.end(); + if (mjpegServer.listening) { + mjpegServer.close(); + } + if (deviceStreamingProc.kill(0)) { + deviceStreamingProc.kill('SIGINT'); + } + if (gstreamerPipeline.isRunning) { + try { + await gstreamerPipeline.stop('SIGINT'); + } catch (e) { + this.log.warn(e); + try { + await gstreamerPipeline.stop('SIGKILL'); + } catch (e1) { + this.log.error(e1); + } + } + } + this.log.info(`Successfully terminated the screen streaming MJPEG server`); + } finally { + this._screenStreamingProps = undefined; + } +} + +// #region Internal helpers + /** * * @param {string} streamName @@ -46,7 +240,7 @@ function createStreamingLogger(streamName, udid) { _.truncate(udid, { length: 8, omission: '', - }) + }), ); } @@ -57,7 +251,7 @@ function createStreamingLogger(streamName, udid) { async function verifyStreamingRequirements(adb) { if (!_.trim(await adb.shell(['which', SCREENRECORD_BINARY]))) { throw new Error( - `The required '${SCREENRECORD_BINARY}' binary is not available on the device under test` + `The required '${SCREENRECORD_BINARY}' binary is not available on the device under test`, ); } @@ -70,10 +264,10 @@ async function verifyStreamingRequirements(adb) { } catch (e) { throw new Error( `The '${binaryName}' binary is not available in the PATH on the host system. ` + - `See ${GST_TUTORIAL_URL} for more details on how to install it.` + `See ${GST_TUTORIAL_URL} for more details on how to install it.`, ); } - })() + })(), ); } await B.all(gstreamerCheckPromises); @@ -86,10 +280,10 @@ async function verifyStreamingRequirements(adb) { if (!_.includes(stdout, modName)) { throw new Error( `The required GStreamer plugin '${name}' from '${modName}' module is not installed. ` + - `See ${GST_TUTORIAL_URL} for more details on how to install it.` + `See ${GST_TUTORIAL_URL} for more details on how to install it.`, ); } - })() + })(), ); } await B.all(moduleCheckPromises); @@ -118,7 +312,7 @@ async function getDeviceInfo(adb, log) { log?.debug(output); throw new Error( `Cannot parse the device ${key} from the adb command output. ` + - `Check the server log for more details.` + `Check the server log for more details.`, ); } result[key] = parseInt(match[1], 10); @@ -198,7 +392,7 @@ async function initDeviceStreamingProc(adb, log, deviceInfo, opts = {}) { log.errorAndThrow( `Cannot start the screen streaming process. Original error: ${ /** @type {Error} */ (e).message - }` + }`, ); } finally { deviceStreaming.stderr.removeListener('data', errorsListener); @@ -253,7 +447,7 @@ async function initGstreamerPipeline(deviceStreamingProc, deviceInfo, log, opts) ], { stdio: [deviceStreamingProc.stdout, 'pipe', 'pipe'], - } + }, ); gstreamerPipeline.on('exit', (code, signal) => { log.debug(`Pipeline streaming process exited with code ${code}, signal ${signal}`); @@ -285,14 +479,14 @@ async function initGstreamerPipeline(deviceStreamingProc, deviceInfo, log, opts) { waitMs: STREAMING_STARTUP_TIMEOUT_MS, intervalMs: 300, - } + }, ); } catch (e) { didFail = true; log.errorAndThrow( `Cannot start the screen streaming pipeline. Original error: ${ /** @type {Error} */ (e).message - }` + }`, ); } finally { if (!logPipelineDetails || didFail) { @@ -316,200 +510,7 @@ function extractRemoteAddress(req) { ); } -/** - * @type {import('./mixins').StreamScreenMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const StreamScreenMixin = { - async mobileStartScreenStreaming(options = {}) { - this.ensureFeatureEnabled(ADB_SCREEN_STREAMING_FEATURE); - - const { - width, - height, - bitRate, - host = DEFAULT_HOST, - port = DEFAULT_PORT, - pathname, - tcpPort = DEFAULT_PORT + 1, - quality = DEFAULT_QUALITY, - considerRotation = false, - logPipelineDetails = false, - } = options; - if (_.isUndefined(this._screenStreamingProps)) { - await verifyStreamingRequirements(this.adb); - } - if (!_.isEmpty(this._screenStreamingProps)) { - this.log.info( - `The screen streaming session is already running. ` + - `Stop it first in order to start a new one.` - ); - return; - } - if ((await checkPortStatus(port, host)) === 'open') { - this.log.info( - `The port #${port} at ${host} is busy. ` + - `Assuming the screen streaming is already running` - ); - return; - } - if ((await checkPortStatus(tcpPort, TCP_HOST)) === 'open') { - this.log.errorAndThrow( - `The port #${tcpPort} at ${TCP_HOST} is busy. ` + - `Make sure there are no leftovers from previous sessions.` - ); - } - this._screenStreamingProps = undefined; - - const deviceInfo = await getDeviceInfo(this.adb, this.log); - const deviceStreamingProc = await initDeviceStreamingProc(this.adb, this.log, deviceInfo, { - width, - height, - bitRate, - }); - let gstreamerPipeline; - try { - gstreamerPipeline = await initGstreamerPipeline(deviceStreamingProc, deviceInfo, this.log, { - width, - height, - quality, - tcpPort, - considerRotation, - logPipelineDetails, - }); - } catch (e) { - if (deviceStreamingProc.kill(0)) { - deviceStreamingProc.kill(); - } - throw e; - } - - /** @type {import('node:net').Socket|undefined} */ - let mjpegSocket; - /** @type {import('node:http').Server|undefined} */ - let mjpegServer; - try { - await new B((resolve, reject) => { - mjpegSocket = net.createConnection(tcpPort, TCP_HOST, () => { - this.log.info(`Successfully connected to MJPEG stream at tcp://${TCP_HOST}:${tcpPort}`); - mjpegServer = http.createServer((req, res) => { - const remoteAddress = extractRemoteAddress(req); - const currentPathname = url.parse(String(req.url)).pathname; - this.log.info( - `Got an incoming screen broadcasting request from ${remoteAddress} ` + - `(${req.headers['user-agent'] || 'User Agent unknown'}) at ${currentPathname}` - ); - - if (pathname && currentPathname !== pathname) { - this.log.info( - 'Rejecting the broadcast request since it does not match the given pathname' - ); - res.writeHead(404, { - Connection: 'close', - 'Content-Type': 'text/plain; charset=utf-8', - }); - res.write(`'${currentPathname}' did not match any known endpoints`); - res.end(); - return; - } - - this.log.info('Starting MJPEG broadcast'); - res.writeHead(200, { - 'Cache-Control': - 'no-store, no-cache, must-revalidate, pre-check=0, post-check=0, max-age=0', - Pragma: 'no-cache', - Connection: 'close', - 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY_STRING}`, - }); - - /** @type {import('node:net').Socket} */ (mjpegSocket).pipe(res); - }); - mjpegServer.on('error', (e) => { - this.log.warn(e); - reject(e); - }); - mjpegServer.on('close', () => { - this.log.debug(`MJPEG server at http://${host}:${port} has been closed`); - }); - mjpegServer.on('listening', () => { - this.log.info(`Successfully started MJPEG server at http://${host}:${port}`); - resolve(); - }); - mjpegServer.listen(port, host); - }); - mjpegSocket.on('error', (e) => { - this.log.error(e); - reject(e); - }); - }).timeout( - STREAMING_STARTUP_TIMEOUT_MS, - `Cannot connect to the streaming server within ${STREAMING_STARTUP_TIMEOUT_MS}ms` - ); - } catch (e) { - if (deviceStreamingProc.kill(0)) { - deviceStreamingProc.kill(); - } - if (gstreamerPipeline.isRunning) { - await gstreamerPipeline.stop(); - } - if (mjpegSocket) { - mjpegSocket.destroy(); - } - if (mjpegServer && mjpegServer.listening) { - mjpegServer.close(); - } - throw e; - } - - this._screenStreamingProps = { - deviceStreamingProc, - gstreamerPipeline, - mjpegSocket, - mjpegServer, - }; - }, - - async mobileStopScreenStreaming() { - if (_.isEmpty(this._screenStreamingProps)) { - if (!_.isUndefined(this._screenStreamingProps)) { - this.log.debug(`Screen streaming is not running. There is nothing to stop`); - } - return; - } - - const {deviceStreamingProc, gstreamerPipeline, mjpegSocket, mjpegServer} = - this._screenStreamingProps; - - try { - mjpegSocket.end(); - if (mjpegServer.listening) { - mjpegServer.close(); - } - if (deviceStreamingProc.kill(0)) { - deviceStreamingProc.kill('SIGINT'); - } - if (gstreamerPipeline.isRunning) { - try { - await gstreamerPipeline.stop('SIGINT'); - } catch (e) { - this.log.warn(e); - try { - await gstreamerPipeline.stop('SIGKILL'); - } catch (e1) { - this.log.error(e1); - } - } - } - this.log.info(`Successfully terminated the screen streaming MJPEG server`); - } finally { - this._screenStreamingProps = undefined; - } - }, -}; - -mixin(StreamScreenMixin); - -export default StreamScreenMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/system-bars.js b/lib/commands/system-bars.js index 7a9d64b3..ec6808d4 100644 --- a/lib/commands/system-bars.js +++ b/lib/commands/system-bars.js @@ -3,7 +3,6 @@ import {errors} from 'appium/driver'; import _ from 'lodash'; import {requireArgs} from '../utils'; -import {mixin} from './mixins'; const WINDOW_TITLE_PATTERN = /^\s+Window\s#\d+\sWindow\{[0-9a-f]+\s\w+\s([\w-]+)\}:$/; const FRAME_PATTERN = /\bm?[Ff]rame=\[([0-9.-]+),([0-9.-]+)\]\[([0-9.-]+),([0-9.-]+)\]/; @@ -20,22 +19,86 @@ const DEFAULT_WINDOW_PROPERTIES = { height: 0, }; +/** + * @this {import('../driver').AndroidDriver} + * @returns {Promise} + */ +export async function getSystemBars() { + /** @type {string} */ + let stdout; + try { + stdout = await this.adb.shell(['dumpsys', 'window', 'windows']); + } catch (e) { + throw new Error( + `Cannot retrieve system bars details. Original error: ${/** @type {Error} */ (e).message}`, + ); + } + return parseWindows.bind(this)(stdout); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').StatusBarCommandOpts} opts + * @returns {Promise} + */ +export async function mobilePerformStatusBarCommand(opts) { + const {command} = requireArgs('command', opts); + + /** + * + * @param {string} cmd + * @param {(() => string[]|string)} [argsCallable] + * @returns + */ + const toStatusBarCommandCallable = (cmd, argsCallable) => async () => + await this.adb.shell([ + 'cmd', + 'statusbar', + cmd, + ...(argsCallable ? _.castArray(argsCallable()) : []), + ]); + const tileCommandArgsCallable = () => + /** @type {string} */ (requireArgs('component', opts).component); + const statusBarCommands = _.fromPairs( + /** @type {const} */ ([ + ['expandNotifications', ['expand-notifications']], + ['expandSettings', ['expand-settings']], + ['collapse', ['collapse']], + ['addTile', ['add-tile', tileCommandArgsCallable]], + ['removeTile', ['remove-tile', tileCommandArgsCallable]], + ['clickTile', ['click-tile', tileCommandArgsCallable]], + ['getStatusIcons', ['get-status-icons']], + ]).map(([name, args]) => [name, toStatusBarCommandCallable(args[0], args[1])]), + ); + + const action = statusBarCommands[command]; + if (!action) { + throw new errors.InvalidArgumentError( + `The '${command}' status bar command is unknown. Only the following commands ` + + `are supported: ${_.keys(statusBarCommands)}`, + ); + } + return await action(); +} + +// #region Internal helpers + /** * Parses window properties from adb dumpsys output * + * @this {import('../driver').AndroidDriver} * @param {string} name The name of the window whose properties are being parsed * @param {Array} props The list of particular window property lines. * Check the corresponding unit tests for more details on the input format. - * @param {import('@appium/types').AppiumLogger} [log] Logger instance * @returns {WindowProperties} Parsed properties object * @throws {Error} If there was an issue while parsing the properties string */ -function parseWindowProperties(name, props, log) { +export function parseWindowProperties(name, props) { const result = _.cloneDeep(DEFAULT_WINDOW_PROPERTIES); const propLines = props.join('\n'); const frameMatch = FRAME_PATTERN.exec(propLines); if (!frameMatch) { - log?.debug(propLines); + this.log.debug(propLines); throw new Error(`Cannot parse the frame size from '${name}' window properties`); } result.x = parseFloat(frameMatch[1]); @@ -44,7 +107,7 @@ function parseWindowProperties(name, props, log) { result.height = parseFloat(frameMatch[4]) - result.y; const visibilityMatch = VIEW_VISIBILITY_PATTERN.exec(propLines); if (!visibilityMatch) { - log?.debug(propLines); + this.log.debug(propLines); throw new Error(`Cannot parse the visibility value from '${name}' window properties`); } result.visible = parseInt(visibilityMatch[1], 16) === VIEW_VISIBLE; @@ -54,14 +117,14 @@ function parseWindowProperties(name, props, log) { /** * Extracts status and navigation bar information from the window manager output. * + * @this {import('../driver').AndroidDriver} * @param {string} lines Output from dumpsys command. * Check the corresponding unit tests for more details on the input format. - * @param {import('@appium/types').AppiumLogger} [log] Logger instance * @return {StringRecord} An object containing two items where keys are statusBar and navigationBar, * and values are corresponding WindowProperties objects * @throws {Error} If no window properties could be parsed */ -function parseWindows(lines, log) { +export function parseWindows(lines) { /** * @type {StringRecord} */ @@ -84,7 +147,7 @@ function parseWindows(lines, log) { } } if (_.isEmpty(windows)) { - log?.debug(lines); + this.log.debug(lines); throw new Error('Cannot parse any window information from the dumpsys output'); } @@ -92,9 +155,9 @@ function parseWindows(lines, log) { const result = {}; for (const [name, props] of _.toPairs(windows)) { if (name.startsWith(STATUS_BAR_WINDOW_NAME_PREFIX)) { - result.statusBar = parseWindowProperties(name, props, log); + result.statusBar = parseWindowProperties.bind(this)(name, props); } else if (name.startsWith(NAVIGATION_BAR_WINDOW_NAME_PREFIX)) { - result.navigationBar = parseWindowProperties(name, props, log); + result.navigationBar = parseWindowProperties.bind(this)(name, props); } } const unmatchedWindows = /** @type {const} */ ([ @@ -102,81 +165,17 @@ function parseWindows(lines, log) { ['navigationBar', NAVIGATION_BAR_WINDOW_NAME_PREFIX], ]).filter(([name]) => _.isNil(result[name])); for (const [window, namePrefix] of unmatchedWindows) { - log?.info( + this.log.info( `No windows have been found whose title matches to ` + `'${namePrefix}'. Assuming it is invisible. ` + - `Only the following windows are available: ${_.keys(windows)}` + `Only the following windows are available: ${_.keys(windows)}`, ); result[window] = _.cloneDeep(DEFAULT_WINDOW_PROPERTIES); } return result; } -/** - * @type {import('./mixins').SystemBarsMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} - */ -const SystemBarsMixin = { - async getSystemBars() { - /** @type {string} */ - let stdout; - try { - stdout = await this.adb.shell(['dumpsys', 'window', 'windows']); - } catch (e) { - throw new Error( - `Cannot retrieve system bars details. Original error: ${/** @type {Error} */ (e).message}` - ); - } - return parseWindows(stdout, this.log); - }, - - async mobilePerformStatusBarCommand(opts) { - const {command} = requireArgs('command', opts); - - /** - * - * @param {string} cmd - * @param {(() => string[]|string)} [argsCallable] - * @returns - */ - const toStatusBarCommandCallable = (cmd, argsCallable) => async () => - await this.adb.shell([ - 'cmd', - 'statusbar', - cmd, - ...(argsCallable ? _.castArray(argsCallable()) : []), - ]); - const tileCommandArgsCallable = () => - /** @type {string} */ (requireArgs('component', opts).component); - const statusBarCommands = _.fromPairs( - /** @type {const} */ ([ - ['expandNotifications', ['expand-notifications']], - ['expandSettings', ['expand-settings']], - ['collapse', ['collapse']], - ['addTile', ['add-tile', tileCommandArgsCallable]], - ['removeTile', ['remove-tile', tileCommandArgsCallable]], - ['clickTile', ['click-tile', tileCommandArgsCallable]], - ['getStatusIcons', ['get-status-icons']], - ]).map(([name, args]) => [name, toStatusBarCommandCallable(args[0], args[1])]) - ); - - const action = statusBarCommands[command]; - if (!action) { - throw new errors.InvalidArgumentError( - `The '${command}' status bar command is unknown. Only the following commands ` + - `are supported: ${_.keys(statusBarCommands)}` - ); - } - return await action(); - }, -}; - -mixin(SystemBarsMixin); - -// for unit tests -export {parseWindowProperties, parseWindows}; - -export default SystemBarsMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/time.js b/lib/commands/time.js new file mode 100644 index 00000000..44b4ed87 --- /dev/null +++ b/lib/commands/time.js @@ -0,0 +1,36 @@ +import moment from 'moment'; + +const MOMENT_FORMAT_ISO8601 = 'YYYY-MM-DDTHH:mm:ssZ'; + +/** + * @this {import('../driver').AndroidDriver} + * @param {string} [format=MOMENT_FORMAT_ISO8601] + * @returns {Promise} + */ +export async function getDeviceTime(format = MOMENT_FORMAT_ISO8601) { + this.log.debug( + 'Attempting to capture android device date and time. ' + `The format specifier is '${format}'`, + ); + const deviceTimestamp = (await this.adb.shell(['date', '+%Y-%m-%dT%T%z'])).trim(); + this.log.debug(`Got device timestamp: ${deviceTimestamp}`); + const parsedTimestamp = moment.utc(deviceTimestamp, 'YYYY-MM-DDTHH:mm:ssZZ'); + if (!parsedTimestamp.isValid()) { + this.log.warn('Cannot parse the returned timestamp. Returning as is'); + return deviceTimestamp; + } + // @ts-expect-error private API + return parsedTimestamp.utcOffset(parsedTimestamp._tzm || 0).format(format); +} + +/** + * @this {import('../driver').AndroidDriver} + * @param {import('./types').DeviceTimeOpts} [opts={}] + * @returns {Promise} + */ +export async function mobileGetDeviceTime(opts = {}) { + return await this.getDeviceTime(opts.format); +} + +/** + * @typedef {import('appium-adb').ADB} ADB + */ diff --git a/lib/commands/touch.js b/lib/commands/touch.js index 0879d7c0..7b7e960e 100644 --- a/lib/commands/touch.js +++ b/lib/commands/touch.js @@ -1,15 +1,248 @@ -// @ts-check +/* eslint-disable @typescript-eslint/no-unused-vars */ import {util} from '@appium/support'; import {errors, isErrorType} from 'appium/driver'; import {asyncmap} from 'asyncbox'; import B from 'bluebird'; import _ from 'lodash'; -import androidHelpers from '../helpers/android'; -import {mixin} from './mixins'; /** - * + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string?} [elementId=null] + * @param {number?} [x=null] + * @param {number?} [y=null] + * @param {number} [count=1] + * @returns {Promise} + */ +export async function tap(elementId = null, x = null, y = null, count = 1) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @param {number} x + * @param {number} y + * @param {number} duration + * @returns {Promise} + */ +export async function touchLongClick(elementId, x, y, duration) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @param {number} x + * @param {number} y + * @returns {Promise} + */ +export async function touchDown(elementId, x, y) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @param {number} x + * @param {number} y + * @returns {Promise} + */ +export async function touchUp(elementId, x, y) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @param {number} x + * @param {number} y + * @returns {Promise} + */ +export async function touchMove(elementId, x, y) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').SwipeOpts} opts + * @returns {Promise} + */ +export async function doSwipe(opts) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchDragAction} opts + * @returns {Promise} + */ +export async function doTouchDrag(opts) { + throw new errors.NotImplementedError('Not implemented'); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchActionKind} action + * @param {import('./types').TouchActionOpts} [opts={}] + * @returns {Promise} + */ +export async function doTouchAction(action, opts = {}) { + const {element, x, y, count, ms, duration} = opts; + // parseTouch precalculates absolute element positions + // so there is no need to pass `element` to the affected gestures + switch (action) { + case 'tap': + return await this.tap('', x, y, count); + case 'press': + return await this.touchDown('', /** @type {number} */ (x), /** @type {number} */ (y)); + case 'release': + return await this.touchUp( + String(element), + /** @type {number} */ (x), + /** @type {number} */ (y), + ); + case 'moveTo': + return await this.touchMove('', /** @type {number} */ (x), /** @type {number} */ (y)); + case 'wait': + return await B.delay(/** @type {number} */ (ms)); + case 'longPress': + return await this.touchLongClick( + '', + /** @type {number} */ (x), + /** @type {number} */ (y), + duration ?? 1000, + ); + case 'cancel': + // TODO: clarify behavior of 'cancel' action and fix this + this.log.warn('Cancel action currently has no effect'); + break; + default: + this.log.errorAndThrow(`unknown action ${action}`); + } +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchAction[]} gestures + * @returns {Promise} + */ +export async function performTouch(gestures) { + // press-wait-moveTo-release is `swipe`, so use native method + if ( + gestures.length === 4 && + gestures[0].action === 'press' && + gestures[1].action === 'wait' && + gestures[2].action === 'moveTo' && + gestures[3].action === 'release' + ) { + let swipeOpts = await getSwipeOptions.bind(this)( + /** @type {import('./types').SwipeAction} */ (gestures), + ); + return await this.doSwipe(swipeOpts); + } + let actions = /** @type {(import('./types').TouchActionKind|TouchAction)[]} */ ( + _.map(gestures, 'action') + ); + + if (actions[0] === 'longPress' && actions[1] === 'moveTo' && actions[2] === 'release') { + // some things are special + return await this.doTouchDrag(/** @type {import('./types').TouchDragAction} */ (gestures)); + } else { + if (actions.length === 2) { + // `press` without a wait is too slow and gets interpretted as a `longPress` + if (_.head(actions) === 'press' && _.last(actions) === 'release') { + actions[0] = 'tap'; + gestures[0].action = 'tap'; + } + + // the `longPress` and `tap` methods release on their own + if ( + (_.head(actions) === 'tap' || _.head(actions) === 'longPress') && + _.last(actions) === 'release' + ) { + gestures.pop(); + actions.pop(); + } + } else { + // longpress followed by anything other than release should become a press and wait + if (actions[0] === 'longPress') { + actions = ['press', 'wait', ...actions.slice(1)]; + + let press = /** @type {NonReleaseTouchAction} */ (gestures.shift()); + press.action = 'press'; + /** @type {NonReleaseTouchAction} */ + let wait = { + action: 'wait', + options: {ms: press.options.duration || 1000}, + }; + delete press.options.duration; + gestures = [press, wait, ...gestures]; + } + } + + let fixedGestures = await parseTouch.bind(this)(gestures, false); + // fix release action then perform all actions + if (actions[actions.length - 1] === 'release') { + actions[actions.length - 1] = /** @type {TouchAction} */ ( + await fixRelease.bind(this)(gestures) + ); + } + for (let g of fixedGestures) { + await performGesture.bind(this)(g); + } + } +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchAction[]} actions + * @param {string} elementId + * @returns {Promise} + */ +export async function performMultiAction(actions, elementId) { + // Android needs at least two actions to be able to perform a multi pointer gesture + if (actions.length === 1) { + throw new Error( + 'Multi Pointer Gestures need at least two actions. ' + + 'Use Touch Actions for a single action.', + ); + } + + const states = await asyncmap( + actions, + async (action) => await parseTouch.bind(this)(action, true), + false, + ); + + return await this.doPerformMultiAction(elementId, states); +} + +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {string} elementId + * @param {import('./types').TouchState[]} states + * @returns {Promise} + */ +export async function doPerformMultiAction(elementId, states) { + throw new errors.NotImplementedError('Not implemented'); +} + +// #region Internal helpers + +/** + * @deprecated * @param {number|null} [val] * @returns {number} */ @@ -21,7 +254,21 @@ function getCoordDefault(val) { } /** - * + * @deprecated + * @param {number} number + * @param {number} digits + * @returns {number} + */ +export function truncateDecimals(number, digits) { + const multiplier = Math.pow(10, digits), + adjustedNum = number * multiplier, + truncatedNum = Math[adjustedNum < 0 ? 'ceil' : 'floor'](adjustedNum); + + return truncatedNum / multiplier; +} + +/** + * @deprecated * @param {NonReleaseTouchAction} waitGesture * @returns {number} */ @@ -41,370 +288,219 @@ function getSwipeTouchDuration(waitGesture) { } /** - * @type {import('./mixins').TouchMixin & ThisType} - * @satisfies {import('@appium/types').ExternalDriver} + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').SwipeAction} gestures + * @param {number} [touchCount=1] + * @returns {Promise} */ -const TouchMixin = { - async doTouchAction(action, opts = {}) { - const {element, x, y, count, ms, duration} = opts; - // parseTouch precalculates absolute element positions - // so there is no need to pass `element` to the affected gestures - switch (action) { - case 'tap': - return await this.tap('', x, y, count); - case 'press': - return await this.touchDown('', /** @type {number} */ (x), /** @type {number} */ (y)); - case 'release': - return await this.touchUp( - String(element), - /** @type {number} */ (x), - /** @type {number} */ (y) - ); - case 'moveTo': - return await this.touchMove('', /** @type {number} */ (x), /** @type {number} */ (y)); - case 'wait': - return await B.delay(/** @type {number} */ (ms)); - case 'longPress': - return await this.touchLongClick( - '', - /** @type {number} */ (x), - /** @type {number} */ (y), - duration ?? 1000 - ); - case 'cancel': - // TODO: clarify behavior of 'cancel' action and fix this - this.log.warn('Cancel action currently has no effect'); - break; - default: - this.log.errorAndThrow(`unknown action ${action}`); - } - }, - - async doTouchDrag(gestures) { - let longPress = gestures[0]; - let moveTo = gestures[1]; - let startX = longPress.options.x || 0, - startY = longPress.options.y || 0, - endX = moveTo.options.x || 0, - endY = moveTo.options.y || 0; - if (longPress.options.element) { - let {x, y} = await this.getLocationInView(longPress.options.element); - startX += x || 0; - startY += y || 0; - } - if (moveTo.options.element) { - let {x, y} = await this.getLocationInView(moveTo.options.element); - endX += x || 0; - endY += y || 0; - } - - let apiLevel = await this.adb.getApiLevel(); - // lollipop takes a little longer to get things rolling - let duration = apiLevel >= 5 ? 2 : 1; - // make sure that if the long press has a duration, we use it. - if (longPress.options && longPress.options.duration) { - duration = Math.max(longPress.options.duration / 1000, duration); - } +async function getSwipeOptions(gestures, touchCount = 1) { + let startX = getCoordDefault(gestures[0].options.x), + startY = getCoordDefault(gestures[0].options.y), + endX = getCoordDefault(gestures[2].options.x), + endY = getCoordDefault(gestures[2].options.y), + duration = getSwipeTouchDuration(gestures[1]), + element = /** @type {string} */ (gestures[0].options.element), + destElement = gestures[2].options.element || gestures[0].options.element; - // `drag` will take care of whether there is an element or not at that level - return await this.drag( - startX, - startY, - endX, - endY, - duration, - 1, - longPress.options.element, - moveTo.options.element - ); - }, - - async fixRelease(gestures) { - let release = /** @type {import('./types').ReleaseTouchAction} */ (_.last(gestures)); - // sometimes there are no options - release.options = release.options || {}; - // nothing to do if release options are already set - if (release.options.element || (release.options.x && release.options.y)) { - return; + // there's no destination element handling in bootstrap and since it applies to all platforms, we handle it here + if (util.hasValue(destElement)) { + let locResult = await this.getLocationInView(destElement); + let sizeResult = await this.getSize(destElement); + let offsetX = Math.abs(endX) < 1 && Math.abs(endX) > 0 ? sizeResult.width * endX : endX; + let offsetY = Math.abs(endY) < 1 && Math.abs(endY) > 0 ? sizeResult.height * endY : endY; + endX = locResult.x + offsetX; + endY = locResult.y + offsetY; + // if the target element was provided, the coordinates for the destination need to be relative to it. + if (util.hasValue(element)) { + let firstElLocation = await this.getLocationInView(element); + endX -= firstElLocation.x; + endY -= firstElLocation.y; } - // without coordinates, `release` uses the center of the screen, which, - // generally speaking, is not what we want - // therefore: loop backwards and use the last command with an element and/or - // offset coordinates - gestures = _.clone(gestures); - let ref = null; - for (let gesture of /** @type {NonReleaseTouchAction[]} */ (gestures.reverse())) { - let opts = gesture.options; - if (opts.element || (opts.x && opts.y)) { - ref = gesture; - break; - } - } - if (ref) { - let opts = ref.options; - if (opts.element) { - let loc = await this.getLocationInView(opts.element); - if (opts.x && opts.y) { - // this is an offset from the element - release.options = { - x: loc.x + opts.x, - y: loc.y + opts.y, - }; - } else { - // this is the center of the element - let size = await this.getSize(opts.element); - release.options = { - x: loc.x + size.width / 2, - y: loc.y + size.height / 2, - }; - } - } else { - release.options = _.pick(opts, 'x', 'y'); - } - } - return release; - }, - - async performGesture(gesture) { - try { - return await this.doTouchAction(gesture.action, gesture.options || {}); - } catch (e) { - // sometime the element is not available when releasing, retry without it - if ( - isErrorType(e, errors.NoSuchElementError) && - gesture.action === 'release' && - gesture.options?.element - ) { - delete gesture.options.element; - this.log.debug(`retrying release without element opts: ${gesture.options}.`); - return await this.doTouchAction(gesture.action, gesture.options || {}); - } - throw e; - } - }, - - async getSwipeOptions(gestures, touchCount = 1) { - let startX = getCoordDefault(gestures[0].options.x), - startY = getCoordDefault(gestures[0].options.y), - endX = getCoordDefault(gestures[2].options.x), - endY = getCoordDefault(gestures[2].options.y), - duration = getSwipeTouchDuration(gestures[1]), - element = /** @type {string} */ (gestures[0].options.element), - destElement = gestures[2].options.element || gestures[0].options.element; - - // there's no destination element handling in bootstrap and since it applies to all platforms, we handle it here - if (util.hasValue(destElement)) { - let locResult = await this.getLocationInView(destElement); - let sizeResult = await this.getSize(destElement); - let offsetX = Math.abs(endX) < 1 && Math.abs(endX) > 0 ? sizeResult.width * endX : endX; - let offsetY = Math.abs(endY) < 1 && Math.abs(endY) > 0 ? sizeResult.height * endY : endY; - endX = locResult.x + offsetX; - endY = locResult.y + offsetY; - // if the target element was provided, the coordinates for the destination need to be relative to it. - if (util.hasValue(element)) { - let firstElLocation = await this.getLocationInView(element); - endX -= firstElLocation.x; - endY -= firstElLocation.y; - } - } - // clients are responsible to use these options correctly - return {startX, startY, endX, endY, duration, touchCount, element}; - }, - - async performTouch(gestures) { - // press-wait-moveTo-release is `swipe`, so use native method - if ( - gestures.length === 4 && - gestures[0].action === 'press' && - gestures[1].action === 'wait' && - gestures[2].action === 'moveTo' && - gestures[3].action === 'release' - ) { - let swipeOpts = await this.getSwipeOptions( - /** @type {import('./types').SwipeAction} */ (gestures) - ); - return await this.swipe( - swipeOpts.startX, - swipeOpts.startY, - swipeOpts.endX, - swipeOpts.endY, - swipeOpts.duration, - swipeOpts.touchCount, - swipeOpts.element - ); - } - let actions = /** @type {(import('./types').TouchActionKind|TouchAction)[]} */ ( - _.map(gestures, 'action') - ); - - if (actions[0] === 'longPress' && actions[1] === 'moveTo' && actions[2] === 'release') { - // some things are special - return await this.doTouchDrag(/** @type {import('./types').TouchDragAction} */ (gestures)); - } else { - if (actions.length === 2) { - // `press` without a wait is too slow and gets interpretted as a `longPress` - if (_.head(actions) === 'press' && _.last(actions) === 'release') { - actions[0] = 'tap'; - gestures[0].action = 'tap'; - } - - // the `longPress` and `tap` methods release on their own - if ( - (_.head(actions) === 'tap' || _.head(actions) === 'longPress') && - _.last(actions) === 'release' - ) { - gestures.pop(); - actions.pop(); - } - } else { - // longpress followed by anything other than release should become a press and wait - if (actions[0] === 'longPress') { - actions = ['press', 'wait', ...actions.slice(1)]; - - let press = /** @type {NonReleaseTouchAction} */ (gestures.shift()); - press.action = 'press'; - /** @type {NonReleaseTouchAction} */ - let wait = { - action: 'wait', - options: {ms: press.options.duration || 1000}, - }; - delete press.options.duration; - gestures = [press, wait, ...gestures]; - } - } - - let fixedGestures = await this.parseTouch(gestures, false); - // fix release action then perform all actions - if (actions[actions.length - 1] === 'release') { - actions[actions.length - 1] = /** @type {TouchAction} */ (await this.fixRelease(gestures)); - } - for (let g of fixedGestures) { - await this.performGesture(g); - } - } - }, + } + // clients are responsible to use these options correctly + return {startX, startY, endX, endY, duration, touchCount, element}; +} - async parseTouch(gestures, multi) { - // because multi-touch releases at the end by default - if (multi && /** @type {TouchAction} */ (_.last(gestures)).action === 'release') { - gestures.pop(); - } +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchAction[]} gestures + * @param {boolean} [multi] + * @returns {Promise} + */ +async function parseTouch(gestures, multi) { + // because multi-touch releases at the end by default + if (multi && /** @type {TouchAction} */ (_.last(gestures)).action === 'release') { + gestures.pop(); + } - let touchStateObjects = await asyncmap( - gestures, - async (gesture) => { - let options = gesture.options || {}; - if (_.includes(['press', 'moveTo', 'tap', 'longPress'], gesture.action)) { - options.offset = false; - let elementId = gesture.options.element; - if (elementId) { - let pos = await this.getLocationInView(elementId); - if (gesture.options.x || gesture.options.y) { - options.x = pos.x + (gesture.options.x || 0); - options.y = pos.y + (gesture.options.y || 0); - } else { - const {width, height} = await this.getSize(elementId); - options.x = pos.x + width / 2; - options.y = pos.y + height / 2; - } - let touchStateObject = { - action: gesture.action, - options, - timeOffset: 0.005, - }; - return touchStateObject; + let touchStateObjects = await asyncmap( + gestures, + async (gesture) => { + let options = gesture.options || {}; + if (_.includes(['press', 'moveTo', 'tap', 'longPress'], gesture.action)) { + options.offset = false; + let elementId = gesture.options.element; + if (elementId) { + let pos = await this.getLocationInView(elementId); + if (gesture.options.x || gesture.options.y) { + options.x = pos.x + (gesture.options.x || 0); + options.y = pos.y + (gesture.options.y || 0); } else { - options.x = gesture.options.x || 0; - options.y = gesture.options.y || 0; - - let touchStateObject = { - action: gesture.action, - options, - timeOffset: 0.005, - }; - return touchStateObject; - } - } else { - let offset = 0.005; - if (gesture.action === 'wait') { - options = gesture.options; - offset = parseInt(gesture.options.ms, 10) / 1000; + const {width, height} = await this.getSize(elementId); + options.x = pos.x + width / 2; + options.y = pos.y + height / 2; } let touchStateObject = { action: gesture.action, options, - timeOffset: offset, + timeOffset: 0.005, }; return touchStateObject; - } - }, - false - ); - // we need to change the time (which is now an offset) - // and the position (which may be an offset) - let prevPos = null, - time = 0; - for (let state of touchStateObjects) { - if (_.isUndefined(state.options.x) && _.isUndefined(state.options.y) && prevPos !== null) { - // this happens with wait - state.options.x = prevPos.x; - state.options.y = prevPos.y; - } - if (state.options.offset && prevPos) { - // the current position is an offset - state.options.x += prevPos.x; - state.options.y += prevPos.y; - } - delete state.options.offset; - if (!_.isUndefined(state.options.x) && !_.isUndefined(state.options.y)) { - prevPos = state.options; - } - - if (multi) { - let timeOffset = state.timeOffset; - time += timeOffset; - state.time = androidHelpers.truncateDecimals(time, 3); + } else { + options.x = gesture.options.x || 0; + options.y = gesture.options.y || 0; - // multi gestures require 'touch' rather than 'options' - if (!_.isUndefined(state.options.x) && !_.isUndefined(state.options.y)) { - state.touch = { - x: state.options.x, - y: state.options.y, + let touchStateObject = { + action: gesture.action, + options, + timeOffset: 0.005, }; + return touchStateObject; + } + } else { + let offset = 0.005; + if (gesture.action === 'wait') { + options = gesture.options; + offset = parseInt(gesture.options.ms, 10) / 1000; } - delete state.options; + let touchStateObject = { + action: gesture.action, + options, + timeOffset: offset, + }; + return touchStateObject; } - delete state.timeOffset; + }, + false, + ); + // we need to change the time (which is now an offset) + // and the position (which may be an offset) + let prevPos = null, + time = 0; + for (let state of touchStateObjects) { + if (_.isUndefined(state.options.x) && _.isUndefined(state.options.y) && prevPos !== null) { + // this happens with wait + state.options.x = prevPos.x; + state.options.y = prevPos.y; } - return touchStateObjects; - }, - - async performMultiAction(actions, elementId) { - // Android needs at least two actions to be able to perform a multi pointer gesture - if (actions.length === 1) { - throw new Error( - 'Multi Pointer Gestures need at least two actions. ' + - 'Use Touch Actions for a single action.' - ); + if (state.options.offset && prevPos) { + // the current position is an offset + state.options.x += prevPos.x; + state.options.y += prevPos.y; + } + delete state.options.offset; + if (!_.isUndefined(state.options.x) && !_.isUndefined(state.options.y)) { + prevPos = state.options; } - const states = await asyncmap( - actions, - async (action) => await this.parseTouch(action, true), - false - ); + if (multi) { + let timeOffset = state.timeOffset; + time += timeOffset; + state.time = truncateDecimals(time, 3); - return await this.doPerformMultiAction(elementId, states); - }, + // multi gestures require 'touch' rather than 'options' + if (!_.isUndefined(state.options.x) && !_.isUndefined(state.options.y)) { + state.touch = { + x: state.options.x, + y: state.options.y, + }; + } + delete state.options; + } + delete state.timeOffset; + } + return touchStateObjects; +} - async doPerformMultiAction(elementId, states) { - throw new errors.NotImplementedError('Not implemented'); - }, -}; +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchAction[]} gestures + * @returns {Promise} + */ +async function fixRelease(gestures) { + let release = /** @type {import('./types').ReleaseTouchAction} */ (_.last(gestures)); + // sometimes there are no options + release.options = release.options || {}; + // nothing to do if release options are already set + if (release.options.element || (release.options.x && release.options.y)) { + return; + } + // without coordinates, `release` uses the center of the screen, which, + // generally speaking, is not what we want + // therefore: loop backwards and use the last command with an element and/or + // offset coordinates + gestures = _.clone(gestures); + let ref = null; + for (let gesture of /** @type {NonReleaseTouchAction[]} */ (gestures.reverse())) { + let opts = gesture.options; + if (opts.element || (opts.x && opts.y)) { + ref = gesture; + break; + } + } + if (ref) { + let opts = ref.options; + if (opts.element) { + let loc = await this.getLocationInView(opts.element); + if (opts.x && opts.y) { + // this is an offset from the element + release.options = { + x: loc.x + opts.x, + y: loc.y + opts.y, + }; + } else { + // this is the center of the element + let size = await this.getSize(opts.element); + release.options = { + x: loc.x + size.width / 2, + y: loc.y + size.height / 2, + }; + } + } else { + release.options = _.pick(opts, 'x', 'y'); + } + } + return release; +} -mixin(TouchMixin); +/** + * @deprecated + * @this {import('../driver').AndroidDriver} + * @param {import('./types').TouchAction} gesture + * @returns {Promise} + */ +async function performGesture(gesture) { + try { + return await this.doTouchAction(gesture.action, gesture.options || {}); + } catch (e) { + // sometime the element is not available when releasing, retry without it + if ( + isErrorType(e, errors.NoSuchElementError) && + gesture.action === 'release' && + gesture.options?.element + ) { + delete gesture.options.element; + this.log.debug(`retrying release without element opts: ${gesture.options}.`); + return await this.doTouchAction(gesture.action, gesture.options || {}); + } + throw e; + } +} -export default TouchMixin; +// #endregion /** * @typedef {import('appium-adb').ADB} ADB diff --git a/lib/commands/types.ts b/lib/commands/types.ts index 4245cdad..8d4f2503 100644 --- a/lib/commands/types.ts +++ b/lib/commands/types.ts @@ -1,5 +1,6 @@ -import {HTTPMethod, StringRecord} from '@appium/types'; -import {InstallOptions, UninstallOptions} from 'appium-adb'; +import type {HTTPMethod, StringRecord} from '@appium/types'; +import type {InstallOptions, UninstallOptions} from 'appium-adb'; +import type {AndroidDriverCaps} from '../driver'; export interface SwipeOpts { startX: number; @@ -1159,3 +1160,123 @@ export interface SmsListResult { items: SmsListResultItem[]; total: number; } + +export interface GetWebviewsOpts { + /** + * device socket name + */ + androidDeviceSocket?: string | null; + /** + * whether to check for webview page presence + */ + ensureWebviewsHavePages?: boolean | null; + /** + * port to use for webview page presence check. + */ + webviewDevtoolsPort?: number | null; + /** + * whether to collect web view details and send them to Chromedriver constructor, so it could select a binary more precisely based on this info. + */ + enableWebviewDetailsCollection?: boolean | null; + /** + * @privateRemarks This is referenced but was not previously declared + */ + isChromeSession?: boolean; + + waitForWebviewMs?: number | string; +} + +export interface ProcessInfo { + /** + * The process name + */ + name: string; + /** + * The process id (if could be retrieved) + */ + id?: string | null; +} + +export interface WebViewDetails { + /** + * Web view process details + */ + process?: ProcessInfo | null; + /** + * Web view details as returned by /json/version CDP endpoint + * @example + * { + * "Browser": "Chrome/72.0.3601.0", + * "Protocol-Version": "1.3", + * "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3601.0 Safari/537.36", + * "V8-Version": "7.2.233", + * "WebKit-Version": "537.36 (@cfede9db1d154de0468cb0538479f34c0755a0f4)", + * "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/b0b8a4fb-bb17-4359-9533-a8d9f3908bd8" + * } + */ + info?: StringRecord; +} + +export interface DetailCollectionOptions { + /** + * The starting port to use for webview page presence check (if not the default of 9222). + */ + webviewDevtoolsPort?: number | null; + /** + * Whether to check for webview pages presence + */ + ensureWebviewsHavePages?: boolean | null; + /** + * Whether to collect web view details and send them to Chromedriver constructor, so it could + * select a binary more precisely based on this info. + */ + enableWebviewDetailsCollection?: boolean | null; +} + +export interface WebviewProps { + /** + * The name of the Devtools Unix socket + */ + proc: string; + /** + * The web view alias. Looks like `WEBVIEW_` prefix plus PID or package name + */ + webview: string; + /** + * Webview information as it is retrieved by /json/version CDP endpoint + */ + info?: object | null; + /** + * Webview pages list as it is retrieved by /json/list CDP endpoint + */ + pages?: object[] | null; +} + +export interface WebviewProc { + /** + * The webview process name (as returned by getPotentialWebviewProcs) + */ + proc: string; + /** + * The actual webview context name + */ + webview: string; +} + +export interface FastUnlockOptions { + credential: string; + /** + * @privateRemarks FIXME: narrow this type to whatever `appium-adb` expects + */ + credentialType: string; +} + +export interface ADBDeviceInfo { + udid: string; + emPort: number | false; +} + +export type ADBLaunchInfo = Pick< + AndroidDriverCaps, + 'appPackage' | 'appWaitActivity' | 'appActivity' | 'appWaitPackage' +>; diff --git a/lib/doctor/checks.js b/lib/doctor/checks.js index d195d558..cc6c7679 100644 --- a/lib/doctor/checks.js +++ b/lib/doctor/checks.js @@ -4,14 +4,16 @@ import path from 'path'; import '@colors/colors'; import {getAndroidBinaryPath, getSdkRootFromEnv} from 'appium-adb'; - const JAVA_HOME_VAR_NAME = system.isWindows() ? '%JAVA_HOME%' : '$JAVA_HOME'; -const ENVIRONMENT_VARS_TUTORIAL_URL = 'https://github.com/appium/java-client/blob/master/docs/environment.md'; -const JAVA_HOME_TUTORIAL = 'https://docs.oracle.com/cd/E21454_01/html/821-2531/inst_jdk_javahome_t.html'; +const ENVIRONMENT_VARS_TUTORIAL_URL = + 'https://github.com/appium/java-client/blob/master/docs/environment.md'; +const JAVA_HOME_TUTORIAL = + 'https://docs.oracle.com/cd/E21454_01/html/821-2531/inst_jdk_javahome_t.html'; const ANDROID_SDK_LINK1 = 'https://developer.android.com/studio#cmdline-tools'; const ANDROID_SDK_LINK2 = 'https://developer.android.com/studio/intro/update#sdk-manager'; const BUNDLETOOL_RELEASES_LINK = 'https://github.com/google/bundletool/releases/'; -const GSTREAMER_INSTALL_LINK = 'https://gstreamer.freedesktop.org/documentation/installing/index.html?gi-language=c'; +const GSTREAMER_INSTALL_LINK = + 'https://gstreamer.freedesktop.org/documentation/installing/index.html?gi-language=c'; const FFMPEG_INSTALL_LINK = 'https://www.ffmpeg.org/download.html'; /** @@ -39,7 +41,7 @@ class EnvVarAndPathCheck { return doctor.nok(`${this.varName} environment variable is NOT set!`); } - if (!await fs.exists(varValue)) { + if (!(await fs.exists(varValue))) { let errMsg = `${this.varName} is set to '${varValue}' but this path does not exist!`; if (system.isWindows() && varValue.includes('%')) { errMsg += ` Consider replacing all references to other environment variables with absolute paths.`; @@ -49,10 +51,14 @@ class EnvVarAndPathCheck { const stat = await fs.stat(varValue); if (this.opts.expectDir && !stat.isDirectory()) { - return doctor.nok(`${this.varName} is expected to be a valid folder, got a file path instead`); + return doctor.nok( + `${this.varName} is expected to be a valid folder, got a file path instead`, + ); } if (this.opts.expectFile && stat.isDirectory()) { - return doctor.nok(`${this.varName} is expected to be a valid file, got a folder path instead`); + return doctor.nok( + `${this.varName} is expected to be a valid file, got a folder path instead`, + ); } return doctor.ok(`${this.varName} is set to: ${varValue}`); @@ -86,18 +92,20 @@ export class JavaHomeValueCheck { const javaBinaryRelativePath = path.join('bin', `java${system.isWindows() ? '.exe' : ''}`); const javaBinary = path.join(envVar, javaBinaryRelativePath); - if (!await fs.exists(javaBinary)) { + if (!(await fs.exists(javaBinary))) { return doctor.nok( `${JAVA_HOME_VAR_NAME} is set to an invalid value. ` + - `It must be pointing to a folder containing ${javaBinaryRelativePath}` + `It must be pointing to a folder containing ${javaBinaryRelativePath}`, ); } return doctor.ok(`'${javaBinaryRelativePath}' exists under '${envVar}'`); } async fix() { - return `Set ${JAVA_HOME_VAR_NAME} environment variable to the root folder path of your local JDK installation. ` + - `Read ${JAVA_HOME_TUTORIAL}`; + return ( + `Set ${JAVA_HOME_VAR_NAME} environment variable to the root folder path of your local JDK installation. ` + + `Read ${JAVA_HOME_TUTORIAL}` + ); } hasAutofix() { @@ -121,9 +129,7 @@ export class AndroidSdkCheck { const listOfTools = this.TOOL_NAMES.join(', '); const sdkRoot = getSdkRootFromEnv(); if (!sdkRoot) { - return doctor.nok( - `${listOfTools} could not be found because ANDROID_HOME is NOT set!` - ); + return doctor.nok(`${listOfTools} could not be found because ANDROID_HOME is NOT set!`); } this.log.info(` Checking ${listOfTools}`); @@ -199,9 +205,11 @@ export class OptionalGstreamerCheck { return gstreamerPath && gstInspectPath ? doctor.okOptional( - `${this.GSTREAMER_BINARY} and ${this.GST_INSPECT_BINARY} are installed at: ${gstreamerPath} and ${gstInspectPath}` + `${this.GSTREAMER_BINARY} and ${this.GST_INSPECT_BINARY} are installed at: ${gstreamerPath} and ${gstInspectPath}`, ) - : doctor.nokOptional(`${this.GSTREAMER_BINARY} and/or ${this.GST_INSPECT_BINARY} cannot be found`); + : doctor.nokOptional( + `${this.GSTREAMER_BINARY} and/or ${this.GST_INSPECT_BINARY} cannot be found`, + ); } async fix() { diff --git a/lib/driver.ts b/lib/driver.ts index 72d224ec..a6c0683b 100644 --- a/lib/driver.ts +++ b/lib/driver.ts @@ -11,17 +11,205 @@ import type { } from '@appium/types'; import _ from 'lodash'; import ADB from 'appium-adb'; +import type {LogcatListener} from 'appium-adb'; import type {default as AppiumChromedriver} from 'appium-chromedriver'; import {BaseDriver} from 'appium/driver'; import ANDROID_DRIVER_CONSTRAINTS, {AndroidDriverConstraints} from './constraints'; -import {helpers} from './helpers'; import {newMethodMap} from './method-map'; -import { SettingsApp } from 'io.appium.settings'; +import {SettingsApp} from 'io.appium.settings'; +import {parseArray, removeAllSessionWebSocketHandlers} from './utils'; +import {CHROME_BROWSER_PACKAGE_ACTIVITY} from './commands/context/helpers'; +import { + getContexts, + setContext, + getCurrentContext, + defaultContextName, + assignContexts, + switchContext, + defaultWebviewName, + isWebContext, + isChromedriverContext, + startChromedriverProxy, + onChromedriverStop, + stopChromedriverProxies, + suspendChromedriverProxy, + startChromeSession, + mobileGetContexts, +} from './commands/context/exports'; +import { + getDeviceInfoFromCaps, + createADB, + getLaunchInfo, + initDevice, +} from './commands/device/common'; +import { + fingerprint, + mobileFingerprint, + sendSMS, + mobileSendSms, + gsmCall, + mobileGsmCall, + gsmSignal, + mobileGsmSignal, + gsmVoice, + mobileGsmVoice, + powerAC, + mobilePowerAc, + powerCapacity, + mobilePowerCapacity, + networkSpeed, + mobileNetworkSpeed, + sensorSet, +} from './commands/device/emulator-actions'; +import {mobileExecEmuConsoleCommand} from './commands/device/emulator-console'; +import { + getThirdPartyPackages, + uninstallOtherPackages, + installOtherApks, + installApk, + resetApp, + background, + getCurrentActivity, + getCurrentPackage, + mobileClearApp, + mobileInstallApp, + installApp, + mobileActivateApp, + mobileIsAppInstalled, + mobileQueryAppState, + mobileRemoveApp, + mobileTerminateApp, + terminateApp, + removeApp, + activateApp, + queryAppState, + isAppInstalled, +} from './commands/app-management'; +import {mobileGetUiMode, mobileSetUiMode} from './commands/appearance'; +import {mobileDeviceidle} from './commands/deviceidle'; +import { + getAttribute, + getName, + elementDisplayed, + elementEnabled, + elementSelected, + setElementValue, + doSetElementValue, + replaceValue, + setValueImmediate, + click, + getLocationInView, + getText, + getLocation, + getSize, +} from './commands/element'; +import {execute, executeMobile} from './commands/execute'; +import { + pullFile, + mobilePullFile, + pullFolder, + mobilePullFolder, + pushFile, + mobilePushFile, + mobileDeleteFile, +} from './commands/file-actions'; +import {findElOrEls, doFindElementOrEls} from './commands/find'; +import { + setGeoLocation, + getGeoLocation, + mobileRefreshGpsCache, + toggleLocationServices, + isLocationServicesEnabled, +} from './commands/geolocation'; +import { + isIMEActivated, + availableIMEEngines, + getActiveIMEEngine, + activateIMEEngine, + deactivateIMEEngine, +} from './commands/ime'; +import { + startActivity, + mobileBroadcast, + mobileStartService, + mobileStopService, +} from './commands/intent'; +import { + hideKeyboard, + isKeyboardShown, + keys, + doSendKeys, + pressKeyCode, + longPressKeyCode, + mobilePerformEditorAction, +} from './commands/keyboard'; +import {lock, unlock, mobileLock, mobileUnlock, isLocked} from './commands/lock/exports'; +import { + supportedLogTypes, + mobileStartLogsBroadcast, + mobileStopLogsBroadcast, + getLogTypes, + getLog, +} from './commands/log'; +import { + mobileIsMediaProjectionRecordingRunning, + mobileStartMediaProjectionRecording, + mobileStopMediaProjectionRecording, +} from './commands/media-projection'; +import {mobileSendTrimMemory} from './commands/memory'; +import { + getWindowRect, + getWindowSize, + getDisplayDensity, + mobileGetNotifications, + mobileListSms, + openNotifications, + setUrl, +} from './commands/misc'; +import { + getNetworkConnection, + isWifiOn, + mobileGetConnectivity, + mobileSetConnectivity, + setNetworkConnection, + setWifiState, + setDataState, + toggleData, + toggleFlightMode, + toggleWiFi, +} from './commands/network'; +import { + getPerformanceData, + getPerformanceDataTypes, + mobileGetPerformanceData, +} from './commands/performance'; +import {mobileChangePermissions, mobileGetPermissions} from './commands/permissions'; +import {startRecordingScreen, stopRecordingScreen} from './commands/recordscreen'; +import {getStrings, ensureDeviceLocale} from './commands/resources'; +import {mobileShell} from './commands/shell'; +import {mobileStartScreenStreaming, mobileStopScreenStreaming} from './commands/streamscreen'; +import {getSystemBars, mobilePerformStatusBarCommand} from './commands/system-bars'; +import {getDeviceTime, mobileGetDeviceTime} from './commands/time'; +import { + tap, + touchLongClick, + touchDown, + touchUp, + touchMove, + doSwipe, + doTouchDrag, + doTouchAction, + performMultiAction, + performTouch, + doPerformMultiAction, +} from './commands/touch'; export type AndroidDriverCaps = DriverCaps; export type W3CAndroidDriverCaps = W3CDriverCaps; export type AndroidDriverOpts = DriverOpts; +const EMULATOR_PATTERN = /\bemulator\b/i; + type AndroidExternalDriver = ExternalDriver; class AndroidDriver extends BaseDriver @@ -34,9 +222,7 @@ class AndroidDriver _settingsApp: SettingsApp; - unlocker: typeof helpers.unlocker; - - apkStrings: StringRecord>; + apkStrings: StringRecord; proxyReqRes?: (...args: any) => any; @@ -56,6 +242,14 @@ class AndroidDriver _wasWindowAnimationDisabled?: boolean; + _cachedActivityArgs: StringRecord; + + _screenStreamingProps?: StringRecord; + + _screenRecordingProperties?: StringRecord; + + _logcatWebsocketListener?: LogcatListener; + opts: AndroidDriverOpts; constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) { @@ -72,27 +266,275 @@ class AndroidDriver this.sessionChromedrivers = {}; this.jwpProxyActive = false; this.apkStrings = {}; - this.unlocker = helpers.unlocker; this.curContext = this.defaultContextName(); this.opts = opts as AndroidDriverOpts; + this._cachedActivityArgs = {}; } - get settingsApp() { + get settingsApp(): SettingsApp { if (!this._settingsApp) { this._settingsApp = new SettingsApp({adb: this.adb}); } return this._settingsApp; } - isEmulator() { - return helpers.isEmulator(this.adb, this.opts); + isEmulator(): boolean { + const possibleNames = [this.opts?.udid, this.adb?.curDeviceId]; + return !!this.opts?.avd || possibleNames.some((x) => EMULATOR_PATTERN.test(String(x))); + } + + get isChromeSession(): boolean { + return _.includes( + Object.keys(CHROME_BROWSER_PACKAGE_ACTIVITY), + (this.opts.browserName || '').toLowerCase(), + ); + } + + override validateDesiredCaps(caps: any): caps is AndroidDriverCaps { + if (!super.validateDesiredCaps(caps)) { + return false; + } + + if (caps.browserName) { + if (caps.app) { + // warn if the capabilities have both `app` and `browser, although this is common with selenium grid + this.log.warn( + `The desired capabilities should generally not include both an 'app' and a 'browserName'`, + ); + } + if (caps.appPackage) { + throw this.log.errorAndThrow( + `The desired should not include both of an 'appPackage' and a 'browserName'`, + ); + } + } + + if (caps.uninstallOtherPackages) { + try { + parseArray(caps.uninstallOtherPackages); + } catch (e) { + throw this.log.errorAndThrow( + `Could not parse "uninstallOtherPackages" capability: ${(e as Error).message}`, + ); + } + } + + return true; } - get isChromeSession() { - return helpers.isChromeBrowser(String(this.opts.browserName)); + override async deleteSession(sessionId?: string | null) { + if (this.server) { + await removeAllSessionWebSocketHandlers(this.server, sessionId); + } + + await super.deleteSession(sessionId); } + + getContexts = getContexts; + getCurrentContext = getCurrentContext; + defaultContextName = defaultContextName; + assignContexts = assignContexts; + switchContext = switchContext; + defaultWebviewName = defaultWebviewName; + isChromedriverContext = isChromedriverContext; + startChromedriverProxy = startChromedriverProxy; + stopChromedriverProxies = stopChromedriverProxies; + suspendChromedriverProxy = suspendChromedriverProxy; + startChromeSession = startChromeSession; + onChromedriverStop = onChromedriverStop; + isWebContext = isWebContext; + mobileGetContexts = mobileGetContexts; + setContext = setContext as any as (this: AndroidDriver, name?: string) => Promise; + + getDeviceInfoFromCaps = getDeviceInfoFromCaps; + createADB = createADB; + getLaunchInfo = getLaunchInfo; + initDevice = initDevice; + + fingerprint = fingerprint; + mobileFingerprint = mobileFingerprint; + sendSMS = sendSMS; + mobileSendSms = mobileSendSms; + gsmCall = gsmCall; + mobileGsmCall = mobileGsmCall; + gsmSignal = gsmSignal; + mobileGsmSignal = mobileGsmSignal; + gsmVoice = gsmVoice; + mobileGsmVoice = mobileGsmVoice; + powerAC = powerAC; + mobilePowerAc = mobilePowerAc; + powerCapacity = powerCapacity; + mobilePowerCapacity = mobilePowerCapacity; + networkSpeed = networkSpeed; + mobileNetworkSpeed = mobileNetworkSpeed; + sensorSet = sensorSet; + + mobileExecEmuConsoleCommand = mobileExecEmuConsoleCommand; + + getThirdPartyPackages = getThirdPartyPackages; + uninstallOtherPackages = uninstallOtherPackages; + installOtherApks = installOtherApks; + installApk = installApk; + resetApp = resetApp; + background = background; + getCurrentActivity = getCurrentActivity; + getCurrentPackage = getCurrentPackage; + mobileClearApp = mobileClearApp; + mobileInstallApp = mobileInstallApp; + installApp = installApp; + mobileActivateApp = mobileActivateApp; + mobileIsAppInstalled = mobileIsAppInstalled; + mobileQueryAppState = mobileQueryAppState; + mobileRemoveApp = mobileRemoveApp; + mobileTerminateApp = mobileTerminateApp; + terminateApp = terminateApp; + removeApp = removeApp; + activateApp = activateApp; + queryAppState = queryAppState; + isAppInstalled = isAppInstalled; + + mobileGetUiMode = mobileGetUiMode; + mobileSetUiMode = mobileSetUiMode; + + mobileDeviceidle = mobileDeviceidle; + + getAttribute = getAttribute; + getName = getName; + elementDisplayed = elementDisplayed; + elementEnabled = elementEnabled; + elementSelected = elementSelected; + setElementValue = setElementValue; + doSetElementValue = doSetElementValue; + replaceValue = replaceValue; + setValueImmediate = setValueImmediate; + click = click; + getLocationInView = getLocationInView; + getText = getText; + getLocation = getLocation; + getSize = getSize; + + execute = execute; + executeMobile = executeMobile; + + pullFile = pullFile; + mobilePullFile = mobilePullFile; + pullFolder = pullFolder; + mobilePullFolder = mobilePullFolder; + pushFile = pushFile; + mobilePushFile = mobilePushFile; + mobileDeleteFile = mobileDeleteFile; + + findElOrEls = findElOrEls; + doFindElementOrEls = doFindElementOrEls; + + setGeoLocation = setGeoLocation; + getGeoLocation = getGeoLocation; + mobileRefreshGpsCache = mobileRefreshGpsCache; + toggleLocationServices = toggleLocationServices; + isLocationServicesEnabled = isLocationServicesEnabled; + + isIMEActivated = isIMEActivated; + availableIMEEngines = availableIMEEngines; + getActiveIMEEngine = getActiveIMEEngine; + activateIMEEngine = activateIMEEngine; + deactivateIMEEngine = deactivateIMEEngine; + + startActivity = startActivity as unknown as ( + appPackage: string, + appActivity: string, + appWaitPackage?: string, + appWaitActivity?: string, + intentAction?: string, + intentCategory?: string, + intentFlags?: string, + optionalIntentArguments?: string, + dontStopAppOnReset?: boolean, + ) => Promise; + mobileBroadcast = mobileBroadcast; + mobileStartService = mobileStartService; + mobileStopService = mobileStopService; + + hideKeyboard = hideKeyboard; + isKeyboardShown = isKeyboardShown; + keys = keys; + doSendKeys = doSendKeys; + pressKeyCode = pressKeyCode; + longPressKeyCode = longPressKeyCode; + mobilePerformEditorAction = mobilePerformEditorAction; + + lock = lock; + unlock = unlock; + mobileLock = mobileLock; + mobileUnlock = mobileUnlock; + isLocked = isLocked; + + supportedLogTypes = supportedLogTypes; + mobileStartLogsBroadcast = mobileStartLogsBroadcast; + mobileStopLogsBroadcast = mobileStopLogsBroadcast; + getLogTypes = getLogTypes; + getLog = getLog; + + mobileIsMediaProjectionRecordingRunning = mobileIsMediaProjectionRecordingRunning; + mobileStartMediaProjectionRecording = mobileStartMediaProjectionRecording; + mobileStopMediaProjectionRecording = mobileStopMediaProjectionRecording; + + mobileSendTrimMemory = mobileSendTrimMemory; + + getWindowRect = getWindowRect; + getWindowSize = getWindowSize; + getDisplayDensity = getDisplayDensity; + mobileGetNotifications = mobileGetNotifications; + mobileListSms = mobileListSms; + openNotifications = openNotifications; + setUrl = setUrl; + + getNetworkConnection = getNetworkConnection; + isWifiOn = isWifiOn; + mobileGetConnectivity = mobileGetConnectivity; + mobileSetConnectivity = mobileSetConnectivity; + setNetworkConnection = setNetworkConnection; + setWifiState = setWifiState; + setDataState = setDataState; + toggleData = toggleData; + toggleFlightMode = toggleFlightMode; + toggleWiFi = toggleWiFi; + + getPerformanceData = getPerformanceData; + getPerformanceDataTypes = getPerformanceDataTypes; + mobileGetPerformanceData = mobileGetPerformanceData; + + mobileChangePermissions = mobileChangePermissions; + mobileGetPermissions = mobileGetPermissions; + + startRecordingScreen = startRecordingScreen; + stopRecordingScreen = stopRecordingScreen; + + getStrings = getStrings; + ensureDeviceLocale = ensureDeviceLocale; + + mobileShell = mobileShell; + + mobileStartScreenStreaming = mobileStartScreenStreaming; + mobileStopScreenStreaming = mobileStopScreenStreaming; + + getSystemBars = getSystemBars; + mobilePerformStatusBarCommand = mobilePerformStatusBarCommand; + + getDeviceTime = getDeviceTime; + mobileGetDeviceTime = mobileGetDeviceTime; + + tap = tap; + touchLongClick = touchLongClick; + touchDown = touchDown; + touchUp = touchUp; + touchMove = touchMove; + doSwipe = doSwipe; + doTouchDrag = doTouchDrag; + doTouchAction = doTouchAction; + performMultiAction = performMultiAction; + performTouch = performTouch; + doPerformMultiAction = doPerformMultiAction; } -export {commands as androidCommands} from './commands'; export {AndroidDriver}; diff --git a/lib/helpers/android.ts b/lib/helpers/android.ts deleted file mode 100644 index 1676bec1..00000000 --- a/lib/helpers/android.ts +++ /dev/null @@ -1,1153 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import {fs, tempDir, util} from '@appium/support'; -import type {AppiumServer, StringRecord} from '@appium/types'; -import {ADB} from 'appium-adb'; -import {retryInterval, waitForCondition} from 'asyncbox'; -import B from 'bluebird'; -import { - path as SETTINGS_APK_PATH, - SettingsApp, - SETTINGS_HELPER_ID, - UNICODE_IME, - EMPTY_IME, -} from 'io.appium.settings'; -import _ from 'lodash'; -import {EOL} from 'node:os'; -import path from 'node:path'; -import semver, {type SemVer} from 'semver'; -import type {SetRequired, ValueOf} from 'type-fest'; -import type {UnlockType} from '../commands/types'; -import type {AndroidDriver, AndroidDriverCaps, AndroidDriverOpts} from '../driver'; -import logger from '../logger'; -import type {ADBDeviceInfo, ADBLaunchInfo} from './types'; -import Unlocker, { - FINGERPRINT_UNLOCK, - PASSWORD_UNLOCK, - PATTERN_UNLOCK, - PIN_UNLOCK, - PIN_UNLOCK_KEY_EVENT, -} from './unlock'; - -const MOCK_APP_IDS_STORE = '/data/local/tmp/mock_apps.json'; -const PACKAGE_INSTALL_TIMEOUT_MS = 90000; -const HELPER_APP_INSTALL_RETRIES = 3; -const HELPER_APP_INSTALL_RETRY_DELAY_MS = 5000; -// https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc -const CHROME_BROWSER_PACKAGE_ACTIVITY = { - chrome: { - pkg: 'com.android.chrome', - activity: 'com.google.android.apps.chrome.Main', - }, - chromium: { - pkg: 'org.chromium.chrome.shell', - activity: '.ChromeShellActivity', - }, - chromebeta: { - pkg: 'com.chrome.beta', - activity: 'com.google.android.apps.chrome.Main', - }, - browser: { - pkg: 'com.android.browser', - activity: 'com.android.browser.BrowserActivity', - }, - 'chromium-browser': { - pkg: 'org.chromium.chrome', - activity: 'com.google.android.apps.chrome.Main', - }, - 'chromium-webview': { - pkg: 'org.chromium.webview_shell', - activity: 'org.chromium.webview_shell.WebViewBrowserActivity', - }, - default: { - pkg: 'com.android.chrome', - activity: 'com.google.android.apps.chrome.Main', - }, -} as const; -const EMULATOR_PATTERN = /\bemulator\b/i; -// These constants are in sync with -// https://developer.apple.com/documentation/xctest/xcuiapplicationstate/xcuiapplicationstaterunningbackground?language=objc -const APP_STATE = { - NOT_INSTALLED: 0, - NOT_RUNNING: 1, - RUNNING_IN_BACKGROUND: 3, - RUNNING_IN_FOREGROUND: 4, -} as const; - -function ensureNetworkSpeed(adb: ADB, networkSpeed: string) { - if (networkSpeed.toUpperCase() in adb.NETWORK_SPEED) { - return networkSpeed; - } - logger.warn( - `Wrong network speed param '${networkSpeed}', using default: ${adb.NETWORK_SPEED.FULL}. ` + - `Supported values: ${_.values(adb.NETWORK_SPEED)}` - ); - return adb.NETWORK_SPEED.FULL; -} - -function prepareAvdArgs(adb: ADB, opts: AndroidDriverOpts): string[] { - const {networkSpeed, isHeadless, avdArgs} = opts; - const result: string[] = []; - if (avdArgs) { - if (_.isArray(avdArgs)) { - result.push(...avdArgs); - } else { - result.push(...(util.shellParse(`${avdArgs}`) as string[])); - } - } - if (networkSpeed) { - result.push('-netspeed', ensureNetworkSpeed(adb, networkSpeed)); - } - if (isHeadless) { - result.push('-no-window'); - } - return result; -} - -function toCredentialType(unlockType: UnlockType) { - const result = { - [PIN_UNLOCK]: 'pin', - [PIN_UNLOCK_KEY_EVENT]: 'pin', - [PASSWORD_UNLOCK]: 'password', - [PATTERN_UNLOCK]: 'pattern', - }[unlockType]; - if (result) { - return result; - } - throw new Error(`Unlock type '${unlockType}' is not known`); -} - -interface AndroidHelpers { - createBaseADB(opts?: AndroidDriverOpts): Promise; - - prepareEmulator(adb: ADB, opts?: any): Promise; - - /** - * Set and ensure the locale name of the device under test. - * - * @param adb - The adb module instance. - * @param language - Language. The language field is case insensitive, but Locale always canonicalizes to lower case. - * format: [a-zA-Z]{2,8}. e.g. en, ja : https://developer.android.com/reference/java/util/Locale.html - * @param country - Country. The country (region) field is case insensitive, but Locale always canonicalizes to upper case. - * format: [a-zA-Z]{2} | [0-9]{3}. e.g. US, JP : https://developer.android.com/reference/java/util/Locale.html - * @param script - Script. The script field is case insensitive but Locale always canonicalizes to title case. - * format: [a-zA-Z]{4}. e.g. Hans in zh-Hans-CN : https://developer.android.com/reference/java/util/Locale.html - * @throws {Error} If it failed to set locale properly - */ - ensureDeviceLocale(adb: ADB, language?: string, country?: string, script?: string): Promise; - - getDeviceInfoFromCaps(opts?: Opts): Promise; - - createADB(opts?: Opts): Promise; - - validatePackageActivityNames(opts: Opts): void; - getLaunchInfo( - adb: ADB, - opts: Opts - ): Promise; - resetApp( - adb: ADB, - opts: SetRequired - ): Promise; - installApk( - adb: ADB, - opts: SetRequired - ): Promise; - - /** - * Installs an array of apks - * @param adb Instance of Appium ADB object - * @param opts Opts defined in driver.js - */ - installOtherApks( - apks: string[], - adb: ADB, - opts: SetRequired - ): Promise; - - /** - * Uninstall an array of packages - * @param adb Instance of Appium ADB object - * @param appPackages An array of package names to uninstall. If this includes `'*'`, uninstall all of 3rd party apps - * @param filterPackages An array of packages does not uninstall when `*` is provided as `appPackages` - */ - uninstallOtherPackages(adb: ADB, appPackages: string[], filterPackages?: string[]): Promise; - - /** - * Get third party packages filtered with `filterPackages` - * @param adb Instance of Appium ADB object - * @param filterPackages An array of packages does not uninstall when `*` is provided as `appPackages` - * @returns An array of installed third pary packages - */ - getThirdPartyPackages(adb: ADB, filterPackages?: string[]): Promise; - /** - * @deprecated Use hideKeyboard instead - */ - initUnicodeKeyboard(adb: ADB): Promise; - hideKeyboard(adb: ADB): Promise; - setMockLocationApp(adb: ADB, app: string): Promise; - resetMockLocation(adb: ADB): Promise; - installHelperApp(adb: ADB, apkPath: string, packageId: string): Promise; - - /** - * Pushes and installs io.appium.settings app. - * Throws an error if the setting app is required - * - * @param adb - The adb module instance. - * @param throwError - Whether throw an error if Settings app fails to start - * @param opts - Driver options dictionary. - * @throws If throwError is true and something happens in installation step - */ - pushSettingsApp(adb: ADB, throwError: boolean, opts: AndroidDriverOpts): Promise; - - /** - * Extracts string.xml and converts it to string.json and pushes - * it to /data/local/tmp/string.json on for use of bootstrap - * If app is not present to extract string.xml it deletes remote strings.json - * If app does not have strings.xml we push an empty json object to remote - * - * @param language - Language abbreviation, for example 'fr'. The default language - * is used if this argument is not defined. - * @param adb - The adb module instance. - * @param opts - Driver options dictionary. - * @returns The dictionary, where string resource identifiers are keys - * along with their corresponding values for the given language or an empty object - * if no matching resources were extracted. - */ - pushStrings( - language: string | undefined, - adb: ADB, - opts: AndroidDriverOpts - ): Promise; - unlock( - driver: D, - adb: ADB, - capabilities: Caps - ): Promise; - verifyUnlock(adb: ADB, timeoutMs?: number | null): Promise; - initDevice(adb: ADB, opts: AndroidDriverOpts): Promise; - removeNullProperties(obj: any): void; - truncateDecimals(number: number, digits: number): number; - isChromeBrowser(browser?: string): boolean; - getChromePkg(browser: string): ValueOf; - removeAllSessionWebSocketHandlers( - server?: AppiumServer, - sessionId?: string | null - ): Promise; - parseArray(cap: string | string[]): string[]; - - /** - * Validate desired capabilities. Returns true if capabilities are valid - * - * @param caps Capabilities - * @return Returns true if the capabilites are valid - * @throws {Error} If the caps has invalid capability - */ - validateDesiredCaps(caps: AndroidDriverCaps): boolean; - - /** - * Adjust the capabilities for a browser session - * - * @param caps - Current capabilities object - * !!! The object is mutated by this method call !!! - * @returns The same possibly mutated `opts` instance. - * No mutation is happening is the current session if - * appPackage/appActivity caps have already been provided. - * @privateRemarks In practice, this fn is only ever provided a `AndroidDriverOpts` object - */ - adjustBrowserSessionCaps(caps: AndroidDriverCaps): AndroidDriverCaps; - - /** - * Checks whether the current device under test is an emulator - * - * @param adb - appium-adb instance - * @param opts - driver options mapping - * @returns `true` if the device is an Android emulator - */ - isEmulator(adb?: ADB, opts?: AndroidDriverOpts): boolean; - unlocker: typeof Unlocker; -} - -const AndroidHelpers: AndroidHelpers = { - async createBaseADB(opts) { - // filter out any unwanted options sent in - // this list should be updated as ADB takes more arguments - const { - adbPort, - suppressKillServer, - remoteAdbHost, - clearDeviceLogsOnStart, - adbExecTimeout, - useKeystore, - keystorePath, - keystorePassword, - keyAlias, - keyPassword, - remoteAppsCacheLimit, - buildToolsVersion, - allowOfflineDevices, - allowDelayAdb, - } = opts ?? {}; - return await ADB.createADB({ - adbPort, - suppressKillServer, - remoteAdbHost, - clearDeviceLogsOnStart, - adbExecTimeout, - useKeystore, - keystorePath, - keystorePassword, - keyAlias, - keyPassword, - remoteAppsCacheLimit, - buildToolsVersion, - allowOfflineDevices, - allowDelayAdb, - }); - }, - - async prepareEmulator(adb, opts) { - const { - avd, - avdEnv: env, - language, - locale: country, - avdLaunchTimeout: launchTimeout, - avdReadyTimeout: readyTimeout, - } = opts; - if (!avd) { - throw new Error('Cannot launch AVD without AVD name'); - } - - const avdName = avd.replace('@', ''); - let isEmulatorRunning = true; - try { - await adb.getRunningAVDWithRetry(avdName, 5000); - } catch (e) { - logger.debug(`Emulator '${avdName}' is not running: ${(e as Error).message}`); - isEmulatorRunning = false; - } - const args = prepareAvdArgs(adb, opts); - if (isEmulatorRunning) { - if (args.includes('-wipe-data')) { - logger.debug(`Killing '${avdName}' because it needs to be wiped at start.`); - await adb.killEmulator(avdName); - } else { - logger.debug('Not launching AVD because it is already running.'); - return; - } - } - await adb.launchAVD(avd, { - args, - env, - language, - country, - launchTimeout, - readyTimeout, - }); - }, - - async ensureDeviceLocale(adb, language, country, script) { - const settingsApp = new SettingsApp({adb}); - await settingsApp.setDeviceLocale(language!, country!, script); - - if (!(await adb.ensureCurrentLocale(language, country, script))) { - const message = script - ? `language: ${language}, country: ${country} and script: ${script}` - : `language: ${language} and country: ${country}`; - throw new Error(`Failed to set ${message}`); - } - }, - - async getDeviceInfoFromCaps(opts) { - // we can create a throwaway ADB instance here, so there is no dependency - // on instantiating on earlier (at this point, we have no udid) - // we can only use this ADB object for commands that would not be confused - // if multiple devices are connected - const adb = await AndroidHelpers.createBaseADB(opts); - let udid: string | undefined = opts?.udid; - let emPort: number | false | undefined; - - // a specific avd name was given. try to initialize with that - if (opts?.avd) { - await AndroidHelpers.prepareEmulator(adb, opts); - udid = adb.curDeviceId; - emPort = adb.emulatorPort; - } else { - // no avd given. lets try whatever's plugged in devices/emulators - logger.info('Retrieving device list'); - const devices = await adb.getDevicesWithRetry(); - - // udid was given, lets try to init with that device - if (udid) { - if (!_.includes(_.map(devices, 'udid'), udid)) { - logger.errorAndThrow(`Device ${udid} was not in the list of connected devices`); - } - emPort = adb.getPortFromEmulatorString(udid); - } else if (opts?.platformVersion) { - opts.platformVersion = `${opts.platformVersion}`.trim(); - - // a platform version was given. lets try to find a device with the same os - const platformVersion = semver.coerce(opts.platformVersion) || opts.platformVersion; - logger.info(`Looking for a device with Android '${platformVersion}'`); - - // in case we fail to find something, give the user a useful log that has - // the device udids and os versions so they know what's available - const availDevices: string[] = []; - let partialMatchCandidate: StringRecord | undefined; - // first try started devices/emulators - for (const device of devices) { - // direct adb calls to the specific device - adb.setDeviceId(device.udid); - const rawDeviceOS = await adb.getPlatformVersion(); - // The device OS could either be a number, like `6.0` - // or an abbreviation, like `R` - availDevices.push(`${device.udid} (${rawDeviceOS})`); - const deviceOS = semver.coerce(rawDeviceOS) || rawDeviceOS; - if (!deviceOS) { - continue; - } - - const semverPV = platformVersion as SemVer; - const semverDO = deviceOS as SemVer; - - const bothVersionsCanBeCoerced = semver.valid(deviceOS) && semver.valid(platformVersion); - const bothVersionsAreStrings = _.isString(deviceOS) && _.isString(platformVersion); - if ( - (bothVersionsCanBeCoerced && semverDO.version === semverPV.version) || - (bothVersionsAreStrings && _.toLower(deviceOS) === _.toLower(platformVersion)) - ) { - // Got an exact match - proceed immediately - udid = device.udid; - break; - } else if (!bothVersionsCanBeCoerced) { - // There is no point to check for partial match if either of version numbers is not coercible - continue; - } - - if ( - ((!_.includes(opts.platformVersion, '.') && semverPV.major === semverDO.major) || - (semverPV.major === semverDO.major && semverPV.minor === semverDO.minor)) && - // Got a partial match - make sure we consider the most recent - // device version available on the host system - ((partialMatchCandidate && semver.gt(deviceOS, _.values(partialMatchCandidate)[0])) || - !partialMatchCandidate) - ) { - partialMatchCandidate = {[device.udid]: deviceOS as string}; - } - } - if (!udid && partialMatchCandidate) { - udid = _.keys(partialMatchCandidate)[0]; - adb.setDeviceId(udid); - } - - if (!udid) { - // we couldn't find anything! quit - logger.errorAndThrow( - `Unable to find an active device or emulator ` + - `with OS ${opts.platformVersion}. The following are available: ` + - availDevices.join(', ') - ); - throw new Error(); // unreachable; for TS - } - - emPort = adb.getPortFromEmulatorString(udid); - } else { - // a udid was not given, grab the first device we see - udid = devices[0].udid; - emPort = adb.getPortFromEmulatorString(udid); - } - } - - logger.info(`Using device: ${udid}`); - return {udid: udid as string, emPort: emPort as number | false}; - }, - - async createADB(opts) { - // @ts-expect-error do not put arbitrary properties on opts - const {udid, emPort} = opts ?? {}; - const adb = await AndroidHelpers.createBaseADB(opts); - adb.setDeviceId(udid ?? ''); - if (emPort) { - adb.setEmulatorPort(emPort); - } - - return adb; - }, - - validatePackageActivityNames(opts) { - for (const key of ['appPackage', 'appActivity', 'appWaitPackage', 'appWaitActivity']) { - const name = opts[key as keyof typeof opts]; - if (!name) { - continue; - } - - const match = /([^\w.*,])+/.exec(String(name)); - if (!match) { - continue; - } - - logger.warn( - `Capability '${key}' is expected to only include latin letters, digits, underscore, dot, comma and asterisk characters.` - ); - logger.warn( - `Current value '${name}' has non-matching character at index ${match.index}: '${String( - name - ).substring(0, match.index + 1)}'` - ); - } - }, - - async getLaunchInfo(adb, opts) { - if (!opts.app) { - logger.warn('No app sent in, not parsing package/activity'); - return; - } - let {appPackage, appActivity, appWaitPackage, appWaitActivity} = opts; - const {app} = opts; - - AndroidHelpers.validatePackageActivityNames(opts); - - if (appPackage && appActivity) { - return; - } - - logger.debug('Parsing package and activity from app manifest'); - const {apkPackage, apkActivity} = await adb.packageAndLaunchActivityFromManifest(app); - if (apkPackage && !appPackage) { - appPackage = apkPackage; - } - if (!appWaitPackage) { - appWaitPackage = appPackage; - } - if (apkActivity && !appActivity) { - appActivity = apkActivity; - } - if (!appWaitActivity) { - appWaitActivity = appActivity; - } - logger.debug(`Parsed package and activity are: ${apkPackage}/${apkActivity}`); - return {appPackage, appWaitPackage, appActivity, appWaitActivity}; - }, - - async resetApp(adb, opts) { - const { - app, - appPackage, - fastReset, - fullReset, - androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, - autoGrantPermissions, - allowTestPackages, - } = opts ?? {}; - - if (!appPackage) { - throw new Error("'appPackage' option is required"); - } - - const isInstalled = await adb.isAppInstalled(appPackage); - - if (isInstalled) { - try { - await adb.forceStop(appPackage); - } catch (ign) {} - // fullReset has priority over fastReset - if (!fullReset && fastReset) { - const output = await adb.clear(appPackage); - if (_.isString(output) && output.toLowerCase().includes('failed')) { - throw new Error( - `Cannot clear the application data of '${appPackage}'. Original error: ${output}` - ); - } - // executing `shell pm clear` resets previously assigned application permissions as well - if (autoGrantPermissions) { - try { - await adb.grantAllPermissions(appPackage); - } catch (error) { - logger.error( - `Unable to grant permissions requested. Original error: ${(error as Error).message}` - ); - } - } - logger.debug( - `Performed fast reset on the installed '${appPackage}' application (stop and clear)` - ); - return; - } - } - - if (!app) { - throw new Error( - `Either provide 'app' option to install '${appPackage}' or ` + - `consider setting 'noReset' to 'true' if '${appPackage}' is supposed to be preinstalled.` - ); - } - - logger.debug(`Running full reset on '${appPackage}' (reinstall)`); - if (isInstalled) { - await adb.uninstallApk(appPackage); - } - await adb.install(app, { - grantPermissions: autoGrantPermissions, - timeout: androidInstallTimeout, - allowTestPackages, - }); - }, - - async installApk(adb, opts) { - const { - app, - appPackage, - fastReset, - fullReset, - androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, - autoGrantPermissions, - allowTestPackages, - enforceAppInstall, - } = opts ?? {}; - - if (!app || !appPackage) { - throw new Error("'app' and 'appPackage' options are required"); - } - - if (fullReset) { - await AndroidHelpers.resetApp(adb, opts); - return; - } - - const {appState, wasUninstalled} = await adb.installOrUpgrade(app, appPackage, { - grantPermissions: autoGrantPermissions, - timeout: androidInstallTimeout, - allowTestPackages, - enforceCurrentBuild: enforceAppInstall, - }); - - // There is no need to reset the newly installed app - const isInstalledOverExistingApp = - !wasUninstalled && appState !== adb.APP_INSTALL_STATE.NOT_INSTALLED; - if (fastReset && isInstalledOverExistingApp) { - logger.info(`Performing fast reset on '${appPackage}'`); - await AndroidHelpers.resetApp(adb, opts); - } - }, - - async installOtherApks(otherApps, adb, opts) { - const { - androidInstallTimeout = PACKAGE_INSTALL_TIMEOUT_MS, - autoGrantPermissions, - allowTestPackages, - } = opts; - - // Install all of the APK's asynchronously - await B.all( - otherApps.map((otherApp) => { - logger.debug(`Installing app: ${otherApp}`); - return adb.installOrUpgrade(otherApp, undefined, { - grantPermissions: autoGrantPermissions, - timeout: androidInstallTimeout, - allowTestPackages, - }); - }) - ); - }, - - async uninstallOtherPackages(adb, appPackages, filterPackages = []) { - if (appPackages.includes('*')) { - logger.debug('Uninstall third party packages'); - appPackages = await AndroidHelpers.getThirdPartyPackages(adb, filterPackages); - } - - logger.debug(`Uninstalling packages: ${appPackages}`); - await B.all(appPackages.map((appPackage) => adb.uninstallApk(appPackage))); - }, - - async getThirdPartyPackages(adb, filterPackages = []) { - try { - const packagesString = await adb.shell(['pm', 'list', 'packages', '-3']); - const appPackagesArray = packagesString - .trim() - .replace(/package:/g, '') - .split(EOL); - logger.debug(`'${appPackagesArray}' filtered with '${filterPackages}'`); - return _.difference(appPackagesArray, filterPackages); - } catch (err) { - logger.warn( - `Unable to get packages with 'adb shell pm list packages -3': ${(err as Error).message}` - ); - return []; - } - }, - - async initUnicodeKeyboard(adb) { - logger.debug('Enabling Unicode keyboard support'); - - // get the default IME so we can return back to it later if we want - const defaultIME = await adb.defaultIME(); - - logger.debug(`Unsetting previous IME ${defaultIME}`); - logger.debug(`Setting IME to '${UNICODE_IME}'`); - await adb.enableIME(UNICODE_IME); - await adb.setIME(UNICODE_IME); - return defaultIME; - }, - - async hideKeyboard(adb) { - logger.debug(`Hiding the on-screen keyboard by setting IME to '${EMPTY_IME}'`); - await adb.enableIME(EMPTY_IME); - await adb.setIME(EMPTY_IME); - }, - - async setMockLocationApp(adb, app) { - try { - if ((await adb.getApiLevel()) < 23) { - await adb.shell(['settings', 'put', 'secure', 'mock_location', '1']); - } else { - await adb.shell(['appops', 'set', app, 'android:mock_location', 'allow']); - } - } catch (err) { - logger.warn(`Unable to set mock location for app '${app}': ${(err as Error).message}`); - return; - } - try { - let pkgIds: string[] = []; - if (await adb.fileExists(MOCK_APP_IDS_STORE)) { - try { - pkgIds = JSON.parse(await adb.shell(['cat', MOCK_APP_IDS_STORE])); - } catch (ign) {} - } - if (pkgIds.includes(app)) { - return; - } - pkgIds.push(app); - const tmpRoot = await tempDir.openDir(); - const srcPath = path.posix.join(tmpRoot, path.posix.basename(MOCK_APP_IDS_STORE)); - try { - await fs.writeFile(srcPath, JSON.stringify(pkgIds), 'utf8'); - await adb.push(srcPath, MOCK_APP_IDS_STORE); - } finally { - await fs.rimraf(tmpRoot); - } - } catch (e) { - logger.warn(`Unable to persist mock location app id '${app}': ${(e as Error).message}`); - } - }, - - async resetMockLocation(adb) { - try { - if ((await adb.getApiLevel()) < 23) { - await adb.shell(['settings', 'put', 'secure', 'mock_location', '0']); - return; - } - - const thirdPartyPkgIdsPromise = AndroidHelpers.getThirdPartyPackages(adb); - let pkgIds = []; - if (await adb.fileExists(MOCK_APP_IDS_STORE)) { - try { - pkgIds = JSON.parse(await adb.shell(['cat', MOCK_APP_IDS_STORE])); - } catch (ign) {} - } - const thirdPartyPkgIds = await thirdPartyPkgIdsPromise; - // Only include currently installed packages - const resultPkgs = _.intersection(pkgIds, thirdPartyPkgIds); - if (_.size(resultPkgs) <= 1) { - await adb.shell([ - 'appops', - 'set', - resultPkgs[0] ?? SETTINGS_HELPER_ID, - 'android:mock_location', - 'deny', - ]); - return; - } - - logger.debug(`Resetting mock_location permission for the following apps: ${resultPkgs}`); - await B.all( - resultPkgs.map((pkgId) => - (async () => { - try { - await adb.shell(['appops', 'set', pkgId, 'android:mock_location', 'deny']); - } catch (ign) {} - })() - ) - ); - } catch (err) { - logger.warn(`Unable to reset mock location: ${(err as Error).message}`); - } - }, - - async installHelperApp(adb, apkPath, packageId) { - // Sometimes adb push or adb instal take more time than expected to install an app - // e.g. https://github.com/appium/io.appium.settings/issues/40#issuecomment-476593174 - await retryInterval( - HELPER_APP_INSTALL_RETRIES, - HELPER_APP_INSTALL_RETRY_DELAY_MS, - async function retryInstallHelperApp() { - await adb.installOrUpgrade(apkPath, packageId, {grantPermissions: true}); - } - ); - }, - - async pushSettingsApp(adb, throwError, opts) { - logger.debug('Pushing settings apk to device...'); - - try { - await AndroidHelpers.installHelperApp(adb, SETTINGS_APK_PATH, SETTINGS_HELPER_ID); - } catch (err) { - if (throwError) { - throw err; - } - - logger.warn( - `Ignored error while installing '${SETTINGS_APK_PATH}': ` + - `'${(err as Error).message}'. Features that rely on this helper ` + - 'require the apk such as toggle WiFi and getting location ' + - 'will raise an error if you try to use them.' - ); - } - - const settingsApp = new SettingsApp({adb}); - // Reinstall would stop the settings helper process anyway, so - // there is no need to continue if the application is still running - if (await settingsApp.isRunningInForeground()) { - logger.debug( - `${SETTINGS_HELPER_ID} is already running. ` + - `There is no need to reset its permissions.` - ); - return; - } - - const fixSettingsAppPermissionsForLegacyApis = async () => { - if (await adb.getApiLevel() > 23) { - return; - } - - // Android 6- devices should have granted permissions - // https://github.com/appium/appium/pull/11640#issuecomment-438260477 - const perms = ['SET_ANIMATION_SCALE', 'CHANGE_CONFIGURATION', 'ACCESS_FINE_LOCATION']; - logger.info(`Granting permissions ${perms} to '${SETTINGS_HELPER_ID}'`); - await adb.grantPermissions( - SETTINGS_HELPER_ID, - perms.map((x) => `android.permission.${x}`) - ); - }; - - try { - await B.all([ - settingsApp.adjustNotificationsPermissions(), - settingsApp.adjustMediaProjectionServicePermissions(), - fixSettingsAppPermissionsForLegacyApis(), - ]); - } catch (e) { - logger.debug(e.stack); - } - - // launch io.appium.settings app due to settings failing to be set - // if the app is not launched prior to start the session on android 7+ - // see https://github.com/appium/appium/issues/8957 - try { - await settingsApp.requireRunning({ - timeout: AndroidHelpers.isEmulator(adb, opts) ? 30000 : 5000, - }); - } catch (err) { - logger.debug(err); - if (throwError) { - throw err; - } - } - }, - - async pushStrings(language, adb, opts) { - const remoteDir = '/data/local/tmp'; - const stringsJson = 'strings.json'; - const remoteFile = path.posix.resolve(remoteDir, stringsJson); - - // clean up remote string.json if present - await adb.rimraf(remoteFile); - - let app: string; - try { - app = opts.app || (await adb.pullApk(opts.appPackage!, opts.tmpDir!)); - } catch (err) { - logger.info( - `Failed to pull an apk from '${opts.appPackage}' to '${opts.tmpDir}'. Original error: ${ - (err as Error).message - }` - ); - } - - if (_.isEmpty(opts.appPackage) || !(await fs.exists(app!))) { - logger.debug(`No app or package specified. Returning empty strings`); - return {}; - } - - const stringsTmpDir = path.resolve(opts.tmpDir!, opts.appPackage!); - try { - logger.debug('Extracting strings from apk', app!, language, stringsTmpDir); - const {apkStrings, localPath} = await adb.extractStringsFromApk( - app!, - language ?? null, - stringsTmpDir - ); - await adb.push(localPath, remoteDir); - return apkStrings; - } catch (err) { - logger.warn( - `Could not get strings, continuing anyway. Original error: ${(err as Error).message}` - ); - await adb.shell(['echo', `'{}' > ${remoteFile}`]); - } finally { - await fs.rimraf(stringsTmpDir); - } - return {}; - }, - - async unlock(driver, adb, capabilities) { - if (!(await adb.isScreenLocked())) { - logger.info('Screen already unlocked, doing nothing'); - return; - } - - logger.debug('Screen is locked, trying to unlock'); - if (!capabilities.unlockType && !capabilities.unlockKey) { - logger.info( - `Neither 'unlockType' nor 'unlockKey' capability is provided. ` + - `Assuming the device is locked with a simple lock screen.` - ); - await adb.dismissKeyguard(); - return; - } - - const {unlockType, unlockKey, unlockStrategy, unlockSuccessTimeout} = - Unlocker.validateUnlockCapabilities(capabilities); - if ( - unlockKey && - unlockType !== FINGERPRINT_UNLOCK && - (_.isNil(unlockStrategy) || _.toLower(unlockStrategy) === 'locksettings') && - (await adb.isLockManagementSupported()) - ) { - await Unlocker.fastUnlock(adb, { - credential: unlockKey, - credentialType: toCredentialType(unlockType as UnlockType), - }); - } else { - const unlockMethod = { - [PIN_UNLOCK]: Unlocker.pinUnlock, - [PIN_UNLOCK_KEY_EVENT]: Unlocker.pinUnlockWithKeyEvent, - [PASSWORD_UNLOCK]: Unlocker.passwordUnlock, - [PATTERN_UNLOCK]: Unlocker.patternUnlock, - [FINGERPRINT_UNLOCK]: Unlocker.fingerprintUnlock, - }[unlockType!]; - await unlockMethod!(adb, driver, capabilities); - } - await AndroidHelpers.verifyUnlock(adb, unlockSuccessTimeout); - }, - - async verifyUnlock(adb, timeoutMs = null) { - try { - await waitForCondition(async () => !(await adb.isScreenLocked()), { - waitMs: timeoutMs ?? 2000, - intervalMs: 500, - }); - } catch (ign) { - throw new Error('The device has failed to be unlocked'); - } - logger.info('The device has been successfully unlocked'); - }, - - async initDevice(adb, opts) { - const { - skipDeviceInitialization, - locale, - language, - localeScript, - unicodeKeyboard, - hideKeyboard, - disableWindowAnimation, - skipUnlock, - mockLocationApp, - skipLogcatCapture, - logcatFormat, - logcatFilterSpecs, - } = opts; - - if (skipDeviceInitialization) { - logger.info(`'skipDeviceInitialization' is set. Skipping device initialization.`); - } else { - if (AndroidHelpers.isEmulator(adb, opts)) { - // Check if the device wake up only for an emulator. - // It takes 1 second or so even when the device is already awake in a real device. - await adb.waitForDevice(); - } - // pushSettingsApp required before calling ensureDeviceLocale for API Level 24+ - - // Some feature such as location/wifi are not necessary for all users, - // but they require the settings app. So, try to configure it while Appium - // does not throw error even if they fail. - const shouldThrowError = Boolean( - language || - locale || - localeScript || - unicodeKeyboard || - hideKeyboard || - disableWindowAnimation || - !skipUnlock - ); - await AndroidHelpers.pushSettingsApp(adb, shouldThrowError, opts); - } - - if (!AndroidHelpers.isEmulator(adb, opts)) { - if (mockLocationApp || _.isUndefined(mockLocationApp)) { - await AndroidHelpers.setMockLocationApp(adb, mockLocationApp || SETTINGS_HELPER_ID); - } else { - await AndroidHelpers.resetMockLocation(adb); - } - } - - if (language || locale) { - await AndroidHelpers.ensureDeviceLocale(adb, language, locale, localeScript); - } - - if (skipLogcatCapture) { - logger.info(`'skipLogcatCapture' is set. Skipping starting logcat capture.`); - } else { - await adb.startLogcat({ - format: logcatFormat, - filterSpecs: logcatFilterSpecs, - }); - } - - if (hideKeyboard) { - await AndroidHelpers.hideKeyboard(adb); - } else if (hideKeyboard === false) { - await adb.shell(['ime', 'reset']); - } - - if (unicodeKeyboard) { - logger.warn( - `The 'unicodeKeyboard' capability has been deprecated and will be removed. ` + - `Set the 'hideKeyboard' capability to 'true' in order to make the on-screen keyboard invisible.` - ); - return await AndroidHelpers.initUnicodeKeyboard(adb); - } - }, - - removeNullProperties(obj) { - for (const key of _.keys(obj)) { - if (_.isNull(obj[key]) || _.isUndefined(obj[key])) { - delete obj[key]; - } - } - }, - - truncateDecimals(number, digits) { - const multiplier = Math.pow(10, digits), - adjustedNum = number * multiplier, - truncatedNum = Math[adjustedNum < 0 ? 'ceil' : 'floor'](adjustedNum); - - return truncatedNum / multiplier; - }, - - isChromeBrowser(browser) { - return _.includes(Object.keys(CHROME_BROWSER_PACKAGE_ACTIVITY), (browser || '').toLowerCase()); - }, - - getChromePkg(browser) { - return ( - CHROME_BROWSER_PACKAGE_ACTIVITY[ - browser.toLowerCase() as keyof typeof CHROME_BROWSER_PACKAGE_ACTIVITY - ] || CHROME_BROWSER_PACKAGE_ACTIVITY.default - ); - }, - - async removeAllSessionWebSocketHandlers(server, sessionId) { - if (!server || !_.isFunction(server.getWebSocketHandlers)) { - return; - } - - const activeHandlers = await server.getWebSocketHandlers(sessionId); - for (const pathname of _.keys(activeHandlers)) { - await server.removeWebSocketHandler(pathname); - } - }, - - parseArray(cap) { - let parsedCaps: string | string[] | undefined; - try { - parsedCaps = JSON.parse(cap as string); - } catch (ign) {} - - if (_.isArray(parsedCaps)) { - return parsedCaps; - } else if (_.isString(cap)) { - return [cap]; - } - - throw new Error(`must provide a string or JSON Array; received ${cap}`); - }, - - validateDesiredCaps(caps) { - if (caps.browserName) { - if (caps.app) { - // warn if the capabilities have both `app` and `browser, although this is common with selenium grid - logger.warn( - `The desired capabilities should generally not include both an 'app' and a 'browserName'` - ); - } - if (caps.appPackage) { - logger.errorAndThrow( - `The desired should not include both of an 'appPackage' and a 'browserName'` - ); - } - } - - if (caps.uninstallOtherPackages) { - try { - AndroidHelpers.parseArray(caps.uninstallOtherPackages); - } catch (e) { - logger.errorAndThrow( - `Could not parse "uninstallOtherPackages" capability: ${(e as Error).message}` - ); - } - } - - return true; - }, - - adjustBrowserSessionCaps(caps) { - const {browserName} = caps; - logger.info(`The current session is considered browser-based`); - logger.info( - `Supported browser names: ${JSON.stringify(_.keys(CHROME_BROWSER_PACKAGE_ACTIVITY))}` - ); - if (caps.appPackage || caps.appActivity) { - logger.info( - `Not overriding appPackage/appActivity capability values for '${browserName}' ` + - 'because some of them have been already provided' - ); - return caps; - } - - const {pkg, activity} = AndroidHelpers.getChromePkg(String(browserName)); - caps.appPackage = pkg; - caps.appActivity = activity; - logger.info( - `appPackage/appActivity capabilities have been automatically set to ${pkg}/${activity} ` + - `for '${browserName}'` - ); - logger.info( - `Consider changing the browserName to the one from the list of supported browser names ` + - `or provide custom appPackage/appActivity capability values if the automatically assigned ones do ` + - `not make sense` - ); - return caps; - }, - - isEmulator(adb, opts) { - const possibleNames = [opts?.udid, adb?.curDeviceId]; - return !!opts?.avd || possibleNames.some((x) => EMULATOR_PATTERN.test(String(x))); - }, - unlocker: Unlocker, -}; - -export const helpers = AndroidHelpers; -export {APP_STATE, SETTINGS_HELPER_ID, ensureNetworkSpeed, prepareAvdArgs}; -export default AndroidHelpers; diff --git a/lib/helpers/index.ts b/lib/helpers/index.ts deleted file mode 100644 index 665afeb9..00000000 --- a/lib/helpers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export {default as WebviewHelpers} from './webview'; -export * from './webview'; -export * from './unlock'; -export * from './types'; -export {default as AndroidHelpers} from './android'; -export * from './android'; diff --git a/lib/helpers/types.ts b/lib/helpers/types.ts deleted file mode 100644 index f3545310..00000000 --- a/lib/helpers/types.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type {ADB} from 'appium-adb'; -import {AndroidDriverCaps} from '../driver'; -import {StringRecord} from '@appium/types'; - -export interface WebviewProc { - /** - * The webview process name (as returned by getPotentialWebviewProcs) - */ - proc: string; - /** - * The actual webview context name - */ - webview: string; -} - -export interface DetailCollectionOptions { - /** - * The starting port to use for webview page presence check (if not the default of 9222). - */ - webviewDevtoolsPort?: number | null; - /** - * Whether to check for webview pages presence - */ - ensureWebviewsHavePages?: boolean | null; - /** - * Whether to collect web view details and send them to Chromedriver constructor, so it could - * select a binary more precisely based on this info. - */ - enableWebviewDetailsCollection?: boolean | null; -} - -export interface WebviewProps { - /** - * The name of the Devtools Unix socket - */ - proc: string; - /** - * The web view alias. Looks like `WEBVIEW_` prefix plus PID or package name - */ - webview: string; - /** - * Webview information as it is retrieved by /json/version CDP endpoint - */ - info?: object | null; - /** - * Webview pages list as it is retrieved by /json/list CDP endpoint - */ - pages?: object[] | null; -} - -export interface GetWebviewsOpts { - /** - * device socket name - */ - androidDeviceSocket?: string | null; - /** - * whether to check for webview page presence - */ - ensureWebviewsHavePages?: boolean | null; - /** - * port to use for webview page presence check. - */ - webviewDevtoolsPort?: number | null; - /** - * whether to collect web view details and send them to Chromedriver constructor, so it could select a binary more precisely based on this info. - */ - enableWebviewDetailsCollection?: boolean | null; - /** - * @privateRemarks This is referenced but was not previously declared - */ - isChromeSession?: boolean; - - waitForWebviewMs?: number | string; -} - -export interface ProcessInfo { - /** - * The process name - */ - name: string; - /** - * The process id (if could be retrieved) - */ - id?: string | null; -} - -export interface WebViewDetails { - /** - * Web view process details - */ - process?: ProcessInfo | null; - /** - * Web view details as returned by /json/version CDP endpoint - * @example - * { - * "Browser": "Chrome/72.0.3601.0", - * "Protocol-Version": "1.3", - * "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3601.0 Safari/537.36", - * "V8-Version": "7.2.233", - * "WebKit-Version": "537.36 (@cfede9db1d154de0468cb0538479f34c0755a0f4)", - * "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/b0b8a4fb-bb17-4359-9533-a8d9f3908bd8" - * } - */ - info?: StringRecord; -} - -/** - * @deprecated - */ -export type TADB = ADB; - -/** - * XXX Placeholder for ADB options - */ -export type TADBOptions = any; - -export interface FastUnlockOptions { - credential: string; - /** - * @privateRemarks FIXME: narrow this type to whatever `appium-adb` expects - */ - credentialType: string; -} - -/** - * XXX May be wrong - */ -export interface ADBDeviceInfo { - udid: string; - emPort: number | false; -} - -export type ADBLaunchInfo = Pick< - AndroidDriverCaps, - 'appPackage' | 'appWaitActivity' | 'appActivity' | 'appWaitPackage' ->; diff --git a/lib/helpers/unlock.ts b/lib/helpers/unlock.ts deleted file mode 100644 index 46b21102..00000000 --- a/lib/helpers/unlock.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Unlocking helpers - * @module - */ - -import {util} from '@appium/support'; -import type {Capabilities, Position, StringRecord} from '@appium/types'; -import {sleep} from 'asyncbox'; -import _ from 'lodash'; -import type {AndroidDriver} from '../driver'; -import type {AndroidDriverConstraints} from '../constraints'; -import logger from '../logger'; -import type {FastUnlockOptions} from './types'; -import ADB from 'appium-adb'; -import {TouchAction} from '../commands/types'; - -const PIN_UNLOCK = 'pin'; -const PIN_UNLOCK_KEY_EVENT = 'pinWithKeyEvent'; -const PASSWORD_UNLOCK = 'password'; -const PATTERN_UNLOCK = 'pattern'; -const FINGERPRINT_UNLOCK = 'fingerprint'; -const UNLOCK_TYPES = [ - PIN_UNLOCK, - PIN_UNLOCK_KEY_EVENT, - PASSWORD_UNLOCK, - PATTERN_UNLOCK, - FINGERPRINT_UNLOCK, -] as const; -const KEYCODE_NUMPAD_ENTER = 66; -const UNLOCK_WAIT_TIME = 100; -const INPUT_KEYS_WAIT_TIME = 100; -const NUMBER_ZERO_KEYCODE = 7; - -interface UnlockHelpers { - validateUnlockCapabilities: ( - caps: Capabilities - ) => Capabilities; - fastUnlock(adb: ADB, opts: FastUnlockOptions): Promise; - encodePassword(key: string): string; - stringKeyToArr(key: any): string[]; - fingerprintUnlock( - adb: ADB, - driver: AndroidDriver, - capabilities: Capabilities - ): Promise; - pinUnlock( - adb: ADB, - driver: AndroidDriver, - capabilities: Capabilities - ): Promise; - pinUnlockWithKeyEvent( - adb: ADB, - driver: AndroidDriver, - capabilities: Capabilities - ): Promise; - passwordUnlock( - adb: ADB, - driver: AndroidDriver, - capabilities: Capabilities - ): Promise; - getPatternKeyPosition(key: number, initPos: Position, piece: number): Position; - getPatternActions(keys: string[] | number[], initPos: Position, piece: number): TouchAction[]; - patternUnlock( - adb: ADB, - driver: AndroidDriver, - capabilities: Capabilities - ): Promise; -} - -function isNonEmptyString(value: any): value is string { - return typeof value === 'string' && value !== ''; -} - -/** - * Wait for the display to be unlocked. - * Some devices automatically accept typed 'pin' and 'password' code - * without pressing the Enter key. But some devices need it. - * This method waits a few seconds first for such automatic acceptance case. - * If the device is still locked, then this method will try to send - * the enter key code. - * - * @param adb The instance of ADB - */ -async function waitForUnlock(adb: ADB) { - await sleep(UNLOCK_WAIT_TIME); - if (!(await adb.isScreenLocked())) { - return; - } - - await adb.keyevent(KEYCODE_NUMPAD_ENTER); - await sleep(UNLOCK_WAIT_TIME); -} - -const UnlockHelpers: UnlockHelpers = { - validateUnlockCapabilities(caps) { - const {unlockKey, unlockType} = (caps ?? {}) as Capabilities; - if (!isNonEmptyString(unlockType)) { - throw new Error('A non-empty unlock key value must be provided'); - } - - if ([PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT, FINGERPRINT_UNLOCK].includes(unlockType)) { - if (!/^[0-9]+$/.test(_.trim(unlockKey))) { - throw new Error(`Unlock key value '${unlockKey}' must only consist of digits`); - } - } else if (unlockType === PATTERN_UNLOCK) { - if (!/^[1-9]{2,9}$/.test(_.trim(unlockKey))) { - throw new Error( - `Unlock key value '${unlockKey}' must only include from two to nine digits in range 1..9` - ); - } - if (/([1-9]).*?\1/.test(_.trim(unlockKey))) { - throw new Error( - `Unlock key value '${unlockKey}' must define a valid pattern where repeats are not allowed` - ); - } - } else if (unlockType === PASSWORD_UNLOCK) { - // Dont trim password key, you can use blank spaces in your android password - // ¯\_(ツ)_/¯ - if (!/.{4,}/g.test(String(unlockKey))) { - throw new Error( - `The minimum allowed length of unlock key value '${unlockKey}' is 4 characters` - ); - } - } else { - throw new Error( - `Invalid unlock type '${unlockType}'. ` + - `Only the following unlock types are supported: ${UNLOCK_TYPES}` - ); - } - return caps; - }, - - async fastUnlock(adb, opts) { - const {credential, credentialType} = opts; - logger.info(`Unlocking the device via ADB using ${credentialType} credential '${credential}'`); - const wasLockEnabled = await adb.isLockEnabled(); - if (wasLockEnabled) { - await adb.clearLockCredential(credential); - // not sure why, but the device's screen still remains locked - // if a preliminary wake up cycle has not been performed - await adb.cycleWakeUp(); - } else { - logger.info('No active lock has been detected. Proceeding to the keyguard dismissal'); - } - try { - await adb.dismissKeyguard(); - } finally { - if (wasLockEnabled) { - await adb.setLockCredential(credentialType, credential); - } - } - }, - - encodePassword(key) { - return `${key}`.replace(/\s/gi, '%s'); - }, - - stringKeyToArr(key) { - return `${key}`.trim().replace(/\s+/g, '').split(/\s*/); - }, - - async fingerprintUnlock(adb, driver, capabilities) { - if ((await adb.getApiLevel()) < 23) { - throw new Error('Fingerprint unlock only works for Android 6+ emulators'); - } - await adb.fingerprint(String(capabilities.unlockKey)); - await sleep(UNLOCK_WAIT_TIME); - }, - - async pinUnlock(adb, driver, capabilities) { - logger.info(`Trying to unlock device using pin ${capabilities.unlockKey}`); - await adb.dismissKeyguard(); - const keys = this.stringKeyToArr(capabilities.unlockKey); - if ((await adb.getApiLevel()) >= 21) { - const els = await driver.findElOrEls('id', 'com.android.systemui:id/digit_text', true); - if (_.isEmpty(els)) { - // fallback to pin with key event - return await this.pinUnlockWithKeyEvent(adb, driver, capabilities); - } - const pins: StringRecord = {}; - for (const el of els) { - const text = await driver.getAttribute('text', util.unwrapElement(el)); - pins[text] = el; - } - for (const pin of keys) { - const el = pins[pin]; - await driver.click(util.unwrapElement(el)); - } - } else { - for (const pin of keys) { - const el = await driver.findElOrEls('id', `com.android.keyguard:id/key${pin}`, false); - if (el === null) { - // fallback to pin with key event - return await this.pinUnlockWithKeyEvent(adb, driver, capabilities); - } - await driver.click(util.unwrapElement(el)); - } - } - await waitForUnlock(adb); - }, - - async pinUnlockWithKeyEvent(adb, driver, capabilities) { - logger.info(`Trying to unlock device using pin with keycode ${capabilities.unlockKey}`); - await adb.dismissKeyguard(); - const keys = this.stringKeyToArr(capabilities.unlockKey); - - // Some device does not have system key ids like 'com.android.keyguard:id/key' - // Then, sending keyevents are more reliable to unlock the screen. - for (const pin of keys) { - // 'pin' is number (0-9) in string. - // Number '0' is keycode '7'. number '9' is keycode '16'. - await adb.shell(['input', 'keyevent', String(parseInt(pin, 10) + NUMBER_ZERO_KEYCODE)]); - } - await waitForUnlock(adb); - }, - - async passwordUnlock(adb, driver, capabilities) { - const {unlockKey} = capabilities; - logger.info(`Trying to unlock device using password ${unlockKey}`); - await adb.dismissKeyguard(); - // Replace blank spaces with %s - const key = this.encodePassword(unlockKey as string); - // Why adb ? It was less flaky - await adb.shell(['input', 'text', key]); - // Why sleeps ? Avoid some flakyness waiting for the input to receive the keys - await sleep(INPUT_KEYS_WAIT_TIME); - await adb.shell(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]); - // Waits a bit for the device to be unlocked - await waitForUnlock(adb); - }, - - getPatternKeyPosition(key, initPos, piece) { - /* - How the math works: - We have 9 buttons divided in 3 columns and 3 rows inside the lockPatternView, - every button has a position on the screen corresponding to the lockPatternView since - it is the parent view right at the middle of each column or row. - */ - const cols = 3; - const pins = 9; - const xPos = (key: number, x: number, piece: number) => - Math.round(x + (key % cols || cols) * piece - piece / 2); - const yPos = (key: number, y: number, piece: number) => - Math.round(y + (Math.ceil((key % pins || pins) / cols) * piece - piece / 2)); - return { - x: xPos(key, initPos.x, piece), - y: yPos(key, initPos.y, piece), - }; - }, - - getPatternActions(keys, initPos, piece) { - const actions: TouchAction[] = []; - keys = keys.map((key: string | number) => (_.isString(key) ? _.parseInt(key) : key)); - let lastPos: Position; - for (const key of keys) { - const keyPos = UnlockHelpers.getPatternKeyPosition(key, initPos, piece); - if (key === keys[0]) { - actions.push({action: 'press', options: {element: undefined, x: keyPos.x, y: keyPos.y}}); - lastPos = keyPos; - continue; - } - const moveTo = {x: 0, y: 0}; - const diffX = keyPos.x - lastPos!.x; - if (diffX > 0) { - moveTo.x = piece; - if (Math.abs(diffX) > piece) { - moveTo.x += piece; - } - } else if (diffX < 0) { - moveTo.x = -1 * piece; - if (Math.abs(diffX) > piece) { - moveTo.x -= piece; - } - } - const diffY = keyPos.y - lastPos!.y; - if (diffY > 0) { - moveTo.y = piece; - if (Math.abs(diffY) > piece) { - moveTo.y += piece; - } - } else if (diffY < 0) { - moveTo.y = -1 * piece; - if (Math.abs(diffY) > piece) { - moveTo.y -= piece; - } - } - actions.push({ - action: 'moveTo', - options: {element: undefined, x: moveTo.x + lastPos!.x, y: moveTo.y + lastPos!.y}, - }); - lastPos = keyPos; - } - actions.push({action: 'release'}); - return actions; - }, - - async patternUnlock(adb, driver, capabilities) { - const {unlockKey} = capabilities; - logger.info(`Trying to unlock device using pattern ${unlockKey}`); - await adb.dismissKeyguard(); - const keys = this.stringKeyToArr(unlockKey); - /* We set the device pattern buttons as number of a regular phone - * | • • • | | 1 2 3 | - * | • • • | --> | 4 5 6 | - * | • • • | | 7 8 9 | - - The pattern view buttons are not seeing by the uiautomator since they are - included inside a FrameLayout, so we are going to try clicking on the buttons - using the parent view bounds and math. - */ - const apiLevel = await adb.getApiLevel(); - const el = await driver.findElOrEls( - 'id', - `com.android.${apiLevel >= 21 ? 'systemui' : 'keyguard'}:id/lockPatternView`, - false - ); - const initPos = await driver.getLocation(util.unwrapElement(el)); - const size = await driver.getSize(util.unwrapElement(el)); - // Get actions to perform - const actions = UnlockHelpers.getPatternActions(keys, initPos, size.width / 3); - // Perform gesture - await driver.performTouch(actions); - // Waits a bit for the device to be unlocked - await sleep(UNLOCK_WAIT_TIME); - }, -}; - -export {FINGERPRINT_UNLOCK, PASSWORD_UNLOCK, PATTERN_UNLOCK, PIN_UNLOCK, PIN_UNLOCK_KEY_EVENT}; -export default UnlockHelpers; diff --git a/lib/helpers/webview.ts b/lib/helpers/webview.ts deleted file mode 100644 index 33c942f8..00000000 --- a/lib/helpers/webview.ts +++ /dev/null @@ -1,610 +0,0 @@ -/** - * Webview-related helper functions - * @module - */ - -import {util, timing} from '@appium/support'; -import {StringRecord} from '@appium/types'; -import axios from 'axios'; -import B from 'bluebird'; -import _ from 'lodash'; -import {LRUCache} from 'lru-cache'; -import os from 'node:os'; -import path from 'node:path'; -import http from 'node:http'; -import {findAPortNotInUse} from 'portscanner'; -import type {WebviewsMapping} from '../commands/types'; -import type {AndroidDriverCaps} from '../driver'; -import logger from '../logger'; -import type { - DetailCollectionOptions, - GetWebviewsOpts, - WebViewDetails, - WebviewProc, - WebviewProps, -} from './types'; -import type {ADB} from 'appium-adb'; -import {sleep} from 'asyncbox'; - -const NATIVE_WIN = 'NATIVE_APP'; -const WEBVIEW_WIN = 'WEBVIEW'; -const CHROMIUM_WIN = 'CHROMIUM'; -const WEBVIEW_BASE = `${WEBVIEW_WIN}_`; -const WEBVIEW_PID_PATTERN = new RegExp(`^${WEBVIEW_BASE}(\\d+)`); -const WEBVIEW_PKG_PATTERN = new RegExp(`^${WEBVIEW_BASE}([^\\d\\s][\\w.]*)`); -export const DEVTOOLS_SOCKET_PATTERN = /@[\w.]+_devtools_remote_?([\w.]+_)?(\d+)?\b/; -const CROSSWALK_SOCKET_PATTERN = /@([\w.]+)_devtools_remote\b/; -const CHROMIUM_DEVTOOLS_SOCKET = 'chrome_devtools_remote'; -const CHROME_PACKAGE_NAME = 'com.android.chrome'; -const KNOWN_CHROME_PACKAGE_NAMES = [ - CHROME_PACKAGE_NAME, - 'com.chrome.beta', - 'com.chrome.dev', - 'com.chrome.canary', -]; -const DEVTOOLS_PORTS_RANGE = [10900, 11000]; -const WEBVIEWS_DETAILS_CACHE = new LRUCache({ - max: 100, - updateAgeOnGet: true, -}); -const CDP_REQ_TIMEOUT = 2000; // ms -const DEVTOOLS_PORT_ALLOCATION_GUARD = util.getLockFileGuard( - path.resolve(os.tmpdir(), 'android_devtools_port_guard'), - {timeout: 7, tryRecovery: true}, -); -const WEBVIEW_WAIT_INTERVAL_MS = 200; - -interface WebviewHelpers { - /** - * Take a webview name like WEBVIEW_4296 and use 'adb shell ps' to figure out - * which app package is associated with that webview. One of the reasons we - * want to do this is to make sure we're listing webviews for the actual AUT, - * not some other running app - * - * @param adb - an ADB instance - * @param webview - a webview process name - * - * @returns {Promise} - the package name of the app running the webview - * @throws {Error} If there was a failure while retrieving the process name - */ - procFromWebview: (adb: ADB, webview: string) => Promise; - - /** - * Parse webview names for getContexts - * - * @param webviewsMapping See note on getWebViewsMapping - * @param opts See note on getWebViewsMapping - * @returns a list of webview names - */ - parseWebviewNames: (webviewsMapping: WebviewsMapping[], opts?: GetWebviewsOpts) => string[]; - - /** - * Get a list of available webviews mapping by introspecting processes with adb, - * where webviews are listed. It's possible to pass in a 'deviceSocket' arg, which - * limits the webview possibilities to the one running on the Chromium devtools - * socket we're interested in (see note on webviewsFromProcs). We can also - * direct this method to verify whether a particular webview process actually - * has any pages (if a process exists but no pages are found, Chromedriver will - * not actually be able to connect to it, so this serves as a guard for that - * strange failure mode). The strategy for checking whether any pages are - * active involves sending a request to the remote debug server on the device, - * hence it is also possible to specify the port on the host machine which - * should be used for this communication. - * - * @param adb - an ADB instance - */ - getWebViewsMapping: (adb: ADB, opts?: GetWebviewsOpts) => Promise; - - /** - * Retrieves web view details previously cached by `getWebviews` call - * - * @param adb ADB instance - * @param webview The name of the web view - * @returns Either `undefined` or the recent web view details - */ - getWebviewDetails: (adb: ADB, webview: string) => WebViewDetails | undefined; - - /** - * Create Chrome driver capabilities based on the provided - * Appium capabilities - * - * @param opts User-provided capabilities object - * @param deviceId The identifier of the Android device under test - * @returns The capabilities object. - * @see {@link https://chromedriver.chromium.org/capabilities Chromedriver docs} for more details - */ - - createChromedriverCaps: ( - opts: any, - deviceId: string, - webViewDetails?: WebViewDetails | null, - ) => object; -} - -function toDetailsCacheKey(adb: ADB, webview: string): string { - return `${adb?.curDeviceId}:${webview}`; -} - -/** - * This function gets a list of android system processes and returns ones - * that look like webviews - * See https://cs.chromium.org/chromium/src/chrome/browser/devtools/device/android_device_info_query.cc - * for more details - * - * @param adb - an ADB instance - * - * @returns a list of matching webview socket names (including the leading '@') - */ -async function getPotentialWebviewProcs(adb: ADB): Promise { - const out = (await adb.shell(['cat', '/proc/net/unix'])) as string; - const names: string[] = []; - const allMatches: string[] = []; - for (const line of out.split('\n')) { - // Num RefCount Protocol Flags Type St Inode Path - const [, , , flags, , st, , sockPath] = line.trim().split(/\s+/); - if (!sockPath) { - continue; - } - if (sockPath.startsWith('@')) { - allMatches.push(line.trim()); - } - if (flags !== '00010000' || st !== '01') { - continue; - } - if (!DEVTOOLS_SOCKET_PATTERN.test(sockPath)) { - continue; - } - - names.push(sockPath); - } - if (_.isEmpty(names)) { - logger.debug('Found no active devtools sockets'); - if (!_.isEmpty(allMatches)) { - logger.debug(`Other sockets are: ${JSON.stringify(allMatches, null, 2)}`); - } - } else { - logger.debug( - `Parsed ${names.length} active devtools ${util.pluralize('socket', names.length, false)}: ` + - JSON.stringify(names), - ); - } - // sometimes the webview process shows up multiple times per app - return _.uniq(names); -} - -/** - * This function retrieves a list of system processes that look like webviews, - * and returns them along with the webview context name appropriate for it. - * If we pass in a deviceSocket, we only attempt to find webviews which match - * that socket name (this is for apps which embed Chromium, which isn't the - * same as chrome-backed webviews). - * - * @param adb - an ADB instance - * @param deviceSocket - the explictly-named device socket to use - */ -async function webviewsFromProcs( - adb: any, - deviceSocket: string | null = null, -): Promise { - const socketNames = await getPotentialWebviewProcs(adb); - const webviews: {proc: string; webview: string}[] = []; - for (const socketName of socketNames) { - if (deviceSocket === CHROMIUM_DEVTOOLS_SOCKET && socketName === `@${deviceSocket}`) { - webviews.push({ - proc: socketName, - webview: CHROMIUM_WIN, - }); - continue; - } - - const socketNameMatch = DEVTOOLS_SOCKET_PATTERN.exec(socketName); - if (!socketNameMatch) { - continue; - } - const matchedSocketName = socketNameMatch[2]; - const crosswalkMatch = CROSSWALK_SOCKET_PATTERN.exec(socketName); - if (!matchedSocketName && !crosswalkMatch) { - continue; - } - - if ((deviceSocket && socketName === `@${deviceSocket}`) || !deviceSocket) { - webviews.push({ - proc: socketName, - webview: matchedSocketName - ? `${WEBVIEW_BASE}${matchedSocketName}` - : // @ts-expect-error: XXX crosswalkMatch can absolutely be null - `${WEBVIEW_BASE}${crosswalkMatch[1]}`, - }); - } - } - return webviews; -} - -/** - * Allocates a local port for devtools communication - * - * @param adb - ADB instance - * @param socketName - The remote Unix socket name - * @param webviewDevtoolsPort - The local port number or null to apply - * autodetection - * @returns The host name and the port number to connect to if the - * remote socket has been forwarded successfully - * @throws {Error} If there was an error while allocating the local port - */ -async function allocateDevtoolsChannel( - adb: any, - socketName: string, - webviewDevtoolsPort: number | null = null, -): Promise<[string, number]> { - // socket names come with '@', but this should not be a part of the abstract - // remote port, so remove it - const remotePort = socketName.replace(/^@/, ''); - let [startPort, endPort] = DEVTOOLS_PORTS_RANGE; - if (webviewDevtoolsPort) { - endPort = webviewDevtoolsPort + (endPort - startPort); - startPort = webviewDevtoolsPort; - } - logger.debug( - `Forwarding remote port ${remotePort} to a local ` + `port in range ${startPort}..${endPort}`, - ); - if (!webviewDevtoolsPort) { - logger.debug( - `You could use the 'webviewDevtoolsPort' capability to customize ` + - `the starting port number`, - ); - } - const port = (await DEVTOOLS_PORT_ALLOCATION_GUARD(async () => { - let localPort: number; - try { - localPort = await findAPortNotInUse(startPort, endPort); - } catch (e) { - throw new Error( - `Cannot find any free port to forward the Devtools socket ` + - `in range ${startPort}..${endPort}. You could set the starting port number ` + - `manually by providing the 'webviewDevtoolsPort' capability`, - ); - } - await adb.adbExec(['forward', `tcp:${localPort}`, `localabstract:${remotePort}`]); - return localPort; - })) as number; - return [adb.adbHost ?? '127.0.0.1', port]; -} - -/** - * This is a wrapper for Chrome Debugger Protocol data collection. - * No error is thrown if CDP request fails - in such case no data will be - * recorded into the corresponding `webviewsMapping` item. - * - * @param adb The ADB instance - * @param webviewsMapping The current webviews mapping - * !!! Each item of this array gets mutated (`info`/`pages` properties get added - * based on the provided `opts`) if the requested details have been - * successfully retrieved for it !!! - * @param opts If both `ensureWebviewsHavePages` and - * `enableWebviewDetailsCollection` properties are falsy then no details collection - * is performed - */ -async function collectWebviewsDetails( - adb: ADB, - webviewsMapping: WebviewProps[], - opts: DetailCollectionOptions = {}, -): Promise { - if (_.isEmpty(webviewsMapping)) { - return; - } - - const { - webviewDevtoolsPort = null, - ensureWebviewsHavePages = null, - enableWebviewDetailsCollection = null, - } = opts; - - if (!ensureWebviewsHavePages) { - logger.info( - `Not checking whether webviews have active pages; use the ` + - `'ensureWebviewsHavePages' cap to turn this check on`, - ); - } - - if (!enableWebviewDetailsCollection) { - logger.info( - `Not collecting web view details. Details collection might help ` + - `to make Chromedriver initialization more precise. Use the 'enableWebviewDetailsCollection' ` + - `cap to turn it on`, - ); - } - - if (!ensureWebviewsHavePages && !enableWebviewDetailsCollection) { - return; - } - - // Connect to each devtools socket and retrieve web view details - logger.debug(`Collecting CDP data of ${util.pluralize('webview', webviewsMapping.length, true)}`); - const detailCollectors: Promise[] = []; - for (const item of webviewsMapping) { - detailCollectors.push( - (async () => { - let port: number | undefined; - let host: string | undefined; - try { - [host, port] = await allocateDevtoolsChannel(adb, item.proc, webviewDevtoolsPort); - if (enableWebviewDetailsCollection) { - item.info = await cdpInfo(host, port); - } - if (ensureWebviewsHavePages) { - item.pages = await cdpList(host, port); - } - } catch (e) { - logger.debug(e); - } finally { - if (port) { - try { - await adb.removePortForward(port); - } catch (e) { - logger.debug(e); - } - } - } - })(), - ); - } - await B.all(detailCollectors); - logger.debug(`CDP data collection completed`); -} - -// https://chromedevtools.github.io/devtools-protocol/ -async function cdpGetRequest(host: string, port: number, endpoint: string): Promise { - return ( - await axios({ - url: `http://${host}:${port}${endpoint}`, - timeout: CDP_REQ_TIMEOUT, - // We need to set this from Node.js v19 onwards. - // Otherwise, in situation with multiple webviews, - // the preceding webview pages will be incorrectly retrieved as the current ones. - // https://nodejs.org/en/blog/announcements/v19-release-announce#https11-keepalive-by-default - httpAgent: new http.Agent({keepAlive: false}), - }) - ).data; -} - -async function cdpList(host: string, port: number): Promise { - return cdpGetRequest(host, port, '/json/list'); -} - -async function cdpInfo(host: string, port: number): Promise { - return cdpGetRequest(host, port, '/json/version'); -} - -const WebviewHelpers: WebviewHelpers = { - async procFromWebview(adb: ADB, webview: string): Promise { - const pidMatch = WEBVIEW_PID_PATTERN.exec(webview); - if (!pidMatch) { - throw new Error(`Could not find PID for webview '${webview}'`); - } - - const pid = pidMatch[1]; - logger.debug(`${webview} mapped to pid ${pid}`); - logger.debug(`Getting process name for webview '${webview}'`); - const pkg = await adb.getNameByPid(pid); - logger.debug(`Got process name: '${pkg}'`); - return pkg; - }, - - parseWebviewNames( - webviewsMapping: WebviewsMapping[], - {ensureWebviewsHavePages = true, isChromeSession = false}: GetWebviewsOpts = {}, - ): string[] { - if (isChromeSession) { - return [CHROMIUM_WIN]; - } - - const result: string[] = []; - for (const {webview, pages, proc, webviewName} of webviewsMapping) { - if (ensureWebviewsHavePages) { - if (_.isUndefined(pages)) { - logger.info(`Skipping the webview '${webview}' at '${proc}' since it is unreachable`); - continue; - } - if (!pages?.length) { - logger.info( - `Skipping the webview '${webview}' at '${proc}' ` + - `since it has reported having zero pages`, - ); - continue; - } - } - if (webviewName) { - result.push(webviewName); - } - } - logger.debug( - `Found ${util.pluralize('webview', result.length, true)}: ${JSON.stringify(result)}`, - ); - return result; - }, - - async getWebViewsMapping( - adb: ADB, - { - androidDeviceSocket = null, - ensureWebviewsHavePages = true, - webviewDevtoolsPort = null, - enableWebviewDetailsCollection = true, - waitForWebviewMs = 0, - }: GetWebviewsOpts = {}, - ): Promise { - logger.debug(`Getting a list of available webviews`); - - if (!_.isNumber(waitForWebviewMs)) { - waitForWebviewMs = parseInt(waitForWebviewMs, 10) || 0; - } - - let webviewsMapping: WebviewsMapping[]; - const timer = new timing.Timer().start(); - do { - webviewsMapping = (await webviewsFromProcs(adb, androidDeviceSocket)) as WebviewsMapping[]; - - if (webviewsMapping.length > 0) { - break; - } - - logger.debug(`No webviews found in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); - await sleep(WEBVIEW_WAIT_INTERVAL_MS); - } while (timer.getDuration().asMilliSeconds < waitForWebviewMs); - - await collectWebviewsDetails(adb, webviewsMapping, { - ensureWebviewsHavePages, - enableWebviewDetailsCollection, - webviewDevtoolsPort, - }); - - for (const webviewMapping of webviewsMapping) { - const {webview, info} = webviewMapping; - webviewMapping.webviewName = null; - - let wvName = webview; - let process: {name: string; id: string | null} | undefined; - if (!androidDeviceSocket) { - const pkgMatch = WEBVIEW_PKG_PATTERN.exec(webview); - try { - // web view name could either be suffixed with PID or the package name - // package names could not start with a digit - const pkg = pkgMatch ? pkgMatch[1] : await WebviewHelpers.procFromWebview(adb, webview); - wvName = `${WEBVIEW_BASE}${pkg}`; - const pidMatch = WEBVIEW_PID_PATTERN.exec(webview); - process = { - name: pkg, - id: pidMatch ? pidMatch[1] : null, - }; - } catch (e) { - logger.warn((e as Error).message); - continue; - } - } - - webviewMapping.webviewName = wvName; - const key = toDetailsCacheKey(adb, wvName); - if (info || process) { - WEBVIEWS_DETAILS_CACHE.set(key, {info, process}); - } else if (WEBVIEWS_DETAILS_CACHE.has(key)) { - WEBVIEWS_DETAILS_CACHE.delete(key); - } - } - return webviewsMapping; - }, - - getWebviewDetails(adb: ADB, webview: string): WebViewDetails | undefined { - const key = toDetailsCacheKey(adb, webview); - return WEBVIEWS_DETAILS_CACHE.get(key); - }, - - createChromedriverCaps( - opts: any, - deviceId: string, - webViewDetails?: WebViewDetails | null, - ): object { - const caps: AndroidDriverCaps & {chromeOptions: StringRecord} = {chromeOptions: {}} as any; - - const androidPackage = - opts.chromeOptions?.androidPackage || - opts.appPackage || - webViewDetails?.info?.['Android-Package']; - if (androidPackage) { - // chromedriver raises an invalid argument error when androidPackage is 'null' - - caps.chromeOptions.androidPackage = androidPackage; - } - if (_.isBoolean(opts.chromeUseRunningApp)) { - caps.chromeOptions.androidUseRunningApp = opts.chromeUseRunningApp; - } - if (opts.chromeAndroidPackage) { - caps.chromeOptions.androidPackage = opts.chromeAndroidPackage; - } - if (opts.chromeAndroidActivity) { - caps.chromeOptions.androidActivity = opts.chromeAndroidActivity; - } - if (opts.chromeAndroidProcess) { - caps.chromeOptions.androidProcess = opts.chromeAndroidProcess; - } else if (webViewDetails?.process?.name && webViewDetails?.process?.id) { - caps.chromeOptions.androidProcess = webViewDetails.process.name; - } - if (_.toLower(opts.browserName) === 'chromium-webview') { - caps.chromeOptions.androidActivity = opts.appActivity; - } - if (opts.pageLoadStrategy) { - caps.pageLoadStrategy = opts.pageLoadStrategy; - } - const isChrome = _.toLower(caps.chromeOptions.androidPackage) === 'chrome'; - if (_.includes(KNOWN_CHROME_PACKAGE_NAMES, caps.chromeOptions.androidPackage) || isChrome) { - // if we have extracted package from context name, it could come in as bare - // "chrome", and so we should make sure the details are correct, including - // not using an activity or process id - if (isChrome) { - caps.chromeOptions.androidPackage = CHROME_PACKAGE_NAME; - } - delete caps.chromeOptions.androidActivity; - delete caps.chromeOptions.androidProcess; - } - // add device id from adb - caps.chromeOptions.androidDeviceSerial = deviceId; - - if (_.isPlainObject(opts.loggingPrefs) || _.isPlainObject(opts.chromeLoggingPrefs)) { - if (opts.loggingPrefs) { - logger.warn( - `The 'loggingPrefs' cap is deprecated; use the 'chromeLoggingPrefs' cap instead`, - ); - } - // @ts-expect-error Why are we using this if it's deprecated? - caps.loggingPrefs = opts.chromeLoggingPrefs || opts.loggingPrefs; - } - if (opts.enablePerformanceLogging) { - logger.warn( - `The 'enablePerformanceLogging' cap is deprecated; simply use ` + - `the 'chromeLoggingPrefs' cap instead, with a 'performance' key set to 'ALL'`, - ); - const newPref = {performance: 'ALL'}; - // don't overwrite other logging prefs that have been sent in if they exist - // @ts-expect-error Why are we using this if it's deprecated? - caps.loggingPrefs = caps.loggingPrefs - ? // @ts-expect-error Why are we using this if it's deprecated? - Object.assign({}, caps.loggingPrefs, newPref) - : newPref; - } - - if (opts.chromeOptions?.Arguments) { - // merge `Arguments` and `args` - opts.chromeOptions.args = [ - ...(opts.chromeOptions.args || []), - ...opts.chromeOptions.Arguments, - ]; - delete opts.chromeOptions.Arguments; - } - - logger.debug( - 'Precalculated Chromedriver capabilities: ' + JSON.stringify(caps.chromeOptions, null, 2), - ); - - const protectedCapNames: string[] = []; - for (const [opt, val] of _.toPairs(opts.chromeOptions)) { - if (_.isUndefined(caps.chromeOptions[opt])) { - caps.chromeOptions[opt] = val; - } else { - protectedCapNames.push(opt); - } - } - if (!_.isEmpty(protectedCapNames)) { - logger.info( - 'The following Chromedriver capabilities cannot be overridden ' + - 'by the provided chromeOptions:', - ); - for (const optName of protectedCapNames) { - logger.info(` ${optName} (${JSON.stringify(opts.chromeOptions[optName])})`); - } - } - - return caps; - }, -}; - -export {CHROMIUM_WIN, KNOWN_CHROME_PACKAGE_NAMES, NATIVE_WIN, WEBVIEW_BASE, WEBVIEW_WIN}; -export default WebviewHelpers; diff --git a/lib/index.ts b/lib/index.ts index 3e38ac54..238b8649 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,21 +2,9 @@ import {install} from 'source-map-support'; install(); import {AndroidDriver} from './driver'; -export type * from './commands'; +export type * from './commands/types'; export {ANDROID_DRIVER_CONSTRAINTS as commonCapConstraints} from './constraints'; export * from './driver'; export * as doctor from './doctor/checks'; -export { - SETTINGS_HELPER_ID as SETTINGS_HELPER_PKG_ID, - default as androidHelpers -} from './helpers/android'; -export type * from './helpers/types'; -export { - CHROMIUM_WIN, - NATIVE_WIN, - WEBVIEW_BASE, - WEBVIEW_WIN, - default as webviewHelpers, -} from './helpers/webview'; export default AndroidDriver; diff --git a/lib/logger.js b/lib/logger.js index c3061e45..c41b59ef 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -1,4 +1,4 @@ -import { logger } from '@appium/support'; +import {logger} from '@appium/support'; const log = logger.getLogger('AndroidDriver'); export default log; diff --git a/lib/method-map.js b/lib/method-map.js index 1bc837c7..0c507f74 100644 --- a/lib/method-map.js +++ b/lib/method-map.js @@ -37,14 +37,6 @@ export const newMethodMap = /** @type {const} */ ({ payloadParams: {required: ['elements']}, }, }, - '/session/:sessionId/touch/flick': { - POST: { - command: 'flick', - payloadParams: { - optional: ['element', 'xspeed', 'yspeed', 'xoffset', 'yoffset', 'speed'], - }, - }, - }, '/session/:sessionId/touch/perform': { POST: { command: 'performTouch', @@ -178,9 +170,6 @@ export const newMethodMap = /** @type {const} */ ({ }, '/session/:sessionId/appium/device/system_bars': {GET: {command: 'getSystemBars'}}, '/session/:sessionId/appium/device/display_density': {GET: {command: 'getDisplayDensity'}}, - '/session/:sessionId/appium/app/launch': {POST: {command: 'launchApp'}}, - '/session/:sessionId/appium/app/close': {POST: {command: 'closeApp'}}, - '/session/:sessionId/appium/app/reset': {POST: {command: 'reset'}}, '/session/:sessionId/appium/app/background': { POST: { command: 'background', diff --git a/lib/utils.js b/lib/utils.js index 420570c3..69e3926f 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { errors } from 'appium/driver'; +import {errors} from 'appium/driver'; export const ADB_SHELL_FEATURE = 'adb_shell'; @@ -10,11 +10,48 @@ export const ADB_SHELL_FEATURE = 'adb_shell'; * @param {any} opts the object to check * @returns {Record} the same given object */ -export function requireArgs (argNames, opts) { - for (const argName of (_.isArray(argNames) ? argNames : [argNames])) { +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`); } } return opts; } + +/** + * + * @param {string | string[]} cap + * @returns {string[]} + */ +export function parseArray(cap) { + let parsedCaps; + try { + // @ts-ignore this is fine + parsedCaps = JSON.parse(cap); + } catch (ign) {} + + if (_.isArray(parsedCaps)) { + return parsedCaps; + } else if (_.isString(cap)) { + return [cap]; + } + + throw new Error(`must provide a string or JSON Array; received ${cap}`); +} + +/** + * @param {import('@appium/types').AppiumServer} server + * @param {string?} [sessionId] + * @returns {Promise} + */ +export async function removeAllSessionWebSocketHandlers(server, sessionId) { + if (!server || !_.isFunction(server.getWebSocketHandlers)) { + return; + } + + const activeHandlers = await server.getWebSocketHandlers(sessionId); + for (const pathname of _.keys(activeHandlers)) { + await server.removeWebSocketHandler(pathname); + } +} diff --git a/test/assets/chromedriver-2.20/linux-32/chromedriver b/test/assets/chromedriver-2.20/linux-32/chromedriver deleted file mode 100755 index 2b507e39..00000000 Binary files a/test/assets/chromedriver-2.20/linux-32/chromedriver and /dev/null differ diff --git a/test/assets/chromedriver-2.20/linux-64/chromedriver b/test/assets/chromedriver-2.20/linux-64/chromedriver deleted file mode 100755 index 157ee20a..00000000 Binary files a/test/assets/chromedriver-2.20/linux-64/chromedriver and /dev/null differ diff --git a/test/assets/chromedriver-2.20/mac/chromedriver b/test/assets/chromedriver-2.20/mac/chromedriver deleted file mode 100755 index 8c6aed5f..00000000 Binary files a/test/assets/chromedriver-2.20/mac/chromedriver and /dev/null differ diff --git a/test/assets/chromedriver-2.20/windows/chromedriver.exe b/test/assets/chromedriver-2.20/windows/chromedriver.exe deleted file mode 100755 index 9463a1e4..00000000 Binary files a/test/assets/chromedriver-2.20/windows/chromedriver.exe and /dev/null differ diff --git a/test/functional/capabilities.js b/test/functional/capabilities.js deleted file mode 100644 index 73578bcd..00000000 --- a/test/functional/capabilities.js +++ /dev/null @@ -1,31 +0,0 @@ -import path from 'path'; -import _ from 'lodash'; -import { node } from '@appium/support'; -import { API_DEMOS_APK_PATH } from 'android-apidemos'; - - -function amendCapabilities (baseCaps, ...newCaps) { - return node.deepFreeze({ - alwaysMatch: _.cloneDeep(Object.assign({}, baseCaps.alwaysMatch, ...newCaps)), - firstMatch: [{}], - }); -} - -const DEFAULT_CAPS = node.deepFreeze({ - alwaysMatch: { - 'appium:app': API_DEMOS_APK_PATH, - 'appium:deviceName': 'Android', - platformName: 'Android', - }, - firstMatch: [{}], -}); - -const CONTACT_MANAGER_CAPS = amendCapabilities(DEFAULT_CAPS, { - 'appium:app': path.resolve(__dirname, '..', 'assets', 'ContactManager.apk'), -}); - -const CHROME_CAPS = amendCapabilities(_.omit(DEFAULT_CAPS, 'alwaysMatch.appium:app'), { - browserName: 'chrome', -}); - -export { API_DEMOS_APK_PATH as app, DEFAULT_CAPS, CONTACT_MANAGER_CAPS, CHROME_CAPS, amendCapabilities }; diff --git a/test/functional/helpers.js b/test/functional/helpers.js deleted file mode 100644 index 602f3ace..00000000 --- a/test/functional/helpers.js +++ /dev/null @@ -1,41 +0,0 @@ -import ADB from 'appium-adb'; -import path from 'path'; -import { system } from '@appium/support'; - - -const MOCHA_TIMEOUT = process.env.MOCHA_TIMEOUT || 60000; - -const CHROMEDRIVER_2_20_ASSET_MAP = { - windows: ['windows', 'chromedriver.exe'], - mac: ['mac', 'chromedriver'], - linux32: ['linux-32', 'chromedriver'], - linux64: ['linux-64', 'chromedriver'], -}; - -async function getChromedriver220Asset () { - let basePath = path.resolve(__dirname, '..', 'assets', 'chromedriver-2.20'); - let dir; - let cmd; - if (system.isWindows()) { - [dir, cmd] = CHROMEDRIVER_2_20_ASSET_MAP.windows; - } else if (system.isMac()) { - [dir, cmd] = CHROMEDRIVER_2_20_ASSET_MAP.mac; - } else { - [dir, cmd] = CHROMEDRIVER_2_20_ASSET_MAP[`linux${await system.arch()}`]; - } - return path.resolve(basePath, dir, cmd); -} - -async function ensureAVDExists (mochaContext, avdName) { - let adb = await ADB.createADB(); - try { - await adb.checkAvdExist(avdName); - } catch (err) { - mochaContext.skip(); - return false; - } - return true; -} - - -export { MOCHA_TIMEOUT, ensureAVDExists, getChromedriver220Asset }; diff --git a/test/unit/android-helper-specs.js b/test/unit/android-helper-specs.js deleted file mode 100644 index bfa9fdda..00000000 --- a/test/unit/android-helper-specs.js +++ /dev/null @@ -1,940 +0,0 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; -import helpers, {prepareAvdArgs, ensureNetworkSpeed} from '../../lib/helpers/android'; -import ADB from 'appium-adb'; -import {withMocks} from '@appium/test-support'; -import {fs} from '@appium/support'; -import unlocker from '../../lib/helpers/unlock'; -import _ from 'lodash'; -import B from 'bluebird'; - -const should = chai.should(); -const REMOTE_TEMP_PATH = '/data/local/tmp'; -chai.use(chaiAsPromised); - -describe('Android Helpers', function () { - let adb = new ADB(); - - describe('isEmulator', function () { - it('should be true if driver opts contain avd', function () { - helpers.isEmulator(null, {avd: 'yolo'}).should.be.true; - }); - it('should be true if driver opts contain emulator udid', function () { - helpers.isEmulator({}, {udid: 'Emulator-5554'}).should.be.true; - }); - it('should be false if driver opts do not contain emulator udid', function () { - helpers.isEmulator({}, {udid: 'ABCD1234'}).should.be.false; - }); - it('should be true if device id in adb contains emulator', function () { - helpers.isEmulator({curDeviceId: 'emulator-5554'}, {}).should.be.true; - }); - it('should be false if device id in adb does not contain emulator', function () { - helpers.isEmulator({curDeviceId: 'ABCD1234'}, {}).should.be.false; - }); - }); - describe( - 'prepareEmulator', - withMocks({adb, helpers}, (mocks) => { - const opts = {avd: 'foo@bar', avdArgs: '', language: 'en', locale: 'us'}; - it('should not launch avd if one is already running', async function () { - mocks.adb.expects('getRunningAVDWithRetry').withArgs('foobar').returns('foo'); - mocks.adb.expects('launchAVD').never(); - mocks.adb.expects('killEmulator').never(); - await helpers.prepareEmulator(adb, opts); - mocks.adb.verify(); - }); - it('should launch avd if one is not running', async function () { - mocks.adb.expects('getRunningAVDWithRetry').withArgs('foobar').throws(); - mocks.adb - .expects('launchAVD') - .withExactArgs('foo@bar', { - args: [], - env: undefined, - language: 'en', - country: 'us', - launchTimeout: undefined, - readyTimeout: undefined, - }) - .returns(''); - await helpers.prepareEmulator(adb, opts); - mocks.adb.verify(); - }); - it('should parse avd string command line args', async function () { - const opts = { - avd: 'foobar', - avdArgs: '--arg1 "value 1" --arg2 "value 2"', - avdEnv: { - k1: 'v1', - k2: 'v2', - }, - }; - mocks.adb.expects('getRunningAVDWithRetry').withArgs('foobar').throws(); - mocks.adb - .expects('launchAVD') - .withExactArgs('foobar', { - args: ['--arg1', 'value 1', '--arg2', 'value 2'], - env: { - k1: 'v1', - k2: 'v2', - }, - language: undefined, - country: undefined, - launchTimeout: undefined, - readyTimeout: undefined, - }) - .returns(''); - await helpers.prepareEmulator(adb, opts); - mocks.adb.verify(); - }); - it('should parse avd array command line args', async function () { - const opts = { - avd: 'foobar', - avdArgs: ['--arg1', 'value 1', '--arg2', 'value 2'], - }; - mocks.adb.expects('getRunningAVDWithRetry').withArgs('foobar').throws(); - mocks.adb - .expects('launchAVD') - .withExactArgs('foobar', { - args: ['--arg1', 'value 1', '--arg2', 'value 2'], - env: undefined, - language: undefined, - country: undefined, - launchTimeout: undefined, - readyTimeout: undefined, - }) - .returns(''); - await helpers.prepareEmulator(adb, opts); - mocks.adb.verify(); - }); - it('should kill emulator if avdArgs contains -wipe-data', async function () { - const opts = {avd: 'foo@bar', avdArgs: '-wipe-data'}; - mocks.adb.expects('getRunningAVDWithRetry').withArgs('foobar').returns('foo'); - mocks.adb.expects('killEmulator').withExactArgs('foobar').once(); - mocks.adb.expects('launchAVD').once(); - await helpers.prepareEmulator(adb, opts); - mocks.adb.verify(); - }); - it('should fail if avd name is not specified', async function () { - await helpers.prepareEmulator(adb, {}).should.eventually.be.rejected; - }); - }) - ); - describe( - 'prepareAvdArgs', - withMocks({adb, helpers}, (mocks) => { - it('should set the correct avdArgs', function () { - let avdArgs = '-wipe-data'; - prepareAvdArgs(adb, {avdArgs}).should.eql([avdArgs]); - }); - it('should add headless arg', function () { - let avdArgs = '-wipe-data'; - let args = prepareAvdArgs(adb, {isHeadless: true, avdArgs}); - args.should.eql(['-wipe-data', '-no-window']); - }); - it('should add network speed arg', function () { - let avdArgs = '-wipe-data'; - let args = prepareAvdArgs(adb, {networkSpeed: 'edge', avdArgs}); - args.should.eql(['-wipe-data', '-netspeed', 'edge']); - mocks.adb.verify(); - }); - it('should not include empty avdArgs', function () { - let avdArgs = ''; - let args = prepareAvdArgs(adb, {isHeadless: true, avdArgs}); - args.should.eql(['-no-window']); - }); - }) - ); - describe('ensureNetworkSpeed', function () { - it('should return value if network speed is valid', function () { - adb.NETWORK_SPEED = {GSM: 'gsm'}; - ensureNetworkSpeed(adb, 'gsm').should.be.equal('gsm'); - }); - it('should return ADB.NETWORK_SPEED.FULL if network speed is invalid', function () { - adb.NETWORK_SPEED = {FULL: 'full'}; - ensureNetworkSpeed(adb, 'invalid').should.be.equal('full'); - }); - }); - - describe('getDeviceInfoFromCaps', function () { - // list of device/emu udids to their os versions - // using list instead of map to preserve order - let devices = [ - {udid: 'emulator-1234', os: '4.9.2'}, - {udid: 'rotalume-1339', os: '5.1.5'}, - {udid: 'rotalume-1338', os: '5.0.1'}, - {udid: 'rotalume-1337', os: '5.0.1'}, - {udid: 'roamulet-9000', os: '6.0'}, - {udid: 'roamulet-0', os: '2.3'}, - {udid: 'roamulet-2019', os: '9'}, - {udid: '0123456789', os: 'wellhellothere'}, - ]; - let curDeviceId = ''; - - before(function () { - sinon.stub(ADB, 'createADB').callsFake(function () { - return { - getDevicesWithRetry() { - return _.map(devices, function getDevice(device) { - return {udid: device.udid}; - }); - }, - - getPortFromEmulatorString() { - return 1234; - }, - - getRunningAVDWithRetry() { - return {udid: 'emulator-1234', port: 1234}; - }, - - setDeviceId(udid) { - curDeviceId = udid; - }, - - getPlatformVersion() { - return _.filter(devices, {udid: curDeviceId})[0].os; - }, - curDeviceId: 'emulator-1234', - emulatorPort: 1234, - }; - }); - }); - - after(function () { - ADB.createADB.restore(); - }); - - it('should throw error when udid not in list', async function () { - let caps = { - udid: 'foomulator', - }; - - await helpers.getDeviceInfoFromCaps(caps).should.be.rejectedWith('foomulator'); - }); - it('should get deviceId and emPort when udid is present', async function () { - let caps = { - udid: 'emulator-1234', - }; - - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('emulator-1234'); - emPort.should.equal(1234); - }); - it('should get first deviceId and emPort if avd, platformVersion, and udid are not given', async function () { - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(); - udid.should.equal('emulator-1234'); - emPort.should.equal(1234); - }); - it('should get deviceId and emPort when avd is present', async function () { - let caps = { - avd: 'AVD_NAME', - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('emulator-1234'); - emPort.should.equal(1234); - }); - it('should fail if the given platformVersion is not found', async function () { - let caps = { - platformVersion: '1234567890', - }; - await helpers - .getDeviceInfoFromCaps(caps) - .should.be.rejectedWith('Unable to find an active device or emulator with OS 1234567890'); - }); - it('should get deviceId and emPort if platformVersion is found and unique', async function () { - let caps = { - platformVersion: '6.0', - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('roamulet-9000'); - emPort.should.equal(1234); - }); - it('should get deviceId and emPort if platformVersion is shorter than os version', async function () { - let caps = { - platformVersion: 9, - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('roamulet-2019'); - emPort.should.equal(1234); - }); - it('should get the first deviceId and emPort if platformVersion is found multiple times', async function () { - let caps = { - platformVersion: '5.0.1', - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('rotalume-1338'); - emPort.should.equal(1234); - }); - it('should get the deviceId and emPort of most recent version if we have partial match', async function () { - let caps = { - platformVersion: '5.0', - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('rotalume-1338'); - emPort.should.equal(1234); - }); - it('should get deviceId and emPort by udid if udid and platformVersion are given', async function () { - let caps = { - udid: '0123456789', - platformVersion: '2.3', - }; - let {udid, emPort} = await helpers.getDeviceInfoFromCaps(caps); - udid.should.equal('0123456789'); - emPort.should.equal(1234); - }); - }); - describe('createADB', function () { - let curDeviceId = ''; - let emulatorPort = -1; - before(function () { - sinon.stub(ADB, 'createADB').callsFake(function () { - return { - setDeviceId: (udid) => { - curDeviceId = udid; - }, - - setEmulatorPort: (emPort) => { - emulatorPort = emPort; - }, - }; - }); - }); - after(function () { - ADB.createADB.restore(); - }); - it('should create adb and set device id and emulator port', async function () { - await helpers.createADB({ - udid: '111222', - emPort: '111', - adbPort: '222', - suppressKillServer: true, - remoteAdbHost: 'remote_host', - clearDeviceLogsOnStart: true, - adbExecTimeout: 50, - useKeystore: true, - keystorePath: '/some/path', - keystorePassword: '123456', - keyAlias: 'keyAlias', - keyPassword: 'keyPassword', - remoteAppsCacheLimit: 5, - buildToolsVersion: '1.2.3', - allowOfflineDevices: true, - }); - ADB.createADB.calledWithExactly({ - adbPort: '222', - suppressKillServer: true, - remoteAdbHost: 'remote_host', - clearDeviceLogsOnStart: true, - adbExecTimeout: 50, - useKeystore: true, - keystorePath: '/some/path', - keystorePassword: '123456', - keyAlias: 'keyAlias', - keyPassword: 'keyPassword', - remoteAppsCacheLimit: 5, - buildToolsVersion: '1.2.3', - allowOfflineDevices: true, - allowDelayAdb: undefined, - }).should.be.true; - curDeviceId.should.equal('111222'); - emulatorPort.should.equal('111'); - }); - it('should not set emulator port if emPort is undefined', async function () { - emulatorPort = 5555; - await helpers.createADB(); - emulatorPort.should.equal(5555); - }); - }); - describe( - 'getLaunchInfoFromManifest', - withMocks({adb}, (mocks) => { - it('should return when no app present', async function () { - mocks.adb.expects('packageAndLaunchActivityFromManifest').never(); - await helpers.getLaunchInfo(adb, {}); - mocks.adb.verify(); - }); - it('should return when appPackage & appActivity are already present', async function () { - mocks.adb.expects('packageAndLaunchActivityFromManifest').never(); - await helpers.getLaunchInfo(adb, { - app: 'foo', - appPackage: 'bar', - appActivity: 'act', - }); - mocks.adb.verify(); - }); - it('should return when all parameters are already present', async function () { - mocks.adb.expects('packageAndLaunchActivityFromManifest').never(); - await helpers.getLaunchInfo(adb, { - app: 'foo', - appPackage: 'bar', - appWaitPackage: '*', - appActivity: 'app.activity', - appWaitActivity: 'app.nameA,app.nameB', - }); - mocks.adb.verify(); - }); - it('should print warn when all parameters are already present but the format is odd', async function () { - // It only prints warn message - mocks.adb.expects('packageAndLaunchActivityFromManifest').never(); - await helpers.getLaunchInfo(adb, { - app: 'foo', - appPackage: 'bar ', - appWaitPackage: '*', - appActivity: 'a_act', - appWaitActivity: '. ', - }); - mocks.adb.verify(); - }); - it('should print warn when appPackage & appActivity are already present but the format is odd', async function () { - // It only prints warn message - mocks.adb.expects('packageAndLaunchActivityFromManifest').never(); - await helpers.getLaunchInfo(adb, {app: 'foo', appPackage: 'bar', appActivity: 'a_act '}); - mocks.adb.verify(); - }); - it('should return package and launch activity from manifest', async function () { - mocks.adb - .expects('packageAndLaunchActivityFromManifest') - .withExactArgs('foo') - .returns({apkPackage: 'pkg', apkActivity: 'ack'}); - const result = { - appPackage: 'pkg', - appWaitPackage: 'pkg', - appActivity: 'ack', - appWaitActivity: 'ack', - }; - (await helpers.getLaunchInfo(adb, {app: 'foo'})).should.deep.equal(result); - mocks.adb.verify(); - }); - it( - 'should not override appPackage, appWaitPackage, appActivity, appWaitActivity ' + - 'from manifest if they are allready defined in opts', - async function () { - let optsFromManifest = {apkPackage: 'mpkg', apkActivity: 'mack'}; - mocks.adb - .expects('packageAndLaunchActivityFromManifest') - .withExactArgs('foo') - .twice() - .returns(optsFromManifest); - - let inOpts = { - app: 'foo', - appActivity: 'ack', - appWaitPackage: 'wpkg', - appWaitActivity: 'wack', - }; - let outOpts = { - appPackage: 'mpkg', - appActivity: 'ack', - appWaitPackage: 'wpkg', - appWaitActivity: 'wack', - }; - (await helpers.getLaunchInfo(adb, inOpts)).should.deep.equal(outOpts); - - inOpts = {app: 'foo', appPackage: 'pkg', appWaitPackage: 'wpkg', appWaitActivity: 'wack'}; - outOpts = { - appPackage: 'pkg', - appActivity: 'mack', - appWaitPackage: 'wpkg', - appWaitActivity: 'wack', - }; - (await helpers.getLaunchInfo(adb, inOpts)).should.deep.equal(outOpts); - mocks.adb.verify(); - } - ); - }) - ); - describe( - 'resetApp', - withMocks({adb, helpers}, (mocks) => { - const localApkPath = 'local'; - const pkg = 'pkg'; - it('should complain if opts arent passed correctly', async function () { - await helpers.resetApp(adb, {}).should.be.rejectedWith(/appPackage/); - }); - it('should be able to do full reset', async function () { - mocks.adb.expects('install').once().withArgs(localApkPath); - mocks.adb.expects('forceStop').withExactArgs(pkg).once(); - mocks.adb.expects('isAppInstalled').once().withExactArgs(pkg).returns(true); - mocks.adb.expects('uninstallApk').once().withExactArgs(pkg); - await helpers.resetApp(adb, {app: localApkPath, appPackage: pkg}); - mocks.adb.verify(); - }); - it('should be able to do fast reset', async function () { - mocks.adb.expects('isAppInstalled').once().withExactArgs(pkg).returns(true); - mocks.adb.expects('forceStop').withExactArgs(pkg).once(); - mocks.adb.expects('clear').withExactArgs(pkg).once().returns('Success'); - mocks.adb.expects('grantAllPermissions').once().withExactArgs(pkg); - await helpers.resetApp(adb, { - app: localApkPath, - appPackage: pkg, - fastReset: true, - autoGrantPermissions: true, - }); - mocks.adb.verify(); - }); - it('should perform reinstall if app is not installed and fast reset is requested', async function () { - mocks.adb.expects('isAppInstalled').once().withExactArgs(pkg).returns(false); - mocks.adb.expects('forceStop').withExactArgs(pkg).never(); - mocks.adb.expects('clear').withExactArgs(pkg).never(); - mocks.adb.expects('uninstallApk').never(); - mocks.adb.expects('install').once().withArgs(localApkPath); - await helpers.resetApp(adb, {app: localApkPath, appPackage: pkg, fastReset: true}); - mocks.adb.verify(); - }); - }) - ); - - describe( - 'installApk', - withMocks({adb, fs, helpers}, function (mocks) { - //use mock appium capabilities for this test - const opts = { - app: 'local', - appPackage: 'pkg', - androidInstallTimeout: 90000, - }; - it('should complain if appPackage is not passed', async function () { - await helpers.installApk(adb, {}).should.be.rejectedWith(/appPackage/); - }); - it('should install/upgrade and reset app if fast reset is set to true', async function () { - mocks.adb - .expects('installOrUpgrade') - .once() - .withArgs(opts.app, opts.appPackage) - .returns({wasUninstalled: false, appState: 'sameVersionInstalled'}); - mocks.helpers.expects('resetApp').once().withArgs(adb); - await helpers.installApk(adb, Object.assign({}, opts, {fastReset: true})); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - it('should reinstall app if full reset is set to true', async function () { - mocks.adb.expects('installOrUpgrade').never(); - mocks.helpers.expects('resetApp').once().withArgs(adb); - await helpers.installApk(adb, Object.assign({}, opts, {fastReset: true, fullReset: true})); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - it('should not run reset if the corresponding option is not set', async function () { - mocks.adb - .expects('installOrUpgrade') - .once() - .withArgs(opts.app, opts.appPackage) - .returns({wasUninstalled: true, appState: 'sameVersionInstalled'}); - mocks.helpers.expects('resetApp').never(); - await helpers.installApk(adb, opts); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - it('should install/upgrade and skip fast resetting the app if this was the fresh install', async function () { - mocks.adb - .expects('installOrUpgrade') - .once() - .withArgs(opts.app, opts.appPackage) - .returns({wasUninstalled: false, appState: 'notInstalled'}); - mocks.helpers.expects('resetApp').never(); - await helpers.installApk(adb, Object.assign({}, opts, {fastReset: true})); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - }) - ); - describe( - 'installOtherApks', - withMocks({adb, fs, helpers}, function (mocks) { - const opts = { - app: 'local', - appPackage: 'pkg', - androidInstallTimeout: 90000, - }; - - const fakeApk = '/path/to/fake/app.apk'; - const otherFakeApk = '/path/to/other/fake/app.apk'; - - const expectedADBInstallOpts = { - allowTestPackages: undefined, - grantPermissions: undefined, - timeout: opts.androidInstallTimeout, - }; - - it('should not call adb.installOrUpgrade if otherApps is empty', async function () { - mocks.adb.expects('installOrUpgrade').never(); - await helpers.installOtherApks([], adb, opts); - mocks.adb.verify(); - }); - it('should call adb.installOrUpgrade once if otherApps has one item', async function () { - mocks.adb - .expects('installOrUpgrade') - .once() - .withArgs(fakeApk, undefined, expectedADBInstallOpts); - await helpers.installOtherApks([fakeApk], adb, opts); - mocks.adb.verify(); - }); - it('should call adb.installOrUpgrade twice if otherApps has two item', async function () { - mocks.adb.expects('installOrUpgrade').twice(); - await helpers.installOtherApks([fakeApk, otherFakeApk], adb, opts); - mocks.adb.verify(); - }); - }) - ); - describe( - 'setMockLocationApp', - withMocks({adb}, (mocks) => { - it('should enable mock location for api level below 23', async function () { - mocks.adb.expects('getApiLevel').returns(B.resolve(18)); - mocks.adb - .expects('shell') - .withExactArgs(['settings', 'put', 'secure', 'mock_location', '1']) - .once() - .returns(''); - mocks.adb.expects('fileExists').throws(); - await helpers.setMockLocationApp(adb, 'io.appium.settings'); - mocks.adb.verify(); - }); - it('should enable mock location for api level 23 and above', async function () { - mocks.adb.expects('getApiLevel').returns(B.resolve(23)); - mocks.adb - .expects('shell') - .withExactArgs(['appops', 'set', 'io.appium.settings', 'android:mock_location', 'allow']) - .once() - .returns(''); - mocks.adb.expects('fileExists').throws(); - await helpers.setMockLocationApp(adb, 'io.appium.settings'); - mocks.adb.verify(); - }); - }) - ); - describe( - 'pushStrings', - withMocks({adb, fs}, (mocks) => { - it('should return {} because of no app, no package and no app in the target device', async function () { - const opts = {tmpDir: '/tmp_dir', appPackage: 'pkg'}; - mocks.adb.expects('rimraf').withExactArgs(`${REMOTE_TEMP_PATH}/strings.json`).once(); - mocks.adb - .expects('pullApk') - .withExactArgs(opts.appPackage, opts.tmpDir) - .throws(`adb: error: remote object ${opts.appPackage} does not exist`); - (await helpers.pushStrings('en', adb, opts)).should.be.deep.equal({}); - mocks.adb.verify(); - mocks.fs.verify(); - }); - it('should extracts string.xml and converts it to string.json and pushes it', async function () { - const opts = {app: 'app', tmpDir: '/tmp_dir', appPackage: 'pkg'}; - mocks.adb.expects('rimraf').withExactArgs(`${REMOTE_TEMP_PATH}/strings.json`).once(); - mocks.fs.expects('exists').withExactArgs(opts.app).returns(true); - mocks.fs.expects('rimraf').once(); - mocks.adb - .expects('extractStringsFromApk') - .withArgs(opts.app, 'en') - .returns({apkStrings: {id: 'string'}, localPath: 'local_path'}); - mocks.adb.expects('push').withExactArgs('local_path', REMOTE_TEMP_PATH).once(); - (await helpers.pushStrings('en', adb, opts)).should.be.deep.equal({id: 'string'}); - mocks.adb.verify(); - }); - it('should delete remote strings.json if app is not present', async function () { - const opts = {app: 'app', tmpDir: '/tmp_dir', appPackage: 'pkg'}; - mocks.adb.expects('rimraf').withExactArgs(`${REMOTE_TEMP_PATH}/strings.json`).once(); - mocks.fs.expects('exists').withExactArgs(opts.app).returns(false); - (await helpers.pushStrings('en', adb, opts)).should.be.deep.equal({}); - mocks.adb.verify(); - mocks.fs.verify(); - }); - it('should push an empty json object if app does not have strings.xml', async function () { - const opts = {app: 'app', tmpDir: '/tmp_dir', appPackage: 'pkg'}; - mocks.adb.expects('rimraf').withExactArgs(`${REMOTE_TEMP_PATH}/strings.json`).once(); - mocks.fs.expects('exists').withExactArgs(opts.app).returns(true); - mocks.fs.expects('rimraf').once(); - mocks.adb.expects('extractStringsFromApk').throws(); - mocks.adb - .expects('shell') - .withExactArgs(['echo', `'{}' > ${REMOTE_TEMP_PATH}/strings.json`]); - (await helpers.pushStrings('en', adb, opts)).should.be.deep.equal({}); - mocks.adb.verify(); - mocks.fs.verify(); - }); - }) - ); - describe( - 'unlock', - withMocks({adb, helpers, unlocker}, (mocks) => { - it('should return if screen is already unlocked', async function () { - mocks.adb.expects('isScreenLocked').withExactArgs().once().returns(false); - mocks.adb.expects('getApiLevel').never(); - mocks.adb.expects('startApp').never(); - mocks.adb.expects('isLockManagementSupported').never(); - await helpers.unlock(helpers, adb, {}); - mocks.adb.verify(); - }); - it('should start unlock app', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('dismissKeyguard').once(); - mocks.adb.expects('isLockManagementSupported').never(); - await helpers.unlock(helpers, adb, {}); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - it('should raise an error on undefined unlockKey when unlockType is defined', async function () { - mocks.adb.expects('isScreenLocked').once().returns(true); - mocks.adb.expects('isLockManagementSupported').never(); - await helpers.unlock(helpers, adb, {unlockType: 'pin'}).should.be.rejected; - mocks.adb.verify(); - mocks.unlocker.verify(); - mocks.helpers.verify(); - }); - it('should call pinUnlock if unlockType is pin', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(false); - mocks.unlocker.expects('pinUnlock').once(); - await helpers.unlock(helpers, adb, {unlockType: 'pin', unlockKey: '1111'}); - mocks.adb.verify(); - mocks.helpers.verify(); - mocks.unlocker.verify(); - }); - it('should call pinUnlock if unlockType is pinWithKeyEvent', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(false); - mocks.unlocker.expects('pinUnlockWithKeyEvent').once(); - await helpers.unlock(helpers, adb, {unlockType: 'pinWithKeyEvent', unlockKey: '1111'}); - mocks.adb.verify(); - mocks.helpers.verify(); - mocks.unlocker.verify(); - }); - it('should call fastUnlock if unlockKey is provided', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(true); - mocks.helpers.expects('verifyUnlock').once(); - mocks.unlocker.expects('fastUnlock').once(); - await helpers.unlock(helpers, adb, {unlockKey: 'appium', unlockType: 'password'}); - mocks.adb.verify(); - mocks.unlocker.verify(); - mocks.helpers.verify(); - }); - it('should call passwordUnlock if unlockType is password', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(false); - mocks.unlocker.expects('passwordUnlock').once(); - await helpers.unlock(helpers, adb, {unlockType: 'password', unlockKey: 'appium'}); - mocks.adb.verify(); - mocks.helpers.verify(); - mocks.unlocker.verify(); - }); - it('should call patternUnlock if unlockType is pattern', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(false); - mocks.unlocker.expects('patternUnlock').once(); - await helpers.unlock(helpers, adb, {unlockType: 'pattern', unlockKey: '123456789'}); - mocks.adb.verify(); - mocks.helpers.verify(); - }); - it('should call fingerprintUnlock if unlockType is fingerprint', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').never(); - mocks.unlocker.expects('fingerprintUnlock').once(); - await helpers.unlock(helpers, adb, {unlockType: 'fingerprint', unlockKey: '1111'}); - mocks.adb.verify(); - mocks.unlocker.verify(); - }); - it('should throw an error is api is lower than 23 and trying to use fingerprintUnlock', async function () { - mocks.adb.expects('isScreenLocked').onCall(0).returns(true); - mocks.adb.expects('isScreenLocked').returns(false); - mocks.adb.expects('isLockManagementSupported').onCall(0).returns(false); - mocks.adb.expects('getApiLevel').once().returns(21); - await helpers - .unlock(helpers, adb, {unlockType: 'fingerprint', unlockKey: '1111'}) - .should.be.rejectedWith('Fingerprint'); - mocks.helpers.verify(); - }); - }) - ); - describe( - 'initDevice', - withMocks({helpers, adb}, (mocks) => { - it('should init a real device', async function () { - const opts = {language: 'en', locale: 'us', localeScript: 'Script'}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers - .expects('ensureDeviceLocale') - .withExactArgs(adb, opts.language, opts.locale, opts.localeScript) - .once(); - mocks.helpers.expects('setMockLocationApp').withExactArgs(adb, 'io.appium.settings').once(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should init device without locale and language', async function () { - const opts = {}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').withExactArgs(adb, 'io.appium.settings').once(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should init device with either locale or language', async function () { - const opts = {language: 'en'}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers - .expects('ensureDeviceLocale') - .withExactArgs(adb, opts.language, opts.locale, opts.localeScript) - .once(); - mocks.helpers.expects('setMockLocationApp').withExactArgs(adb, 'io.appium.settings').once(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should not install mock location on emulator', async function () { - const opts = {avd: 'avd'}; - mocks.adb.expects('waitForDevice').once(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').never(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should set empty IME if hideKeyboard is set to true', async function () { - const opts = {hideKeyboard: true}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').once(); - mocks.helpers - .expects('hideKeyboard') - .withExactArgs(adb) - .once(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should return defaultIME if unicodeKeyboard is set to true', async function () { - const opts = {unicodeKeyboard: true}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').once(); - mocks.helpers - .expects('initUnicodeKeyboard') - .withExactArgs(adb) - .once() - .returns('defaultIME'); - await helpers.initDevice(adb, opts).should.become('defaultIME'); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should return undefined if unicodeKeyboard is set to false', async function () { - const opts = {unicodeKeyboard: false}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').once(); - mocks.helpers.expects('initUnicodeKeyboard').never(); - should.not.exist(await helpers.initDevice(adb, opts)); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should not push unlock app if unlockType is defined', async function () { - const opts = {unlockType: 'unlock_type'}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').once(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').once(); - mocks.helpers.expects('initUnicodeKeyboard').never(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - it('should init device without starting logcat', async function () { - const opts = {skipLogcatCapture: true}; - mocks.adb.expects('waitForDevice').never(); - mocks.adb.expects('startLogcat').never(); - mocks.helpers.expects('pushSettingsApp').once(); - mocks.helpers.expects('ensureDeviceLocale').never(); - mocks.helpers.expects('setMockLocationApp').withExactArgs(adb, 'io.appium.settings').once(); - await helpers.initDevice(adb, opts); - mocks.helpers.verify(); - mocks.adb.verify(); - }); - }) - ); - describe('removeNullProperties', function () { - it('should ignore null properties', function () { - let test = {foo: null, bar: true}; - helpers.removeNullProperties(test); - _.keys(test).length.should.equal(1); - }); - it('should ignore undefined properties', function () { - let test = {foo: undefined, bar: true}; - helpers.removeNullProperties(test); - _.keys(test).length.should.equal(1); - }); - it('should not ignore falsy properties like 0 and false', function () { - let test = {foo: false, bar: true, zero: 0}; - helpers.removeNullProperties(test); - _.keys(test).length.should.equal(3); - }); - }); - describe('truncateDecimals', function () { - it('should use floor when number is positive', function () { - helpers.truncateDecimals(12.345, 2).should.equal(12.34); - }); - it('should use ceil when number is negative', function () { - helpers.truncateDecimals(-12.345, 2).should.equal(-12.34); - }); - }); - describe('getChromePkg', function () { - it('should return pakage for chromium', function () { - helpers - .getChromePkg('chromium') - .should.deep.equal({pkg: 'org.chromium.chrome.shell', activity: '.ChromeShellActivity'}); - }); - it('should return pakage for chromebeta', function () { - helpers.getChromePkg('chromebeta').should.deep.equal({ - pkg: 'com.chrome.beta', - activity: 'com.google.android.apps.chrome.Main', - }); - }); - it('should return pakage for browser', function () { - helpers.getChromePkg('browser').should.deep.equal({ - pkg: 'com.android.browser', - activity: 'com.android.browser.BrowserActivity', - }); - }); - it('should return pakage for chromium-browser', function () { - helpers.getChromePkg('chromium-browser').should.deep.equal({ - pkg: 'org.chromium.chrome', - activity: 'com.google.android.apps.chrome.Main', - }); - }); - it('should return pakage for chromium-webview', function () { - helpers.getChromePkg('chromium-webview').should.deep.equal({ - pkg: 'org.chromium.webview_shell', - activity: 'org.chromium.webview_shell.WebViewBrowserActivity', - }); - }); - }); - - describe('#parseArray', function () { - it('should parse array string to array', function () { - helpers.parseArray('["a", "b", "c"]').should.eql(['a', 'b', 'c']); - }); - it('should parse a simple string to one item array', function () { - helpers.parseArray('abc').should.eql(['abc']); - }); - }); -}); diff --git a/test/unit/commands/general-specs.js b/test/unit/commands/app-management-specs.js similarity index 50% rename from test/unit/commands/general-specs.js rename to test/unit/commands/app-management-specs.js index 1538ab05..3bd62a69 100644 --- a/test/unit/commands/general-specs.js +++ b/test/unit/commands/app-management-specs.js @@ -2,8 +2,6 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; import {AndroidDriver} from '../../../lib/driver'; -import helpers from '../../../lib/helpers/android'; -import {withMocks} from '@appium/test-support'; import {fs} from '@appium/support'; import B from 'bluebird'; import ADB from 'appium-adb'; @@ -11,11 +9,11 @@ import ADB from 'appium-adb'; chai.should(); chai.use(chaiAsPromised); +/** @type {AndroidDriver} */ let driver; let sandbox = sinon.createSandbox(); -let expect = chai.expect; -describe('General', function () { +describe('App Management', function () { beforeEach(function () { driver = new AndroidDriver(); driver.adb = new ADB(); @@ -23,70 +21,7 @@ describe('General', function () { driver.opts = {}; }); afterEach(function () { - sandbox.restore(); - }); - describe('isKeyboardShown', function () { - it('should return true if the keyboard is shown', async function () { - driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { - return {isKeyboardShown: true, canCloseKeyboard: true}; - }; - (await driver.isKeyboardShown()).should.equal(true); - }); - it('should return false if the keyboard is not shown', async function () { - driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { - return {isKeyboardShown: false, canCloseKeyboard: true}; - }; - (await driver.isKeyboardShown()).should.equal(false); - }); - }); - describe('hideKeyboard', function () { - it('should hide keyboard with ESC command', async function () { - sandbox.stub(driver.adb, 'keyevent'); - let callIdx = 0; - driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { - callIdx++; - return { - isKeyboardShown: callIdx <= 1, - canCloseKeyboard: callIdx <= 1, - }; - }; - await driver.hideKeyboard().should.eventually.be.fulfilled; - driver.adb.keyevent.calledWithExactly(111).should.be.true; - }); - it('should throw if cannot close keyboard', async function () { - this.timeout(10000); - sandbox.stub(driver.adb, 'keyevent'); - driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { - return { - isKeyboardShown: true, - canCloseKeyboard: false, - }; - }; - await driver.hideKeyboard().should.eventually.be.rejected; - driver.adb.keyevent.notCalled.should.be.true; - }); - it('should not throw if no keyboard is present', async function () { - driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { - return { - isKeyboardShown: false, - canCloseKeyboard: false, - }; - }; - await driver.hideKeyboard().should.eventually.be.fulfilled; - }); - }); - describe('openSettingsActivity', function () { - it('should open settings activity', async function () { - sandbox - .stub(driver.adb, 'getFocusedPackageAndActivity') - .returns({appPackage: 'pkg', appActivity: 'act'}); - sandbox.stub(driver.adb, 'shell'); - sandbox.stub(driver.adb, 'waitForNotActivity'); - await driver.openSettingsActivity('set1'); - driver.adb.shell.calledWithExactly(['am', 'start', '-a', 'android.settings.set1']).should.be - .true; - driver.adb.waitForNotActivity.calledWithExactly('pkg', 'act', 5000).should.be.true; - }); + sandbox.verifyAndRestore(); }); describe('getCurrentActivity', function () { it('should get current activity', async function () { @@ -227,31 +162,6 @@ describe('General', function () { driver.adb.startApp.notCalled.should.be.true; }); }); - describe( - 'getStrings', - withMocks({helpers}, (mocks) => { - it('should return app strings', async function () { - mocks.helpers.expects('pushStrings').returns({test: 'en_value'}); - let strings = await driver.getStrings('en'); - strings.test.should.equal('en_value'); - mocks.helpers.verify(); - }); - it('should return cached app strings for the specified language', async function () { - driver.adb.getDeviceLanguage = () => 'en'; - driver.apkStrings.en = {test: 'en_value'}; - driver.apkStrings.fr = {test: 'fr_value'}; - let strings = await driver.getStrings('fr'); - strings.test.should.equal('fr_value'); - }); - it('should return cached app strings for the device language', async function () { - driver.adb.getDeviceLanguage = () => 'en'; - driver.apkStrings.en = {test: 'en_value'}; - driver.apkStrings.fr = {test: 'fr_value'}; - let strings = await driver.getStrings(); - strings.test.should.equal('en_value'); - }); - }) - ); describe('startActivity', function () { let params; beforeEach(function () { @@ -287,109 +197,126 @@ describe('General', function () { driver.adb.startApp.calledWithExactly(params).should.be.true; }); }); - describe('reset', function () { - it('should reset app via reinstall if fullReset is true', async function () { - driver.opts.fullReset = true; - driver.opts.appPackage = 'pkg'; - sandbox.stub(driver, 'startAUT').returns('aut'); - sandbox.stub(helpers, 'resetApp').returns(undefined); - await driver.reset().should.eventually.be.equal('aut'); - helpers.resetApp.calledWith(driver.adb).should.be.true; - driver.startAUT.calledOnce.should.be.true; + + describe('resetApp', function() { + const localApkPath = 'local'; + const pkg = 'pkg'; + + afterEach(function () { + sandbox.verifyAndRestore(); }); - it('should do fast reset if fullReset is false', async function () { - driver.opts.fullReset = false; - driver.opts.appPackage = 'pkg'; - sandbox.stub(helpers, 'resetApp').returns(undefined); - sandbox.stub(driver, 'startAUT').returns('aut'); - await driver.reset().should.eventually.be.equal('aut'); - helpers.resetApp.calledWith(driver.adb).should.be.true; - driver.startAUT.calledOnce.should.be.true; - expect(driver.curContext).to.eql('NATIVE_APP'); + + it('should complain if opts arent passed correctly', async function () { + await driver.resetApp({}).should.be.rejectedWith(/appPackage/); }); - }); - describe('startAUT', function () { - it('should start AUT', async function () { - driver.opts = { - appPackage: 'pkg', - appActivity: 'act', - intentAction: 'actn', - intentCategory: 'cat', - intentFlags: 'flgs', - appWaitPackage: 'wpkg', - appWaitActivity: 'wact', - appWaitForLaunch: true, - appWaitDuration: 'wdur', - optionalIntentArguments: 'opt', - userProfile: 1, - }; - let params = { - pkg: 'pkg', - activity: 'act', - action: 'actn', - category: 'cat', - flags: 'flgs', - waitPkg: 'wpkg', - waitActivity: 'wact', - waitForLaunch: true, - waitDuration: 'wdur', - optionalIntentArguments: 'opt', - stopApp: false, - user: 1, - }; - driver.opts.dontStopAppOnReset = true; - params.stopApp = false; - sandbox.stub(driver.adb, 'startApp'); - await driver.startAUT(); - driver.adb.startApp.calledWithExactly(params).should.be.true; + it('should be able to do full reset', async function () { + sandbox.stub(driver.adb, 'install').withArgs(localApkPath).onFirstCall(); + sandbox.stub(driver.adb, 'forceStop').withArgs(pkg).onFirstCall(); + sandbox.stub(driver.adb, 'isAppInstalled').withArgs(pkg).onFirstCall().returns(true); + sandbox.stub(driver.adb, 'uninstallApk').withArgs(pkg).onFirstCall(); + await driver.resetApp({app: localApkPath, appPackage: pkg}); }); - }); - describe('setUrl', function () { - it('should set url', async function () { - driver.opts = {appPackage: 'pkg'}; - sandbox.stub(driver.adb, 'startUri'); - await driver.setUrl('url'); - driver.adb.startUri.calledWithExactly('url', 'pkg').should.be.true; + it('should be able to do fast reset', async function () { + sandbox.stub(driver.adb, 'isAppInstalled').withArgs(pkg).onFirstCall().returns(true); + sandbox.stub(driver.adb, 'forceStop').withArgs(pkg).onFirstCall(); + sandbox.stub(driver.adb, 'clear').withArgs(pkg).onFirstCall().returns('Success'); + sandbox.stub(driver.adb, 'grantAllPermissions').withArgs(pkg).onFirstCall(); + await driver.resetApp({ + app: localApkPath, + appPackage: pkg, + fastReset: true, + autoGrantPermissions: true, + }); + }); + it('should perform reinstall if app is not installed and fast reset is requested', async function () { + sandbox.stub(driver.adb, 'isAppInstalled').withArgs(pkg).onFirstCall().returns(false); + sandbox.stub(driver.adb, 'forceStop').throws(); + sandbox.stub(driver.adb, 'clear').throws(); + sandbox.stub(driver.adb, 'uninstallApk').throws(); + sandbox.stub(driver.adb, 'install').withArgs(localApkPath).onFirstCall(); + await driver.resetApp({app: localApkPath, appPackage: pkg, fastReset: true}); }); }); - describe('closeApp', function () { - it('should close app', async function () { - driver.opts = {appPackage: 'pkg'}; - sandbox.stub(driver.adb, 'forceStop'); - await driver.closeApp(); - driver.adb.forceStop.calledWithExactly('pkg').should.be.true; + + describe('installApk', function () { + //use mock appium capabilities for this test + const opts = { + app: 'local', + appPackage: 'pkg', + androidInstallTimeout: 90000, + }; + + afterEach(function () { + sandbox.verifyAndRestore(); + }); + + it('should complain if appPackage is not passed', async function () { + await driver.installApk({}).should.be.rejectedWith(/appPackage/); + }); + it('should install/upgrade and reset app if fast reset is set to true', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade') + .withArgs(opts.app, opts.appPackage) + .onFirstCall() + .returns({wasUninstalled: false, appState: 'sameVersionInstalled'}); + sandbox.stub(driver, 'resetApp').onFirstCall(); + await driver.installApk(Object.assign({}, opts, {fastReset: true})); + }); + it('should reinstall app if full reset is set to true', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade').throws(); + sandbox.stub(driver, 'resetApp').onFirstCall(); + await driver.installApk(Object.assign({}, opts, {fastReset: true, fullReset: true})); + }); + it('should not run reset if the corresponding option is not set', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade') + .withArgs(opts.app, opts.appPackage) + .onFirstCall() + .returns({wasUninstalled: true, appState: 'sameVersionInstalled'}); + sandbox.stub(driver, 'resetApp').throws(); + await driver.installApk(opts); + }); + it('should install/upgrade and skip fast resetting the app if this was the fresh install', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade') + .withArgs(opts.app, opts.appPackage) + .onFirstCall() + .returns({wasUninstalled: false, appState: 'notInstalled'}); + sandbox.stub(driver, 'resetApp').throws(); + await driver.installApk(Object.assign({}, opts, {fastReset: true})); }); }); - describe('getDisplayDensity', function () { - it('should return the display density of a device', async function () { - driver.adb.shell = () => '123'; - (await driver.getDisplayDensity()).should.equal(123); + describe('installOtherApks', function () { + const opts = { + app: 'local', + appPackage: 'pkg', + androidInstallTimeout: 90000, + }; + + afterEach(function () { + sandbox.verifyAndRestore(); }); - it('should return the display density of an emulator', async function () { - driver.adb.shell = (cmd) => { - let joinedCmd = cmd.join(' '); - if (joinedCmd.indexOf('ro.sf') !== -1) { - // device property look up - return ''; - } else if (joinedCmd.indexOf('qemu.sf') !== -1) { - // emulator property look up - return '456'; - } - return ''; - }; - (await driver.getDisplayDensity()).should.equal(456); + + const fakeApk = '/path/to/fake/app.apk'; + const otherFakeApk = '/path/to/other/fake/app.apk'; + + const expectedADBInstallOpts = { + allowTestPackages: undefined, + grantPermissions: undefined, + timeout: opts.androidInstallTimeout, + }; + + it('should not call adb.installOrUpgrade if otherApps is empty', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade').throws(); + await driver.installOtherApks([], opts); }); - it("should throw an error if the display density property can't be found", async function () { - driver.adb.shell = () => ''; - await driver - .getDisplayDensity() - .should.be.rejectedWith(/Failed to get display density property/); + it('should call adb.installOrUpgrade once if otherApps has one item', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade') + .withArgs(fakeApk, undefined, expectedADBInstallOpts) + .onFirstCall(); + await driver.installOtherApks([fakeApk], opts); }); - it('should throw and error if the display density is not a number', async function () { - driver.adb.shell = () => 'abc'; - await driver - .getDisplayDensity() - .should.be.rejectedWith(/Failed to get display density property/); + it('should call adb.installOrUpgrade twice if otherApps has two item', async function () { + sandbox.stub(driver.adb, 'installOrUpgrade'); + await driver.installOtherApks([fakeApk, otherFakeApk], opts); + driver.adb.installOrUpgrade.calledTwice.should.be.true; }); }); }); diff --git a/test/unit/commands/context-specs.js b/test/unit/commands/context-specs.js index 45453f2a..c14cf3a4 100644 --- a/test/unit/commands/context-specs.js +++ b/test/unit/commands/context-specs.js @@ -1,18 +1,21 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; +import * as webviewHelpers from '../../../lib/commands/context/helpers'; import { - default as webviewHelpers, NATIVE_WIN, WEBVIEW_BASE, WEBVIEW_WIN, CHROMIUM_WIN, -} from '../../../lib/helpers/webview'; + setupNewChromedriver, +} from '../../../lib/commands/context/helpers'; import {AndroidDriver} from '../../../lib/driver'; import Chromedriver from 'appium-chromedriver'; import {errors} from 'appium/driver'; +/** @type {AndroidDriver} */ let driver; +/** @type {Chromedriver} */ let stubbedChromedriver; let sandbox = sinon.createSandbox(); let expect = chai.expect; @@ -49,7 +52,7 @@ describe('Context', function () { }); it('should return NATIVE_APP if no context is set', async function () { driver.curContext = null; - await driver.getCurrentContext().should.become(NATIVE_WIN); + await driver.getCurrentContext().should.become(webviewHelpers.NATIVE_WIN); }); }); describe('getContexts', function () { @@ -78,6 +81,7 @@ describe('Context', function () { }); it('should switch to default context if name is null', async function () { sandbox.stub(driver, 'defaultContextName').returns('DEFAULT'); + sandbox.stub(webviewHelpers, 'parseWebviewNames').returns(['DEFAULT', 'VW', 'ANOTHER']); await driver.setContext(null); driver.switchContext.calledWithExactly('DEFAULT', [ {webviewName: 'DEFAULT', pages: ['PAGE']}, @@ -88,6 +92,7 @@ describe('Context', function () { }); it('should switch to default web view if name is WEBVIEW', async function () { sandbox.stub(driver, 'defaultWebviewName').returns('WV'); + sandbox.stub(webviewHelpers, 'parseWebviewNames').returns(['DEFAULT', 'WV', 'ANOTHER']); await driver.setContext(WEBVIEW_WIN); driver.switchContext.calledWithExactly('WV', [ {webviewName: 'DEFAULT', pages: ['PAGE']}, @@ -140,24 +145,24 @@ describe('Context', function () { }); }); describe('defaultContextName', function () { - it('should return NATIVE_WIN', async function () { - await driver.defaultContextName().should.be.equal(NATIVE_WIN); + it('should return NATIVE_WIN', function () { + driver.defaultContextName().should.be.equal(NATIVE_WIN); }); }); describe('defaultWebviewName', function () { - it('should return WEBVIEW with package if "autoWebviewName" option is not set', async function () { + it('should return WEBVIEW with package if "autoWebviewName" option is not set', function () { driver.opts = {appPackage: 'pkg'}; - await driver.defaultWebviewName().should.be.equal(WEBVIEW_BASE + 'pkg'); + driver.defaultWebviewName().should.be.equal(WEBVIEW_BASE + 'pkg'); }); - it('should return WEBVIEW with value from "autoWebviewName" option', async function () { + it('should return WEBVIEW with value from "autoWebviewName" option', function () { driver.opts = {appPackage: 'pkg', autoWebviewName: 'foo'}; - await driver.defaultWebviewName().should.be.equal(WEBVIEW_BASE + 'foo'); + driver.defaultWebviewName().should.be.equal(WEBVIEW_BASE + 'foo'); }); }); describe('isWebContext', function () { - it('should return true if current context is not native', async function () { + it('should return true if current context is not native', function () { driver.curContext = 'current_context'; - await driver.isWebContext().should.be.true; + driver.isWebContext().should.be.true; }); }); describe('startChromedriverProxy', function () { @@ -218,7 +223,7 @@ describe('Context', function () { }); describe('suspendChromedriverProxy', function () { it('should suspend chrome driver proxy', async function () { - await driver.suspendChromedriverProxy(); + driver.suspendChromedriverProxy(); (driver.chromedriver == null).should.be.true; (driver.proxyReqRes == null).should.be.true; driver.jwpProxyActive.should.be.false; @@ -257,20 +262,20 @@ describe('Context', function () { }); }); describe('isChromedriverContext', function () { - it('should return true if context is webview or chromium', async function () { - await driver.isChromedriverContext(WEBVIEW_WIN + '_1').should.be.true; - await driver.isChromedriverContext(CHROMIUM_WIN).should.be.true; + it('should return true if context is webview or chromium', function () { + driver.isChromedriverContext(WEBVIEW_WIN + '_1').should.be.true; + driver.isChromedriverContext(CHROMIUM_WIN).should.be.true; }); }); describe('setupNewChromedriver', function () { it('should be able to set app package from chrome options', async function () { - let chromedriver = await driver.setupNewChromedriver({ + let chromedriver = await setupNewChromedriver.bind(driver)({ chromeOptions: {androidPackage: 'apkg'}, }); chromedriver.start.getCall(0).args[0].chromeOptions.androidPackage.should.be.equal('apkg'); }); it('should use prefixed chromeOptions', async function () { - let chromedriver = await driver.setupNewChromedriver({ + let chromedriver = await setupNewChromedriver.bind(driver)({ 'goog:chromeOptions': { androidPackage: 'apkg', }, @@ -278,7 +283,7 @@ describe('Context', function () { chromedriver.start.getCall(0).args[0].chromeOptions.androidPackage.should.be.equal('apkg'); }); it('should merge chromeOptions', async function () { - let chromedriver = await driver.setupNewChromedriver({ + let chromedriver = await setupNewChromedriver.bind(driver)({ chromeOptions: { androidPackage: 'apkg', }, @@ -296,19 +301,19 @@ describe('Context', function () { .args[0].chromeOptions.androidWaitPackage.should.be.equal('bpkg'); }); it('should be able to set androidActivity chrome option', async function () { - let chromedriver = await driver.setupNewChromedriver({chromeAndroidActivity: 'act'}); + let chromedriver = await setupNewChromedriver.bind(driver)({chromeAndroidActivity: 'act'}); chromedriver.start.getCall(0).args[0].chromeOptions.androidActivity.should.be.equal('act'); }); it('should be able to set androidProcess chrome option', async function () { - let chromedriver = await driver.setupNewChromedriver({chromeAndroidProcess: 'proc'}); + let chromedriver = await setupNewChromedriver.bind(driver)({chromeAndroidProcess: 'proc'}); chromedriver.start.getCall(0).args[0].chromeOptions.androidProcess.should.be.equal('proc'); }); it('should be able to set loggingPrefs capability', async function () { - let chromedriver = await driver.setupNewChromedriver({enablePerformanceLogging: true}); + let chromedriver = await setupNewChromedriver.bind(driver)({enablePerformanceLogging: true}); chromedriver.start.getCall(0).args[0].loggingPrefs.should.deep.equal({performance: 'ALL'}); }); it('should set androidActivity to appActivity if browser name is chromium-webview', async function () { - let chromedriver = await driver.setupNewChromedriver({ + let chromedriver = await setupNewChromedriver.bind(driver)({ browserName: 'chromium-webview', appActivity: 'app_act', }); @@ -317,8 +322,40 @@ describe('Context', function () { .args[0].chromeOptions.androidActivity.should.be.equal('app_act'); }); it('should be able to set loggingPrefs capability', async function () { - let chromedriver = await driver.setupNewChromedriver({pageLoadStrategy: 'strategy'}); + let chromedriver = await setupNewChromedriver.bind(driver)({pageLoadStrategy: 'strategy'}); chromedriver.start.getCall(0).args[0].pageLoadStrategy.should.be.equal('strategy'); }); }); + + describe('getChromePkg', function () { + it('should return pakage for chromium', function () { + webviewHelpers + .getChromePkg('chromium') + .should.deep.equal({pkg: 'org.chromium.chrome.shell', activity: '.ChromeShellActivity'}); + }); + it('should return pakage for chromebeta', function () { + webviewHelpers.getChromePkg('chromebeta').should.deep.equal({ + pkg: 'com.chrome.beta', + activity: 'com.google.android.apps.chrome.Main', + }); + }); + it('should return pakage for browser', function () { + webviewHelpers.getChromePkg('browser').should.deep.equal({ + pkg: 'com.android.browser', + activity: 'com.android.browser.BrowserActivity', + }); + }); + it('should return pakage for chromium-browser', function () { + webviewHelpers.getChromePkg('chromium-browser').should.deep.equal({ + pkg: 'org.chromium.chrome', + activity: 'com.google.android.apps.chrome.Main', + }); + }); + it('should return pakage for chromium-webview', function () { + webviewHelpers.getChromePkg('chromium-webview').should.deep.equal({ + pkg: 'org.chromium.webview_shell', + activity: 'org.chromium.webview_shell.WebViewBrowserActivity', + }); + }); + }); }); diff --git a/test/unit/commands/device-specs.js b/test/unit/commands/device-specs.js new file mode 100644 index 00000000..7e4b106a --- /dev/null +++ b/test/unit/commands/device-specs.js @@ -0,0 +1,447 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import ADB from 'appium-adb'; +import _ from 'lodash'; +import { AndroidDriver } from '../../../lib/driver'; +import { prepareAvdArgs, prepareEmulator } from '../../../lib/commands/device/utils'; +import * as deviceUtils from '../../../lib/commands/device/utils'; +import * as geolocationHelpers from '../../../lib/commands/geolocation'; +import * as keyboardHelpers from '../../../lib/commands/keyboard'; + + +chai.use(chaiAsPromised); + +describe('Device Helpers', function () { + /** @type {AndroidDriver} */ + let driver; + let sandbox = sinon.createSandbox(); + + beforeEach(function () { + const adb = new ADB(); + driver = new AndroidDriver(); + driver.adb = adb; + }); + afterEach(function () { + sandbox.restore(); + }); + + describe('isEmulator', function () { + it('should be true if driver opts contain avd', function () { + const driver = new AndroidDriver(); + driver.opts = {avd: 'yolo'}; + driver.isEmulator().should.be.true; + }); + it('should be true if driver opts contain emulator udid', function () { + const driver = new AndroidDriver(); + driver.opts = {udid: 'Emulator-5554'}; + driver.isEmulator().should.be.true; + }); + it('should be false if driver opts do not contain emulator udid', function () { + const driver = new AndroidDriver(); + driver.opts = {udid: 'ABCD1234'}; + driver.isEmulator().should.be.false; + }); + it('should be true if device id in adb contains emulator', function () { + const driver = new AndroidDriver(); + driver.adb = {curDeviceId: 'emulator-5554'}; + driver.isEmulator().should.be.true; + }); + it('should be false if device id in adb does not contain emulator', function () { + const driver = new AndroidDriver(); + driver.adb = {curDeviceId: 'ABCD1234'}; + driver.isEmulator().should.be.false; + }); + }); + describe('prepareEmulator', function () { + beforeEach(function () { + const opts = {avd: 'foo@bar', avdArgs: '', language: 'en', locale: 'us'}; + driver.opts = opts; + }); + this.afterEach(function () { + sandbox.verify(); + }); + + it('should not launch avd if one is already running', async function () { + sandbox.stub(driver.adb, 'getRunningAVDWithRetry').withArgs('foobar').returns('foo'); + sandbox.stub(driver.adb, 'launchAVD').throws(); + sandbox.stub(driver.adb, 'killEmulator').throws(); + await prepareEmulator.bind(driver)(); + }); + it('should launch avd if one is not running', async function () { + sandbox.stub(driver.adb, 'getRunningAVDWithRetry').withArgs('foobar').throws(); + sandbox.stub(driver.adb, 'launchAVD') + .withArgs('foo@bar', { + args: [], + env: undefined, + language: 'en', + country: 'us', + launchTimeout: undefined, + readyTimeout: undefined, + }) + .returns(''); + await prepareEmulator.bind(driver)(); + }); + it('should parse avd string command line args', async function () { + const opts = { + avd: 'foobar', + avdArgs: '--arg1 "value 1" --arg2 "value 2"', + avdEnv: { + k1: 'v1', + k2: 'v2', + }, + }; + driver.opts = opts; + sandbox.stub(driver.adb, 'getRunningAVDWithRetry').withArgs('foobar').throws(); + sandbox.stub(driver.adb, 'launchAVD') + .withArgs('foobar', { + args: ['--arg1', 'value 1', '--arg2', 'value 2'], + env: { + k1: 'v1', + k2: 'v2', + }, + language: undefined, + country: undefined, + launchTimeout: undefined, + readyTimeout: undefined, + }) + .returns(''); + await prepareEmulator.bind(driver)(); + }); + it('should parse avd array command line args', async function () { + const opts = { + avd: 'foobar', + avdArgs: ['--arg1', 'value 1', '--arg2', 'value 2'], + }; + driver.opts = opts; + sandbox.stub(driver.adb, 'getRunningAVDWithRetry').withArgs('foobar').throws(); + sandbox.stub(driver.adb, 'launchAVD') + .withArgs('foobar', { + args: ['--arg1', 'value 1', '--arg2', 'value 2'], + env: undefined, + language: undefined, + country: undefined, + launchTimeout: undefined, + readyTimeout: undefined, + }) + .returns(''); + await prepareEmulator.bind(driver)(); + }); + it('should kill emulator if avdArgs contains -wipe-data', async function () { + const opts = {avd: 'foo@bar', avdArgs: '-wipe-data'}; + driver.opts = opts; + sandbox.stub(driver.adb, 'getRunningAVDWithRetry').withArgs('foobar').returns('foo'); + sandbox.stub(driver.adb, 'killEmulator').withArgs('foobar').onFirstCall(); + sandbox.stub(driver.adb, 'launchAVD').onFirstCall(); + await prepareEmulator.bind(driver)(); + }); + it('should fail if avd name is not specified', async function () { + driver.opts = {}; + await prepareEmulator.bind(driver)().should.eventually.be.rejected; + }); + }); + describe('prepareAvdArgs', function () { + it('should set the correct avdArgs', function () { + driver.opts = {avdArgs: '-wipe-data'}; + prepareAvdArgs.bind(driver)().should.eql(['-wipe-data']); + }); + it('should add headless arg', function () { + driver.opts = {avdArgs: '-wipe-data', isHeadless: true}; + let args = prepareAvdArgs.bind(driver)(); + args.should.eql(['-wipe-data', '-no-window']); + }); + it('should add network speed arg', function () { + driver.opts = {avdArgs: '-wipe-data', networkSpeed: 'edge'}; + let args = prepareAvdArgs.bind(driver)(); + args.should.eql(['-wipe-data', '-netspeed', 'edge']); + }); + it('should not include empty avdArgs', function () { + driver.opts = {avdArgs: '', isHeadless: true}; + let args = prepareAvdArgs.bind(driver)(); + args.should.eql(['-no-window']); + }); + }); + + describe('getDeviceInfoFromCaps', function () { + // list of device/emu udids to their os versions + // using list instead of map to preserve order + let devices = [ + {udid: 'emulator-1234', os: '4.9.2'}, + {udid: 'rotalume-1339', os: '5.1.5'}, + {udid: 'rotalume-1338', os: '5.0.1'}, + {udid: 'rotalume-1337', os: '5.0.1'}, + {udid: 'roamulet-9000', os: '6.0'}, + {udid: 'roamulet-0', os: '2.3'}, + {udid: 'roamulet-2019', os: '9'}, + {udid: '0123456789', os: 'wellhellothere'}, + ]; + let curDeviceId = ''; + + before(function () { + sinon.stub(ADB, 'createADB').callsFake(function () { + return { + getDevicesWithRetry() { + return _.map(devices, function getDevice(device) { + return {udid: device.udid}; + }); + }, + + getPortFromEmulatorString() { + return 1234; + }, + + getRunningAVDWithRetry() { + return {udid: 'emulator-1234', port: 1234}; + }, + + setDeviceId(udid) { + curDeviceId = udid; + }, + + getPlatformVersion() { + return _.filter(devices, {udid: curDeviceId})[0].os; + }, + curDeviceId: 'emulator-1234', + emulatorPort: 1234, + }; + }); + }); + + after(function () { + ADB.createADB.restore(); + }); + + it('should throw error when udid not in list', async function () { + const driver = new AndroidDriver(); + driver.opts = { + udid: 'foomulator', + }; + driver.adb = await ADB.createADB(); + await driver.getDeviceInfoFromCaps().should.be.rejectedWith('foomulator'); + }); + it('should get deviceId and emPort when udid is present', async function () { + const driver = new AndroidDriver(); + driver.opts = { + udid: 'emulator-1234', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('emulator-1234'); + emPort.should.equal(1234); + }); + it('should get first deviceId and emPort if avd, platformVersion, and udid are not given', async function () { + const driver = new AndroidDriver(); + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('emulator-1234'); + emPort.should.equal(1234); + }); + it('should get deviceId and emPort when avd is present', async function () { + const driver = new AndroidDriver(); + driver.opts = { + avd: 'AVD_NAME', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('emulator-1234'); + emPort.should.equal(1234); + }); + it('should fail if the given platformVersion is not found', async function () { + const driver = new AndroidDriver(); + driver.opts = { + platformVersion: '1234567890', + }; + driver.adb = await ADB.createADB(); + await driver + .getDeviceInfoFromCaps() + .should.be.rejectedWith('Unable to find an active device or emulator with OS 1234567890'); + }); + it('should get deviceId and emPort if platformVersion is found and unique', async function () { + const driver = new AndroidDriver(); + driver.opts = { + platformVersion: '6.0', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('roamulet-9000'); + emPort.should.equal(1234); + }); + it('should get deviceId and emPort if platformVersion is shorter than os version', async function () { + const driver = new AndroidDriver(); + driver.opts = { + platformVersion: 9, + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('roamulet-2019'); + emPort.should.equal(1234); + }); + it('should get the first deviceId and emPort if platformVersion is found multiple times', async function () { + const driver = new AndroidDriver(); + driver.opts = { + platformVersion: '5.0.1', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('rotalume-1338'); + emPort.should.equal(1234); + }); + it('should get the deviceId and emPort of most recent version if we have partial match', async function () { + const driver = new AndroidDriver(); + driver.opts = { + platformVersion: '5.0', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('rotalume-1338'); + emPort.should.equal(1234); + }); + it('should get deviceId and emPort by udid if udid and platformVersion are given', async function () { + const driver = new AndroidDriver(); + driver.opts = { + udid: '0123456789', + platformVersion: '2.3', + }; + driver.adb = await ADB.createADB(); + let {udid, emPort} = await driver.getDeviceInfoFromCaps(); + udid.should.equal('0123456789'); + emPort.should.equal(1234); + }); + }); + describe('createADB', function () { + let curDeviceId = ''; + let emulatorPort = -1; + before(function () { + sinon.stub(ADB, 'createADB').callsFake(function () { + return { + setDeviceId: (udid) => { + curDeviceId = udid; + }, + + setEmulatorPort: (emPort) => { + emulatorPort = emPort; + }, + }; + }); + }); + after(function () { + ADB.createADB.restore(); + }); + it('should create adb and set device id and emulator port', async function () { + await driver.createADB({ + udid: '111222', + emPort: '111', + adbPort: '222', + suppressKillServer: true, + remoteAdbHost: 'remote_host', + clearDeviceLogsOnStart: true, + adbExecTimeout: 50, + useKeystore: true, + keystorePath: '/some/path', + keystorePassword: '123456', + keyAlias: 'keyAlias', + keyPassword: 'keyPassword', + remoteAppsCacheLimit: 5, + buildToolsVersion: '1.2.3', + allowOfflineDevices: true, + }); + ADB.createADB.calledWithExactly({ + adbPort: '222', + suppressKillServer: true, + remoteAdbHost: 'remote_host', + clearDeviceLogsOnStart: true, + adbExecTimeout: 50, + useKeystore: true, + keystorePath: '/some/path', + keystorePassword: '123456', + keyAlias: 'keyAlias', + keyPassword: 'keyPassword', + remoteAppsCacheLimit: 5, + buildToolsVersion: '1.2.3', + allowOfflineDevices: true, + allowDelayAdb: undefined, + }).should.be.true; + curDeviceId.should.equal('111222'); + emulatorPort.should.equal('111'); + }); + it('should not set emulator port if emPort is undefined', async function () { + emulatorPort = 5555; + await driver.createADB(); + emulatorPort.should.equal(5555); + }); + }); + describe('initDevice', function () { + it('should init a real device', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {language: 'en', locale: 'us', localeScript: 'Script'}; + sandbox.stub(driver.adb, 'waitForDevice').throws(); + sandbox.stub(driver.adb, 'startLogcat').onFirstCall(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale') + .withArgs(driver.opts.language, driver.opts.locale, driver.opts.localeScript) + .onFirstCall(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').withArgs('io.appium.settings').onFirstCall(); + await driver.initDevice(); + }); + it('should init device without locale and language', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {}; + sandbox.stub(driver.adb, 'waitForDevice').throws(); + sandbox.stub(driver.adb, 'startLogcat').onFirstCall(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale').throws(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').withArgs('io.appium.settings').onFirstCall(); + await driver.initDevice(); + }); + it('should init device with either locale or language', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {language: 'en'}; + sandbox.stub(driver.adb, 'waitForDevice').throws(); + sandbox.stub(driver.adb, 'startLogcat').onFirstCall(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale') + .withArgs(driver.opts.language, driver.opts.locale, driver.opts.localeScript) + .onFirstCall(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').withArgs('io.appium.settings').onFirstCall(); + await driver.initDevice(); + }); + it('should not install mock location on emulator', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {avd: 'avd'}; + sandbox.stub(driver.adb, 'waitForDevice').onFirstCall(); + sandbox.stub(driver.adb, 'startLogcat').onFirstCall(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale').throws(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').throws(); + await driver.initDevice(); + }); + it('should set empty IME if hideKeyboard is set to true', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {hideKeyboard: true}; + sandbox.stub(driver.adb, 'waitForDevice').throws(); + sandbox.stub(driver.adb, 'startLogcat').onFirstCall(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale').throws(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').onFirstCall(); + sandbox.stub(keyboardHelpers, 'hideKeyboardCompletely').onFirstCall(); + await driver.initDevice(); + }); + it('should init device without starting logcat', async function () { + const driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.opts = {skipLogcatCapture: true}; + sandbox.stub(driver.adb, 'waitForDevice').throws(); + sandbox.stub(driver.adb, 'startLogcat').throws(); + sandbox.stub(deviceUtils, 'pushSettingsApp').onFirstCall(); + sandbox.stub(driver, 'ensureDeviceLocale').throws(); + sandbox.stub(geolocationHelpers, 'setMockLocationApp').withArgs('io.appium.settings').onFirstCall(); + await driver.initDevice(); + }); + }); + +}); diff --git a/test/unit/commands/execute-specs.js b/test/unit/commands/emulator-actions-specs.js similarity index 90% rename from test/unit/commands/execute-specs.js rename to test/unit/commands/emulator-actions-specs.js index 38b15c22..820ac6c7 100644 --- a/test/unit/commands/execute-specs.js +++ b/test/unit/commands/emulator-actions-specs.js @@ -3,19 +3,20 @@ import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; import {AndroidDriver} from '../../../lib/driver'; +/** @type {AndroidDriver} */ let driver; let sandbox = sinon.createSandbox(); chai.should(); chai.use(chaiAsPromised); -describe('Execute', function () { +describe('Emulator Actions', function () { beforeEach(function () { driver = new AndroidDriver(); }); afterEach(function () { sandbox.restore(); }); - describe('execute', function () { + describe('sensorSet', function () { it('should call sensorSet', async function () { sandbox.stub(driver, 'sensorSet'); await driver.executeMobile('sensorSet', {sensorType: 'light', value: 0}); diff --git a/test/unit/commands/file-actions-specs.js b/test/unit/commands/file-actions-specs.js index cecddf79..0506ab83 100644 --- a/test/unit/commands/file-actions-specs.js +++ b/test/unit/commands/file-actions-specs.js @@ -5,6 +5,7 @@ import {AndroidDriver} from '../../../lib/driver'; import * as support from '@appium/support'; import ADB from 'appium-adb'; +/** @type {AndroidDriver} */ let driver; let sandbox = sinon.createSandbox(); chai.should(); diff --git a/test/unit/commands/gelolocation-specs.js b/test/unit/commands/gelolocation-specs.js new file mode 100644 index 00000000..14e180ed --- /dev/null +++ b/test/unit/commands/gelolocation-specs.js @@ -0,0 +1,45 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import ADB from 'appium-adb'; +import B from 'bluebird'; +import { AndroidDriver } from '../../../lib/driver'; +import { setMockLocationApp } from '../../../lib/commands/geolocation'; + +chai.use(chaiAsPromised); + +describe('Geolocation', function () { + let driver; + let sandbox = sinon.createSandbox(); + + beforeEach(function () { + const adb = new ADB(); + driver = new AndroidDriver(); + driver.adb = adb; + }); + afterEach(function () { + sandbox.verifyAndRestore(); + }); + + describe('setMockLocationApp', function () { + it('should enable mock location for api level below 23', async function () { + sandbox.stub(driver.adb, 'getApiLevel').resolves(18); + sandbox.stub(driver.adb, 'shell') + .withArgs(['settings', 'put', 'secure', 'mock_location', '1']) + .onFirstCall() + .returns(''); + sandbox.stub(driver.adb, 'fileExists').throws(); + await setMockLocationApp.bind(driver)('io.appium.settings'); + }); + it('should enable mock location for api level 23 and above', async function () { + sandbox.stub(driver.adb, 'getApiLevel').resolves(23); + sandbox.stub(driver.adb, 'shell') + .withArgs(['appops', 'set', 'io.appium.settings', 'android:mock_location', 'allow']) + .onFirstCall() + .returns(''); + sandbox.stub(driver.adb, 'fileExists').throws(); + await setMockLocationApp.bind(driver)('io.appium.settings'); + }); + }); + +}); diff --git a/test/unit/commands/ime-specs.js b/test/unit/commands/ime-specs.js index fb226d14..5ea0ce11 100644 --- a/test/unit/commands/ime-specs.js +++ b/test/unit/commands/ime-specs.js @@ -9,6 +9,7 @@ chai.should(); chai.use(chaiAsPromised); describe('IME', function () { + /** @type {AndroidDriver} */ let driver; let sandbox = sinon.createSandbox(); beforeEach(function () { diff --git a/test/unit/commands/keyboard-specs.js b/test/unit/commands/keyboard-specs.js new file mode 100644 index 00000000..8e4fee5e --- /dev/null +++ b/test/unit/commands/keyboard-specs.js @@ -0,0 +1,74 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import {AndroidDriver} from '../../../lib/driver'; +import ADB from 'appium-adb'; + +chai.should(); +chai.use(chaiAsPromised); + +/** @type {AndroidDriver} */ +let driver; +let sandbox = sinon.createSandbox(); + +describe('Keyboard', function () { + beforeEach(function () { + driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.caps = {}; + driver.opts = {}; + }); + afterEach(function () { + sandbox.restore(); + }); + describe('isKeyboardShown', function () { + it('should return true if the keyboard is shown', async function () { + driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { + return {isKeyboardShown: true, canCloseKeyboard: true}; + }; + (await driver.isKeyboardShown()).should.equal(true); + }); + it('should return false if the keyboard is not shown', async function () { + driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { + return {isKeyboardShown: false, canCloseKeyboard: true}; + }; + (await driver.isKeyboardShown()).should.equal(false); + }); + }); + describe('hideKeyboard', function () { + it('should hide keyboard with ESC command', async function () { + sandbox.stub(driver.adb, 'keyevent'); + let callIdx = 0; + driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { + callIdx++; + return { + isKeyboardShown: callIdx <= 1, + canCloseKeyboard: callIdx <= 1, + }; + }; + await driver.hideKeyboard().should.eventually.be.fulfilled; + driver.adb.keyevent.calledWithExactly(111).should.be.true; + }); + it('should throw if cannot close keyboard', async function () { + this.timeout(10000); + sandbox.stub(driver.adb, 'keyevent'); + driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { + return { + isKeyboardShown: true, + canCloseKeyboard: false, + }; + }; + await driver.hideKeyboard().should.eventually.be.rejected; + driver.adb.keyevent.notCalled.should.be.true; + }); + it('should not throw if no keyboard is present', async function () { + driver.adb.isSoftKeyboardPresent = function isSoftKeyboardPresent() { + return { + isKeyboardShown: false, + canCloseKeyboard: false, + }; + }; + await driver.hideKeyboard().should.eventually.be.fulfilled; + }); + }); +}); diff --git a/test/unit/commands/lock-specs.js b/test/unit/commands/lock-specs.js new file mode 100644 index 00000000..95951070 --- /dev/null +++ b/test/unit/commands/lock-specs.js @@ -0,0 +1,366 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import ADB from 'appium-adb'; +import { AndroidDriver } from '../../../lib/driver'; +import { + validateUnlockCapabilities, + encodePassword, + stringKeyToArr, + UNLOCK_WAIT_TIME, + fingerprintUnlock, + passwordUnlock, + pinUnlock, + KEYCODE_NUMPAD_ENTER, + getPatternKeyPosition, + getPatternActions, + patternUnlock, +} from '../../../lib/commands/lock/helpers'; +import {unlockWithOptions} from '../../../lib/commands/lock/exports'; +import * as unlockHelpers from '../../../lib/commands/lock/helpers'; +import * as asyncboxHelpers from 'asyncbox'; + +chai.use(chaiAsPromised); + +describe('Lock', function () { + /** @type {AndroidDriver} */ + let driver; + let sandbox = sinon.createSandbox(); + + beforeEach(function () { + const adb = new ADB(); + driver = new AndroidDriver(); + driver.adb = adb; + }); + afterEach(function () { + sandbox.verifyAndRestore(); + }); + + describe('unlockWithOptions', function () { + it('should return if screen is already unlocked', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').withArgs().onFirstCall().returns(false); + sandbox.stub(driver.adb, 'getApiLevel').throws(); + sandbox.stub(driver.adb, 'startApp').throws(); + sandbox.stub(driver.adb, 'isLockManagementSupported').throws(); + await unlockWithOptions.bind(driver)({}); + }); + it('should start unlock app', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onFirstCall().returns(true); + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(driver.adb, 'isLockManagementSupported').throws(); + await unlockWithOptions.bind(driver)({}); + }); + it('should raise an error on undefined unlockKey when unlockType is defined', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onFirstCall().returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').throws(); + await unlockWithOptions.bind(driver)({unlockType: 'pin'}).should.be.rejected; + }); + it('should call pinUnlock if unlockType is pin', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onFirstCall().returns(true); + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(false); + sandbox.stub(unlockHelpers, 'pinUnlock'); + await unlockWithOptions.bind(driver)({unlockType: 'pin', unlockKey: '1111'}); + }); + it('should call pinUnlock if unlockType is pinWithKeyEvent', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(false); + sandbox.stub(unlockHelpers, 'pinUnlockWithKeyEvent').onFirstCall(); + await unlockWithOptions.bind(driver)({unlockType: 'pinWithKeyEvent', unlockKey: '1111'}); + }); + it('should call fastUnlock if unlockKey is provided', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(true); + sandbox.stub(unlockHelpers, 'verifyUnlock').onFirstCall(); + sandbox.stub(unlockHelpers, 'fastUnlock').onFirstCall(); + await unlockWithOptions.bind(driver)({unlockKey: 'appium', unlockType: 'password'}); + }); + it('should call passwordUnlock if unlockType is password', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(false); + sandbox.stub(unlockHelpers, 'passwordUnlock').onFirstCall(); + await unlockWithOptions.bind(driver)({unlockType: 'password', unlockKey: 'appium'}); + }); + it('should call patternUnlock if unlockType is pattern', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(false); + sandbox.stub(unlockHelpers, 'patternUnlock').onFirstCall(); + await unlockWithOptions.bind(driver)({unlockType: 'pattern', unlockKey: '123456789'}); + }); + it('should call fingerprintUnlock if unlockType is fingerprint', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').throws(); + sandbox.stub(unlockHelpers, 'fingerprintUnlock').onFirstCall(); + await unlockWithOptions.bind(driver)({unlockType: 'fingerprint', unlockKey: '1111'}); + }); + it('should throw an error is api is lower than 23 and trying to use fingerprintUnlock', async function () { + sandbox.stub(driver.adb, 'isScreenLocked').onCall(0).returns(true); + sandbox.stub(driver.adb, 'isLockManagementSupported').onCall(0).returns(false); + sandbox.stub(driver.adb, 'getApiLevel').onFirstCall().returns(21); + await unlockWithOptions.bind(driver)({unlockType: 'fingerprint', unlockKey: '1111'}) + .should.be.rejectedWith('Fingerprint'); + }); + }); + describe('validateUnlockCapabilities', function () { + function toCaps(unlockType, unlockKey) { + return { + unlockType, + unlockKey, + }; + } + + it('should verify the unlock keys for pin/pinWithKeyEvent', function () { + for (const invalidValue of [undefined, ' ', '1abc']) { + chai.expect(() => validateUnlockCapabilities(toCaps('pin', invalidValue))).to.throw; + chai.expect(() => validateUnlockCapabilities(toCaps('pinWithKeyEvent', invalidValue))).to + .throw; + } + validateUnlockCapabilities(toCaps('pin', '1111')); + validateUnlockCapabilities(toCaps('pinWithKeyEvent', '1111')); + }); + it('should verify the unlock keys for fingerprint', function () { + for (const invalidValue of [undefined, ' ', '1abc']) { + chai.expect(() => validateUnlockCapabilities(toCaps('fingerprint', invalidValue))).to + .throw; + } + validateUnlockCapabilities(toCaps('fingerprint', '1')); + }); + it('should verify the unlock keys for pattern', function () { + for (const invalidValue of [undefined, '1abc', '', '1', '1213', '01234', ' ']) { + chai.expect(() => validateUnlockCapabilities(toCaps('pattern', invalidValue))).to.throw; + } + for (const validValue of ['1234', '123456789']) { + validateUnlockCapabilities(toCaps('pattern', validValue)); + } + }); + it('should verify the unlock keys for password', function () { + for (const invalidValue of [undefined, '123', ' ']) { + chai.expect(() => validateUnlockCapabilities(toCaps('password', invalidValue))).to.throw; + } + for (const validValue of [ + '121c3', + 'appium', + 'appium-android-driver', + '@#$%&-+()*"\':;!?,_ ./~`|={}\\[]', + ]) { + validateUnlockCapabilities(toCaps('password', validValue)); + } + }); + it('should throw error if unlock type is invalid', function () { + chai.expect(() => validateUnlockCapabilities(toCaps('invalid_unlock_type', '1'))).to.throw; + }); + }); + describe('encodePassword', function () { + it('should verify the password with blank space is encoded', function () { + encodePassword('a p p i u m').should.equal('a%sp%sp%si%su%sm'); + encodePassword(' ').should.equal('%s%s%s'); + }); + }); + describe('stringKeyToArr', function () { + it('should cast string keys to array', function () { + stringKeyToArr('1234').should.eql(['1', '2', '3', '4']); + stringKeyToArr(' 1234 ').should.eql(['1', '2', '3', '4']); + stringKeyToArr('1 2 3 4').should.eql(['1', '2', '3', '4']); + stringKeyToArr('1 2 3 4').should.eql(['1', '2', '3', '4']); + }); + }); + describe('fingerprintUnlock', function () { + it('should be able to unlock device via fingerprint if API level >= 23', async function () { + let caps = {unlockKey: '123'}; + sandbox.stub(driver.adb, 'getApiLevel').returns(23); + sandbox.stub(driver.adb, 'fingerprint').withArgs(caps.unlockKey).onFirstCall(); + sandbox.stub(asyncboxHelpers, 'sleep').withArgs(UNLOCK_WAIT_TIME).onFirstCall(); + await fingerprintUnlock.bind(driver)(caps).should.be.fulfilled; + }); + it('should throw error if API level < 23', async function () { + sandbox.stub(driver.adb, 'getApiLevel').returns(22); + sandbox.stub(driver.adb, 'fingerprint').throws(); + sandbox.stub(asyncboxHelpers, 'sleep').throws(); + await fingerprintUnlock.bind(driver)({}) + .should.eventually.be.rejectedWith('only works for Android 6+'); + }); + }); + describe('pinUnlock', function() { + const caps = {unlockKey: '13579'}; + const keys = ['1', '3', '5', '7', '9']; + const els = [ + {ELEMENT: 1}, + {ELEMENT: 2}, + {ELEMENT: 3}, + {ELEMENT: 4}, + {ELEMENT: 5}, + {ELEMENT: 6}, + {ELEMENT: 7}, + {ELEMENT: 8}, + {ELEMENT: 9}, + ]; + afterEach(function () { + sandbox.verifyAndRestore(); + }); + it('should be able to unlock device using pin (API level >= 21)', async function () { + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(unlockHelpers, 'stringKeyToArr').returns(keys); + sandbox.stub(driver.adb, 'getApiLevel').returns(21); + sandbox.stub(driver, 'findElOrEls') + .withArgs('id', 'com.android.systemui:id/digit_text', true) + .returns(els); + sandbox.stub(driver.adb, 'isScreenLocked').returns(true); + sandbox.stub(driver.adb, 'keyevent').withArgs(66).onFirstCall(); + const getAttributeStub = sandbox.stub(driver, 'getAttribute'); + for (let e of els) { + getAttributeStub + .withArgs('text', e.ELEMENT) + .returns(e.ELEMENT.toString()); + } + sandbox.stub(asyncboxHelpers, 'sleep').withArgs(UNLOCK_WAIT_TIME); + sandbox.stub(driver, 'click'); + + await pinUnlock.bind(driver)(caps); + + driver.click.getCall(0).args[0].should.equal(1); + driver.click.getCall(1).args[0].should.equal(3); + driver.click.getCall(2).args[0].should.equal(5); + driver.click.getCall(3).args[0].should.equal(7); + driver.click.getCall(4).args[0].should.equal(9); + }); + it('should be able to unlock device using pin (API level < 21)', async function () { + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(unlockHelpers, 'stringKeyToArr').returns(keys); + sandbox.stub(driver.adb, 'getApiLevel').returns(20); + const findElOrElsStub = sandbox.stub(driver, 'findElOrEls'); + for (let pin of keys) { + findElOrElsStub + .withArgs('id', `com.android.keyguard:id/key${pin}`, false) + .returns({ELEMENT: parseInt(pin, 10)}); + } + sandbox.stub(driver.adb, 'isScreenLocked').returns(false); + sandbox.stub(asyncboxHelpers, 'sleep').withArgs(UNLOCK_WAIT_TIME).onFirstCall(); + sandbox.stub(driver, 'click'); + + await pinUnlock.bind(driver)(caps); + + driver.click.getCall(0).args[0].should.equal(1); + driver.click.getCall(1).args[0].should.equal(3); + driver.click.getCall(2).args[0].should.equal(5); + driver.click.getCall(3).args[0].should.equal(7); + driver.click.getCall(4).args[0].should.equal(9); + }); + }); + describe('passwordUnlock', function() { + it('should be able to unlock device using password', async function () { + let caps = {unlockKey: 'psswrd'}; + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(unlockHelpers, 'encodePassword') + .withArgs(caps.unlockKey) + .returns(caps.unlockKey); + sandbox.stub(driver.adb, 'shell') + .withArgs(['input', 'text', caps.unlockKey]) + .withArgs(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]); + sandbox.stub(asyncboxHelpers, 'sleep'); + sandbox.stub(driver.adb, 'isScreenLocked').returns(true); + sandbox.stub(driver.adb, 'keyevent').withArgs(66).onFirstCall(); + await passwordUnlock.bind(driver)(caps); + }); + }); + describe('getPatternKeyPosition', function () { + it('should verify pattern pin is aproximatelly to its position', function () { + let pins = [1, 2, 3, 4, 5, 6, 7, 8, 9].map(function mapPins(pin) { + return getPatternKeyPosition(pin, {x: 33, y: 323}, 137.6); + }); + let cols = [101, 238, 375]; + let rows = [391, 528, 665]; + chai.expect(pins[0].x).to.be.within(cols[0] - 5, cols[0] + 5); + chai.expect(pins[1].x).to.be.within(cols[1] - 5, cols[1] + 5); + chai.expect(pins[2].x).to.be.within(cols[2] - 5, cols[2] + 5); + chai.expect(pins[3].x).to.be.within(cols[0] - 5, cols[0] + 5); + chai.expect(pins[4].x).to.be.within(cols[1] - 5, cols[1] + 5); + chai.expect(pins[5].x).to.be.within(cols[2] - 5, cols[2] + 5); + chai.expect(pins[6].x).to.be.within(cols[0] - 5, cols[0] + 5); + chai.expect(pins[7].x).to.be.within(cols[1] - 5, cols[1] + 5); + chai.expect(pins[8].x).to.be.within(cols[2] - 5, cols[2] + 5); + chai.expect(pins[0].y).to.be.within(rows[0] - 5, rows[0] + 5); + chai.expect(pins[1].y).to.be.within(rows[0] - 5, rows[0] + 5); + chai.expect(pins[2].y).to.be.within(rows[0] - 5, rows[0] + 5); + chai.expect(pins[3].y).to.be.within(rows[1] - 5, rows[1] + 5); + chai.expect(pins[4].y).to.be.within(rows[1] - 5, rows[1] + 5); + chai.expect(pins[5].y).to.be.within(rows[1] - 5, rows[1] + 5); + chai.expect(pins[6].y).to.be.within(rows[2] - 5, rows[2] + 5); + chai.expect(pins[7].y).to.be.within(rows[2] - 5, rows[2] + 5); + chai.expect(pins[8].y).to.be.within(rows[2] - 5, rows[2] + 5); + }); + }); + describe('getPatternActions', function () { + it('should generate press, moveTo, relase gesture scheme to unlock by pattern', function () { + let keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; + let actions = getPatternActions(keys, {x: 0, y: 0}, 1); + actions.map((action, i) => { + if (i === 0) { + action.action.should.equal('press'); + } else if (i === keys.length) { + action.action.should.equal('release'); + } else { + action.action.should.equal('moveTo'); + } + }); + }); + it('should verify pattern gestures moves to non consecutives pins', function () { + let keys = ['7', '2', '9', '8', '5', '6', '1', '4', '3']; + let actions = getPatternActions(keys, {x: 0, y: 0}, 1); + // Move from pin 7 to pin 2 + actions[1].options.x.should.equal(2); + actions[1].options.y.should.equal(1); + // Move from pin 2 to pin 9 + actions[2].options.x.should.equal(3); + actions[2].options.y.should.equal(3); + // Move from pin 9 to pin 8 + actions[3].options.x.should.equal(2); + actions[3].options.y.should.equal(3); + // Move from pin 8 to pin 5 + actions[4].options.x.should.equal(2); + actions[4].options.y.should.equal(2); + // Move from pin 5 to pin 6 + actions[5].options.x.should.equal(3); + actions[5].options.y.should.equal(2); + // Move from pin 6 to pin 1 + actions[6].options.x.should.equal(1); + actions[6].options.y.should.equal(1); + // Move from pin 1 to pin 4 + actions[7].options.x.should.equal(1); + actions[7].options.y.should.equal(2); + // Move from pin 4 to pin 3 + actions[8].options.x.should.equal(3); + actions[8].options.y.should.equal(1); + }); + }); + describe('patternUnlock', function () { + const el = {ELEMENT: 1}; + const pos = {x: 10, y: 20}; + const size = {width: 300}; + const keys = ['1', '3', '5', '7', '9']; + const caps = {unlockKey: '13579'}; + beforeEach(function () { + sandbox.stub(driver.adb, 'dismissKeyguard').onFirstCall(); + sandbox.stub(unlockHelpers, 'stringKeyToArr').returns(keys); + sandbox.stub(driver, 'getLocation').withArgs(el.ELEMENT).returns(pos); + sandbox.stub(driver, 'getSize').withArgs(el.ELEMENT).returns(size); + sandbox.stub(unlockHelpers, 'getPatternActions').withArgs(keys, pos, 100).returns('actions'); + sandbox.stub(driver, 'performTouch').withArgs('actions').onFirstCall(); + sandbox.stub(asyncboxHelpers, 'sleep').withArgs(UNLOCK_WAIT_TIME).onFirstCall(); + }); + it('should be able to unlock device using pattern (API level >= 21)', async function () { + sandbox.stub(driver.adb, 'getApiLevel').returns(21); + sandbox.stub(driver, 'findElOrEls') + .withArgs('id', 'com.android.systemui:id/lockPatternView', false) + .returns(el); + await patternUnlock.bind(driver)(caps); + }); + it('should be able to unlock device using pattern (API level < 21)', async function () { + sandbox.stub(driver.adb, 'getApiLevel').returns(20); + sandbox.stub(driver, 'findElOrEls') + .withArgs('id', 'com.android.keyguard:id/lockPatternView', false) + .returns(el); + await patternUnlock.bind(driver)(caps); + }); + }); +}); diff --git a/test/unit/commands/log-specs.js b/test/unit/commands/log-specs.js index 189d2b37..ec1a5dfa 100644 --- a/test/unit/commands/log-specs.js +++ b/test/unit/commands/log-specs.js @@ -10,6 +10,7 @@ chai.should(); chai.use(chaiAsPromised); describe('commands - logging', function () { + /** @type {AndroidDriver} */ let driver; before(function () { driver = new AndroidDriver(); diff --git a/test/unit/commands/misc-specs.js b/test/unit/commands/misc-specs.js new file mode 100644 index 00000000..b43af633 --- /dev/null +++ b/test/unit/commands/misc-specs.js @@ -0,0 +1,64 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import {AndroidDriver} from '../../../lib/driver'; +import ADB from 'appium-adb'; + +chai.should(); +chai.use(chaiAsPromised); + +/** @type {AndroidDriver} */ +let driver; +let sandbox = sinon.createSandbox(); + +describe('General', function () { + beforeEach(function () { + driver = new AndroidDriver(); + driver.adb = new ADB(); + driver.caps = {}; + driver.opts = {}; + }); + afterEach(function () { + sandbox.restore(); + }); + describe('setUrl', function () { + it('should set url', async function () { + driver.opts = {appPackage: 'pkg'}; + sandbox.stub(driver.adb, 'startUri'); + await driver.setUrl('url'); + driver.adb.startUri.calledWithExactly('url', 'pkg').should.be.true; + }); + }); + describe('getDisplayDensity', function () { + it('should return the display density of a device', async function () { + driver.adb.shell = () => '123'; + (await driver.getDisplayDensity()).should.equal(123); + }); + it('should return the display density of an emulator', async function () { + driver.adb.shell = (cmd) => { + let joinedCmd = cmd.join(' '); + if (joinedCmd.indexOf('ro.sf') !== -1) { + // device property look up + return ''; + } else if (joinedCmd.indexOf('qemu.sf') !== -1) { + // emulator property look up + return '456'; + } + return ''; + }; + (await driver.getDisplayDensity()).should.equal(456); + }); + it("should throw an error if the display density property can't be found", async function () { + driver.adb.shell = () => ''; + await driver + .getDisplayDensity() + .should.be.rejectedWith(/Failed to get display density property/); + }); + it('should throw and error if the display density is not a number', async function () { + driver.adb.shell = () => 'abc'; + await driver + .getDisplayDensity() + .should.be.rejectedWith(/Failed to get display density property/); + }); + }); +}); diff --git a/test/unit/commands/network-specs.js b/test/unit/commands/network-specs.js index 71e585cc..1a872d5f 100644 --- a/test/unit/commands/network-specs.js +++ b/test/unit/commands/network-specs.js @@ -6,8 +6,11 @@ import {AndroidDriver} from '../../../lib/driver'; import B from 'bluebird'; import { SettingsApp } from 'io.appium.settings'; +/** @type {AndroidDriver} */ let driver; +/** @type {ADB} */ let adb; +/** @type {SettingsApp} */ let settingsApp; let sandbox = sinon.createSandbox(); chai.should(); diff --git a/test/unit/commands/systems-bars-specs.js b/test/unit/commands/systems-bars-specs.js index 95e26f4e..fb04ebdc 100644 --- a/test/unit/commands/systems-bars-specs.js +++ b/test/unit/commands/systems-bars-specs.js @@ -8,9 +8,16 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('System Bars', function () { + /** @type {AndroidDriver} */ + let driver; + + before(function () { + driver = new AndroidDriver(); + }); + describe('parseWindowProperties', function () { it('should return visible true if the surface is visible', function () { - parseWindowProperties( + parseWindowProperties.bind(driver)( 'yolo', ` mDisplayId=0 rootTaskId=1 mSession=Session{6fdbba 684:u0a10144} mClient=android.os.BinderProxy@dbd59e0 @@ -58,7 +65,7 @@ describe('System Bars', function () { }); }); it('should return visible false if the surface is not visible', function () { - parseWindowProperties( + parseWindowProperties.bind(driver)( 'foo', ` mDisplayId=0 rootTaskId=1 mSession=Session{6fdbba 684:u0a10144} mClient=android.os.BinderProxy@dbd59e0 @@ -107,7 +114,7 @@ describe('System Bars', function () { }); it('should throw an error if no info is found', function () { expect(() => { - parseWindowProperties('bar', []); + parseWindowProperties.bind(driver)('bar', []); }).to.throw(Error); }); }); @@ -1138,11 +1145,11 @@ WINDOW MANAGER WINDOWS (dumpsys window windows) describe('parseWindows', function () { it('should throw an error if no windows were found', function () { expect(() => { - parseWindows(''); + parseWindows.bind(driver)(''); }).to.throw(Error); }); it('should return defaults if only non matching windows were found', function () { - parseWindows(` + parseWindows.bind(driver)(` WINDOW MANAGER WINDOWS (dumpsys window windows) Window #0 Window{d1b7133 u0 pip-dismiss-overlay}: mDisplayId=0 rootTaskId=1 mSession=Session{6fdbba 684:u0a10144} mClient=android.os.BinderProxy@a5e1e9f @@ -1180,13 +1187,13 @@ WINDOW MANAGER WINDOWS (dumpsys window windows) }); }); it('should return status and navigation bar for Android 11 and below', function () { - parseWindows(validWindowOutputA11).should.be.eql(validSystemBarsA11); + parseWindows.bind(driver)(validWindowOutputA11).should.be.eql(validSystemBarsA11); }); it('should return status and navigation bar for Android 12', function () { - parseWindows(validWindowOutputA12).should.be.eql(validSystemBarsA12); + parseWindows.bind(driver)(validWindowOutputA12).should.be.eql(validSystemBarsA12); }); it('should return status and navigation bar for Android 13 and above', function () { - parseWindows(validWindowOutputA13).should.be.eql(validSystemBarsA13); + parseWindows.bind(driver)(validWindowOutputA13).should.be.eql(validSystemBarsA13); }); }); diff --git a/test/unit/commands/touch-specs.js b/test/unit/commands/touch-specs.js deleted file mode 100644 index b2a26d59..00000000 --- a/test/unit/commands/touch-specs.js +++ /dev/null @@ -1,209 +0,0 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import {AndroidDriver} from '../../../lib/driver'; -import {withMocks} from '@appium/test-support'; -import ADB from 'appium-adb'; - -chai.should(); -chai.use(chaiAsPromised); - -describe('Touch', function () { - let adb = new ADB(); - let driver = new AndroidDriver(); - driver.adb = adb; - - describe('#parseTouch', function () { - describe('given a touch sequence with absolute coordinates', function () { - it('should use offsets for moveTo', async function () { - // let driver = new AndroidDriver({foo: 'bar'}); - let actions = [ - {action: 'press', options: {x: 100, y: 101}}, - {action: 'moveTo', options: {x: 50, y: 51}}, - {action: 'wait', options: {ms: 5000}}, - {action: 'moveTo', options: {x: -40, y: -41}}, - {action: 'release', options: {}}, - ]; - let touchStates = await driver.parseTouch(actions, false); - touchStates.length.should.equal(5); - let parsedActions = [ - {action: 'press', x: 100, y: 101}, - {action: 'moveTo', x: 50, y: 51}, - {action: 'wait', x: 50, y: 51}, - {action: 'moveTo', x: -40, y: -41}, - {action: 'release'}, - ]; - let index = 0; - for (let state of touchStates) { - state.action.should.equal(parsedActions[index].action); - if (actions[index].action !== 'release') { - state.options.x.should.equal(parsedActions[index].x); - state.options.y.should.equal(parsedActions[index].y); - } - index++; - } - }); - }); - }); - - describe( - 'fixRelease', - withMocks({driver, adb}, (mocks) => { - it('should be able to get the correct release coordinates', async function () { - let actions = [ - {action: 'press', options: {x: 20, y: 21}}, - {action: 'moveTo', options: {x: 10, y: 11}}, - {action: 'release'}, - ]; - let release = await driver.fixRelease(actions, false); - release.options.should.eql({x: 10, y: 11}); - }); - it('should be able to get the correct element release offset', async function () { - mocks.driver.expects('getLocationInView').withExactArgs(2).returns({x: 100, y: 101}); - let actions = [ - {action: 'press', options: {element: 1, x: 20, y: 21}}, - {action: 'moveTo', options: {element: 2, x: 10, y: 11}}, - {action: 'release'}, - ]; - let release = await driver.fixRelease(actions, false); - release.options.should.eql({x: 110, y: 112}); - }); - it('should be able to get the correct element release', async function () { - mocks.driver.expects('getLocationInView').withExactArgs(2).returns({x: 100, y: 101}); - mocks.driver.expects('getSize').withExactArgs(2).returns({width: 5, height: 6}); - let actions = [ - {action: 'press', options: {element: 1, x: 20, y: 21}}, - {action: 'moveTo', options: {element: 2}}, - {action: 'release'}, - ]; - let release = await driver.fixRelease(actions, false); - release.options.should.eql({x: 102.5, y: 104}); - }); - }) - ); - - describe( - 'doTouchDrag', - withMocks({driver, adb}, (mocks) => { - let tests = (apiLevel, defaultDuration) => { - it('should handle longPress not having duration', async function () { - let expectedDuration = defaultDuration; - let actions = [ - {action: 'longPress', options: {x: 100, y: 101}}, - {action: 'moveTo', options: {x: 50, y: 51}}, - {action: 'release', options: {}}, - ]; - - mocks.driver - .expects('drag') - .withExactArgs( - actions[0].options.x, - actions[0].options.y, - actions[1].options.x, - actions[1].options.y, - expectedDuration, - 1, - undefined, - undefined - ) - .returns(''); - await driver.doTouchDrag(actions); - - mocks.driver.verify(); - }); - it('should handle longPress having duration', async function () { - let expectedDuration = 4; - let actions = [ - {action: 'longPress', options: {x: 100, y: 101, duration: expectedDuration * 1000}}, - {action: 'moveTo', options: {x: 50, y: 51}}, - {action: 'release', options: {}}, - ]; - - mocks.driver - .expects('drag') - .withExactArgs( - actions[0].options.x, - actions[0].options.y, - actions[1].options.x, - actions[1].options.y, - expectedDuration, - 1, - undefined, - undefined - ) - .returns(''); - await driver.doTouchDrag(actions); - - mocks.driver.verify(); - }); - it('should handle longPress having duration less than minimum', async function () { - let expectedDuration = defaultDuration; - let actions = [ - {action: 'longPress', options: {x: 100, y: 101, duration: 500}}, - {action: 'moveTo', options: {x: 50, y: 51}}, - {action: 'release', options: {}}, - ]; - - mocks.driver - .expects('drag') - .withExactArgs( - actions[0].options.x, - actions[0].options.y, - actions[1].options.x, - actions[1].options.y, - expectedDuration, - 1, - undefined, - undefined - ) - .returns(''); - await driver.doTouchDrag(actions); - - mocks.driver.verify(); - }); - }; - - describe('android >5', function () { - beforeEach(function () { - mocks.adb.expects('getApiLevel').returns(5); - }); - afterEach(function () { - mocks.adb.verify(); - mocks.adb.restore(); - }); - tests(5, 2); - }); - describe('android <5', function () { - beforeEach(function () { - mocks.adb.expects('getApiLevel').returns(4.4); - }); - afterEach(function () { - mocks.adb.verify(); - mocks.adb.restore(); - }); - tests(4.4, 1); - }); - }) - ); - - describe('parseTouch', function () { - it('should handle actions starting with wait', async function () { - let actions = [ - {action: 'wait', options: {ms: 500}}, - {action: 'tap', options: {x: 100, y: 101}}, - ]; - - let touchStateObject = await driver.parseTouch(actions, true); - touchStateObject.should.eql([ - { - action: 'wait', - time: 0.5, - }, - { - action: 'tap', - touch: {x: 100, y: 101}, - time: 0.505, - }, - ]); - }); - }); -}); diff --git a/test/unit/webview-helper-specs.js b/test/unit/commands/webview-helper-specs.js similarity index 68% rename from test/unit/webview-helper-specs.js rename to test/unit/commands/webview-helper-specs.js index 279e270d..c14da48d 100644 --- a/test/unit/webview-helper-specs.js +++ b/test/unit/commands/webview-helper-specs.js @@ -1,7 +1,9 @@ import sinon from 'sinon'; -import helpers, {DEVTOOLS_SOCKET_PATTERN} from '../../lib/helpers/webview'; +import {DEVTOOLS_SOCKET_PATTERN} from '../../../lib/commands/context/helpers'; +import * as webviewHelpers from '../../../lib/commands/context/helpers'; import ADB from 'appium-adb'; import chai from 'chai'; +import { AndroidDriver } from '../../../lib/driver'; chai.should(); @@ -9,6 +11,11 @@ let sandbox = sinon.createSandbox(); describe('Webview Helpers', function () { let adb = new ADB(); + let driver = new AndroidDriver(); + + before(function () { + driver.adb = adb; + }); afterEach(function () { sandbox.restore(); @@ -39,10 +46,12 @@ describe('Webview Helpers', function () { '0000000000000000: 00000002 00000000 00010000 0001 01 2826 /dev/socket/installd\n' ); }); - const webviewsMapping = await helpers.getWebViewsMapping(adb, { + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)({ androidDeviceSocket: 'webview_devtools_remote_123', + ensureWebviewsHavePages: false, + enableWebviewDetailsCollection: false, }); - webViews = helpers.parseWebviewNames(webviewsMapping, { + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping, { ensureWebviewsHavePages: false, }); }); @@ -72,10 +81,12 @@ describe('Webview Helpers', function () { ); }); - const webviewsMapping = await helpers.getWebViewsMapping(adb, { + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)({ androidDeviceSocket: 'chrome_devtools_remote', + ensureWebviewsHavePages: false, + enableWebviewDetailsCollection: false, }); - webViews = helpers.parseWebviewNames(webviewsMapping, { + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping, { ensureWebviewsHavePages: false, }); }); @@ -104,8 +115,11 @@ describe('Webview Helpers', function () { ); }); - const webviewsMapping = await helpers.getWebViewsMapping(adb); - webViews = helpers.parseWebviewNames(webviewsMapping); + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)(); + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping, { + ensureWebviewsHavePages: false, + enableWebviewDetailsCollection: false, + }); }); it('then the unix sockets are queried', function () { @@ -118,62 +132,6 @@ describe('Webview Helpers', function () { }); }); - describe('and page existence is ensured', function () { - let webViews; - - beforeEach(async function () { - sandbox.stub(adb, 'shell').callsFake(function () { - return ( - 'Num RefCount Protocol Flags Type St Inode Path\n' + - '0000000000000000: 00000002 00000000 00010000 0001 01 2818 /dev/socket/ss_conn_daemon\n' + - '0000000000000000: 00000002 00000000 00010000 0001 01 9231 @mcdaemon\n' + - '0000000000000000: 00000002 00000000 00010000 0001 01 245445 @webview_devtools_remote_123\n' + - '0000000000000000: 00000002 00000000 00010000 0001 01 2826 /dev/socket/installd\n' - ); - }); - }); - - describe('and webviews are unreachable', function () { - beforeEach(async function () { - const webviewsMapping = await helpers.getWebViewsMapping(adb, { - androidDeviceSocket: 'webview_devtools_remote_123', - }); - webviewsMapping.length.should.equal(1); - webviewsMapping[0].should.not.have.key('pages'); - webViews = helpers.parseWebviewNames(webviewsMapping); - }); - - it('then the unix sockets are queried', function () { - adb.shell.calledOnce.should.be.true; - adb.shell.getCall(0).args[0].should.deep.equal(['cat', '/proc/net/unix']); - }); - - it('then no webviews are returned', function () { - webViews.length.should.equal(0); - }); - }); - - describe('and webviews have no pages', function () { - beforeEach(async function () { - const webviewsMapping = await helpers.getWebViewsMapping(adb, { - androidDeviceSocket: 'webview_devtools_remote_123', - }); - webviewsMapping.length.should.equal(1); - webviewsMapping[0].pages = []; - webViews = helpers.parseWebviewNames(webviewsMapping); - }); - - it('then the unix sockets are queried', function () { - adb.shell.calledOnce.should.be.true; - adb.shell.getCall(0).args[0].should.deep.equal(['cat', '/proc/net/unix']); - }); - - it('then no webviews are returned', function () { - webViews.length.should.equal(0); - }); - }); - }); - describe('and crosswalk webviews exist', function () { let webViews; @@ -191,8 +149,11 @@ describe('Webview Helpers', function () { describe('and the device socket is not specified', function () { beforeEach(async function () { - const webviewsMapping = await helpers.getWebViewsMapping(adb); - webViews = helpers.parseWebviewNames(webviewsMapping, { + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)({ + ensureWebviewsHavePages: false, + enableWebviewDetailsCollection: false, + }); + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping, { ensureWebviewsHavePages: false, }); }); @@ -210,10 +171,12 @@ describe('Webview Helpers', function () { describe('and the device socket is specified', function () { beforeEach(async function () { - const webviewsMapping = await helpers.getWebViewsMapping(adb, { + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)({ androidDeviceSocket: 'com.application.myapp_devtools_remote', + ensureWebviewsHavePages: false, + enableWebviewDetailsCollection: false, }); - webViews = helpers.parseWebviewNames(webviewsMapping, { + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping, { ensureWebviewsHavePages: false, }); }); @@ -231,10 +194,10 @@ describe('Webview Helpers', function () { describe('and the device socket is specified but is not found', function () { beforeEach(async function () { - const webviewsMapping = await helpers.getWebViewsMapping(adb, { + const webviewsMapping = await webviewHelpers.getWebViewsMapping.bind(driver)({ androidDeviceSocket: 'com.application.myotherapp_devtools_remote', }); - webViews = helpers.parseWebviewNames(webviewsMapping); + webViews = webviewHelpers.parseWebviewNames.bind(driver)(webviewsMapping); }); it('then the unix sockets are queried', function () { diff --git a/test/unit/test-helper-specs.js b/test/unit/test-helper-specs.js deleted file mode 100644 index b930b2ee..00000000 --- a/test/unit/test-helper-specs.js +++ /dev/null @@ -1,46 +0,0 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import { withMocks } from '@appium/test-support'; -import { system } from '@appium/support'; -import path from 'path'; -import { getChromedriver220Asset } from '../functional/helpers'; - - -chai.should(); -chai.use(chaiAsPromised); - -describe('test helpers', function () { - describe('getChromedriver220Asset', withMocks({system}, (mocks) => { - let basePath = path.resolve(__dirname, '..', 'assets', 'chromedriver-2.20'); - - it('should get the correct path for Windows', async function () { - mocks.system.expects('isWindows').once().returns(true); - let cdPath = path.normalize(await getChromedriver220Asset()); - cdPath.should.eql(path.resolve(basePath, 'windows', 'chromedriver.exe')); - mocks.system.verify(); - }); - it('should get the correct path for Mac', async function () { - mocks.system.expects('isWindows').once().returns(false); - mocks.system.expects('isMac').once().returns(true); - let cdPath = path.normalize(await getChromedriver220Asset()); - cdPath.should.eql(path.resolve(basePath, 'mac', 'chromedriver')); - mocks.system.verify(); - }); - it('should get the correct path for Unix 32-bit', async function () { - mocks.system.expects('isWindows').once().returns(false); - mocks.system.expects('isMac').once().returns(false); - mocks.system.expects('arch').once().returns('32'); - let cdPath = path.normalize(await getChromedriver220Asset()); - cdPath.should.eql(path.resolve(basePath, 'linux-32', 'chromedriver')); - mocks.system.verify(); - }); - it('should get the correct path for Unix 64-bit', async function () { - mocks.system.expects('isWindows').once().returns(false); - mocks.system.expects('isMac').once().returns(false); - mocks.system.expects('arch').once().returns('64'); - let cdPath = path.normalize(await getChromedriver220Asset()); - cdPath.should.eql(path.resolve(basePath, 'linux-64', 'chromedriver')); - mocks.system.verify(); - }); - })); -}); diff --git a/test/unit/unlock-helper-specs.js b/test/unit/unlock-helper-specs.js deleted file mode 100644 index 9a1ff513..00000000 --- a/test/unit/unlock-helper-specs.js +++ /dev/null @@ -1,352 +0,0 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import {withMocks} from '@appium/test-support'; -import sinon from 'sinon'; -import helpers from '../../lib/helpers/unlock'; -import {AndroidDriver} from '../../lib/driver'; -import * as asyncbox from 'asyncbox'; -import ADB from 'appium-adb'; - -const KEYCODE_NUMPAD_ENTER = 66; -const INPUT_KEYS_WAIT_TIME = 100; -const UNLOCK_WAIT_TIME = 100; - -chai.should(); -chai.use(chaiAsPromised); - -describe('Unlock Helpers', function () { - let adb = new ADB(); - let driver = new AndroidDriver(); - let sandbox = sinon.createSandbox(); - let expect = chai.expect; - describe('validateUnlockCapabilities', function () { - function toCaps(unlockType, unlockKey) { - return { - unlockType, - unlockKey, - }; - } - - it('should verify the unlock keys for pin/pinWithKeyEvent', function () { - for (const invalidValue of [undefined, ' ', '1abc']) { - expect(() => helpers.validateUnlockCapabilities(toCaps('pin', invalidValue))).to.throw; - expect(() => helpers.validateUnlockCapabilities(toCaps('pinWithKeyEvent', invalidValue))).to - .throw; - } - helpers.validateUnlockCapabilities(toCaps('pin', '1111')); - helpers.validateUnlockCapabilities(toCaps('pinWithKeyEvent', '1111')); - }); - it('should verify the unlock keys for fingerprint', function () { - for (const invalidValue of [undefined, ' ', '1abc']) { - expect(() => helpers.validateUnlockCapabilities(toCaps('fingerprint', invalidValue))).to - .throw; - } - helpers.validateUnlockCapabilities(toCaps('fingerprint', '1')); - }); - it('should verify the unlock keys for pattern', function () { - for (const invalidValue of [undefined, '1abc', '', '1', '1213', '01234', ' ']) { - expect(() => helpers.validateUnlockCapabilities(toCaps('pattern', invalidValue))).to.throw; - } - for (const validValue of ['1234', '123456789']) { - helpers.validateUnlockCapabilities(toCaps('pattern', validValue)); - } - }); - it('should verify the unlock keys for password', function () { - for (const invalidValue of [undefined, '123', ' ']) { - expect(() => helpers.validateUnlockCapabilities(toCaps('password', invalidValue))).to.throw; - } - for (const validValue of [ - '121c3', - 'appium', - 'appium-android-driver', - '@#$%&-+()*"\':;!?,_ ./~`|={}\\[]', - ]) { - helpers.validateUnlockCapabilities(toCaps('password', validValue)); - } - }); - it('should throw error if unlock type is invalid', function () { - expect(() => helpers.validateUnlockCapabilities(toCaps('invalid_unlock_type', '1'))).to.throw; - }); - }); - describe('encodePassword', function () { - it('should verify the password with blank space is encoded', function () { - helpers.encodePassword('a p p i u m').should.equal('a%sp%sp%si%su%sm'); - helpers.encodePassword(' ').should.equal('%s%s%s'); - }); - }); - describe('stringKeyToArr', function () { - it('should cast string keys to array', function () { - helpers.stringKeyToArr('1234').should.eql(['1', '2', '3', '4']); - helpers.stringKeyToArr(' 1234 ').should.eql(['1', '2', '3', '4']); - helpers.stringKeyToArr('1 2 3 4').should.eql(['1', '2', '3', '4']); - helpers.stringKeyToArr('1 2 3 4').should.eql(['1', '2', '3', '4']); - }); - }); - describe( - 'fingerprintUnlock', - withMocks({adb, asyncbox}, (mocks) => { - it('should be able to unlock device via fingerprint if API level >= 23', async function () { - let caps = {unlockKey: '123'}; - mocks.adb.expects('getApiLevel').returns(23); - mocks.adb.expects('fingerprint').withExactArgs(caps.unlockKey).once(); - mocks.asyncbox.expects('sleep').withExactArgs(UNLOCK_WAIT_TIME).once(); - await helpers.fingerprintUnlock(adb, driver, caps).should.be.fulfilled; - mocks.adb.verify(); - mocks.asyncbox.verify(); - }); - it('should throw error if API level < 23', async function () { - mocks.adb.expects('getApiLevel').returns(22); - mocks.adb.expects('fingerprint').never(); - mocks.asyncbox.expects('sleep').never(); - await helpers - .fingerprintUnlock(adb) - .should.eventually.be.rejectedWith('only works for Android 6+'); - mocks.adb.verify(); - mocks.asyncbox.verify(); - }); - }) - ); - describe( - 'pinUnlock', - withMocks({adb, helpers, driver, asyncbox}, (mocks) => { - const caps = {unlockKey: '13579'}; - const keys = ['1', '3', '5', '7', '9']; - const els = [ - {ELEMENT: 1}, - {ELEMENT: 2}, - {ELEMENT: 3}, - {ELEMENT: 4}, - {ELEMENT: 5}, - {ELEMENT: 6}, - {ELEMENT: 7}, - {ELEMENT: 8}, - {ELEMENT: 9}, - ]; - afterEach(function () { - sandbox.restore(); - }); - it('should be able to unlock device using pin (API level >= 21)', async function () { - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers.expects('stringKeyToArr').returns(keys); - mocks.adb.expects('getApiLevel').returns(21); - mocks.driver - .expects('findElOrEls') - .withExactArgs('id', 'com.android.systemui:id/digit_text', true) - .returns(els); - mocks.adb.expects('isScreenLocked').returns(true); - mocks.adb.expects('keyevent').withExactArgs(66).once(); - for (let e of els) { - mocks.driver - .expects('getAttribute') - .withExactArgs('text', e.ELEMENT) - .returns(e.ELEMENT.toString()); - } - mocks.asyncbox.expects('sleep').withExactArgs(UNLOCK_WAIT_TIME).twice(); - sandbox.stub(driver, 'click'); - - await helpers.pinUnlock(adb, driver, caps); - - driver.click.getCall(0).args[0].should.equal(1); - driver.click.getCall(1).args[0].should.equal(3); - driver.click.getCall(2).args[0].should.equal(5); - driver.click.getCall(3).args[0].should.equal(7); - driver.click.getCall(4).args[0].should.equal(9); - - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.adb.verify(); - mocks.asyncbox.verify(); - }); - it('should be able to unlock device using pin (API level < 21)', async function () { - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers.expects('stringKeyToArr').returns(keys); - mocks.adb.expects('getApiLevel').returns(20); - for (let pin of keys) { - mocks.driver - .expects('findElOrEls') - .withExactArgs('id', `com.android.keyguard:id/key${pin}`, false) - .returns({ELEMENT: parseInt(pin, 10)}); - } - mocks.adb.expects('isScreenLocked').returns(false); - mocks.asyncbox.expects('sleep').withExactArgs(UNLOCK_WAIT_TIME).once(); - sandbox.stub(driver, 'click'); - - await helpers.pinUnlock(adb, driver, caps); - - driver.click.getCall(0).args[0].should.equal(1); - driver.click.getCall(1).args[0].should.equal(3); - driver.click.getCall(2).args[0].should.equal(5); - driver.click.getCall(3).args[0].should.equal(7); - driver.click.getCall(4).args[0].should.equal(9); - - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.adb.verify(); - mocks.asyncbox.verify(); - }); - it('should throw error if pin buttons does not exist (API level >= 21)', async function () { - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers.expects('stringKeyToArr').once(); - mocks.adb.expects('getApiLevel').returns(21); - mocks.driver.expects('findElOrEls').returns(null); - mocks.helpers.expects('pinUnlockWithKeyEvent').once(); - await helpers.pinUnlock(adb, driver, caps); - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.adb.verify(); - }); - it('should throw error if pin buttons does not exist (API level < 21)', async function () { - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers.expects('stringKeyToArr').returns(keys); - mocks.adb.expects('getApiLevel').returns(20); - mocks.driver - .expects('findElOrEls') - .withExactArgs('id', 'com.android.keyguard:id/key1', false) - .returns(null); - mocks.helpers.expects('pinUnlockWithKeyEvent').once(); - await helpers.pinUnlock(adb, driver, caps); - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.adb.verify(); - }); - }) - ); - describe( - 'passwordUnlock', - withMocks({adb, helpers, driver, asyncbox}, (mocks) => { - it('should be able to unlock device using password', async function () { - let caps = {unlockKey: 'psswrd'}; - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers - .expects('encodePassword') - .withExactArgs(caps.unlockKey) - .returns(caps.unlockKey); - mocks.adb.expects('shell').withExactArgs(['input', 'text', caps.unlockKey]).once(); - mocks.asyncbox.expects('sleep').withExactArgs(INPUT_KEYS_WAIT_TIME).once(); - mocks.adb - .expects('shell') - .withExactArgs(['input', 'keyevent', String(KEYCODE_NUMPAD_ENTER)]); - mocks.adb.expects('isScreenLocked').returns(true); - mocks.adb.expects('keyevent').withExactArgs(66).once(); - mocks.asyncbox.expects('sleep').withExactArgs(UNLOCK_WAIT_TIME).twice(); - await helpers.passwordUnlock(adb, driver, caps); - mocks.helpers.verify(); - mocks.adb.verify(); - mocks.asyncbox.verify(); - }); - }) - ); - describe('getPatternKeyPosition', function () { - it('should verify pattern pin is aproximatelly to its position', function () { - let pins = [1, 2, 3, 4, 5, 6, 7, 8, 9].map(function mapPins(pin) { - return helpers.getPatternKeyPosition(pin, {x: 33, y: 323}, 137.6); - }); - let cols = [101, 238, 375]; - let rows = [391, 528, 665]; - expect(pins[0].x).to.be.within(cols[0] - 5, cols[0] + 5); - expect(pins[1].x).to.be.within(cols[1] - 5, cols[1] + 5); - expect(pins[2].x).to.be.within(cols[2] - 5, cols[2] + 5); - expect(pins[3].x).to.be.within(cols[0] - 5, cols[0] + 5); - expect(pins[4].x).to.be.within(cols[1] - 5, cols[1] + 5); - expect(pins[5].x).to.be.within(cols[2] - 5, cols[2] + 5); - expect(pins[6].x).to.be.within(cols[0] - 5, cols[0] + 5); - expect(pins[7].x).to.be.within(cols[1] - 5, cols[1] + 5); - expect(pins[8].x).to.be.within(cols[2] - 5, cols[2] + 5); - expect(pins[0].y).to.be.within(rows[0] - 5, rows[0] + 5); - expect(pins[1].y).to.be.within(rows[0] - 5, rows[0] + 5); - expect(pins[2].y).to.be.within(rows[0] - 5, rows[0] + 5); - expect(pins[3].y).to.be.within(rows[1] - 5, rows[1] + 5); - expect(pins[4].y).to.be.within(rows[1] - 5, rows[1] + 5); - expect(pins[5].y).to.be.within(rows[1] - 5, rows[1] + 5); - expect(pins[6].y).to.be.within(rows[2] - 5, rows[2] + 5); - expect(pins[7].y).to.be.within(rows[2] - 5, rows[2] + 5); - expect(pins[8].y).to.be.within(rows[2] - 5, rows[2] + 5); - }); - }); - describe('getPatternActions', function () { - it('should generate press, moveTo, relase gesture scheme to unlock by pattern', function () { - let keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; - let actions = helpers.getPatternActions(keys, {x: 0, y: 0}, 1); - actions.map((action, i) => { - if (i === 0) { - action.action.should.equal('press'); - } else if (i === keys.length) { - action.action.should.equal('release'); - } else { - action.action.should.equal('moveTo'); - } - }); - }); - it('should verify pattern gestures moves to non consecutives pins', function () { - let keys = ['7', '2', '9', '8', '5', '6', '1', '4', '3']; - let actions = helpers.getPatternActions(keys, {x: 0, y: 0}, 1); - // Move from pin 7 to pin 2 - actions[1].options.x.should.equal(2); - actions[1].options.y.should.equal(1); - // Move from pin 2 to pin 9 - actions[2].options.x.should.equal(3); - actions[2].options.y.should.equal(3); - // Move from pin 9 to pin 8 - actions[3].options.x.should.equal(2); - actions[3].options.y.should.equal(3); - // Move from pin 8 to pin 5 - actions[4].options.x.should.equal(2); - actions[4].options.y.should.equal(2); - // Move from pin 5 to pin 6 - actions[5].options.x.should.equal(3); - actions[5].options.y.should.equal(2); - // Move from pin 6 to pin 1 - actions[6].options.x.should.equal(1); - actions[6].options.y.should.equal(1); - // Move from pin 1 to pin 4 - actions[7].options.x.should.equal(1); - actions[7].options.y.should.equal(2); - // Move from pin 4 to pin 3 - actions[8].options.x.should.equal(3); - actions[8].options.y.should.equal(1); - }); - }); - describe( - 'patternUnlock', - withMocks({driver, helpers, adb, asyncbox}, (mocks) => { - const el = {ELEMENT: 1}; - const pos = {x: 10, y: 20}; - const size = {width: 300}; - const keys = ['1', '3', '5', '7', '9']; - const caps = {unlockKey: '13579'}; - beforeEach(function () { - mocks.adb.expects('dismissKeyguard').once(); - mocks.helpers.expects('stringKeyToArr').returns(keys); - mocks.driver.expects('getLocation').withExactArgs(el.ELEMENT).returns(pos); - mocks.driver.expects('getSize').withExactArgs(el.ELEMENT).returns(size); - mocks.helpers.expects('getPatternActions').withExactArgs(keys, pos, 100).returns('actions'); - mocks.driver.expects('performTouch').withExactArgs('actions').once(); - mocks.asyncbox.expects('sleep').withExactArgs(UNLOCK_WAIT_TIME).once(); - }); - it('should be able to unlock device using pattern (API level >= 21)', async function () { - mocks.adb.expects('getApiLevel').returns(21); - mocks.driver - .expects('findElOrEls') - .withExactArgs('id', 'com.android.systemui:id/lockPatternView', false) - .returns(el); - await helpers.patternUnlock(adb, driver, caps); - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.asyncbox.verify(); - mocks.adb.verify(); - }); - it('should be able to unlock device using pattern (API level < 21)', async function () { - mocks.adb.expects('getApiLevel').returns(20); - mocks.driver - .expects('findElOrEls') - .withExactArgs('id', 'com.android.keyguard:id/lockPatternView', false) - .returns(el); - await helpers.patternUnlock(adb, driver, caps); - mocks.helpers.verify(); - mocks.driver.verify(); - mocks.asyncbox.verify(); - mocks.adb.verify(); - }); - }) - ); -}); diff --git a/test/unit/utils-specs.js b/test/unit/utils-specs.js new file mode 100644 index 00000000..7ff2e86b --- /dev/null +++ b/test/unit/utils-specs.js @@ -0,0 +1,14 @@ +import { parseArray } from '../../lib/utils'; + +describe('Utils', function () { + + describe('#parseArray', function () { + it('should parse array string to array', function () { + parseArray('["a", "b", "c"]').should.eql(['a', 'b', 'c']); + }); + it('should parse a simple string to one item array', function () { + parseArray('abc').should.eql(['abc']); + }); + }); + +});