初始化提交

This commit is contained in:
2026-02-03 16:52:44 +08:00
commit d2f9806384
512 changed files with 65167 additions and 0 deletions

50
lib/check-dependencies.js Normal file
View File

@@ -0,0 +1,50 @@
import { fs } from '@appium/support';
import _ from 'lodash';
import { exec } from 'teen_process';
import path from 'path';
import {XcodeBuild} from './xcodebuild';
import * as xcode from 'appium-xcode';
import {
WDA_SCHEME, SDK_SIMULATOR, WDA_RUNNER_APP
} from './constants';
import { BOOTSTRAP_PATH } from './utils';
import log from './logger';
async function buildWDASim () {
const args = [
'-project', path.join(BOOTSTRAP_PATH, 'WebDriverAgent.xcodeproj'),
'-scheme', WDA_SCHEME,
'-sdk', SDK_SIMULATOR,
'CODE_SIGN_IDENTITY=""',
'CODE_SIGNING_REQUIRED="NO"',
'GCC_TREAT_WARNINGS_AS_ERRORS=0',
];
await exec('xcodebuild', args);
}
export async function checkForDependencies () {
log.debug('Dependencies are up to date');
return false;
}
/**
*
* @param {XcodeBuild} xcodebuild
* @returns {Promise<string>}
*/
export async function bundleWDASim (xcodebuild) {
if (xcodebuild && !_.isFunction(xcodebuild.retrieveDerivedDataPath)) {
xcodebuild = new XcodeBuild(/** @type {import('appium-xcode').XcodeVersion} */ (await xcode.getVersion(true)), {});
}
const derivedDataPath = await xcodebuild.retrieveDerivedDataPath();
if (!derivedDataPath) {
throw new Error('Cannot retrieve the path to the Xcode derived data folder');
}
const wdaBundlePath = path.join(derivedDataPath, 'Build', 'Products', 'Debug-iphonesimulator', WDA_RUNNER_APP);
if (await fs.exists(wdaBundlePath)) {
return wdaBundlePath;
}
await buildWDASim();
return wdaBundlePath;
}

24
lib/constants.js Normal file
View File

@@ -0,0 +1,24 @@
import path from 'path';
const DEFAULT_TEST_BUNDLE_SUFFIX = '.xctrunner';
const WDA_RUNNER_BUNDLE_ID = 'com.facebook.WebDriverAgentRunner';
const WDA_RUNNER_BUNDLE_ID_FOR_XCTEST = `${WDA_RUNNER_BUNDLE_ID}${DEFAULT_TEST_BUNDLE_SUFFIX}`;
const WDA_RUNNER_APP = 'WebDriverAgentRunner-Runner.app';
const WDA_SCHEME = 'WebDriverAgentRunner';
const PROJECT_FILE = 'project.pbxproj';
const WDA_BASE_URL = 'http://127.0.0.1';
const PLATFORM_NAME_TVOS = 'tvOS';
const PLATFORM_NAME_IOS = 'iOS';
const SDK_SIMULATOR = 'iphonesimulator';
const SDK_DEVICE = 'iphoneos';
const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time');
export {
WDA_RUNNER_BUNDLE_ID, WDA_RUNNER_APP, PROJECT_FILE,
WDA_SCHEME, PLATFORM_NAME_TVOS, PLATFORM_NAME_IOS,
SDK_SIMULATOR, SDK_DEVICE, WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH,
WDA_RUNNER_BUNDLE_ID_FOR_XCTEST, DEFAULT_TEST_BUNDLE_SUFFIX
};

5
lib/logger.js Normal file
View File

@@ -0,0 +1,5 @@
import { logger } from '@appium/support';
const log = logger.getLogger('WebDriverAgent');
export default log;

26
lib/no-session-proxy.js Normal file
View File

@@ -0,0 +1,26 @@
import { JWProxy } from '@appium/base-driver';
class NoSessionProxy extends JWProxy {
constructor (opts = {}) {
super(opts);
}
getUrlForProxy (url) {
if (url === '') {
url = '/';
}
const proxyBase = `${this.scheme}://${this.server}:${this.port}${this.base}`;
let remainingUrl = '';
if ((new RegExp('^/')).test(url)) {
remainingUrl = url;
} else {
throw new Error(`Did not know what to do with url '${url}'`);
}
remainingUrl = remainingUrl.replace(/\/$/, ''); // can't have trailing slashes
return proxyBase + remainingUrl;
}
}
export { NoSessionProxy };
export default NoSessionProxy;

52
lib/types.ts Normal file
View File

@@ -0,0 +1,52 @@
// WebDriverAgentLib/Utilities/FBSettings.h
export interface WDASettings {
elementResponseAttribute?: string;
shouldUseCompactResponses?: boolean;
mjpegServerScreenshotQuality?: number;
mjpegServerFramerate?: number;
screenshotQuality?: number;
elementResponseAttributes?: string;
mjpegScalingFactor?: number;
mjpegFixOrientation?: boolean;
keyboardAutocorrection?: boolean;
keyboardPrediction?: boolean;
customSnapshotTimeout?: number;
snapshotMaxDepth?: number;
useFirstMatch?: boolean;
boundElementsByIndex?: boolean;
reduceMotion?: boolean;
defaultActiveApplication?: string;
activeAppDetectionPoint?: string;
includeNonModalElements?: boolean;
defaultAlertAction?: 'accept' | 'dismiss';
acceptAlertButtonSelector?: string;
dismissAlertButtonSelector?: string;
screenshotOrientation?: 'auto' | 'portrait' | 'portraitUpsideDown' | 'landscapeRight' | 'landscapeLeft'
waitForIdleTimeout?: number;
animationCoolOffTimeout?: number;
maxTypingFrequency?: number;
useClearTextShortcut?: boolean;
}
// WebDriverAgentLib/Utilities/FBCapabilities.h
export interface WDACapabilities {
bundleId?: string;
initialUrl?: string;
arguments?: string[];
environment?: Record<string, string>;
eventloopIdleDelaySec?: number;
shouldWaitForQuiescence?: boolean;
shouldUseTestManagerForVisibilityDetection?: boolean;
maxTypingFrequency?: number;
shouldUseSingletonTestManager?: boolean;
waitForIdleTimeout?: number;
shouldUseCompactResponses?: number;
elementResponseFields?: unknown;
disableAutomaticScreenshots?: boolean;
shouldTerminateApp?: boolean;
forceAppLaunch?: boolean;
useNativeCachingStrategy?: boolean;
forceSimulatorSoftwareKeyboardPresence?: boolean;
defaultAlertAction?: 'accept' | 'dismiss';
appLaunchStateTimeoutSec?: number;
}

398
lib/utils.js Normal file
View File

