appium-xcuitest-driver-conan
Version:
Appium driver for iOS using XCUITest for backend
487 lines (419 loc) • 17.2 kB
JavaScript
import _ from 'lodash';
import path from 'path';
import url from 'url';
import B from 'bluebird';
import { retryInterval } from 'asyncbox';
import { SubProcess } from 'teen_process';
import { JWProxy } from 'appium-base-driver';
import { fs, logger } from 'appium-support';
import log from './logger';
import { NoSessionProxy } from "./no-session-proxy";
import { killAppUsingAppName, generateXcodeConfigFile } from './utils.js';
import { updateProjectFile, resetProjectFile, checkForDependencies,
setRealDeviceSecurity, fixForXcode7 } from './webdriveragent-utils';
const xcodeLog = logger.getLogger('Xcode');
const iproxyLog = logger.getLogger('iProxy');
const BOOTSTRAP_PATH = path.resolve(__dirname, '..', '..', 'WebDriverAgent');
const WDA_BUNDLE_ID = 'com.apple.test.WebDriverAgentRunner-Runner';
const DEFAULT_SIGNING_ID = "iPhone Developer";
const WDA_LAUNCH_TIMEOUT = 60 * 1000;
const IPROXY_TIMEOUT = 5000;
const WDA_AGENT_PORT = 8100;
const WDA_BASE_URL = 'http://localhost';
const BUILD_TEST_DELAY = 1000;
class WebDriverAgent {
// agentPath (optional): Path to WebdriverAgent Executable (inside WebDriverAgent.app)
constructor (xcodeVersion, args = {}) {
this.xcodeVersion = xcodeVersion;
this.device = args.device;
this.platformVersion = args.platformVersion;
this.host = args.host;
this.realDevice = !!args.realDevice;
this.setWDAPaths(args.bootstrapPath, args.agentPath);
this.wdaLocalPort = args.wdaLocalPort;
this.showXcodeLog = !!args.showXcodeLog;
this.xcodeConfigFile = args.xcodeConfigFile;
this.xcodeOrgId = args.xcodeOrgId;
this.xcodeSigningId = args.xcodeSigningId || DEFAULT_SIGNING_ID;
this.keychainPath = args.keychainPath;
this.keychainPassword = args.keychainPassword;
this.prebuildWDA = args.prebuildWDA;
this.usePrebuiltWDA = args.usePrebuiltWDA;
this.useSimpleBuildTest = args.useSimpleBuildTest;
this.webDriverAgentUrl = args.webDriverAgentUrl;
this.updatedWDABundleId = args.updatedWDABundleId;
this.expectIProxyErrors = true;
this.wdaLaunchTimeout = args.wdaLaunchTimeout || WDA_LAUNCH_TIMEOUT;
this.wdaConnectionTimeout = args.wdaConnectionTimeout;
this.useCarthageSsl = _.isBoolean(args.useCarthageSsl) && args.useCarthageSsl;
}
setWDAPaths (bootstrapPath, agentPath) {
// allow the user to specify a place for WDA. This is undocumented and
// only here for the purposes of testing development of WDA
this.bootstrapPath = bootstrapPath || BOOTSTRAP_PATH;
log.info(`Using WDA path: '${this.bootstrapPath}'`);
// for backward compatibility we need to be able to specify agentPath too
this.agentPath = agentPath || path.resolve(this.bootstrapPath, 'WebDriverAgent.xcodeproj');
log.info(`Using WDA agent: '${this.agentPath}'`);
}
async uninstall () {
log.debug(`Removing WDA application from device`);
await this.device.removeApp(WDA_BUNDLE_ID);
}
async launch (sessionId) {
if (this.webDriverAgentUrl) {
log.info(`Using provided WebdriverAgent at '${this.webDriverAgentUrl}'`);
this.url = this.webDriverAgentUrl;
this.setupProxies(sessionId);
return this.webDriverAgentUrl;
}
log.info('Launching WebDriverAgent on the device');
this.setupProxies(sessionId);
if (!await fs.exists(this.agentPath)) {
throw new Error(`Trying to use WebDriverAgent project at '${this.agentPath}' but the ` +
'file does not exist');
}
// make sure that the WDA dependencies have been built
await checkForDependencies(this.bootstrapPath, this.useCarthageSsl);
// if necessary, update the bundleId to user's specification
if (this.realDevice && this.updatedWDABundleId) {
await updateProjectFile(this.agentPath, this.updatedWDABundleId);
}
//kill all hanging processes
await this.killHangingProcesses();
if (this.xcodeVersion.major === 7 || (this.xcodeVersion.major === 8 && this.xcodeVersion.minor === 0)) {
log.debug(`Using Xcode ${this.xcodeVersion.versionString}, so fixing WDA codebase`);
await fixForXcode7(this.bootstrapPath, true);
}
if (this.prebuildWDA) {
if (this.xcodeVersion.major === 7) {
log.debug(`Capability 'prebuildWDA' set, but on xcode version ${this.xcodeVersion.versionString} so skipping`);
} else {
// first do a build phase
log.debug('Pre-building WDA before launching test');
this.usePrebuiltWDA = true;
this.xcodebuild = await this.createXcodeBuildSubProcess(true);
await this.startXcodebuild(true);
this.xcodebuild = null;
// pause a moment
await B.delay(BUILD_TEST_DELAY);
}
}
this.xcodebuild = await this.createXcodeBuildSubProcess();
if (this.realDevice) {
this.iproxy = this.createiProxySubProcess(this.url.port, WDA_AGENT_PORT);
await this.startiproxy();
}
// start the xcodebuild process
return await this.startXcodebuild();
}
setupProxies (sessionId) {
const proxyOpts = {
server: this.url.hostname,
port: this.url.port,
base: '',
timeout: this.wdaConnectionTimeout,
};
this.jwproxy = new JWProxy(proxyOpts);
this.jwproxy.sessionId = sessionId;
this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy);
this.noSessionProxy = new NoSessionProxy(proxyOpts);
this.noSessionProxyReqRes = this.noSessionProxy.proxyReqRes.bind(this.noSessionProxy);
}
getXcodeBuildCommand (buildOnly = false) {
let cmd = 'xcodebuild';
let args;
// figure out the targets for xcodebuild
if (this.xcodeVersion.major < 8) {
if (this.usePrebuiltWDA) {
let msg = `'usePrebuiltWDA' set, but on Xcode ` +
`'${this.xcodeVersion.versionString}', so skipping, as it ` +
`needs a version >= 8`;
log.warn(msg);
}
args =[
'build',
'test',
];
} else {
let [buildCmd, testCmd] = this.useSimpleBuildTest ? ['build', 'test'] : ['build-for-testing', 'test-without-building'];
if (buildOnly) {
args = [buildCmd];
} else if (this.usePrebuiltWDA) {
args = [testCmd];
} else {
args = [buildCmd, testCmd];
}
}
// add the rest of the arguments for the xcodebuild command
let genericArgs = [
'-project', this.agentPath,
'-scheme', 'WebDriverAgentRunner',
'-destination', `id=${this.device.udid}`,
'-configuration', 'Debug'
];
args.push(...genericArgs);
const versionMatch = new RegExp(/^(\d+)\.(\d+)/).exec(this.platformVersion);
if (versionMatch) {
args.push(`IPHONEOS_DEPLOYMENT_TARGET=${versionMatch[1]}.${versionMatch[2]}`);
} else {
log.warn(`Cannot parse major and minor version numbers from platformVersion "${this.platformVersion}". ` +
'Will build for the default platform instead');
}
if (this.realDevice && this.xcodeConfigFile) {
log.debug(`Using Xcode configuration file: '${this.xcodeConfigFile}'`);
args.push('-xcconfig', this.xcodeConfigFile);
}
return {cmd, args};
}
async createXcodeBuildSubProcess (buildOnly = false) {
if (this.realDevice) {
if (this.keychainPath && this.keychainPassword) {
await setRealDeviceSecurity(this.keychainPath, this.keychainPassword);
}
if (this.xcodeOrgId && this.xcodeSigningId && !this.xcodeConfigFile) {
this.xcodeConfigFile = await generateXcodeConfigFile(this.xcodeOrgId, this.xcodeSigningId);
}
}
let {cmd, args} = this.getXcodeBuildCommand(buildOnly);
log.debug(`Beginning ${buildOnly ? 'build' : 'test'} with command '${cmd} ${args.join(' ')}' ` +
`in directory '${this.bootstrapPath}'`);
let xcodebuild = new SubProcess(cmd, args, {cwd: this.bootstrapPath});
let logXcodeOutput = this.showXcodeLog;
log.debug(`Output from xcodebuild ${logXcodeOutput ? 'will' : 'will not'} be logged`);
xcodebuild.on('output', (stdout, stderr) => {
let out = stdout || stderr;
// we want to pull out the log file that is created, and highlight it
// for diagnostic purposes
if (out.indexOf('Writing diagnostic log for test session to') !== -1) {
// pull out the first line that begins with the path separator
// which *should* be the line indicating the log file generated
xcodebuild.logLocation = _.first(_.remove(out.trim().split('\n'), (v) => v.indexOf(path.sep) === 0));
log.debug(`Log file for xcodebuild test: ${xcodebuild.logLocation}`);
}
// if we have an error we want to output the logs
// otherwise the failure is inscrutible
// but do not log permission errors from trying to write to attachments folder
if (out.indexOf('Error Domain=') !== -1 && out.indexOf('Error writing attachment data to file') === -1) {
logXcodeOutput = true;
// terrible hack to handle case where xcode return 0 but is failing
xcodebuild._wda_error_occurred = true;
}
if (logXcodeOutput) {
// do not log permission errors from trying to write to attachments folder
if (out.indexOf('Error writing attachment data to file') === -1) {
for (let line of out.split('\n')) {
xcodeLog.info(line);
}
}
}
});
return xcodebuild;
}
createiProxySubProcess (localport, deviceport) {
log.debug(`Starting iproxy to forward traffic from local port ${localport} to device port ${deviceport} over USB`);
return new SubProcess(`iproxy`, [localport, deviceport, this.device.udid]);
}
async startXcodebuild (buildOnly = false) {
// wrap the start procedure in a promise so that we can catch, and report,
// any startup errors that are thrown as events
return await new B((resolve, reject) => {
this.xcodebuild.on('exit', async (code, signal) => {
log.info(`xcodebuild exited with code '${code}' and signal '${signal}'`);
// print out the xcodebuild file if users have asked for it
if (this.showXcodeLog && this.xcodebuild.logLocation) {
xcodeLog.info(`Contents of xcodebuild log file '${this.xcodebuild.logLocation}':`);
try {
let data = await fs.readFile(this.xcodebuild.logLocation, 'utf-8');
for (let line of data.split('\n')) {
xcodeLog.info(line);
}
} catch (err) {
log.debug(`Unable to access xcodebuild log file: '${err.message}'`);
}
}
this.xcodebuild.processExited = true;
if (this.xcodebuild._wda_error_occurred || (!signal && code !== 0)) {
return reject(new Error(`xcodebuild failed with code ${code}`));
}
// in the case of just building, the process will exit and that is our finish
if (buildOnly) {
return resolve();
}
});
return (async () => {
try {
let startTime = process.hrtime();
await this.xcodebuild.start();
if (!buildOnly) {
let status = await this.waitForStart(startTime);
resolve(status);
}
} catch (err) {
let msg = `Unable to start WebDriverAgent: ${err}`;
log.error(msg);
reject(new Error(msg));
}
})();
});
}
async waitForStart (startTime) {
// try to connect once every 0.5 seconds, until `wdaLaunchTimeout` is up
log.debug(`Waiting up to ${this.wdaLaunchTimeout}ms for WebDriverAgent to start`);
let currentStatus = null;
try {
let retries = parseInt(this.wdaLaunchTimeout / 500, 10);
await retryInterval(retries, 500, async () => {
if (this.xcodebuild.processExited) {
// there has been an error elsewhere and we need to short-circuit
return;
}
const proxyTimeout = this.noSessionProxy.timeout;
this.noSessionProxy.timeout = 1000;
try {
currentStatus = await this.noSessionProxy.command('/status', 'GET');
if (currentStatus && currentStatus.ios && currentStatus.ios.ip) {
this.agentUrl = currentStatus.ios.ip;
log.debug(`WebDriverAgent running on ip '${this.agentUrl}'`);
}
} catch (err) {
throw new Error(`Unable to connect to running WebDriverAgent: ${err.message}`);
} finally {
this.noSessionProxy.timeout = proxyTimeout;
}
});
if (this.xcodebuild.processExited) {
// there has been an error elsewhere and we need to short-circuit
return currentStatus;
}
let endTime = process.hrtime(startTime);
// must get [s, ns] array into ms
let startupTime = parseInt((endTime[0] * 1e9 + endTime[1]) / 1e6, 10);
log.debug(`WebDriverAgent successfully started after ${startupTime}ms`);
} catch (err) {
// at this point, if we have not had any errors from xcode itself (reported
// elsewhere), we can let this go through and try to create the session
log.debug(err.message);
log.warn(`Getting status of WebDriverAgent on device timed out. Continuing`);
}
return currentStatus;
}
async startiproxy () {
return await new B((resolve, reject) => {
this.iproxy.on('exit', (code) => {
log.debug(`iproxy exited with code '${code}'`);
if (code) {
return reject(new Error(`iproxy exited with code '${code}'`));
}
});
this.iproxy.on('output', (stdout, stderr) => {
// do nothing if we expect errors
if (this.expectIProxyErrors) {
return;
}
let out = stdout || stderr;
for (let line of out.split('\n')) {
if (!line.length) {
continue;
}
if (line.indexOf('Resource temporarily unavailable') !== -1) {
// this generally happens when WDA does not respond,
// so print a more useful message
log.debug('Connection to WDA timed out');
} else {
iproxyLog.debug(line);
}
}
});
return (async () => {
try {
await this.iproxy.start(IPROXY_TIMEOUT);
resolve();
} catch (err) {
log.error(`Error starting iproxy: '${err.message}'`);
reject(new Error('Unable to start iproxy. Is it installed?'));
}
})();
});
}
async killHangingProcesses () {
log.debug('Killing hanging processes');
await killAppUsingAppName(this.device.udid, `xcodebuild`);
let procNames = this.realDevice ? ['iproxy']
: ['XCTRunner'];
for (let proc of procNames) {
await killAppUsingAppName(this.device.udid, proc);
}
}
async quit () {
log.info('Shutting down sub-processes');
async function killProcess (name, proc) {
if (proc && proc.proc) {
log.info(`Shutting down ${name} process (pid ${proc.proc.pid})`);
try {
await proc.stop('SIGTERM', 1000);
} catch (err) {
if (err.message.indexOf(`Process didn't end after`) === -1) {
throw err;
}
log.debug(`${name} process did not end in a timely fashion: '${err.message}'. ` +
`Sending 'SIGKILL'...`);
try {
await proc.stop('SIGKILL');
} catch (err) {
if (err.message.indexOf('not currently running') !== -1) {
// the process ended but for some reason we were not informed
return;
}
throw err;
}
}
}
}
await killProcess('xcodebuild', this.xcodebuild);
await killProcess('iproxy', this.iproxy);
// if necessary, reset the bundleId to original value
if (this.realDevice && this.updatedWDABundleId) {
await resetProjectFile(this.agentPath, this.updatedWDABundleId);
}
if (this.jwproxy) {
this.jwproxy.sessionId = null;
}
this.expectIProxyErrors = true;
}
get url () {
if (!this._url) {
if (this.realDevice && this.wdaLocalPort) {
this._url = url.parse(`${WDA_BASE_URL}:${this.wdaLocalPort}`);
} else {
this._url = url.parse(`${WDA_BASE_URL}:${WDA_AGENT_PORT}`);
}
}
return this._url;
}
set url (_url) {
this._url = url.parse(_url);
}
get fullyStarted () {
return !this.expectIProxyErrors;
}
set fullyStarted (started = false) {
// before WDA is started we expect errors from iproxy, since it is not
// communicating with anything yet
this.expectIProxyErrors = !started;
}
get derivedDataPath () {
if (!this._derivedDataPath && this.xcodebuild) {
// https://regex101.com/r/PqmX8I/1
const folderRegexp = /(.+\/WebDriverAgent-[^\/]+)/;
let match = folderRegexp.exec(this.xcodebuild.logLocation);
if (!match) {
return;
}
this._derivedDataPath = match[1];
}
return this._derivedDataPath;
}
}
export default WebDriverAgent;
export { WebDriverAgent, WDA_BUNDLE_ID, BOOTSTRAP_PATH };