kuben-appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
1,137 lines (998 loc) • 42 kB
JavaScript
import { BaseDriver, DeviceSettings } from 'appium-base-driver';
import { util, fs, mjpeg } from 'appium-support';
import _ from 'lodash';
import url from 'url';
import { launch } from 'kuben-node-simctl';
import WebDriverAgent from './wda/webdriveragent';
import log from './logger';
import { createSim, getExistingSim, runSimulatorReset, installToSimulator,
shutdownOtherSimulators, shutdownSimulator } from './simulator-management';
import { simExists, getSimulator, installSSLCert, hasSSLCert } from 'kuben-appium-ios-simulator';
import { retryInterval, retry } from 'asyncbox';
import { settings as iosSettings, defaultServerCaps, appUtils, IWDP } from 'kuben-appium-ios-driver';
import desiredCapConstraints from './desired-caps';
import commands from './commands/index';
import { detectUdid, getAndCheckXcodeVersion, getAndCheckIosSdkVersion,
adjustWDAAttachmentsPermissions, checkAppPresent, getDriverInfo,
clearSystemFiles, translateDeviceName, normalizeCommandTimeouts,
DEFAULT_TIMEOUT_KEY, markSystemFilesForCleanup,
printUser, removeAllSessionWebSocketHandlers, verifyApplicationPlatform } from './utils';
import { getConnectedDevices, runRealDeviceReset, installToRealDevice,
getRealDeviceObj } from './real-device-management';
import B from 'bluebird';
import AsyncLock from 'async-lock';
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,
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,
};
// 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', /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$/],
['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/],
];
const NO_PROXY_WEB_LIST = [
['DELETE', /cookie/],
['GET', /attribute/],
['GET', /cookie/],
['GET', /element/],
['GET', /text/],
['GET', /title/],
['POST', /clear/],
['POST', /click/],
['POST', /cookie/],
['POST', /element/],
['POST', /forward/],
['POST', /frame/],
['POST', /keys/],
['POST', /refresh/],
].concat(NO_PROXY_NATIVE_LIST);
/* eslint-enable no-useless-escape */
const MEMOIZED_FUNCTIONS = [
'getWindowSizeNative',
'getWindowSizeWeb',
'getStatusBarHeight',
'getDevicePixelRatio',
'getScreenInfo',
'getSafariIsIphone',
'getSafariIsIphoneX',
];
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));
// 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') {
return await this.proxyCommand('/appium/settings', 'POST', {
settings: {[key]: value}
});
}
this.opts.nativeWebTap = !!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 appium-ios-driver need
this.curWebFrames = [];
this.webElementIds = [];
this._currentUrl = null;
this.curContext = null;
this.xcodeVersion = {};
this.iosSdkVersion = null;
this.contexts = [];
this.implicitWaitMs = 0;
this.asynclibWaitMs = 0;
this.pageLoadMs = 6000;
this.landscapeWebCoordsOffset = 0;
}
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 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 (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(e);
await this.deleteSession();
throw e;
}
}
async start () {
this.opts.noReset = !!this.opts.noReset;
this.opts.fullReset = !!this.opts.fullReset;
await printUser();
if (this.opts.platformVersion && parseFloat(this.opts.platformVersion) < 9.3) {
throw Error(`Platform version must be 9.3 or above. '${this.opts.platformVersion}' is not supported.`);
}
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;
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();
const tools = !this.xcodeVersion.toolsVersion ? '' : `(tools v${this.xcodeVersion.toolsVersion})`;
log.debug(`Xcode version set to '${this.xcodeVersion.versionString}' ${tools}`);
this.iosSdkVersion = await getAndCheckIosSdkVersion();
log.debug(`iOS SDK Version set to '${this.iosSdkVersion}'`);
}
this.logEvent('xcodeDetailsRetrieved');
if (this.opts.enableAsyncExecuteFromHttps && !this.isRealDevice()) {
// shutdown the simulator so that the ssl cert is recognized
await shutdownSimulator(this.opts.device);
await this.startHttpsAsyncServer();
}
// at this point if there is no platformVersion, get it from the device
if (!this.opts.platformVersion) {
if (this.opts.device && _.isFunction(this.opts.device.getPlatformVersion)) {
this.opts.platformVersion = await this.opts.device.getPlatformVersion();
log.info(`No platformVersion specified. Using device version: '${this.opts.platformVersion}'`);
} else {
// TODO: this is when it is a real device. when we have a real object wire it in
}
}
if (!this.opts.webDriverAgentUrl && this.iosSdkVersion) {
// make sure that the xcode we are using can handle the platform
if (parseFloat(this.opts.platformVersion) > parseFloat(this.iosSdkVersion)) {
let msg = `Xcode ${this.xcodeVersion.versionString} has a maximum SDK version of ${this.iosSdkVersion}. ` +
`It does not support iOS version ${this.opts.platformVersion}`;
log.errorAndThrow(msg);
}
} else {
log.debug('Xcode version will not be validated against iOS SDK version.');
}
if ((this.opts.browserName || '').toLowerCase() === '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`
);
this.opts.processArguments.args = ['-u', this._currentUrl];
} else {
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 startLogCapture = async () => {
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) {
if (!this.relaxedSecurityEnabled) {
log.errorAndThrow(`Appium server must have relaxed security flag set in order ` +
`for 'shutdownOtherSimulators' capability to work`);
}
await shutdownOtherSimulators(this.opts.device);
}
// set reduceMotion if capability is set
if (util.hasValue(this.opts.reduceMotion)) {
await this.opts.device.setReduceMotion(this.opts.reduceMotion);
}
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());
});
await this.startSim();
if (this.opts.customSSLCert) {
if (await hasSSLCert(this.opts.customSSLCert, this.opts.udid)) {
log.info(`SSL cert '${_.truncate(this.opts.customSSLCert, {length: 20})}' already installed`);
} else {
log.info(`Installing ssl cert '${_.truncate(this.opts.customSSLCert, {length: 20})}'`);
await shutdownSimulator(this.opts.device);
await installSSLCert(this.opts.customSSLCert, this.opts.udid);
log.info(`Restarting Simulator so that SSL certificate installation takes effect`);
await this.startSim();
this.logEvent('customCertInstalled');
}
}
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) {
if (!await this.opts.device.isAppInstalled(this.opts.bundleId)) {
log.errorAndThrow(`App with bundle identifier '${this.opts.bundleId}' unknown`);
}
}
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 SHARED_RESOURCES_GUARD.acquire(XCUITestDriver.name,
async () => await this.startWda(this.opts.sessionId, realDevice));
await this.setInitialOrientation(this.opts.orientation);
this.logEvent('orientationSet');
if (this.isRealDevice() && this.opts.startIWDP) {
try {
await this.startIWDP();
log.debug(`Started ios_webkit_debug proxy server at: ${this.iwdpServer.endpoint}`);
} catch (err) {
log.errorAndThrow(`Could not start ios_webkit_debug_proxy server: ${err.message}`);
}
}
if (this.isSafari() || this.opts.autoWebview) {
log.debug('Waiting for initial webview');
await this.navToInitialWebview();
this.logEvent('initialWebviewNavigated');
}
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);
await this.wda.cleanupObsoleteProcesses();
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(this.opts.updatedWDABundleId);
}
// 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 and unsinstalling WebDriverAgent as webDriverAgentUrl is provided');
throw new Error(msg);
}
log.warn('Quitting and uninstalling WebDriverAgent, then retrying');
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`);
await retryInterval(startupRetries, startupRetryInterval, async () => {
this.logEvent('wdaStartAttempted');
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);
// this.cachedWdaStatus = await this.wda.launch(sessionId, realDevice);
} catch (err) {
this.logEvent('wdaStartFailed');
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;
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) {
log.debug(`Failed to create WDA session (${err.message}). Retrying...`);
throw err;
}
});
this.logEvent('wdaSessionStarted');
} catch (err) {
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 (!util.hasValue(this.opts.preventWDAAttachments)) {
// XCTest prior to Xcode 9 SDK has no native way to disable attachments
this.opts.preventWDAAttachments = this.xcodeVersion.major < 9;
if (this.opts.preventWDAAttachments) {
log.info('Enabled WDA attachments prevention by default to save the disk space. ' +
`Set 'preventWDAAttachments' capability to false if this is an undesired behavior.`);
}
}
if (this.opts.preventWDAAttachments) {
await adjustWDAAttachmentsPermissions(this.wda, this.opts.preventWDAAttachments ? '555' : '755');
this.logEvent('wdaPermsAdjusted');
}
if (this.opts.clearSystemFiles) {
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);
await SHARED_RESOURCES_GUARD.acquire(XCUITestDriver.name, async () => {
await this.stop();
// reset the permissions on the derived data folder, if necessary
if (this.opts.preventWDAAttachments) {
await adjustWDAAttachmentsPermissions(this.wda, '755');
}
if (this.opts.clearSystemFiles) {
if (this.isAppTemporary) {
await fs.rimraf(this.opts.app);
}
await clearSystemFiles(this.wda, !!this.opts.showXcodeLog);
} else {
log.debug('Not clearing log files. Use `clearSystemFiles` capability to turn on.');
}
});
if (this.isWebContext()) {
log.debug('In a web session. Removing remote debugger');
await this.stopRemote();
}
if (this.opts.resetOnSessionStartOnly === false) {
await this.runReset();
}
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.iwdpServer) {
await this.stopIWDP();
}
if (this.opts.enableAsyncExecuteFromHttps && !this.isRealDevice()) {
await this.stopHttpsAsyncServer();
}
if (this.mjpegStream) {
log.info('Closing MJPEG stream');
this.mjpegStream.stop();
}
this.resetIos();
await super.deleteSession();
}
async stop () {
this.jwpProxyActive = false;
this.proxyReqRes = null;
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 && !this.wda.webDriverAgentUrl && this.opts.useNewWDA) {
await this.wda.quit();
}
}
}
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
if (this.opts.app && this.opts.app.toLowerCase() === 'settings') {
this.opts.bundleId = 'com.apple.Preferences';
this.opts.app = null;
return;
} else if (this.opts.app && this.opts.app.toLowerCase() === 'calendar') {
this.opts.bundleId = 'com.apple.mobilecal';
this.opts.app = null;
return;
}
const originalAppPath = this.opts.app;
try {
// download if necessary
this.opts.app = await this.helpers.configureApp(this.opts.app, '.app');
} 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 file`);
}
this.isAppTemporary = this.opts.app && await fs.exists(this.opts.app)
&& !await util.isSameDestination(originalAppPath, this.opts.app);
}
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);
if (this.opts.udid) {
if (this.opts.udid.toLowerCase() === 'auto') {
try {
this.opts.udid = await detectUdid();
} 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;
return {device, realDevice: false, udid: device.udid};
}
} else {
// make sure it is a connected device. If not, the udid passed in is invalid
const devices = await getConnectedDevices();
log.debug(`Available devices: ${devices.join(', ')}`);
if (!devices.includes(this.opts.udid)) {
// check for a particular simulator
if (await simExists(this.opts.udid)) {
const device = await getSimulator(this.opts.udid);
return {device, realDevice: false, udid: this.opts.udid};
}
throw new Error(`Unknown device or simulator UDID: '${this.opts.udid}'`);
}
}
const device = await getRealDeviceObj(this.opts.udid);
return {device, realDevice: true, udid: this.opts.udid};
}
// figure out the correct simulator to use, given the desired capabilities
let device = await getExistingSim(this.opts);
// check for an existing simulator
if (device) {
return {device, realDevice: false, udid: device.udid};
}
// no device of this type exists, so create one
log.info('Simulator udid not provided, using desired caps to create a new simulator');
if (!this.opts.platformVersion && this.iosSdkVersion) {
log.info(`No platformVersion specified. Using latest version Xcode supports: '${this.iosSdkVersion}' ` +
`This may cause problems if a simulator does not exist for this platform version.`);
this.opts.platformVersion = this.iosSdkVersion;
}
if (this.opts.noReset) {
// Check for existing simulator just with correct capabilities
let device = await getExistingSim(this.opts);
if (device) {
return {device, realDevice: false, udid: device.udid};
}
}
device = await this.createSim();
return {device, realDevice: false, udid: device.udid};
}
async startSim () {
const runOpts = {
scaleFactor: this.opts.scaleFactor,
connectHardwareKeyboard: !!this.opts.connectHardwareKeyboard,
isHeadless: !!this.opts.isHeadless,
devicePreferences: {},
};
// add the window center, if it is specified
if (this.opts.SimulatorWindowCenter) {
runOpts.devicePreferences.SimulatorWindowCenter = this.opts.SimulatorWindowCenter;
}
// 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;
// create sim for caps
let sim = await createSim(this.opts);
log.info(`Created simulator with udid '${sim.udid}'.`);
return sim;
}
async launchApp () {
const APP_LAUNCH_TIMEOUT = 20 * 1000;
this.logEvent('appLaunchAttempted');
await launch(this.opts.device.udid, 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;
if (util.hasValue(this.opts.simpleIsVisibleCheck)) {
shouldUseTestManagerForVisibilityDetection = this.opts.simpleIsVisibleCheck;
}
if (!isNaN(parseFloat(this.opts.platformVersion)) && parseFloat(this.opts.platformVersion).toFixed(1) === '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);
}
let desired = {
desiredCapabilities: {
bundleId,
arguments: args,
environment: env,
shouldWaitForQuiescence,
shouldUseTestManagerForVisibilityDetection,
maxTypingFrequency,
shouldUseSingletonTestManager,
}
};
if (util.hasValue(this.opts.shouldUseCompactResponses)) {
desired.desiredCapabilities.shouldUseCompactResponses = this.opts.shouldUseCompactResponses;
}
if (util.hasValue(this.opts.elementResponseFields)) {
desired.desiredCapabilities.elementResponseFields = this.opts.elementResponseFields;
}
if (this.opts.autoAcceptAlerts) {
desired.desiredCapabilities.defaultAlertAction = 'accept';
} else if (this.opts.autoDismissAlerts) {
desired.desiredCapabilities.defaultAlertAction = 'dismiss';
}
await this.proxyCommand('/session', 'POST', desired);
}
// 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;
}
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 ((caps.browserName || '').toLowerCase() !== 'safari' && !caps.app && !caps.bundleId) {
let msg = 'The desired capabilities must include either an app or a bundleId for iOS';
log.errorAndThrow(msg);
}
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}`);
}
}
// finally, return true since the superclass check passed, as did this
return true;
}
async installAUT () {
if (this.isSafari()) {
return;
}
// if user has passed in desiredCaps.autoLaunch = false
// meaning they will manage app install / launching
if (this.opts.autoLaunch === false) {
return;
}
try {
await verifyApplicationPlatform(this.opts.app, this.isSimulator());
} catch (err) {
// TODO: Let it throw after we confirm the architecture verification algorithm is stable
log.warn(`*********************************`);
log.warn(`${this.isSimulator() ? 'Simulator' : 'Real device'} architecture appears to be unsupported ` +
`by the '${this.opts.app}' application. ` +
`Make sure the correct deployment target has been selected for its compilation in Xcode.`);
log.warn('Don\'t be surprised if the application fails to launch.');
log.warn(`*********************************`);
}
if (this.isRealDevice()) {
await installToRealDevice(this.opts.device, this.opts.app, this.opts.bundleId, this.opts.noReset);
} else {
await installToSimulator(this.opts.device, this.opts.app, this.opts.bundleId, this.opts.noReset);
}
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 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 startIWDP () {
this.logEvent('iwdpStarting');
this.iwdpServer = new IWDP(this.opts.webkitDebugProxyPort, this.opts.udid);
await this.iwdpServer.start();
this.logEvent('iwdpStarted');
}
async stopIWDP () {
if (this.iwdpServer) {
await this.iwdpServer.stop();
delete this.iwdpServer;
}
}
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 };