@@ -0,0 +1,398 @@
import { fs, plist } from '@appium/support';
import { exec } from 'teen_process';
import path from 'path';
import log from './logger';
import _ from 'lodash';
import { WDA_RUNNER_BUNDLE_ID, PLATFORM_NAME_TVOS } from './constants';
import B from 'bluebird';
import _fs from 'fs';
import { waitForCondition } from 'asyncbox';
import { arch } from 'os';
const PROJECT_FILE = 'project.pbxproj';
/**
* Calculates the path to the current module's root folder
*
* @returns {string} The full path to module root
* @throws {Error} If the current module root folder cannot be determined
*/
const getModuleRoot = _.memoize(function getModuleRoot () {
let currentDir = path.dirname(path.resolve(__filename));
let isAtFsRoot = false;
while (!isAtFsRoot) {
const manifestPath = path.join(currentDir, 'package.json');
try {
if (_fs.existsSync(manifestPath) &&
JSON.parse(_fs.readFileSync(manifestPath, 'utf8')).name === 'appium-webdriveragent') {
return currentDir;
}
} catch {}
currentDir = path.dirname(currentDir);
isAtFsRoot = currentDir.length <= path.dirname(currentDir).length;
}
throw new Error('Cannot find the root folder of the appium-webdriveragent Node.js module');
});
export const BOOTSTRAP_PATH = getModuleRoot();
async function getPIDsUsingPattern (pattern) {
const args = [
'-if', // case insensitive, full cmdline match
pattern,
];
try {
const {stdout} = await exec('pgrep', args);
return stdout.split(/\s+/)
.map((x) => parseInt(x, 10))
.filter(_.isInteger)
.map((x) => `${x}`);
} catch (err) {
log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`);
return [];
}
}
async function killAppUsingPattern (pgrepPattern) {
const signals = [2, 15, 9];
for (const signal of signals) {
const matchedPids = await getPIDsUsingPattern(pgrepPattern);
if (_.isEmpty(matchedPids)) {
return;
}
const args = [`-${signal}`, ...matchedPids];
try {
await exec('kill', args);
} catch (err) {
log.debug(`kill ${args.join(' ')} -> ${err.message}`);
}
if (signal === _.last(signals)) {
// there is no need to wait after SIGKILL
return;
}
try {
await waitForCondition(async () => {
const pidCheckPromises = matchedPids
.map((pid) => exec('kill', ['-0', pid])
// the process is still alive
.then(() => false)
// the process is dead
.catch(() => true)
);
return (await B.all(pidCheckPromises))
.every((x) => x === true);
}, {
waitMs: 1000,
intervalMs: 100,
});
return;
} catch {
// try the next signal
}
}
}
/**
* Return true if the platformName is tvOS
* @param {string} platformName The name of the platorm
* @returns {boolean} Return true if the platformName is tvOS
*/
function isTvOS (platformName) {
return _.toLower(platformName) === _.toLower(PLATFORM_NAME_TVOS);
}
async function replaceInFile (file, find, replace) {
let contents = await fs.readFile(file, 'utf8');
let newContents = contents.replace(find, replace);
if (newContents !== contents) {
await fs.writeFile(file, newContents, 'utf8');
}
}
/**
* Update WebDriverAgentRunner project bundle ID with newBundleId.
* This method assumes project file is in the correct state.
* @param {string} agentPath - Path to the .xcodeproj directory.
* @param {string} newBundleId the new bundle ID used to update.
*/
async function updateProjectFile (agentPath, newBundleId) {
let projectFilePath = path.resolve(agentPath, PROJECT_FILE);
try {
// Assuming projectFilePath is in the correct state, create .old from projectFilePath
await fs.copyFile(projectFilePath, `${projectFilePath}.old`);
await replaceInFile(projectFilePath, new RegExp(_.escapeRegExp(WDA_RUNNER_BUNDLE_ID), 'g'), newBundleId);
log.debug(`Successfully updated '${projectFilePath}' with bundle id '${newBundleId}'`);
} catch (err) {
log.debug(`Error updating project file: ${err.message}`);
log.warn(`Unable to update project file '${projectFilePath}' with ` +
`bundle id '${newBundleId}'. WebDriverAgent may not start`);
}
}
/**
* Reset WebDriverAgentRunner project bundle ID to correct state.
* @param {string} agentPath - Path to the .xcodeproj directory.
*/
async function resetProjectFile (agentPath) {
const projectFilePath = path.join(agentPath, PROJECT_FILE);
try {
// restore projectFilePath from .old file
if (!await fs.exists(`${projectFilePath}.old`)) {
return; // no need to reset
}
await fs.mv(`${projectFilePath}.old`, projectFilePath);
log.debug(`Successfully reset '${projectFilePath}' with bundle id '${WDA_RUNNER_BUNDLE_ID}'`);
} catch (err) {
log.debug(`Error resetting project file: ${err.message}`);
log.warn(`Unable to reset project file '${projectFilePath}' with ` +
`bundle id '${WDA_RUNNER_BUNDLE_ID}'. WebDriverAgent has been ` +
`modified and not returned to the original state.`);
}
}
async function setRealDeviceSecurity (keychainPath, keychainPassword) {
log.debug('Setting security for iOS device');
await exec('security', ['-v', 'list-keychains', '-s', keychainPath]);
await exec('security', ['-v', 'unlock-keychain', '-p', keychainPassword, keychainPath]);
await exec('security', ['set-keychain-settings', '-t', '3600', '-l', keychainPath]);
}
/**
* Information of the device under test
* @typedef {Object} DeviceInfo
* @property {string} isRealDevice - Equals to true if the current device is a real device
* @property {string} udid - The device UDID.
* @property {string} platformVersion - The platform version of OS.
* @property {string} platformName - The platform name of iOS, tvOS
*/
/**
* Creates xctestrun file per device & platform version.
* We expects to have WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device
* and WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-${x86_64|arm64}.xctestrun for simulator located @bootstrapPath
* Newer Xcode (Xcode 10.0 at least) generate xctestrun file following sdkVersion.
* e.g. Xcode which has iOS SDK Version 12.2 on an intel Mac host machine generates WebDriverAgentRunner_iphonesimulator.2-x86_64.xctestrun
* even if the cap has platform version 11.4
*
* @param {DeviceInfo} deviceInfo
* @param {string} sdkVersion - The Xcode SDK version of OS.
* @param {string} bootstrapPath - The folder path containing xctestrun file.
* @param {number|string} wdaRemotePort - The remote port WDA is listening on.
* @return {Promise<string>} returns xctestrunFilePath for given device
* @throws if WebDriverAgentRunner_iphoneos${sdkVersion|platformVersion}-arm64.xctestrun for real device
* or WebDriverAgentRunner_iphonesimulator${sdkVersion|platformVersion}-x86_64.xctestrun for simulator is not found @bootstrapPath,
* then it will throw file not found exception
*/
async function setXctestrunFile (deviceInfo, sdkVersion, bootstrapPath, wdaRemotePort) {
const xctestrunFilePath = await getXctestrunFilePath(deviceInfo, sdkVersion, bootstrapPath);
const xctestRunContent = await plist.parsePlistFile(xctestrunFilePath);
const updateWDAPort = getAdditionalRunContent(deviceInfo.platformName, wdaRemotePort);
const newXctestRunContent = _.merge(xctestRunContent, updateWDAPort);
await plist.updatePlistFile(xctestrunFilePath, newXctestRunContent, true);
return xctestrunFilePath;
}
/**
* Return the WDA object which appends existing xctest runner content
* @param {string} platformName - The name of the platform
* @param {number|string} wdaRemotePort - The remote port number
* @return {object} returns a runner object which has USE_PORT
*/
function getAdditionalRunContent (platformName, wdaRemotePort) {
const runner = `WebDriverAgentRunner${isTvOS(platformName) ? '_tvOS' : ''}`;
return {
[runner]: {
EnvironmentVariables: {
// USE_PORT must be 'string'
USE_PORT: `${wdaRemotePort}`
}
}
};
}
/**
* Return the path of xctestrun if it exists
* @param {DeviceInfo} deviceInfo
* @param {string} sdkVersion - The Xcode SDK version of OS.
* @param {string} bootstrapPath - The folder path containing xctestrun file.
* @returns {Promise<string>}
*/
async function getXctestrunFilePath (deviceInfo, sdkVersion, bootstrapPath) {
// First try the SDK path, for Xcode 10 (at least)
const sdkBased = [
path.resolve(bootstrapPath, `${deviceInfo.udid}_${sdkVersion}.xctestrun`),
sdkVersion,
];
// Next try Platform path, for earlier Xcode versions
const platformBased = [
path.resolve(bootstrapPath, `${deviceInfo.udid}_${deviceInfo.platformVersion}.xctestrun`),
deviceInfo.platformVersion,
];
for (const [filePath, version] of [sdkBased, platformBased]) {
if (await fs.exists(filePath)) {
log.info(`Using '${filePath}' as xctestrun file`);
return filePath;
}
const originalXctestrunFile = path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, version));
if (await fs.exists(originalXctestrunFile)) {
// If this is first time run for given device, then first generate xctestrun file for device.
// We need to have a xctestrun file **per device** because we cant not have same wda port for all devices.
await fs.copyFile(originalXctestrunFile, filePath);
log.info(`Using '${filePath}' as xctestrun file copied by '${originalXctestrunFile}'`);
return filePath;
}
}
throw new Error(
`If you are using 'useXctestrunFile' capability then you ` +
`need to have a xctestrun file (expected: ` +
`'${path.resolve(bootstrapPath, getXctestrunFileName(deviceInfo, sdkVersion))}')`
);
}
/**
* Return the name of xctestrun file
* @param {DeviceInfo} deviceInfo
* @param {string} version - The Xcode SDK version of OS.
* @return {string} returns xctestrunFilePath for given device
*/
function getXctestrunFileName (deviceInfo, version) {
const archSuffix = deviceInfo.isRealDevice
? `os${version}-arm64`
: `simulator${version}-${arch() === 'arm64' ? 'arm64' : 'x86_64'}`;
return `WebDriverAgentRunner_${isTvOS(deviceInfo.platformName) ? 'tvOS_appletv' : 'iphone'}${archSuffix}.xctestrun`;
}
/**
* Ensures the process is killed after the timeout
*
* @param {string} name
* @param {import('teen_process').SubProcess} proc
* @returns {Promise<void>}
*/
async function killProcess (name, proc) {
if (!proc || !proc.isRunning) {
return;
}
log.info(`Shutting down '${name}' process (pid '${proc.proc?.pid}')`);
log.info(`Sending 'SIGTERM'...`);
try {
await proc.stop('SIGTERM', 1000);
return;
} catch (err) {
if (!err.message.includes(`Process didn't end after`)) {
throw err;
}
log.debug(`${name} process did not end in a timely fashion: '${err.message}'.`);
}
log.info(`Sending 'SIGKILL'...`);
try {
await proc.stop('SIGKILL');
} catch (err) {
if (err.message.includes('not currently running')) {
// the process ended but for some reason we were not informed
return;
}
throw err;
}
}
/**
* Generate a random integer.
*
* @return {number} A random integer number in range [low, hight). `low`` is inclusive and `high` is exclusive.
*/
function randomInt (low, high) {
return Math.floor(Math.random() * (high - low) + low);
}
/**
* Retrieves WDA upgrade timestamp
*
* @return {Promise<number?>} The UNIX timestamp of the package manifest. The manifest only gets modified on
* package upgrade.
*/
async function getWDAUpgradeTimestamp () {
const packageManifest = path.resolve(getModuleRoot(), 'package.json');
if (!await fs.exists(packageManifest)) {
return null;
}
const {mtime} = await fs.stat(packageManifest);
return mtime.getTime();
}
/**
* Kills running XCTest processes for the particular device.
*
* @param {string} udid - The device UDID.
* @param {boolean} isSimulator - Equals to true if the current device is a Simulator
*/
async function resetTestProcesses (udid, isSimulator) {
const processPatterns = [`xcodebuild.*${udid}`];
if (isSimulator) {
processPatterns.push(`${udid}.*XCTRunner`);
// The pattern to find in case idb was used
processPatterns.push(`xctest.*${udid}`);
}
log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`);
await B.all(processPatterns.map(killAppUsingPattern));
}
/**
* Get the IDs of processes listening on the particular system port.
* It is also possible to apply additional filtering based on the
* process command line.
*
* @param {string|number} port - The port number.
* @param {?Function} filteringFunc - Optional lambda function, which
* receives command line string of the particular process
* listening on given port, and is expected to return
* either true or false to include/exclude the corresponding PID
* from the resulting array.
* @returns {Promise<string[]>} - the list of matched process ids.
*/
async function getPIDsListeningOnPort (port, filteringFunc = null) {
const result = [];
try {
// This only works since Mac OS X El Capitan
const {stdout} = await exec('lsof', ['-ti', `tcp:${port}`]);
result.push(...(stdout.trim().split(/\n+/)));
} catch (e) {
if (e.code !== 1) {
// code 1 means no processes. Other errors need reporting
log.debug(`Error getting processes listening on port '${port}': ${e.stderr || e.message}`);
}
return result;
}
if (!_.isFunction(filteringFunc)) {
return result;
}
return await B.filter(result, async (pid) => {
let stdout;
try {
({stdout} = await exec('ps', ['-p', pid, '-o', 'command']));
} catch (e) {
if (e.code === 1) {
// The process does not exist anymore, there's nothing to filter
return false;
}
throw e;
}
return await filteringFunc(stdout);
});
}
export { updateProjectFile, resetProjectFile, setRealDeviceSecurity,
getAdditionalRunContent, getXctestrunFileName,
setXctestrunFile, getXctestrunFilePath, killProcess, randomInt,
getWDAUpgradeTimestamp, resetTestProcesses,
getPIDsListeningOnPort, killAppUsingPattern, isTvOS
};

741
lib/webdriveragent.js Normal file
View File

@@ -0,0 +1,741 @@
import { waitForCondition } from 'asyncbox';
import _ from 'lodash';
import path from 'path';
import url from 'url';
import B from 'bluebird';
import { JWProxy } from '@appium/base-driver';
import { fs, util, plist } from '@appium/support';
import defaultLogger from './logger';
import { NoSessionProxy } from './no-session-proxy';
import {
getWDAUpgradeTimestamp, resetTestProcesses, getPIDsListeningOnPort, BOOTSTRAP_PATH
} from './utils';
import {XcodeBuild} from './xcodebuild';
import AsyncLock from 'async-lock';
import { exec } from 'teen_process';
import { bundleWDASim } from './check-dependencies';
import {
WDA_RUNNER_BUNDLE_ID, WDA_RUNNER_APP,
WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH, DEFAULT_TEST_BUNDLE_SUFFIX
} from './constants';
import {Xctest} from 'appium-ios-device';
import {strongbox} from '@appium/strongbox';
const WDA_LAUNCH_TIMEOUT = 60 * 1000;
const WDA_AGENT_PORT = 8100;
const WDA_CF_BUNDLE_NAME = 'WebDriverAgentRunner-Runner';
const SHARED_RESOURCES_GUARD = new AsyncLock();
const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion';
export class WebDriverAgent {
/** @type {string} */
bootstrapPath;
/** @type {string} */
agentPath;
/**
* @param {import('appium-xcode').XcodeVersion} xcodeVersion
* // TODO: make args typed
* @param {import('@appium/types').StringRecord} [args={}]
* @param {import('@appium/types').AppiumLogger?} [log=null]
*/
constructor (xcodeVersion, args = {}, log = null) {
this.xcodeVersion = xcodeVersion;
this.args = _.clone(args);
this.log = log ?? defaultLogger;
this.device = args.device;
this.platformVersion = args.platformVersion;
this.platformName = args.platformName;
this.iosSdkVersion = args.iosSdkVersion;
this.host = args.host;
this.isRealDevice = !!args.realDevice;
this.idb = (args.device || {}).idb;
this.wdaBundlePath = args.wdaBundlePath;
this.setWDAPaths(args.bootstrapPath, args.agentPath);
this.wdaLocalPort = args.wdaLocalPort;
this.wdaRemotePort = ((this.isRealDevice ? args.wdaRemotePort : null) ?? args.wdaLocalPort)
|| WDA_AGENT_PORT;
this.wdaBaseUrl = args.wdaBaseUrl || WDA_BASE_URL;
this.prebuildWDA = args.prebuildWDA;
// this.args.webDriverAgentUrl guiarantees the capabilities acually
// gave 'appium:webDriverAgentUrl' but 'this.webDriverAgentUrl'
// could be used for caching WDA with xcodebuild.
this.webDriverAgentUrl = args.webDriverAgentUrl;
this.started = false;
this.wdaConnectionTimeout = args.wdaConnectionTimeout;
this.useXctestrunFile = args.useXctestrunFile;
this.usePrebuiltWDA = args.usePrebuiltWDA;
this.derivedDataPath = args.derivedDataPath;
this.mjpegServerPort = args.mjpegServerPort;
this.updatedWDABundleId = args.updatedWDABundleId;
this.wdaLaunchTimeout = args.wdaLaunchTimeout || WDA_LAUNCH_TIMEOUT;
this.usePreinstalledWDA = args.usePreinstalledWDA;
this.xctestApiClient = null;
this.updatedWDABundleIdSuffix = args.updatedWDABundleIdSuffix ?? DEFAULT_TEST_BUNDLE_SUFFIX;
this.xcodebuild = this.canSkipXcodebuild
? null
: new XcodeBuild(this.xcodeVersion, this.device, {
platformVersion: this.platformVersion,
platformName: this.platformName,
iosSdkVersion: this.iosSdkVersion,
agentPath: this.agentPath,
bootstrapPath: this.bootstrapPath,
realDevice: this.isRealDevice,
showXcodeLog: args.showXcodeLog,
xcodeConfigFile: args.xcodeConfigFile,
xcodeOrgId: args.xcodeOrgId,
xcodeSigningId: args.xcodeSigningId,
keychainPath: args.keychainPath,
keychainPassword: args.keychainPassword,
useSimpleBuildTest: args.useSimpleBuildTest,
usePrebuiltWDA: args.usePrebuiltWDA,
updatedWDABundleId: this.updatedWDABundleId,
launchTimeout: this.wdaLaunchTimeout,
wdaRemotePort: this.wdaRemotePort,
useXctestrunFile: this.useXctestrunFile,
derivedDataPath: args.derivedDataPath,
mjpegServerPort: this.mjpegServerPort,
allowProvisioningDeviceRegistration: args.allowProvisioningDeviceRegistration,
resultBundlePath: args.resultBundlePath,
resultBundleVersion: args.resultBundleVersion,
}, this.log);
}
/**
* Return true if the session does not need xcodebuild.
* @returns {boolean} Whether the session needs/has xcodebuild.
*/
get canSkipXcodebuild () {
// Use this.args.webDriverAgentUrl to guarantee
// the capabilities set gave the `appium:webDriverAgentUrl`.
return this.usePreinstalledWDA || this.args.webDriverAgentUrl;
}
/**
* Return bundle id for WebDriverAgent to launch the WDA.
* The primary usage is with 'this.usePreinstalledWDA'.
* It adds `.xctrunner` as suffix by default but 'this.updatedWDABundleIdSuffix'
* lets skip it.
*
* @returns {string} Bundle ID for Xctest.
*/
get bundleIdForXctest () {
return `${this.updatedWDABundleId ? this.updatedWDABundleId : WDA_RUNNER_BUNDLE_ID}${this.updatedWDABundleIdSuffix}`;
}
/**
* @param {string} [bootstrapPath]
* @param {string} [agentPath]
*/
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;
this.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');
this.log.info(`Using WDA agent: '${this.agentPath}'`);
}
/**
* @returns {Promise<void>}
*/
async cleanupObsoleteProcesses () {
const obsoletePids = await getPIDsListeningOnPort(/** @type {string} */ (this.url.port),
(cmdLine) => cmdLine.includes('/WebDriverAgentRunner') &&
!cmdLine.toLowerCase().includes(this.device.udid.toLowerCase()));
if (_.isEmpty(obsoletePids)) {
this.log.debug(`No obsolete cached processes from previous WDA sessions ` +
`listening on port ${this.url.port} have been found`);
return;
}
this.log.info(`Detected ${obsoletePids.length} obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} ` +
`from previous WDA sessions. Cleaning them up`);
try {
await exec('kill', obsoletePids);
} catch (e) {
this.log.warn(`Failed to kill obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} '${obsoletePids}'. ` +
`Original error: ${e.message}`);
}
}
/**
* Return boolean if WDA is running or not
* @return {Promise<boolean>} True if WDA is running
* @throws {Error} If there was invalid response code or body
*/
async isRunning () {
return !!(await this.getStatus());
}
/**
* @returns {string}
*/
get basePath () {
if (this.url.path === '/') {
return '';
}
return this.url.path || '';
}
/**
* Return current running WDA's status like below
* {
* "state": "success",
* "os": {
* "name": "iOS",
* "version": "11.4",
* "sdkVersion": "11.3"
* },
* "ios": {
* "simulatorVersion": "11.4",
* "ip": "172.254.99.34"
* },
* "build": {
* "time": "Jun 24 2018 17:08:21",
* "productBundleIdentifier": "com.facebook.WebDriverAgentRunner"
* }
* }
*
* @param {number} [timeoutMs=0] If the given timeoutMs is zero or negative number,
* this function will return the response of `/status` immediately. If the given timeoutMs,
* this function will try to get the response of `/status` up to the timeoutMs.
* @return {Promise<import('@appium/types').StringRecord|null>} State Object
* @throws {Error} If there was an error within timeoutMs timeout.
* No error is raised if zero or negative number for the timeoutMs.
*/
async getStatus (timeoutMs = 0) {
const noSessionProxy = new NoSessionProxy({
server: this.url.hostname,
port: this.url.port,
base: this.basePath,
timeout: 3000,
});
const sendGetStatus = async () => await /** @type import('@appium/types').StringRecord */ (noSessionProxy.command('/status', 'GET'));
if (_.isNil(timeoutMs) || timeoutMs <= 0) {
try {
return await sendGetStatus();
} catch (err) {
this.log.debug(`WDA is not listening at '${this.url.href}'. Original error:: ${err.message}`);
return null;
}
}
let lastError = null;
let status = null;
try {
await waitForCondition(async () => {
try {
status = await sendGetStatus();
return true;
} catch (err) {
lastError = err;
}
return false;
}, {
waitMs: timeoutMs,
intervalMs: 300,
});
} catch (err) {
this.log.debug(`Failed to get the status endpoint in ${timeoutMs} ms. ` +
`The last error while accessing ${this.url.href}: ${lastError}. Original error:: ${err.message}.`);
throw new Error(`WDA was not ready in ${timeoutMs} ms.`);
}
return status;
}
/**
* Uninstall WDAs from the test device.
* Over Xcode 11, multiple WDA can be in the device since Xcode 11 generates different WDA.
* Appium does not expect multiple WDAs are running on a device.
*
* @returns {Promise<void>}
*/
async uninstall () {
try {
const bundleIds = await this.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME);
if (_.isEmpty(bundleIds)) {
this.log.debug('No WDAs on the device.');
return;
}
this.log.debug(`Uninstalling WDAs: '${bundleIds}'`);
for (const bundleId of bundleIds) {
await this.device.removeApp(bundleId);
}
} catch (e) {
this.log.debug(e);
this.log.warn(`WebDriverAgent uninstall failed. Perhaps, it is already uninstalled? ` +
`Original error: ${e.message}`);
}
}
async _cleanupProjectIfFresh () {
if (this.canSkipXcodebuild) {
return;
}
const packageInfo = JSON.parse(await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8'));
const box = strongbox(packageInfo.name);
let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME);
if (!boxItem) {
const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH);
if (await fs.exists(timestampPath)) {
// TODO: It is probably a bit ugly to hardcode the recent version string,
// TODO: hovewer it should do the job as a temporary transition trick
// TODO: to switch from a hardcoded file path to the strongbox usage.
try {
boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0');
} catch (e) {
this.log.warn(`The actual module version cannot be persisted: ${e.message}`);
return;
}
} else {
this.log.info('There is no need to perform the project cleanup. A fresh install has been detected');
try {
await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version);
} catch (e) {
this.log.warn(`The actual module version cannot be persisted: ${e.message}`);
}
return;
}
}
let recentModuleVersion = await boxItem.read();
try {
recentModuleVersion = util.coerceVersion(recentModuleVersion, true);
} catch (e) {
this.log.warn(`The persisted module version string has been damaged: ${e.message}`);
this.log.info(`Updating it to '${packageInfo.version}' assuming the project clenup is not needed`);
await boxItem.write(packageInfo.version);
return;
}
if (util.compareVersions(recentModuleVersion, '>=', packageInfo.version)) {
this.log.info(
`WebDriverAgent does not need a cleanup. The project sources are up to date ` +
`(${recentModuleVersion} >= ${packageInfo.version})`
);
return;
}
this.log.info(
`Cleaning up the WebDriverAgent project after the module upgrade has happened ` +
`(${recentModuleVersion} < ${packageInfo.version})`
);
try {
// @ts-ignore xcodebuild should be set
await this.xcodebuild.cleanProject();
await boxItem.write(packageInfo.version);
} catch (e) {
this.log.warn(`Cannot perform WebDriverAgent project cleanup. Original error: ${e.message}`);
}
}
/**
* @typedef {Object} LaunchWdaViaDeviceCtlOptions
* @property {Record<string, string|number>} [env] environment variables for the launching WDA process
*/
/**
* Launch WDA with preinstalled package with 'xcrun devicectl device process launch'.
* The WDA package must be prepared properly like published via
* https://github.com/appium/WebDriverAgent/releases
* with proper sign for this case.
*
* When we implement launching XCTest service via appium-ios-device,
* this implementation can be replaced with it.
*
* @param {LaunchWdaViaDeviceCtlOptions} [opts={}] launching WDA with devicectl command options.
* @return {Promise<void>}
*/
async _launchViaDevicectl(opts = {}) {
const {env} = opts;
await this.device.devicectl.launchApp(
this.bundleIdForXctest, { env, terminateExisting: true }
);
}
/**
* Launch WDA with preinstalled package without xcodebuild.
* @param {string} sessionId Launch WDA and establish the session with this sessionId
* @return {Promise<import('@appium/types').StringRecord|null>} State Object
* @throws {Error} If there was an error within timeoutMs timeout.
* No error is raised if zero or negative number for the timeoutMs.
*/
async launchWithPreinstalledWDA(sessionId) {
const xctestEnv = {
USE_PORT: this.wdaLocalPort || WDA_AGENT_PORT,
WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest
};
if (this.mjpegServerPort) {
xctestEnv.MJPEG_SERVER_PORT = this.mjpegServerPort;
}
this.log.info('Launching WebDriverAgent on the device without xcodebuild');
if (this.isRealDevice) {
// Current method to launch WDA process can be done via 'xcrun devicectl',
// but it has limitation about the WDA preinstalled package.
// https://github.com/appium/appium/issues/19206#issuecomment-2014182674
if (util.compareVersions(this.platformVersion, '>=', '17.0')) {
await this._launchViaDevicectl({env: xctestEnv});
} else {
this.xctestApiClient = new Xctest(this.device.udid, this.bundleIdForXctest, null, {env: xctestEnv});
await this.xctestApiClient.start();
}
} else {
await this.device.simctl.exec('launch', {
args: [
'--terminate-running-process',
this.device.udid,
this.bundleIdForXctest,
],
env: xctestEnv,
});
}
this.setupProxies(sessionId);
let status;
try {
status = await this.getStatus(this.wdaLaunchTimeout);
} catch {
throw new Error(
`Failed to start the preinstalled WebDriverAgent in ${this.wdaLaunchTimeout} ms. ` +
`The WebDriverAgent might not be properly built or the device might be locked. ` +
`The 'appium:wdaLaunchTimeout' capability modifies the timeout.`
);
}
this.started = true;
return status;
}
/**
* Return current running WDA's status like below after launching WDA
* {
* "state": "success",
* "os": {
* "name": "iOS",
* "version": "11.4",
* "sdkVersion": "11.3"
* },
* "ios": {
* "simulatorVersion": "11.4",
* "ip": "172.254.99.34"
* },
* "build": {
* "time": "Jun 24 2018 17:08:21",
* "productBundleIdentifier": "com.facebook.WebDriverAgentRunner"
* }
* }
*
* @param {string} sessionId Launch WDA and establish the session with this sessionId
* @return {Promise<any?>} State Object
* @throws {Error} If there was invalid response code or body
*/
async launch (sessionId) {
if (this.webDriverAgentUrl) {
this.log.info(`Using provided WebdriverAgent at '${this.webDriverAgentUrl}'`);
this.url = this.webDriverAgentUrl;
this.setupProxies(sessionId);
return await this.getStatus();
}
if (this.usePreinstalledWDA) {
return await this.launchWithPreinstalledWDA(sessionId);
}
this.log.info('Launching WebDriverAgent on the device');
this.setupProxies(sessionId);
if (!this.useXctestrunFile && !await fs.exists(this.agentPath)) {
throw new Error(`Trying to use WebDriverAgent project at '${this.agentPath}' but the ` +
'file does not exist');
}
// useXctestrunFile and usePrebuiltWDA use existing dependencies
// It depends on user side
if (this.idb || this.useXctestrunFile || this.usePrebuiltWDA) {
this.log.info('Skipped WDA project cleanup according to the provided capabilities');
} else {
const synchronizationKey = path.normalize(this.bootstrapPath);
await SHARED_RESOURCES_GUARD.acquire(synchronizationKey,
async () => await this._cleanupProjectIfFresh());
}
// We need to provide WDA local port, because it might be occupied
await resetTestProcesses(this.device.udid, !this.isRealDevice);
if (this.idb) {
return await this.startWithIDB();
}
// @ts-ignore xcodebuild should be set
await this.xcodebuild.init(this.noSessionProxy);
// Start the xcodebuild process
if (this.prebuildWDA) {
// @ts-ignore xcodebuild should be set
await this.xcodebuild.prebuild();
}
// @ts-ignore xcodebuild should be set
return await this.xcodebuild.start();
}
/**
* @returns {Promise<void>}
*/
async startWithIDB () {
this.log.info('Will launch WDA with idb instead of xcodebuild since the corresponding flag is enabled');
const {wdaBundleId, testBundleId} = await this.prepareWDA();
const env = {
USE_PORT: this.wdaRemotePort,
WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest,
};
if (this.mjpegServerPort) {
env.MJPEG_SERVER_PORT = this.mjpegServerPort;
}
return await this.idb.runXCUITest(wdaBundleId, wdaBundleId, testBundleId, {env});
}
/**
*
* @param {string} wdaBundlePath
* @returns {Promise<string>}
*/
async parseBundleId (wdaBundlePath) {
const infoPlistPath = path.join(wdaBundlePath, 'Info.plist');
const infoPlist = await plist.parsePlist(await fs.readFile(infoPlistPath));
if (!infoPlist.CFBundleIdentifier) {
throw new Error(`Could not find bundle id in '${infoPlistPath}'`);
}
return infoPlist.CFBundleIdentifier;
}
/**
* @returns {Promise<{wdaBundleId: string, testBundleId: string, wdaBundlePath: string}>}
*/
async prepareWDA () {
const wdaBundlePath = this.wdaBundlePath || await this.fetchWDABundle();
const wdaBundleId = await this.parseBundleId(wdaBundlePath);
if (!await this.device.isAppInstalled(wdaBundleId)) {
await this.device.installApp(wdaBundlePath);
}
const testBundleId = await this.idb.installXCTestBundle(path.join(wdaBundlePath, 'PlugIns', 'WebDriverAgentRunner.xctest'));
return {wdaBundleId, testBundleId, wdaBundlePath};
}
/**
* @returns {Promise<string>}
*/
async fetchWDABundle () {
if (!this.derivedDataPath) {
return await bundleWDASim(/** @type {XcodeBuild} */ (this.xcodebuild));
}
const wdaBundlePaths = await fs.glob(`${this.derivedDataPath}/**/*${WDA_RUNNER_APP}/`, {
absolute: true,
});
if (_.isEmpty(wdaBundlePaths)) {
throw new Error(`Could not find the WDA bundle in '${this.derivedDataPath}'`);
}
return wdaBundlePaths[0];
}
/**
* @returns {Promise<boolean>}
*/
async isSourceFresh () {
const existsPromises = [
'Resources',
`Resources${path.sep}WebDriverAgent.bundle`,
].map((subPath) => fs.exists(path.resolve(/** @type {String} */ (this.bootstrapPath), subPath)));
return (await B.all(existsPromises)).some((v) => v === false);
}
/**
* @param {string} sessionId
* @returns {void}
*/
setupProxies (sessionId) {
const proxyOpts = {
log: this.log,
server: this.url.hostname ?? undefined,
port: parseInt(this.url.port ?? '', 10) || undefined,
base: this.basePath,
timeout: this.wdaConnectionTimeout,
keepAlive: true,
scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http',
};
if (this.args.reqBasePath) {
proxyOpts.reqBasePath = this.args.reqBasePath;
}
this.jwproxy = new JWProxy(proxyOpts);
this.jwproxy.sessionId = sessionId;
this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy);
this.noSessionProxy = new NoSessionProxy(proxyOpts);
}
/**
* @returns {Promise<void>}
*/
async quit () {
if (this.usePreinstalledWDA) {
this.log.info('Stopping the XCTest session');
if (this.xctestApiClient) {
this.xctestApiClient.stop();
this.xctestApiClient = null;
} else {
try {
await this.device.simctl.terminateApp(this.bundleIdForXctest);
} catch (e) {
this.log.warn(e.message);
}
}
} else if (!this.args.webDriverAgentUrl) {
this.log.info('Shutting down sub-processes');
await this.xcodebuild?.quit();
await this.xcodebuild?.reset();
} else {
this.log.debug('Do not stop xcodebuild nor XCTest session ' +
'since the WDA session is managed by outside this driver.');
}
if (this.jwproxy) {
this.jwproxy.sessionId = null;
}
this.started = false;
if (!this.args.webDriverAgentUrl) {
// if we populated the url ourselves (during `setupCaching` call, for instance)
// then clean that up. If the url was supplied, we want to keep it
this.webDriverAgentUrl = null;
}
}
/**
* @returns {import('url').UrlWithStringQuery}
*/
get url () {
if (!this._url) {
if (this.webDriverAgentUrl) {
this._url = url.parse(this.webDriverAgentUrl);
} else {
const port = this.wdaLocalPort || WDA_AGENT_PORT;
const {protocol, hostname} = url.parse(this.wdaBaseUrl || WDA_BASE_URL);
this._url = url.parse(`${protocol}//${hostname}:${port}`);
}
}
return this._url;
}
/**
* @param {string} _url
* @returns {void}
*/
set url (_url) {
this._url = url.parse(_url);
}
/**
* @returns {boolean}
*/
get fullyStarted () {
return this.started;
}
/**
* @param {boolean} started
* @returns {void}s
*/
set fullyStarted (started) {
this.started = started ?? false;
}
/**
* @returns {Promise<string|undefined>}
*/
async retrieveDerivedDataPath () {
if (this.canSkipXcodebuild) {
return;
}
return await /** @type {XcodeBuild} */ (this.xcodebuild).retrieveDerivedDataPath();
}
/**
* Reuse running WDA if it has the same bundle id with updatedWDABundleId.
* Or reuse it if it has the default id without updatedWDABundleId.
* Uninstall it if the method faces an exception for the above situation.
* @returns {Promise<void>}
*/
async setupCaching () {
const status = await this.getStatus();
if (!status || !status.build) {
this.log.debug('WDA is currently not running. There is nothing to cache');
return;
}
const {
productBundleIdentifier,
upgradedAt,
} = status.build;
// for real device
if (util.hasValue(productBundleIdentifier) && util.hasValue(this.updatedWDABundleId) && this.updatedWDABundleId !== productBundleIdentifier) {
this.log.info(`Will uninstall running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`);
return await this.uninstall();
}
// for simulator
if (util.hasValue(productBundleIdentifier) && !util.hasValue(this.updatedWDABundleId) && WDA_RUNNER_BUNDLE_ID !== productBundleIdentifier) {
this.log.info(`Will uninstall running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`);
return await this.uninstall();
}
const actualUpgradeTimestamp = await getWDAUpgradeTimestamp();
this.log.debug(`Upgrade timestamp of the currently bundled WDA: ${actualUpgradeTimestamp}`);
this.log.debug(`Upgrade timestamp of the WDA on the device: ${upgradedAt}`);
if (actualUpgradeTimestamp && upgradedAt && _.toLower(`${actualUpgradeTimestamp}`) !== _.toLower(`${upgradedAt}`)) {
this.log.info('Will uninstall running WDA since it has different version in comparison to the one ' +
`which is bundled with appium-xcuitest-driver module (${actualUpgradeTimestamp} != ${upgradedAt})`);
return await this.uninstall();
}
const message = util.hasValue(productBundleIdentifier)
? `Will reuse previously cached WDA instance at '${this.url.href}' with '${productBundleIdentifier}'`
: `Will reuse previously cached WDA instance at '${this.url.href}'`;
this.log.info(`${message}. Set the wdaLocalPort capability to a value different from ${this.url.port} if this is an undesired behavior.`);
this.webDriverAgentUrl = this.url.href;
}
/**
* Quit and uninstall running WDA.
* @returns {Promise<void>}
*/
async quitAndUninstall () {
await this.quit();
await this.uninstall();
}
}
export default WebDriverAgent;

