kuben-appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
373 lines (320 loc) • 13.5 kB
JavaScript
import { retryInterval } from 'asyncbox';
import { SubProcess, exec } from 'teen_process';
import { fs, logger } from 'appium-support';
import log from '../logger';
import B from 'bluebird';
import { fixForXcode7, fixForXcode9, setRealDeviceSecurity, generateXcodeConfigFile,
setXctestrunFile, updateProjectFile, resetProjectFile, killProcess,
WDA_RUNNER_BUNDLE_ID, getWDAUpgradeTimestamp } from './utils';
import _ from 'lodash';
import path from 'path';
const DEFAULT_SIGNING_ID = "iPhone Developer";
const BUILD_TEST_DELAY = 1000;
const RUNNER_SCHEME = 'WebDriverAgentRunner';
const LIB_SCHEME = 'WebDriverAgentLib';
const xcodeLog = logger.getLogger('Xcode');
class XcodeBuild {
constructor (xcodeVersion, device, args = {}) {
this.xcodeVersion = xcodeVersion;
this.device = device;
this.realDevice = args.realDevice;
this.agentPath = args.agentPath;
this.bootstrapPath = args.bootstrapPath;
this.platformVersion = args.platformVersion;
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.useXctestrunFile = args.useXctestrunFile;
this.launchTimeout = args.launchTimeout;
this.wdaRemotePort = args.wdaRemotePort;
this.updatedWDABundleId = args.updatedWDABundleId;
this.derivedDataPath = args.derivedDataPath;
this.mjpegServerPort = args.mjpegServerPort;
}
async init (noSessionProxy) {
this.noSessionProxy = noSessionProxy;
if (this.useXctestrunFile) {
if (this.xcodeVersion.major <= 7) {
log.errorAndThrow('useXctestrunFile can only be used with xcode version 8 onwards');
}
this.xctestrunFilePath = await setXctestrunFile(this.realDevice, this.device.udid, this.platformVersion, this.bootstrapPath, this.wdaRemotePort);
return;
}
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.xcodeVersion.major === 9) {
log.debug(`Using Xcode ${this.xcodeVersion.versionString}, so fixing WDA codebase`);
await fixForXcode9(this.bootstrapPath, true);
}
// if necessary, update the bundleId to user's specification
if (this.realDevice) {
// In case the project still has the user specific bundle ID, reset the project file first.
// - We do this reset even if updatedWDABundleId is not specified,
// since the previous updatedWDABundleId test has generated the user specific bundle ID project file.
// - We don't call resetProjectFile for simulator,
// since simulator test run will work with any user specific bundle ID.
await resetProjectFile(this.agentPath);
if (this.updatedWDABundleId) {
await updateProjectFile(this.agentPath, this.updatedWDABundleId);
}
}
}
async retrieveDerivedDataPath () {
if (this.derivedDataPath) {
return this.derivedDataPath;
}
let stdout;
try {
({stdout} = await exec('xcodebuild', ['-project', this.agentPath, '-showBuildSettings']));
} catch (err) {
log.warn(`Cannot retrieve WDA build settings. Original error: ${err.message}`);
return;
}
const pattern = /^\s*BUILD_DIR\s+=\s+(\/.*)/m;
const match = pattern.exec(stdout);
if (!match) {
log.warn(`Cannot parse WDA build dir from ${_.truncate(stdout, {length: 300})}`);
return;
}
log.debug(`Parsed BUILD_DIR configuration value: '${match[1]}'`);
// Derived data root is two levels higher over the build dir
this.derivedDataPath = path.dirname(path.dirname(path.normalize(match[1])));
log.debug(`Got derived data root: '${this.derivedDataPath}'`);
return this.derivedDataPath;
}
async reset () {
// if necessary, reset the bundleId to original value
if (this.realDevice && this.updatedWDABundleId) {
await resetProjectFile(this.agentPath);
}
}
async prebuild () {
if (this.xcodeVersion.major === 7) {
log.debug(`Capability 'prebuildWDA' set, but on xcode version ${this.xcodeVersion.versionString} so skipping`);
return;
}
// first do a build phase
log.debug('Pre-building WDA before launching test');
this.usePrebuiltWDA = true;
this.xcodebuild = await this.createSubProcess(true);
await this.start(true);
this.xcodebuild = null;
// pause a moment
await B.delay(BUILD_TEST_DELAY);
}
async cleanProject () {
for (const scheme of [LIB_SCHEME, RUNNER_SCHEME]) {
log.debug(`Cleaning the project scheme '${scheme}' to make sure there are no leftovers from previous installs`);
await exec('xcodebuild', [
'clean',
'-project', this.agentPath,
'-scheme', scheme,
]);
}
}
getCommand (buildOnly = false) {
let cmd = 'xcodebuild';
let args;
// figure out the targets for xcodebuild
if (this.xcodeVersion.major < 8) {
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 || this.useXctestrunFile) {
args = [testCmd];
} else {
args = [buildCmd, testCmd];
}
}
if (this.useXctestrunFile) {
args.push('-xctestrun', this.xctestrunFilePath);
} else {
args.push('-project', this.agentPath, '-scheme', RUNNER_SCHEME);
if (this.derivedDataPath) {
args.push('-derivedDataPath', this.derivedDataPath);
}
}
args.push('-destination', `id=${this.device.udid}`);
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 createSubProcess (buildOnly = false) {
if (!this.useXctestrunFile) {
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);
}
}
}
const {cmd, args} = this.getCommand(buildOnly);
log.debug(`Beginning ${buildOnly ? 'build' : 'test'} with command '${cmd} ${args.join(' ')}' ` +
`in directory '${this.bootstrapPath}'`);
const env = Object.assign({}, process.env, {
USE_PORT: this.wdaRemotePort,
WDA_PRODUCT_BUNDLE_IDENTIFIER: this.updatedWDABundleId || WDA_RUNNER_BUNDLE_ID,
});
if (this.mjpegServerPort) {
// https://github.com/appium/WebDriverAgent/pull/105
env.MJPEG_SERVER_PORT = this.mjpegServerPort;
}
const upgradeTimestamp = await getWDAUpgradeTimestamp(this.bootstrapPath);
if (upgradeTimestamp) {
env.UPGRADE_TIMESTAMP = upgradeTimestamp;
}
const xcodebuild = new SubProcess(cmd, args, {
cwd: this.bootstrapPath,
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let logXcodeOutput = this.showXcodeLog;
log.debug(`Output from xcodebuild ${logXcodeOutput ? 'will' : 'will not'} be logged. To change this, use 'showXcodeLog' desired capability`);
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.includes('Writing diagnostic log for test session to')) {
// 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.startsWith(path.sep)));
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.includes('Error Domain=') &&
!out.includes('Error writing attachment data to file') &&
!out.includes('Failed to remove screenshot at path')) {
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.includes('Error writing attachment data to file')) {
for (const line of out.split('\n')) {
xcodeLog.info(line);
}
}
}
});
return xcodebuild;
}
async start (buildOnly = false) {
this.xcodebuild = await this.createSubProcess(buildOnly);
// 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, 'utf8');
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(true);
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 `launchTimeout` is up
log.debug(`Waiting up to ${this.launchTimeout}ms for WebDriverAgent to start`);
let currentStatus = null;
try {
let retries = parseInt(this.launchTimeout / 500, 10);
await retryInterval(retries, 1000, 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 information:`);
log.debug(JSON.stringify(currentStatus, null, 2));
} 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 quit () {
await killProcess('xcodebuild', this.xcodebuild);
}
}
export { XcodeBuild };
export default XcodeBuild;