gst-atom-xcuitest-driver
Version:
ATOM driver for iOS using XCUITest for backend
1,323 lines (1,169 loc) • 48.9 kB
JavaScript
import { BaseDriver, DeviceSettings } from 'gst-atom-base-driver';
import { util, mjpeg } from 'appium-support';
import _ from 'lodash';
import url from 'url';
import { WebDriverAgent } from 'appium-webdriveragent';
import log from './logger';
import {
createSim, getExistingSim, runSimulatorReset, installToSimulator,
shutdownOtherSimulators, shutdownSimulator } from './simulator-management';
import { getSimulator, installSSLCert, hasSSLCert } from 'appium-ios-simulator';
import { retryInterval, retry } from 'asyncbox';
import { settings as iosSettings, defaultServerCaps, appUtils } from 'gst-atom-ios-driver';
import { desiredCapConstraints, PLATFORM_NAME_IOS, PLATFORM_NAME_TVOS } from './desired-caps';
import commands from './commands/index';
import {
detectUdid, getAndCheckXcodeVersion, getAndCheckIosSdkVersion,
checkAppPresent, getDriverInfo,
clearSystemFiles, translateDeviceName, normalizeCommandTimeouts,
DEFAULT_TIMEOUT_KEY, markSystemFilesForCleanup,
printUser, removeAllSessionWebSocketHandlers, verifyApplicationPlatform,
normalizePlatformVersion, isLocalHost } from './utils';
import {
getConnectedDevices, runRealDeviceReset, installToRealDevice,
getRealDeviceObj } from './real-device-management';
import B from 'bluebird';
import AsyncLock from 'async-lock';
import path from 'path';
import IDB from 'appium-idb';
import DEVICE_CONNECTIONS_FACTORY from './device-connections-factory';
import fetch from 'node-fetch';
const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims';
const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
const WDA_SIM_STARTUP_RETRIES = 2;
const WDA_REAL_DEV_STARTUP_RETRIES = 1;
const WDA_REAL_DEV_TUTORIAL_URL = 'https://github.com/appium/appium-xcuitest-driver/blob/master/docs/real-device-config.md';
const WDA_STARTUP_RETRY_INTERVAL = 10000;
const DEFAULT_SETTINGS = {
nativeWebTap: false,
nativeWebTapStrict: false,
useJSONSource: false,
shouldUseCompactResponses: true,
elementResponseAttributes: 'type,label',
// Read https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Utilities/FBConfiguration.m for following settings' values
mjpegServerScreenshotQuality: 25,
mjpegServerFramerate: 10,
screenshotQuality: 1,
mjpegScalingFactor: 100,
// set `reduceMotion` to `null` so that it will be verified but still set either true/false
reduceMotion: null,
};
// This lock assures, that each driver session does not
// affect shared resources of the other parallel sessions
const SHARED_RESOURCES_GUARD = new AsyncLock();
/* eslint-disable no-useless-escape */
const NO_PROXY_NATIVE_LIST = [
['DELETE', /window/],
['GET', /^\/session\/[^\/]+$/],
['GET', /alert_text/],
['GET', /alert\/[^\/]+/],
['GET', /appium/],
['GET', /attribute/],
['GET', /context/],
['GET', /location/],
['GET', /log/],
['GET', /screenshot/],
['GET', /size/],
['GET', /source/],
['GET', /timeouts$/],
['GET', /url/],
['GET', /window/],
['POST', /accept_alert/],
['POST', /actions$/],
['POST', /alert_text/],
['POST', /alert\/[^\/]+/],
['POST', /appium/],
['POST', /appium\/device\/is_locked/],
['POST', /appium\/device\/lock/],
['POST', /appium\/device\/unlock/],
['POST', /back/],
['POST', /clear/],
['POST', /context/],
['POST', /dismiss_alert/],
['POST', /element\/active/], // MJSONWP get active element should proxy
['POST', /element$/],
['POST', /elements$/],
['POST', /execute/],
['POST', /keys/],
['POST', /log/],
['POST', /moveto/],
['POST', /receive_async_response/], // always, in case context switches while waiting
['POST', /session\/[^\/]+\/location/], // geo location, but not element location
['POST', /shake/],
['POST', /timeouts/],
['POST', /touch/],
['POST', /url/],
['POST', /value/],
['POST', /window/],
['DELETE', /cookie/],
['GET', /cookie/],
['POST', /cookie/],
];
const NO_PROXY_WEB_LIST = [
['GET', /attribute/],
['GET', /element/],
['GET', /text/],
['GET', /title/],
['POST', /clear/],
['POST', /click/],
['POST', /element/],
['POST', /forward/],
['POST', /frame/],
['POST', /keys/],
['POST', /refresh/],
].concat(NO_PROXY_NATIVE_LIST);
/* eslint-enable no-useless-escape */
const MEMOIZED_FUNCTIONS = [
'getStatusBarHeight',
'getDevicePixelRatio',
'getScreenInfo',
];
class XCUITestDriver extends BaseDriver {
constructor (opts = {}, shouldValidateCaps = true) {
super(opts, shouldValidateCaps);
this.desiredCapConstraints = desiredCapConstraints;
this.locatorStrategies = [
'xpath',
'id',
'name',
'class name',
'-ios predicate string',
'-ios class chain',
'accessibility id'
];
this.webLocatorStrategies = [
'link text',
'css selector',
'tag name',
'link text',
'partial link text'
];
this.resetIos();
this.settings = new DeviceSettings(DEFAULT_SETTINGS, this.onSettingsUpdate.bind(this));
this.logs = {};
// memoize functions here, so that they are done on a per-instance basis
for (const fn of MEMOIZED_FUNCTIONS) {
this[fn] = _.memoize(this[fn]);
}
}
async onSettingsUpdate (key, value) {
if (key !== 'nativeWebTap' && key !== 'nativeWebTapStrict') {
return await this.proxyCommand('/appium/settings', 'POST', {
settings: {[key]: value}
});
}
this.opts[key] = !!value;
}
resetIos () {
this.opts = this.opts || {};
this.wda = null;
this.opts.device = null;
this.jwpProxyActive = false;
this.proxyReqRes = null;
this.jwpProxyAvoid = [];
this.safari = false;
this.cachedWdaStatus = null;
// some things that commands imported from gst-atom-ios-driver need
this.curWebFrames = [];
this.webElementIds = [];
this._currentUrl = null;
this.curContext = null;
this.xcodeVersion = {};
this.contexts = [];
this.implicitWaitMs = 0;
this.asynclibWaitMs = 0;
this.pageLoadMs = 6000;
this.landscapeWebCoordsOffset = 0;
this.remote = null;
}
get driverData () {
// TODO fill out resource info here
return {};
}
async getStatus () {
if (typeof this.driverInfo === 'undefined') {
this.driverInfo = await getDriverInfo();
}
let status = {build: {version: this.driverInfo.version}};
if (this.cachedWdaStatus) {
status.wda = this.cachedWdaStatus;
}
return status;
}
async createSession (...args) {
this.lifecycleData = {}; // this is used for keeping track of the state we start so when we delete the session we can put things back
try {
// TODO add validation on caps
let [sessionId, caps] = await super.createSession(...args);
this.opts.sessionId = sessionId;
await this.start();
// merge server capabilities + desired capabilities
caps = Object.assign({}, defaultServerCaps, caps);
// update the udid with what is actually used
caps.udid = this.opts.udid;
// ensure we track nativeWebTap capability as a setting as well
if (_.has(this.opts, 'nativeWebTap')) {
await this.updateSettings({nativeWebTap: this.opts.nativeWebTap});
}
// ensure we track nativeWebTapStrict capability as a setting as well
if (_.has(this.opts, 'nativeWebTapStrict')) {
await this.updateSettings({nativeWebTapStrict: this.opts.nativeWebTapStrict});
}
// ensure we track useJSONSource capability as a setting as well
if (_.has(this.opts, 'useJSONSource')) {
await this.updateSettings({useJSONSource: this.opts.useJSONSource});
}
let wdaSettings = {
elementResponseAttributes: DEFAULT_SETTINGS.elementResponseAttributes,
shouldUseCompactResponses: DEFAULT_SETTINGS.shouldUseCompactResponses,
};
if (_.has(this.opts, 'elementResponseAttributes')) {
wdaSettings.elementResponseAttributes = this.opts.elementResponseAttributes;
}
if (_.has(this.opts, 'shouldUseCompactResponses')) {
wdaSettings.shouldUseCompactResponses = this.opts.shouldUseCompactResponses;
}
if (_.has(this.opts, 'mjpegServerScreenshotQuality')) {
wdaSettings.mjpegServerScreenshotQuality = this.opts.mjpegServerScreenshotQuality;
}
if (_.has(this.opts, 'mjpegServerFramerate')) {
wdaSettings.mjpegServerFramerate = this.opts.mjpegServerFramerate;
}
if (_.has(this.opts, 'screenshotQuality')) {
log.info(`Setting the quality of phone screenshot: '${this.opts.screenshotQuality}'`);
wdaSettings.screenshotQuality = this.opts.screenshotQuality;
}
// ensure WDA gets our defaults instead of whatever its own might be
await this.updateSettings(wdaSettings);
// turn on mjpeg stream reading if requested
if (this.opts.mjpegScreenshotUrl) {
log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`);
this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl);
await this.mjpegStream.start();
}
return [sessionId, caps];
} catch (e) {
log.error(JSON.stringify(e));
await this.deleteSession();
throw e;
}
}
async start () {
this.opts.noReset = !!this.opts.noReset;
this.opts.fullReset = !!this.opts.fullReset;
await printUser();
this.opts.iosSdkVersion = null; // For WDA and xcodebuild
const {device, udid, realDevice} = await this.determineDevice();
log.info(`Determining device to run tests on: udid: '${udid}', real device: ${realDevice}`);
this.opts.device = device;
this.opts.udid = udid;
this.opts.realDevice = realDevice;
// at this point if there is no platformVersion, get it from the device
if (!this.opts.platformVersion && this.opts.device) {
this.opts.platformVersion = await this.opts.device.getPlatformVersion();
log.info(`No platformVersion specified. Using device version: '${this.opts.platformVersion}'`);
}
const normalizedVersion = normalizePlatformVersion(this.opts.platformVersion);
if (this.opts.platformVersion !== normalizedVersion) {
log.info(`Normalized platformVersion capability value '${this.opts.platformVersion}' to '${normalizedVersion}'`);
this.opts.platformVersion = normalizedVersion;
}
if (util.compareVersions(this.opts.platformVersion, '<', '9.3')) {
throw new Error(`Platform version must be 9.3 or above. '${this.opts.platformVersion}' is not supported.`);
}
if (_.isEmpty(this.xcodeVersion) && (!this.opts.webDriverAgentUrl || !this.opts.realDevice)) {
// no `webDriverAgentUrl`, or on a simulator, so we need an Xcode version
this.xcodeVersion = await getAndCheckXcodeVersion();
}
this.logEvent('xcodeDetailsRetrieved');
// GST-NHUNH
// Take device
const body = {
serial: this.opts.udid,
email: this.opts.userEmail
};
if (this.opts.dfTakeDeviceApi) {
await fetch(this.opts.dfTakeDeviceApi, {
method: 'post',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this.opts.dfToken
},
timeout: 10000
}).catch(err => {
log.error(`[${this.opts.udid}] Cannot take the device ${err.message}`);
log.errorAndThrow(err);
});
log.info(`The device [${this.opts.udid}] now owned by [${this.opts.userEmail}]`);
}
// GST-NHUNH
if (_.toLower(this.opts.browserName) === 'safari') {
log.info('Safari test requested');
this.safari = true;
this.opts.app = undefined;
this.opts.processArguments = this.opts.processArguments || {};
this.opts.bundleId = SAFARI_BUNDLE_ID;
this._currentUrl = this.opts.safariInitialUrl || (
this.isRealDevice()
? 'http://appium.io'
: `http://${this.opts.address}:${this.opts.port}/welcome`
);
if (util.compareVersions(this.opts.platformVersion, '<', '12.2')) {
// this option does not work on 12.2 and above
this.opts.processArguments.args = ['-u', this._currentUrl];
}
} else if (this.opts.app || this.opts.bundleId) {
await this.configureApp();
}
this.logEvent('appConfigured');
// fail very early if the app doesn't actually exist
// or if bundle id doesn't point to an installed app
if (this.opts.app) {
await checkAppPresent(this.opts.app);
if (!this.opts.bundleId) {
this.opts.bundleId = await appUtils.extractBundleId(this.opts.app);
}
}
await this.runReset();
const memoizedLogInfo = _.memoize(function logInfo () {
log.info("'skipLogCapture' is set. Skipping starting logs such as crash, system, safari console and safari network.");
});
const startLogCapture = async () => {
if (this.opts.skipLogCapture) {
memoizedLogInfo();
return false;
}
const result = await this.startLogCapture();
if (result) {
this.logEvent('logCaptureStarted');
}
return result;
};
const isLogCaptureStarted = await startLogCapture();
log.info(`Setting up ${this.isRealDevice() ? 'real device' : 'simulator'}`);
if (this.isSimulator()) {
if (this.opts.shutdownOtherSimulators) {
this.ensureFeatureEnabled(SHUTDOWN_OTHER_FEAT_NAME);
await shutdownOtherSimulators(this.opts.device);
}
// this should be done before the simulator is started
// if it is already running, this cap won't work, which is documented
if (this.isSafari() && this.opts.safariGlobalPreferences) {
if (await this.opts.device.updateSafariGlobalSettings(this.opts.safariGlobalPreferences)) {
log.debug(`Safari global preferences updated`);
}
}
this.localConfig = await iosSettings.setLocaleAndPreferences(this.opts.device, this.opts, this.isSafari(), async (sim) => {
await shutdownSimulator(sim);
// we don't know if there needs to be changes a priori, so change first.
// sometimes the shutdown process changes the settings, so reset them,
// knowing that the sim is already shut
await iosSettings.setLocaleAndPreferences(sim, this.opts, this.isSafari());
});
if (this.opts.customSSLCert) {
const certHead = _.truncate(this.opts.customSSLCert, {length: 20});
log.info(`Installing the custom SSL certificate '${certHead}'`);
try {
await this.opts.device.simctl.addRootCertificate(this.opts.customSSLCert, {raw: true});
} catch (ign) {
if (await hasSSLCert(this.opts.customSSLCert, this.opts.udid)) {
log.info(`SSL certificate '${certHead}' already installed`);
} else {
log.info(`Making sure Simulator is shut down, ' +
'so that SSL certificate installation takes effect`);
await shutdownSimulator(this.opts.device);
await installSSLCert(this.opts.customSSLCert, this.opts.udid);
}
}
this.logEvent('customCertInstalled');
}
await this.startSim();
if (this.opts.launchWithIDB && this.isSimulator()) {
try {
const idb = new IDB({udid});
await idb.connect();
this.opts.device.idb = idb;
} catch (e) {
log.info(`idb will not be used for Simulator interaction. Original error: ${e.message}`);
}
}
this.logEvent('simStarted');
if (!isLogCaptureStarted) {
// Retry log capture if Simulator was not running before
await startLogCapture();
}
}
if (this.opts.app) {
await this.installAUT();
this.logEvent('appInstalled');
}
// if we only have bundle identifier and no app, fail if it is not already installed
if (!this.opts.app && this.opts.bundleId && !this.safari) {
// NhuNH - ignore bundleId check
// if (!await this.opts.device.isAppInstalled(this.opts.bundleId)) {
// log.errorAndThrow(`App with bundle identifier '${this.opts.bundleId}' unknown`);
// }
// NhuNH - ignore bundleId check
}
if (this.opts.permissions) {
if (this.isSimulator()) {
log.debug('Setting the requested permissions before WDA is started');
for (const [bundleId, permissionsMapping] of _.toPairs(JSON.parse(this.opts.permissions))) {
await this.opts.device.setPermissions(bundleId, permissionsMapping);
}
} else {
log.warn('Setting permissions is only supported on Simulator. ' +
'The "permissions" capability will be ignored.');
}
}
await this.startWda(this.opts.sessionId, realDevice);
await this.setReduceMotion(this.opts.reduceMotion);
await this.setInitialOrientation(this.opts.orientation);
this.logEvent('orientationSet');
// real devices will be handled later, after the web context has been initialized
if (this.isSafari() && !this.isRealDevice()
&& util.compareVersions(this.opts.platformVersion, '>=', '12.2')) {
// on 12.2 the page is not opened in WDA
await this.opts.device.openUrl(this._currentUrl);
}
if (this.isSafari() || this.opts.autoWebview) {
log.debug('Waiting for initial webview');
await this.navToInitialWebview();
this.logEvent('initialWebviewNavigated');
}
if (this.isSafari() && this.isRealDevice() && util.compareVersions(this.opts.platformVersion, '>=', '12.2')) {
// on 12.2 the page is not opened in WDA
await this.setUrl(this._currentUrl);
}
if (!this.isRealDevice()) {
if (this.opts.calendarAccessAuthorized) {
await this.opts.device.enableCalendarAccess(this.opts.bundleId);
} else if (this.opts.calendarAccessAuthorized === false) {
await this.opts.device.disableCalendarAccess(this.opts.bundleId);
}
}
}
/**
* Start WebDriverAgentRunner
* @param {string} sessionId - The id of the target session to launch WDA with.
* @param {boolean} realDevice - Equals to true if the test target device is a real device.
*/
async startWda (sessionId, realDevice) {
this.wda = new WebDriverAgent(this.xcodeVersion, this.opts);
// Don't cleanup the processes if webDriverAgentUrl is set
if (!util.hasValue(this.wda.webDriverAgentUrl)) {
await this.wda.cleanupObsoleteProcesses();
}
const usePortForwarding = this.isRealDevice()
&& !this.wda.webDriverAgentUrl
&& isLocalHost(this.wda.wdaBaseUrl);
var options = {
udid: this.opts.udid,
port: this.wda.url.port,
devicePort: this.wda.wdaRemotePort,
usePortForwarding,
usbmuxdRemoteHost: this.opts.usbmuxdRemoteHost,
usbmuxdRemotePort: this.opts.usbmuxdRemotePort
}
await DEVICE_CONNECTIONS_FACTORY.requestConnection(options);
// Let multiple WDA binaries with different derived data folders be built in parallel
// Concurrent WDA builds from the same source will cause xcodebuild synchronization errors
let synchronizationKey = XCUITestDriver.name;
if (this.opts.useXctestrunFile || !(await this.wda.isSourceFresh())) {
// First-time compilation is an expensive operation, which is done faster if executed
// sequentially. Xcodebuild spreads the load caused by the clang compiler to all available CPU cores
const derivedDataPath = await this.wda.retrieveDerivedDataPath();
if (derivedDataPath) {
synchronizationKey = path.normalize(derivedDataPath);
}
}
log.debug(`Starting WebDriverAgent initialization with the synchronization key '${synchronizationKey}'`);
if (SHARED_RESOURCES_GUARD.isBusy() && !this.opts.derivedDataPath && !this.opts.bootstrapPath) {
log.debug(`Consider setting a unique 'derivedDataPath' capability value for each parallel driver instance ` +
`to avoid conflicts and speed up the building process`);
}
return await SHARED_RESOURCES_GUARD.acquire(synchronizationKey, async () => {
if (this.opts.useNewWDA) {
log.debug(`Capability 'useNewWDA' set to true, so uninstalling WDA before proceeding`);
await this.wda.quitAndUninstall();
this.logEvent('wdaUninstalled');
} else if (!util.hasValue(this.wda.webDriverAgentUrl)) {
await this.wda.setupCaching();
}
// local helper for the two places we need to uninstall wda and re-start it
const quitAndUninstall = async (msg) => {
log.debug(msg);
if (this.opts.webDriverAgentUrl) {
log.debug('Not quitting/uninstalling WebDriverAgent since webDriverAgentUrl capability is provided');
throw new Error(msg);
}
log.warn('Quitting and uninstalling WebDriverAgent');
await this.wda.quitAndUninstall();
throw new Error(msg);
};
const startupRetries = this.opts.wdaStartupRetries || (this.isRealDevice() ? WDA_REAL_DEV_STARTUP_RETRIES : WDA_SIM_STARTUP_RETRIES);
const startupRetryInterval = this.opts.wdaStartupRetryInterval || WDA_STARTUP_RETRY_INTERVAL;
log.debug(`Trying to start WebDriverAgent ${startupRetries} times with ${startupRetryInterval}ms interval`);
if (!util.hasValue(this.opts.wdaStartupRetries) && !util.hasValue(this.opts.wdaStartupRetryInterval)) {
log.debug(`These values can be customized by changing wdaStartupRetries/wdaStartupRetryInterval capabilities`);
}
let retryCount = 0;
await retryInterval(startupRetries, startupRetryInterval, async () => {
this.logEvent('wdaStartAttempted');
if (retryCount > 0) {
log.info(`Retrying WDA startup (${retryCount + 1} of ${startupRetries})`);
}
try {
// on xcode 10 installd will often try to access the app from its staging
// directory before fully moving it there, and fail. Retrying once
// immediately helps
const retries = this.xcodeVersion.major >= 10 ? 2 : 1;
this.cachedWdaStatus = await retry(retries, this.wda.launch.bind(this.wda), sessionId, realDevice);
} catch (err) {
this.logEvent('wdaStartFailed');
retryCount++;
let errorMsg = `Unable to launch WebDriverAgent because of xcodebuild failure: ${err.message}`;
if (this.isRealDevice()) {
errorMsg += `. Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}. ` +
`Try to remove the WebDriverAgentRunner application from the device if it is installed ` +
`and reboot the device.`;
}
await quitAndUninstall(errorMsg);
}
this.proxyReqRes = this.wda.proxyReqRes.bind(this.wda);
this.jwpProxyActive = true;
let originalStacktrace = null;
try {
await retryInterval(15, 1000, async () => {
this.logEvent('wdaSessionAttempted');
log.debug('Sending createSession command to WDA');
try {
this.cachedWdaStatus = this.cachedWdaStatus || await this.proxyCommand('/status', 'GET');
await this.startWdaSession(this.opts.bundleId, this.opts.processArguments);
} catch (err) {
originalStacktrace = err.stack;
log.debug(`Failed to create WDA session (${err.message}). Retrying...`);
throw err;
}
});
this.logEvent('wdaSessionStarted');
} catch (err) {
if (originalStacktrace) {
log.debug(originalStacktrace);
}
let errorMsg = `Unable to start WebDriverAgent session because of xcodebuild failure: ${err.message}`;
if (this.isRealDevice()) {
errorMsg += ` Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}. ` +
`Try to remove the WebDriverAgentRunner application from the device if it is installed ` +
`and reboot the device.`;
}
await quitAndUninstall(errorMsg);
}
if (this.opts.clearSystemFiles && !this.opts.webDriverAgentUrl) {
await markSystemFilesForCleanup(this.wda);
}
// we expect certain socket errors until this point, but now
// mark things as fully working
this.wda.fullyStarted = true;
this.logEvent('wdaStarted');
});
});
}
async runReset (opts = null) {
this.logEvent('resetStarted');
if (this.isRealDevice()) {
await runRealDeviceReset(this.opts.device, opts || this.opts);
} else {
await runSimulatorReset(this.opts.device, opts || this.opts);
}
this.logEvent('resetComplete');
}
async deleteSession () {
await removeAllSessionWebSocketHandlers(this.server, this.sessionId);
if (this._recentScreenRecorder) {
await this._recentScreenRecorder.interrupt(true);
await this._recentScreenRecorder.cleanup();
this._recentScreenRecorder = null;
}
if (!_.isEmpty(this._perfRecorders)) {
await B.all(this._perfRecorders.map((x) => x.stop(true)));
this._perfRecorders = [];
}
await this.stop();
if (this.wda && !this.opts.webDriverAgentUrl) {
if (this.opts.clearSystemFiles) {
let synchronizationKey = XCUITestDriver.name;
const derivedDataPath = await this.wda.retrieveDerivedDataPath();
if (derivedDataPath) {
synchronizationKey = path.normalize(derivedDataPath);
}
await SHARED_RESOURCES_GUARD.acquire(synchronizationKey, async () => {
await clearSystemFiles(this.wda);
});
} else {
log.debug('Not clearing log files. Use `clearSystemFiles` capability to turn on.');
}
}
if (this.remote) {
log.debug('Found a remote debugger session. Removing...');
await this.stopRemote();
}
if (this.opts.resetOnSessionStartOnly === false) {
await this.runReset(Object.assign({}, this.opts, {
enforceSimulatorShutdown: true,
}));
}
if (this.isSimulator() && !this.opts.noReset && !!this.opts.device) {
if (this.lifecycleData.createSim) {
log.debug(`Deleting simulator created for this run (udid: '${this.opts.udid}')`);
await shutdownSimulator(this.opts.device);
await this.opts.device.delete();
}
}
if (!_.isEmpty(this.logs)) {
await this.logs.syslog.stopCapture();
this.logs = {};
}
if (this.mjpegStream) {
log.info('Closing MJPEG stream');
this.mjpegStream.stop();
}
this.resetIos();
await super.deleteSession();
}
async stop () {
this.jwpProxyActive = false;
this.proxyReqRes = null;
// GST-NHUNH
// Release device
const body = {
serial: this.opts.udid
};
if (this.opts.dfReleaseDeviceApi) {
await fetch(this.opts.dfReleaseDeviceApi, {
method: 'post',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this.opts.dfToken
},
timeout: 10000
}).catch(err => {
log.error(`[${this.opts.udid}] Cannot release the device ${err.message}`);
log.errorAndThrow(err);
});
log.info(`Released device [${this.opts.udid}]`);
}
// GST-NHUNH
if (this.wda && this.wda.fullyStarted) {
if (this.wda.jwproxy) {
try {
await this.proxyCommand(`/session/${this.sessionId}`, 'DELETE');
} catch (err) {
// an error here should not short-circuit the rest of clean up
log.debug(`Unable to DELETE session on WDA: '${err.message}'. Continuing shutdown.`);
}
}
if (!this.wda.webDriverAgentUrl && this.opts.useNewWDA) {
await this.wda.quit();
}
}
DEVICE_CONNECTIONS_FACTORY.releaseConnection(this.opts.udid);
}
async executeCommand (cmd, ...args) {
log.debug(`Executing command '${cmd}'`);
if (cmd === 'receiveAsyncResponse') {
return await this.receiveAsyncResponse(...args);
}
// TODO: once this fix gets into base driver remove from here
if (cmd === 'getStatus') {
return await this.getStatus();
}
return await super.executeCommand(cmd, ...args);
}
async configureApp () {
function appIsPackageOrBundle (app) {
return (/^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/).test(app);
}
// the app name is a bundleId assign it to the bundleId property
if (!this.opts.bundleId && appIsPackageOrBundle(this.opts.app)) {
this.opts.bundleId = this.opts.app;
this.opts.app = '';
}
// we have a bundle ID, but no app, or app is also a bundle
if ((this.opts.bundleId && appIsPackageOrBundle(this.opts.bundleId)) &&
(this.opts.app === '' || appIsPackageOrBundle(this.opts.app))) {
log.debug('App is an iOS bundle, will attempt to run as pre-existing');
return;
}
// check for supported build-in apps
switch (_.toLower(this.opts.app)) {
case 'settings':
this.opts.bundleId = 'com.apple.Preferences';
this.opts.app = null;
return;
case 'calendar':
this.opts.bundleId = 'com.apple.mobilecal';
this.opts.app = null;
return;
}
try {
// download if necessary
this.opts.appUrl = this.opts.app
var setting = {
atompServiceKey: this.opts.atompServiceKey,
atompDomain: this.opts.atompDomain
};
this.opts.app = await this.helpers.configureApp(this.opts.app, '.app', setting);
} catch (err) {
log.error(err);
throw new Error(`Bad app: ${this.opts.app}. ` +
`App paths need to be absolute or an URL to a compressed app file: ${err.message}`);
}
}
async determineDevice () {
// in the one case where we create a sim, we will set this state
this.lifecycleData.createSim = false;
// if we get generic names, translate them
this.opts.deviceName = translateDeviceName(this.opts.platformVersion, this.opts.deviceName);
const setupVersionCaps = async () => {
this.opts.iosSdkVersion = await getAndCheckIosSdkVersion();
log.info(`iOS SDK Version set to '${this.opts.iosSdkVersion}'`);
if (!this.opts.platformVersion && this.opts.iosSdkVersion) {
log.info(`No platformVersion specified. Using the latest version Xcode supports: '${this.opts.iosSdkVersion}'. ` +
`This may cause problems if a simulator does not exist for this platform version.`);
this.opts.platformVersion = normalizePlatformVersion(this.opts.iosSdkVersion);
}
};
if (this.opts.udid) {
var option = {
udid: this.opts.udid,
usbmuxdRemoteHost: this.opts.usbmuxdRemoteHost,
usbmuxdRemotePort: this.opts.usbmuxdRemotePort
}
if (this.opts.udid.toLowerCase() === 'auto') {
try {
this.opts.udid = await detectUdid(this.opts.usbmuxdRemoteHost, this.opts.usbmuxdRemotePort);
} catch (err) {
// Trying to find matching UDID for Simulator
log.warn(`Cannot detect any connected real devices. Falling back to Simulator. Original error: ${err.message}`);
const device = await getExistingSim(this.opts);
if (!device) {
// No matching Simulator is found. Throw an error
log.errorAndThrow(`Cannot detect udid for ${this.opts.deviceName} Simulator running iOS ${this.opts.platformVersion}`);
}
// Matching Simulator exists and is found. Use it
this.opts.udid = device.udid;
const devicePlatform = normalizePlatformVersion(await device.getPlatformVersion());
if (this.opts.platformVersion !== devicePlatform) {
this.opts.platformVersion = devicePlatform;
log.info(`Set platformVersion to '${devicePlatform}' to match the device with given UDID`);
}
await setupVersionCaps();
return {device, realDevice: false, udid: device.udid};
}
} else if (!this.opts.webDriverAgentUrl){ // NhuNH - modified for ignore udid check
// make sure it is a connected device. If not, the udid passed in is invalid
const devices = await getConnectedDevices(option);
log.debug(`Available devices: ${devices.join(', ')}`);
if (!devices.includes(this.opts.udid)) {
// check for a particular simulator
log.debug(`No real device with udid '${this.opts.udid}'. Looking for simulator`);
try {
const device = await getSimulator(this.opts.udid);
return {device, realDevice: false, udid: this.opts.udid};
} catch (ign) {
throw new Error(`Unknown device or simulator UDID: '${this.opts.udid}'`);
}
}
}
// NhuNH - modified for ignore udid check
var res = await getRealDeviceObj(this.opts);
return {device: res, realDevice: true, udid: this.opts.udid};
}
// Now we know for sure the device will be a Simulator
await setupVersionCaps();
if (this.opts.enforceFreshSimulatorCreation) {
log.debug(`New simulator is requested. If this is not wanted, set 'enforceFreshSimulatorCreation' capability to false`);
} else {
// figure out the correct simulator to use, given the desired capabilities
const device = await getExistingSim(this.opts);
// check for an existing simulator
if (device) {
return {device, realDevice: false, udid: device.udid};
}
log.info('Simulator udid not provided');
}
// no device of this type exists, or they request new sim, so create one
log.info('Using desired caps to create a new simulator');
const device = await this.createSim();
return {device, realDevice: false, udid: device.udid};
}
async startSim () {
const runOpts = {
scaleFactor: this.opts.scaleFactor,
connectHardwareKeyboard: !!this.opts.connectHardwareKeyboard,
pasteboardAutomaticSync: this.opts.simulatorPasteboardAutomaticSync || 'off',
isHeadless: !!this.opts.isHeadless,
tracePointer: !!this.opts.simulatorTracePointer,
devicePreferences: {},
};
// add the window center, if it is specified
if (this.opts.SimulatorWindowCenter) {
runOpts.devicePreferences.SimulatorWindowCenter = this.opts.SimulatorWindowCenter;
}
if (_.isInteger(this.opts.simulatorStartupTimeout)) {
runOpts.startupTimeout = this.opts.simulatorStartupTimeout;
}
// This is to workaround XCTest bug about changing Simulator
// orientation is not synchronized to the actual window orientation
const orientation = _.isString(this.opts.orientation) && this.opts.orientation.toUpperCase();
switch (orientation) {
case 'LANDSCAPE':
runOpts.devicePreferences.SimulatorWindowOrientation = 'LandscapeLeft';
runOpts.devicePreferences.SimulatorWindowRotationAngle = 90;
break;
case 'PORTRAIT':
runOpts.devicePreferences.SimulatorWindowOrientation = 'Portrait';
runOpts.devicePreferences.SimulatorWindowRotationAngle = 0;
break;
}
await this.opts.device.run(runOpts);
}
async createSim () {
this.lifecycleData.createSim = true;
// Get platform name from const since it must be case sensitive to create a new simulator
const platformName = this.isTvOS() ? PLATFORM_NAME_TVOS : PLATFORM_NAME_IOS;
// create sim for caps
let sim = await createSim(this.opts, platformName);
log.info(`Created simulator with udid '${sim.udid}'.`);
return sim;
}
async launchApp () {
const APP_LAUNCH_TIMEOUT = 20 * 1000;
this.logEvent('appLaunchAttempted');
await this.opts.device.simctl.launchApp(this.opts.bundleId);
let checkStatus = async () => {
let response = await this.proxyCommand('/status', 'GET');
let currentApp = response.currentApp.bundleID;
if (currentApp !== this.opts.bundleId) {
throw new Error(`${this.opts.bundleId} not in foreground. ${currentApp} is in foreground`);
}
};
log.info(`Waiting for '${this.opts.bundleId}' to be in foreground`);
let retries = parseInt(APP_LAUNCH_TIMEOUT / 200, 10);
await retryInterval(retries, 200, checkStatus);
log.info(`${this.opts.bundleId} is in foreground`);
this.logEvent('appLaunched');
}
async startWdaSession (bundleId, processArguments) {
let args = processArguments ? (processArguments.args || []) : [];
if (!_.isArray(args)) {
throw new Error(`processArguments.args capability is expected to be an array. ` +
`${JSON.stringify(args)} is given instead`);
}
let env = processArguments ? (processArguments.env || {}) : {};
if (!_.isPlainObject(env)) {
throw new Error(`processArguments.env capability is expected to be a dictionary. ` +
`${JSON.stringify(env)} is given instead`);
}
let shouldWaitForQuiescence = util.hasValue(this.opts.waitForQuiescence) ? this.opts.waitForQuiescence : true;
let maxTypingFrequency = util.hasValue(this.opts.maxTypingFrequency) ? this.opts.maxTypingFrequency : 60;
let shouldUseSingletonTestManager = util.hasValue(this.opts.shouldUseSingletonTestManager) ? this.opts.shouldUseSingletonTestManager : true;
let shouldUseTestManagerForVisibilityDetection = false;
let eventloopIdleDelaySec = this.opts.wdaEventloopIdleDelay || 0;
if (util.hasValue(this.opts.simpleIsVisibleCheck)) {
shouldUseTestManagerForVisibilityDetection = this.opts.simpleIsVisibleCheck;
}
if (util.compareVersions(this.opts.platformVersion, '==', '9.3')) {
log.info(`Forcing shouldUseSingletonTestManager capability value to true, because of known XCTest issues under 9.3 platform version`);
shouldUseTestManagerForVisibilityDetection = true;
}
if (util.hasValue(this.opts.language)) {
args.push('-AppleLanguages', `(${this.opts.language})`);
args.push('-NSLanguages', `(${this.opts.language})`);
}
if (util.hasValue(this.opts.locale)) {
args.push('-AppleLocale', this.opts.locale);
}
const wdaCaps = {
bundleId: this.opts.autoLaunch === false ? undefined : bundleId,
arguments: args,
environment: env,
eventloopIdleDelaySec,
shouldWaitForQuiescence,
shouldUseTestManagerForVisibilityDetection,
maxTypingFrequency,
shouldUseSingletonTestManager,
};
if (util.hasValue(this.opts.shouldUseCompactResponses)) {
wdaCaps.shouldUseCompactResponses = this.opts.shouldUseCompactResponses;
}
if (util.hasValue(this.opts.elementResponseFields)) {
wdaCaps.elementResponseFields = this.opts.elementResponseFields;
}
if (this.opts.autoAcceptAlerts) {
wdaCaps.defaultAlertAction = 'accept';
} else if (this.opts.autoDismissAlerts) {
wdaCaps.defaultAlertAction = 'dismiss';
}
await this.proxyCommand('/session', 'POST', {
capabilities: {
firstMatch: [wdaCaps],
alwaysMatch: {},
}
});
}
// Override Proxy methods from BaseDriver
proxyActive () {
return this.jwpProxyActive;
}
getProxyAvoidList () {
if (this.isWebview()) {
return NO_PROXY_WEB_LIST;
}
return NO_PROXY_NATIVE_LIST;
}
canProxy () {
return true;
}
isSafari () {
return !!this.safari;
}
isRealDevice () {
return this.opts.realDevice;
}
isSimulator () {
return !this.opts.realDevice;
}
isTvOS () {
return _.toLower(this.opts.platformName) === _.toLower(PLATFORM_NAME_TVOS);
}
isWebview () {
return this.isSafari() || this.isWebContext();
}
validateLocatorStrategy (strategy) {
super.validateLocatorStrategy(strategy, this.isWebContext());
}
validateDesiredCaps (caps) {
if (!super.validateDesiredCaps(caps)) {
return false;
}
// make sure that the capabilities have one of `app` or `bundleId`
if (_.toLower(caps.browserName) !== 'safari' && !caps.app && !caps.bundleId) {
log.info('The desired capabilities include neither an app nor a bundleId. ' +
'WebDriverAgent will be started without the default app');
}
if (!util.coerceVersion(caps.platformVersion, false)) {
log.warn(`'platformVersion' capability ('${caps.platformVersion}') is not a valid version number. ` +
`Consider fixing it or be ready to experience an inconsistent driver behavior.`);
}
let verifyProcessArgument = (processArguments) => {
const {args, env} = processArguments;
if (!_.isNil(args) && !_.isArray(args)) {
log.errorAndThrow('processArguments.args must be an array of strings');
}
if (!_.isNil(env) && !_.isPlainObject(env)) {
log.errorAndThrow('processArguments.env must be an object <key,value> pair {a:b, c:d}');
}
};
// `processArguments` should be JSON string or an object with arguments and/ environment details
if (caps.processArguments) {
if (_.isString(caps.processArguments)) {
try {
// try to parse the string as JSON
caps.processArguments = JSON.parse(caps.processArguments);
verifyProcessArgument(caps.processArguments);
} catch (err) {
log.errorAndThrow(`processArguments must be a JSON format or an object with format {args : [], env : {a:b, c:d}}. ` +
`Both environment and argument can be null. Error: ${err}`);
}
} else if (_.isPlainObject(caps.processArguments)) {
verifyProcessArgument(caps.processArguments);
} else {
log.errorAndThrow(`'processArguments must be an object, or a string JSON object with format {args : [], env : {a:b, c:d}}. ` +
`Both environment and argument can be null.`);
}
}
// there is no point in having `keychainPath` without `keychainPassword`
if ((caps.keychainPath && !caps.keychainPassword) || (!caps.keychainPath && caps.keychainPassword)) {
log.errorAndThrow(`If 'keychainPath' is set, 'keychainPassword' must also be set (and vice versa).`);
}
// `resetOnSessionStartOnly` should be set to true by default
this.opts.resetOnSessionStartOnly = !util.hasValue(this.opts.resetOnSessionStartOnly) || this.opts.resetOnSessionStartOnly;
this.opts.useNewWDA = util.hasValue(this.opts.useNewWDA) ? this.opts.useNewWDA : false;
if (caps.commandTimeouts) {
caps.commandTimeouts = normalizeCommandTimeouts(caps.commandTimeouts);
}
if (_.isString(caps.webDriverAgentUrl)) {
const {protocol, host} = url.parse(caps.webDriverAgentUrl);
if (_.isEmpty(protocol) || _.isEmpty(host)) {
log.errorAndThrow(`'webDriverAgentUrl' capability is expected to contain a valid WebDriverAgent server URL. ` +
`'${caps.webDriverAgentUrl}' is given instead`);
}
}
if (caps.browserName) {
if (caps.bundleId) {
log.errorAndThrow(`'browserName' cannot be set together with 'bundleId' capability`);
}
// warn if the capabilities have both `app` and `browser, although this
// is common with selenium grid
if (caps.app) {
log.warn(`The capabilities should generally not include both an 'app' and a 'browserName'`);
}
}
if (caps.permissions) {
try {
for (const [bundleId, perms] of _.toPairs(JSON.parse(caps.permissions))) {
if (!_.isString(bundleId)) {
throw new Error(`'${JSON.stringify(bundleId)}' must be a string`);
}
if (!_.isPlainObject(perms)) {
throw new Error(`'${JSON.stringify(perms)}' must be a JSON object`);
}
}
} catch (e) {
log.errorAndThrow(`'${caps.permissions}' is expected to be a valid object with format ` +
`{"<bundleId1>": {"<serviceName1>": "<serviceStatus1>", ...}, ...}. Original error: ${e.message}`);
}
}
if (caps.platformVersion && !util.coerceVersion(caps.platformVersion, false)) {
log.errorAndThrow(`'platformVersion' must be a valid version number. ` +
`'${caps.platformVersion}' is given instead.`);
}
// additionalWebviewBundleIds is an array, JSON array, or string
if (caps.additionalWebviewBundleIds) {
caps.additionalWebviewBundleIds = this.helpers.parseCapsArray(caps.additionalWebviewBundleIds);
}
// finally, return true since the superclass check passed, as did this
return true;
}
async installAUT () {
if (this.isSafari()) {
return;
}
await verifyApplicationPlatform(this.opts.app, {
isSimulator: this.isSimulator(),
isTvOS: this.isTvOS(),
});
if (this.isRealDevice()) {
await installToRealDevice(this.opts.device, this.opts.app, this.opts.bundleId, {
noReset: this.opts.noReset,
timeout: this.opts.appPushTimeout,
});
} else {
await installToSimulator(this.opts.device, this.opts.app, this.opts.bundleId, {
noReset: this.opts.noReset,
newSimulator: this.lifecycleData.createSim,
});
}
if (this.opts.otherApps) {
await this.installOtherApps(this.opts.otherApps);
}
if (util.hasValue(this.opts.iosInstallPause)) {
// https://github.com/appium/appium/issues/6889
let pause = parseInt(this.opts.iosInstallPause, 10);
log.debug(`iosInstallPause set. Pausing ${pause} ms before continuing`);
await B.delay(pause);
}
}
async installOtherApps (otherApps) {
if (this.isRealDevice()) {
log.warn('Capability otherApps is only supported for Simulators');
return;
}
try {
otherApps = this.helpers.parseCapsArray(otherApps);
} catch (e) {
log.errorAndThrow(`Could not parse "otherApps" capability: ${e.message}`);
}
for (const otherApp of otherApps) {
await installToSimulator(this.opts.device, otherApp, undefined, {
noReset: this.opts.noReset,
newSimulator: this.lifecycleData.createSim,
});
}
}
/**
* Set reduceMotion as 'isEnabled' only when the capabilities has 'reduceMotion'
* The call is ignored for real devices.
* @param {?boolean} isEnabled Wether enable reduceMotion
*/
async setReduceMotion (isEnabled) {
if (this.isRealDevice() || !_.isBoolean(isEnabled)) {
return;
}
log.info(`Setting reduceMotion to ${isEnabled}`);
await this.updateSettings({reduceMotion: isEnabled});
}
async setInitialOrientation (orientation) {
if (!_.isString(orientation)) {
log.info('Skipping setting of the initial display orientation. ' +
'Set the "orientation" capability to either "LANDSCAPE" or "PORTRAIT", if this is an undesired behavior.');
return;
}
orientation = orientation.toUpperCase();
if (!_.includes(['LANDSCAPE', 'PORTRAIT'], orientation)) {
log.debug(`Unable to set initial orientation to '${orientation}'`);
return;
}
log.debug(`Setting initial orientation to '${orientation}'`);
try {
await this.proxyCommand('/orientation', 'POST', {orientation});
this.opts.curOrientation = orientation;
} catch (err) {
log.warn(`Setting initial orientation failed with: ${err.message}`);
}
}
_getCommandTimeout (cmdName) {
if (this.opts.commandTimeouts) {
if (cmdName && _.has(this.opts.commandTimeouts, cmdName)) {
return this.opts.commandTimeouts[cmdName];
}
return this.opts.commandTimeouts[DEFAULT_TIMEOUT_KEY];
}
}
/**
* Get session capabilities merged with what WDA reports
* This is a library command but needs to call 'super' so can't be on
* a helper object
*/
async getSession () {
// call super to get event timings, etc...
const driverSession = await super.getSession();
if (!this.wdaCaps) {
this.wdaCaps = await this.proxyCommand('/', 'GET');
}
if (!this.deviceCaps) {
const {statusBarSize, scale} = await this.getScreenInfo();
this.deviceCaps = {
pixelRatio: scale,
statBarHeight: statusBarSize.height,
viewportRect: await this.getViewportRect(),
};
}
log.info('Merging WDA caps over Appium caps for session detail response');
return Object.assign({udid: this.opts.udid}, driverSession,
this.wdaCaps.capabilities, this.deviceCaps);
}
async reset () {
if (this.opts.noReset) {
// This is to make sure reset happens even if noReset is set to true
let opts = _.cloneDeep(this.opts);
opts.noReset = false;
opts.fullReset = false;
const shutdownHandler = this.resetOnUnexpectedShutdown;
this.resetOnUnexpectedShutdown = () => {};
try {
await this.runReset(opts);
} finally {
this.resetOnUnexpectedShutdown = shutdownHandler;
}
}
await super.reset();
}
}
Object.assign(XCUITestDriver.prototype, commands);
export default XCUITestDriver;
export { XCUITestDriver };