466
lib/xcodebuild.js Normal file
View File

@@ -0,0 +1,466 @@
import { retryInterval } from 'asyncbox';
import { SubProcess, exec } from 'teen_process';
import { logger, timing } from '@appium/support';
import defaultLogger from './logger';
import B from 'bluebird';
import {
setRealDeviceSecurity, setXctestrunFile,
updateProjectFile, resetProjectFile, killProcess,
getWDAUpgradeTimestamp, isTvOS
} from './utils';
import _ from 'lodash';
import path from 'path';
import { WDA_RUNNER_BUNDLE_ID } from './constants';
const DEFAULT_SIGNING_ID = 'iPhone Developer';
const PREBUILD_DELAY = 0;
const RUNNER_SCHEME_IOS = 'WebDriverAgentRunner';
const LIB_SCHEME_IOS = 'WebDriverAgentLib';
const ERROR_WRITING_ATTACHMENT = 'Error writing attachment data to file';
const ERROR_COPYING_ATTACHMENT = 'Error copying testing attachment';
const IGNORED_ERRORS = [
ERROR_WRITING_ATTACHMENT,
ERROR_COPYING_ATTACHMENT,
'Failed to remove screenshot at path',
];
const IGNORED_ERRORS_PATTERN = new RegExp(
'(' +
IGNORED_ERRORS
.map((errStr) => _.escapeRegExp(errStr))
.join('|') +
')'
);
const RUNNER_SCHEME_TV = 'WebDriverAgentRunner_tvOS';
const LIB_SCHEME_TV = 'WebDriverAgentLib_tvOS';
const REAL_DEVICES_CONFIG_DOCS_LINK = 'https://appium.github.io/appium-xcuitest-driver/latest/preparation/real-device-config/';
const xcodeLog = logger.getLogger('Xcode');
export class XcodeBuild {
/** @type {SubProcess} */
xcodebuild;
/**
* @param {import('appium-xcode').XcodeVersion} xcodeVersion
* @param {any} device
* // TODO: make args typed
* @param {import('@appium/types').StringRecord} [args={}]
* @param {import('@appium/types').AppiumLogger?} [log=null]
*/
constructor (xcodeVersion, device, args = {}, log = null) {
this.xcodeVersion = xcodeVersion;
this.device = device;
this.log = log ?? defaultLogger;
this.realDevice = args.realDevice;
this.agentPath = args.agentPath;
this.bootstrapPath = args.bootstrapPath;
this.platformVersion = args.platformVersion;
this.platformName = args.platformName;
this.iosSdkVersion = args.iosSdkVersion;
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;
this.prebuildDelay = _.isNumber(args.prebuildDelay) ? args.prebuildDelay : PREBUILD_DELAY;
this.allowProvisioningDeviceRegistration = args.allowProvisioningDeviceRegistration;
this.resultBundlePath = args.resultBundlePath;
this.resultBundleVersion = args.resultBundleVersion;
this._didBuildFail = false;
this._didProcessExit = false;
}
/**
*
* @param {any} noSessionProxy
* @returns {Promise<void>}
*/
async init (noSessionProxy) {
this.noSessionProxy = noSessionProxy;
if (this.useXctestrunFile) {
const deviveInfo = {
isRealDevice: this.realDevice,
udid: this.device.udid,
platformVersion: this.platformVersion,
platformName: this.platformName
};
this.xctestrunFilePath = await setXctestrunFile(deviveInfo, this.iosSdkVersion, this.bootstrapPath, this.wdaRemotePort);
return;
}
// 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);
}
}
}
/**
* @returns {Promise<string|undefined>}
*/
async retrieveDerivedDataPath () {
if (this.derivedDataPath) {
return this.derivedDataPath;
}
// avoid race conditions
if (this._derivedDataPathPromise) {
return await this._derivedDataPathPromise;
}
this._derivedDataPathPromise = (async () => {
let stdout;
try {
({stdout} = await exec('xcodebuild', ['-project', this.agentPath, '-showBuildSettings']));
} catch (err) {
this.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) {
this.log.warn(`Cannot parse WDA build dir from ${_.truncate(stdout, {length: 300})}`);
return;
}
this.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])));
this.log.debug(`Got derived data root: '${this.derivedDataPath}'`);
return this.derivedDataPath;
})();
return await this._derivedDataPathPromise;
}
/**
* @returns {Promise<void>}
*/
async reset () {
// if necessary, reset the bundleId to original value
if (this.realDevice && this.updatedWDABundleId) {
await resetProjectFile(this.agentPath);
}
}
/**
* @returns {Promise<void>}
*/
async prebuild () {
// first do a build phase
this.log.debug('Pre-building WDA before launching test');
this.usePrebuiltWDA = true;
await this.start(true);
if (this.prebuildDelay > 0) {
// pause a moment
await B.delay(this.prebuildDelay);
}
}
/**
* @returns {Promise<void>}
*/
async cleanProject () {
const libScheme = isTvOS(this.platformName) ? LIB_SCHEME_TV : LIB_SCHEME_IOS;
const runnerScheme = isTvOS(this.platformName) ? RUNNER_SCHEME_TV : RUNNER_SCHEME_IOS;
for (const scheme of [libScheme, runnerScheme]) {
this.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,
]);
}
}
/**
*
* @param {boolean} [buildOnly=false]
* @returns {{cmd: string, args: string[]}}
*/
getCommand (buildOnly = false) {
const cmd = 'xcodebuild';
/** @type {string[]} */
const args = [];
// figure out the targets for xcodebuild
const [buildCmd, testCmd] = this.useSimpleBuildTest ? ['build', 'test'] : ['build-for-testing', 'test-without-building'];
if (buildOnly) {
args.push(buildCmd);
} else if (this.usePrebuiltWDA || this.useXctestrunFile) {
args.push(testCmd);
} else {
args.push(buildCmd, testCmd);
}
if (this.allowProvisioningDeviceRegistration) {
// To -allowProvisioningDeviceRegistration flag takes effect, -allowProvisioningUpdates needs to be passed as well.
args.push('-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration');
}
if (this.resultBundlePath) {
args.push('-resultBundlePath', this.resultBundlePath);
}
if (this.resultBundleVersion) {
args.push('-resultBundleVersion', this.resultBundleVersion);
}
if (this.useXctestrunFile && this.xctestrunFilePath) {
args.push('-xctestrun', this.xctestrunFilePath);
} else {
const runnerScheme = isTvOS(this.platformName) ? RUNNER_SCHEME_TV : RUNNER_SCHEME_IOS;
args.push('-project', this.agentPath, '-scheme', runnerScheme);
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(
`${isTvOS(this.platformName) ? 'TV' : 'IPHONE'}OS_DEPLOYMENT_TARGET=${versionMatch[1]}.${versionMatch[2]}`
);
} else {
this.log.warn(`Cannot parse major and minor version numbers from platformVersion "${this.platformVersion}". ` +
'Will build for the default platform instead');
}
if (this.realDevice) {
if (this.xcodeConfigFile) {
this.log.debug(`Using Xcode configuration file: '${this.xcodeConfigFile}'`);
args.push('-xcconfig', this.xcodeConfigFile);
}
if (this.xcodeOrgId && this.xcodeSigningId) {
args.push(
`DEVELOPMENT_TEAM=${this.xcodeOrgId}`,
`CODE_SIGN_IDENTITY=${this.xcodeSigningId}`,
);
}
}
if (!process.env.APPIUM_XCUITEST_TREAT_WARNINGS_AS_ERRORS) {
// This sometimes helps to survive Xcode updates
args.push('GCC_TREAT_WARNINGS_AS_ERRORS=0');
}
// Below option slightly reduces build time in debug build
// with preventing to generate `/Index/DataStore` which is used by development
args.push('COMPILER_INDEX_STORE_ENABLE=NO');
return {cmd, args};
}
/**
* @param {boolean} [buildOnly=false]
* @returns {Promise<SubProcess>}
*/
async createSubProcess (buildOnly = false) {
if (!this.useXctestrunFile && this.realDevice) {
if (this.keychainPath && this.keychainPassword) {
await setRealDeviceSecurity(this.keychainPath, this.keychainPassword);
}
}
const {cmd, args} = this.getCommand(buildOnly);
this.log.debug(`Beginning ${buildOnly ? 'build' : 'test'} with command '${cmd} ${args.join(' ')}' ` +
`in directory '${this.bootstrapPath}'`);
/** @type {Record<string, any>} */
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();
if (upgradeTimestamp) {
env.UPGRADE_TIMESTAMP = upgradeTimestamp;
}
this._didBuildFail = false;
const xcodebuild = new SubProcess(cmd, args, {
cwd: this.bootstrapPath,
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
let logXcodeOutput = !!this.showXcodeLog;
const logMsg = _.isBoolean(this.showXcodeLog)
? `Output from xcodebuild ${this.showXcodeLog ? 'will' : 'will not'} be logged`
: 'Output from xcodebuild will only be logged if any errors are present there';
this.log.debug(`${logMsg}. To change this, use 'showXcodeLog' desired capability`);
const onStreamLine = (/** @type {string} */ line) => {
if (this.showXcodeLog === false || IGNORED_ERRORS_PATTERN.test(line)) {
return;
}
// 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 (line.includes('Error Domain=')) {
logXcodeOutput = true;
// handle case where xcode returns 0 but is failing
this._didBuildFail = true;
}
if (logXcodeOutput) {
xcodeLog.info(line);
}
};
for (const streamName of ['stderr', 'stdout']) {
xcodebuild.on(`line-${streamName}`, onStreamLine);
}
return xcodebuild;
}
/**
* @param {boolean} [buildOnly=false]
* @returns {Promise<import('@appium/types').StringRecord>}
*/
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.once('exit', (code, signal) => {
xcodeLog.error(`xcodebuild exited with code '${code}' and signal '${signal}'`);
this.xcodebuild.removeAllListeners();
this.didProcessExit = true;
if (this._didBuildFail || (!signal && code !== 0)) {
let errorMessage = `xcodebuild failed with code ${code}.` +
` This usually indicates an issue with the local Xcode setup or WebDriverAgent` +
` project configuration or the driver-to-platform version mismatch.`;
if (!this.showXcodeLog) {
errorMessage += ` Consider setting 'showXcodeLog' capability to true in` +
` order to check the Appium server log for build-related error messages.`;
} else if (this.realDevice) {
errorMessage += ` Consider checking the WebDriverAgent configuration guide` +
` for real iOS devices at ${REAL_DEVICES_CONFIG_DOCS_LINK}.`;
}
return reject(new Error(errorMessage));
}
// in the case of just building, the process will exit and that is our finish
if (buildOnly) {
return resolve();
}
});
return (async () => {
try {
const timer = new timing.Timer().start();
await this.xcodebuild.start(true);
if (!buildOnly) {
resolve(/** @type {import('@appium/types').StringRecord} */ (await this.waitForStart(timer)));
}
} catch (err) {
let msg = `Unable to start WebDriverAgent: ${err}`;
this.log.error(msg);
reject(new Error(msg));
}
})();
});
}
/**
*
* @param {any} timer
* @returns {Promise<import('@appium/types').StringRecord?>}
*/
async waitForStart (timer) {
// try to connect once every 0.5 seconds, until `launchTimeout` is up
this.log.debug(`Waiting up to ${this.launchTimeout}ms for WebDriverAgent to start`);
let currentStatus = null;
try {
const retries = Math.trunc(this.launchTimeout / 500);
await retryInterval(retries, 1000, async () => {
if (this._didProcessExit) {
// there has been an error elsewhere and we need to short-circuit
return currentStatus;
}
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;
}
this.log.debug(`WebDriverAgent information:`);
this.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._didProcessExit) {
// there has been an error elsewhere and we need to short-circuit
return currentStatus;
}
this.log.debug(`WebDriverAgent successfully started after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
} catch (err) {
this.log.debug(err.stack);
throw new Error(
`We were not able to retrieve the /status response from the WebDriverAgent server after ${this.launchTimeout}ms timeout.` +
`Try to increase the value of 'appium:wdaLaunchTimeout' capability as a possible workaround.`
);
}
return currentStatus;
}
/**
* @returns {Promise<void>}
*/
async quit () {
await killProcess('xcodebuild', this.xcodebuild);
}
}
export default XcodeBuild;