初始化提交
This commit is contained in:
50
lib/check-dependencies.js
Normal file
50
lib/check-dependencies.js
Normal 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
24
lib/constants.js
Normal 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
5
lib/logger.js
Normal 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
26
lib/no-session-proxy.js
Normal 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
52
lib/types.ts
Normal 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
398
lib/utils.js
Normal 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
741
lib/webdriveragent.js
Normal 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
466
lib/xcodebuild.js
Normal 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;
|
||||
Reference in New Issue
Block a user