UNPKG

detox

Version:

E2E tests and automation for mobile

430 lines (361 loc) 14.3 kB
/* eslint @typescript-eslint/no-unused-vars: ["error", { "args": "none" }] */ // @ts-nocheck const path = require('path'); const URL = require('url').URL; const fs = require('fs-extra'); const _ = require('lodash'); const DetoxApi = require('../../../../android/espressoapi/Detox'); const EspressoDetoxApi = require('../../../../android/espressoapi/EspressoDetox'); const UiDeviceProxy = require('../../../../android/espressoapi/UiDeviceProxy'); const temporaryPath = require('../../../../artifacts/utils/temporaryPath'); const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError'); const getAbsoluteBinaryPath = require('../../../../utils/getAbsoluteBinaryPath'); const logger = require('../../../../utils/logger'); const pressAnyKey = require('../../../../utils/pressAnyKey'); const retry = require('../../../../utils/retry'); const sleep = require('../../../../utils/sleep'); const apkUtils = require('../../../common/drivers/android/tools/apk'); const DeviceDriverBase = require('../DeviceDriverBase'); const log = logger.child({ cat: 'device' }); /** * @typedef AndroidDriverProps * @property adbName { String } The unique identifier associated with ADB */ /** * @typedef { DeviceDriverDeps } AndroidDriverDeps * @property invocationManager { InvocationManager } * @property adb { ADB } * @property aapt { AAPT } * @property apkValidator { ApkValidator } * @property fileTransfer { FileTransfer } * @property appInstallHelper { AppInstallHelper } * @property appUninstallHelper { AppUninstallHelper } * @property devicePathBuilder { AndroidDevicePathBuilder } * @property instrumentation { MonitoredInstrumentation } */ class AndroidDriver extends DeviceDriverBase { /** * @param deps { AndroidDriverDeps } * @param props { AndroidDriverProps } */ constructor(deps, { adbName }) { super(deps); this.adbName = adbName; this.adb = deps.adb; this.aapt = deps.aapt; this.apkValidator = deps.apkValidator; this.invocationManager = deps.invocationManager; this.fileTransfer = deps.fileTransfer; this.appInstallHelper = deps.appInstallHelper; this.appUninstallHelper = deps.appUninstallHelper; this.devicePathBuilder = deps.devicePathBuilder; this.instrumentation = deps.instrumentation; this.uiDevice = new UiDeviceProxy(this.invocationManager).getUIDevice(); } getExternalId() { return this.adbName; } async getBundleIdFromBinary(apkPath) { const binaryPath = getAbsoluteBinaryPath(apkPath); return await this.aapt.getPackageName(binaryPath); } async installApp(_appBinaryPath, _testBinaryPath) { const { appBinaryPath, testBinaryPath, } = this._getAppInstallPaths(_appBinaryPath, _testBinaryPath); await this._validateAppBinaries(appBinaryPath, testBinaryPath); await this._installAppBinaries(appBinaryPath, testBinaryPath); } async uninstallApp(bundleId) { await this.emitter.emit('beforeUninstallApp', { deviceId: this.adbName, bundleId }); await this.appUninstallHelper.uninstall(this.adbName, bundleId); } async installUtilBinaries(paths) { for (const path of paths) { const packageId = await this.getBundleIdFromBinary(path); if (!await this.adb.isPackageInstalled(this.adbName, packageId)) { await this.appInstallHelper.install(this.adbName, path); } } } async launchApp(bundleId, launchArgs, languageAndLocale) { return await this._handleLaunchApp({ manually: false, bundleId, launchArgs, languageAndLocale, }); } async waitForAppLaunch(bundleId, launchArgs, languageAndLocale) { return await this._handleLaunchApp({ manually: true, bundleId, launchArgs, languageAndLocale, }); } async _handleLaunchApp({ manually, bundleId, launchArgs }) { const { adbName } = this; await this.emitter.emit('beforeLaunchApp', { deviceId: adbName, bundleId, launchArgs }); launchArgs = await this._modifyArgsForNotificationHandling(adbName, bundleId, launchArgs); if (manually) { await this._waitForAppLaunch(adbName, bundleId, launchArgs); } else { await this._launchApp(adbName, bundleId, launchArgs); } const pid = await this._waitForProcess(adbName, bundleId); if (manually) { log.info({}, `Found the app (${bundleId}) with process ID = ${pid}. Proceeding...`); } await this.emitter.emit('launchApp', { deviceId: adbName, bundleId, launchArgs, pid }); return pid; } async deliverPayload(params) { if (params.delayPayload) { return; } const { url, detoxUserNotificationDataURL } = params; if (url) { await this._startActivityWithUrl(url); } else if (detoxUserNotificationDataURL) { const payloadPathOnDevice = await this._sendNotificationDataToDevice(detoxUserNotificationDataURL, this.adbName); await this._startActivityFromNotification(payloadPathOnDevice); } } async waitUntilReady() { try { await Promise.race([ super.waitUntilReady(), this.instrumentation.waitForCrash() ]); } catch (e) { log.warn({ error: e }, 'An error occurred while waiting for the app to become ready. Waiting for disconnection...'); await this.client.waitUntilDisconnected(); log.warn('The app disconnected.'); throw e; } finally { this.instrumentation.abortWaitForCrash(); } } async pressBack() { await this.uiDevice.pressBack(); } async sendToHome(params) { await this.uiDevice.pressHome(); } async typeText(text) { await this.adb.typeText(this.adbName, text); } async terminate(bundleId) { const { adbName } = this; await this.emitter.emit('beforeTerminateApp', { deviceId: adbName, bundleId }); await this._terminateInstrumentation(); await this.adb.terminate(adbName, bundleId); await this.emitter.emit('terminateApp', { deviceId: adbName, bundleId }); } async cleanup(bundleId) { await this._terminateInstrumentation(); await super.cleanup(bundleId); } getPlatform() { return 'android'; } getUiDevice() { return this.uiDevice; } async reverseTcpPort(port) { await this.adb.reverse(this.adbName, port); } async unreverseTcpPort(port) { await this.adb.reverseRemove(this.adbName, port); } async setURLBlacklist(urlList) { await this.invocationManager.execute(EspressoDetoxApi.setURLBlacklist(urlList)); } async enableSynchronization() { await this.invocationManager.execute(EspressoDetoxApi.setSynchronization(true)); } async disableSynchronization() { await this.invocationManager.execute(EspressoDetoxApi.setSynchronization(false)); } async takeScreenshot(screenshotName) { const { adbName } = this; const pathOnDevice = this.devicePathBuilder.buildTemporaryArtifactPath('.png'); await this.adb.screencap(adbName, pathOnDevice); const tempPath = temporaryPath.for.png(); await this.adb.pull(adbName, pathOnDevice, tempPath); await this.adb.rm(adbName, pathOnDevice); await this.emitter.emit('createExternalArtifact', { pluginId: 'screenshot', artifactName: screenshotName || path.basename(tempPath, '.png'), artifactPath: tempPath, }); return tempPath; } async setOrientation(orientation) { const orientationMapping = { landscape: 1, // top at left side landscape portrait: 0 // non-reversed portrait. }; const call = EspressoDetoxApi.changeOrientation(orientationMapping[orientation]); await this.invocationManager.execute(call); } async tap(point, shouldIgnoreStatusBar) { let x = point?.x ?? 100; let y = point?.y ?? 100; let _shouldIgnoreStatusBar = shouldIgnoreStatusBar ?? true; const call = EspressoDetoxApi.tap(x, y, _shouldIgnoreStatusBar); await this.invocationManager.execute(call); } async longPress(point, duration, shouldIgnoreStatusBar) { let x = point?.x ?? 100; let y = point?.y ?? 100; let _shouldIgnoreStatusBar = shouldIgnoreStatusBar ?? true; const call = duration ? EspressoDetoxApi.longPress(x, y, duration, _shouldIgnoreStatusBar): EspressoDetoxApi.longPress(x, y, _shouldIgnoreStatusBar); await this.invocationManager.execute(call); } async generateViewHierarchyXml(shouldInjectTestIds) { const hierarchy = await this.invocationManager.execute(DetoxApi.generateViewHierarchyXml(shouldInjectTestIds)); return hierarchy.result; } _getAppInstallPaths(_appBinaryPath, _testBinaryPath) { const appBinaryPath = getAbsoluteBinaryPath(_appBinaryPath); const testBinaryPath = _testBinaryPath ? getAbsoluteBinaryPath(_testBinaryPath) : this._getTestApkPath(appBinaryPath); return { appBinaryPath, testBinaryPath, }; } async _validateAppBinaries(appBinaryPath, testBinaryPath) { try { await this.apkValidator.validateAppApk(appBinaryPath); } catch (e) { logger.warn(e.toString()); } try { await this.apkValidator.validateTestApk(testBinaryPath); } catch (e) { logger.warn(e.toString()); } } async _installAppBinaries(appBinaryPath, testBinaryPath) { await this.adb.install(this.adbName, appBinaryPath); await this.adb.install(this.adbName, testBinaryPath); } _getTestApkPath(originalApkPath) { const testApkPath = apkUtils.getTestApkPath(originalApkPath); if (!fs.existsSync(testApkPath)) { throw new DetoxRuntimeError({ message: `The test APK could not be found at path: '${testApkPath}'`, hint: 'Try running the detox build command, and make sure it was configured to execute a build command (e.g. \'./gradlew assembleAndroidTest\')' + '\nFor further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup', }); } return testApkPath; } async _modifyArgsForNotificationHandling(adbName, bundleId, launchArgs) { let _launchArgs = launchArgs; if (launchArgs.detoxUserNotificationDataURL) { const notificationPayloadTargetPath = await this._sendNotificationDataToDevice(launchArgs.detoxUserNotificationDataURL, adbName); _launchArgs = { ...launchArgs, detoxUserNotificationDataURL: notificationPayloadTargetPath, }; } return _launchArgs; } async _launchApp(adbName, bundleId, launchArgs) { if (!this.instrumentation.isRunning()) { await this._launchInstrumentationProcess(adbName, bundleId, launchArgs); await sleep(500); } else if (launchArgs.detoxURLOverride) { await this._startActivityWithUrl(launchArgs.detoxURLOverride); } else if (launchArgs.detoxUserNotificationDataURL) { await this._startActivityFromNotification(launchArgs.detoxUserNotificationDataURL); } else { await this._resumeMainActivity(); } } async _launchInstrumentationProcess(adbName, bundleId, userLaunchArgs) { const serverPort = await this._reverseServerPort(adbName); this.instrumentation.setTerminationFn(async () => { await this._terminateInstrumentation(); await this.adb.reverseRemove(adbName, serverPort); }); await this.instrumentation.launch(adbName, bundleId, userLaunchArgs); } async _reverseServerPort(adbName) { const serverPort = new URL(this.client.serverUrl).port; await this.adb.reverse(adbName, serverPort); return serverPort; } async _terminateInstrumentation() { await this.instrumentation.terminate(); await this.instrumentation.setTerminationFn(null); } async _sendNotificationDataToDevice(dataFileLocalPath, adbName) { await this.fileTransfer.prepareDestinationDir(adbName); return await this.fileTransfer.send(adbName, dataFileLocalPath, 'notification.json'); } _startActivityWithUrl(url) { return this.invocationManager.execute(DetoxApi.startActivityFromUrl(url)); } _startActivityFromNotification(dataFilePath) { return this.invocationManager.execute(DetoxApi.startActivityFromNotification(dataFilePath)); } _resumeMainActivity() { return this.invocationManager.execute(DetoxApi.launchMainActivity()); } async _waitForProcess(adbName, bundleId) { let pid = NaN; try { const queryPid = () => this._queryPID(adbName, bundleId); const retryQueryPid = () => retry({ backoff: 'none', retries: 4 }, queryPid); const retryQueryPidMultiple = () => retry({ backoff: 'linear' }, retryQueryPid); pid = await retryQueryPidMultiple(); } catch (e) { log.warn(await this.adb.shell(adbName, 'ps')); throw e; } return pid; } async _queryPID(adbName, bundleId) { const pid = await this.adb.pidof(adbName, bundleId); if (!pid) { throw new DetoxRuntimeError('PID still not available'); } return pid; } async _waitForAppLaunch(adbName, bundleId, launchArgs) { const instrumentationClass = await this.adb.getInstrumentationRunner(adbName, bundleId); this._printInstrumentationHint({ instrumentationClass, launchArgs }); await pressAnyKey(); await this._reverseServerPort(adbName); } _printInstrumentationHint({ instrumentationClass, launchArgs }) { const keyMaxLength = Math.max(3, _(launchArgs).keys().maxBy('length').length); const valueMaxLength = Math.max(5, _(launchArgs).values().map(String).maxBy('length').length); const rows = _.map(launchArgs, (v, k) => { const paddedKey = k.padEnd(keyMaxLength, ' '); const paddedValue = `${v}`.padEnd(valueMaxLength, ' '); return `${paddedKey} | ${paddedValue}`; }); const keyHeader = 'Key'.padEnd(keyMaxLength, ' '); const valueHeader = 'Value'.padEnd(valueMaxLength, ' '); const header = `${keyHeader} | ${valueHeader}`; const separator = '-'.repeat(header.length); log.info({}, 'Waiting for you to manually launch your app in Android Studio.\n\n' + `Instrumentation class: ${instrumentationClass}\n` + 'Instrumentation arguments:\n' + `${separator}\n` + `${header}\n` + `${separator}\n` + `${rows.join('\n')}\n` + `${separator}\n\n` + 'Press any key to continue...' ); } } module.exports = AndroidDriver;