appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
469 lines (419 loc) • 16.2 kB
text/typescript
import {errors} from 'appium/driver';
import {fs, timing, util} from 'appium/support';
import type {StringRecord} from '@appium/types';
import {retryInterval} from 'asyncbox';
import {isPlainObject} from '../../utils';
import path from 'node:path';
import {installToRealDevice} from '../../device/real-device-management';
import {installToSimulator} from '../../device/simulator-management';
import type {XCUITestDriver} from '../../driver';
import {isIos17OrNewerPlatform, isLocalHost} from '../helpers';
import {markSystemFilesForCleanup} from './cleanup';
import {
CAP_NAMES_NO_XCODEBUILD_REQUIRED,
CUSTOMIZE_RESULT_BUNDLE_PATH,
isXcodebuildNeeded,
SHARED_RESOURCES_GUARD,
WDA_CF_BUNDLE_NAME,
WDA_REAL_DEV_STARTUP_RETRIES,
WDA_REAL_DEV_TUTORIAL_URL,
WDA_SIM_STARTUP_RETRIES,
WDA_STARTUP_RETRY_INTERVAL,
XCUITEST_DRIVER_SYNC_NAME,
} from './constants';
interface StartupRetryOptions {
startupRetries: number;
startupRetryInterval: number;
}
/**
* Initializes the WebDriverAgent connection, prepares the device, and starts WDA.
*/
export async function start(this: XCUITestDriver): Promise<void> {
await setupConnection(this);
const synchronizationKey = await getSynchronizationKey(this);
logSynchronizationDetails(this, synchronizationKey);
assertUsePreinstalledWdaSupported(this);
await assertPrebuiltPathExists(this);
return await SHARED_RESOURCES_GUARD.acquire(synchronizationKey, async () => {
await startUnderSynchronizationLock(this);
});
}
/**
* Creates a WebDriverAgent session with the given application bundle and process arguments.
*/
export async function startWdaSession(
this: XCUITestDriver,
bundleId?: string,
processArguments?: any,
): Promise<void> {
return createWdaSession(this, bundleId, processArguments);
}
/**
* Prepares a preinstalled WebDriverAgent bundle on the device before launch.
*/
async function preparePreinstalled(driver: XCUITestDriver): Promise<void> {
if (driver.isRealDevice()) {
await driver.mobileKillApp(driver.wda.bundleIdForXctest);
}
if (!driver.opts.prebuiltWDAPath) {
await cleanupApps(driver, [driver.wda.bundleIdForXctest]);
return;
}
const candidateBundleId = await driver.appInfosCache.extractBundleId(driver.opts.prebuiltWDAPath);
await cleanupApps(driver, [candidateBundleId]);
driver.wda.updatedWDABundleId = candidateBundleId.replace('.xctrunner', '');
driver.log.info(
`Installing prebuilt WDA at '${driver.opts.prebuiltWDAPath}'. Bundle identifier: ${candidateBundleId}.`,
);
if (driver.isRealDevice()) {
await installToRealDevice.bind(driver)(driver.opts.prebuiltWDAPath, candidateBundleId, {
skipUninstall: true,
timeout: driver.opts.appPushTimeout,
});
} else {
await installToSimulator.bind(driver)(driver.opts.prebuiltWDAPath, candidateBundleId);
}
}
/**
* Removes WebDriverAgent runner apps that share the same CFBundleName except those listed in `keepBundleIds`.
*
* @param driver - The driver instance
* @param keepBundleIds - Bundle identifiers to preserve on the device
*/
async function cleanupApps(driver: XCUITestDriver, keepBundleIds: string[] = []): Promise<void> {
const installedBundleIds =
await driver.device.getUserInstalledBundleIdsByBundleName(WDA_CF_BUNDLE_NAME);
const keep = new Set(keepBundleIds.filter(Boolean));
for (const bundleId of installedBundleIds.filter((id) => !keep.has(id))) {
driver.log.info(
`Removing WebDriverAgent runner app '${bundleId}' ` +
`(CFBundleName '${WDA_CF_BUNDLE_NAME}')`,
);
try {
await driver.device.removeApp(bundleId);
} catch (e: any) {
driver.log.warn(`Failed to remove WebDriverAgent apps: ${e.message}`);
}
}
}
async function setupConnection(driver: XCUITestDriver): Promise<void> {
if (!util.hasValue(driver.wda.webDriverAgentUrl)) {
await driver.wda.cleanupObsoleteProcesses();
}
const usePortForwarding =
driver.isRealDevice() && !driver.wda.webDriverAgentUrl && isLocalHost(driver.wda.wdaBaseUrl);
await driver.deviceConnectionsFactory.requestConnection(
driver.opts.udid,
Number(driver.wda.url.port),
{
devicePort: usePortForwarding ? driver.wda.wdaRemotePort : null,
platformVersion: driver.opts.platformVersion,
usePortForwarding,
},
);
}
async function getSynchronizationKey(driver: XCUITestDriver): Promise<string> {
if (driver.opts.useXctestrunFile || !(await driver.wda.isSourceFresh())) {
const derivedDataPath = await driver.wda.retrieveDerivedDataPath();
if (derivedDataPath) {
return path.normalize(derivedDataPath);
}
}
return XCUITEST_DRIVER_SYNC_NAME;
}
function logSynchronizationDetails(driver: XCUITestDriver, synchronizationKey: string): void {
driver.log.debug(
`Starting WebDriverAgent initialization with the synchronization key '${synchronizationKey}'`,
);
if (
SHARED_RESOURCES_GUARD.isBusy() &&
!driver.opts.derivedDataPath &&
!driver.opts.bootstrapPath
) {
driver.log.debug(
`Consider setting a unique 'derivedDataPath' capability value for each parallel driver instance ` +
`to avoid conflicts and speed up the building process`,
);
}
}
function assertUsePreinstalledWdaSupported(driver: XCUITestDriver): void {
if (!driver.opts.usePreinstalledWDA) {
return;
}
const {platformVersion} = driver.opts;
if (!isIos17OrNewerPlatform(platformVersion)) {
throw new Error(
`The 'usePreinstalledWDA' capability is only supported on iOS/tvOS 17.0 and newer ` +
`(the current 'platformVersion' capability value is '${platformVersion}'). ` +
`WebDriverAgent v13 no longer uses the legacy XCTest launch path that was required on iOS 16 and below. ` +
`Use the default xcodebuild flow or provide 'webDriverAgentUrl' instead.`,
);
}
}
async function assertPrebuiltPathExists(driver: XCUITestDriver): Promise<void> {
if (
driver.opts.usePreinstalledWDA &&
driver.opts.prebuiltWDAPath &&
!(await fs.exists(driver.opts.prebuiltWDAPath))
) {
throw new Error(
`The prebuilt WebDriverAgent app at '${driver.opts.prebuiltWDAPath}' provided as 'prebuiltWDAPath' ` +
`capability value does not exist or is not accessible`,
);
}
}
async function startUnderSynchronizationLock(driver: XCUITestDriver): Promise<void> {
await prepareForXcodebuild(driver);
if (driver.opts.resultBundlePath) {
driver.assertFeatureEnabled(CUSTOMIZE_RESULT_BUNDLE_PATH);
}
const {startupRetries, startupRetryInterval} = getStartupRetryOptions(driver);
await runStartupWithRetries(driver, startupRetries, startupRetryInterval);
}
async function prepareForXcodebuild(driver: XCUITestDriver): Promise<void> {
if (!isXcodebuildNeeded(driver.opts)) {
return;
}
if (driver.opts.useNewWDA) {
driver.log.debug(
`Capability 'useNewWDA' set to true, so quitting and uninstalling WDA before proceeding`,
);
await driver.wda.quit();
await cleanupApps(driver);
driver.logEvent('wdaUninstalled');
return;
}
if (await driver.wda.setupCaching()) {
return;
}
// Cleanup only WDA runners whose bundle identifiers are not the current one
const bundleIdsToKeep: string[] = [];
if (driver.opts.updatedWDABundleId) {
bundleIdsToKeep.push(driver.wda.bundleIdForXctest);
} else {
const currentRunnerBundleId = (
await driver.wda.retrieveBuildSettings({
scheme: 'WebDriverAgentRunner',
})
)?.PRODUCT_BUNDLE_IDENTIFIER;
if (currentRunnerBundleId) {
bundleIdsToKeep.push(`${currentRunnerBundleId}.xctrunner`);
}
}
await cleanupApps(driver, bundleIdsToKeep);
}
function getStartupRetryOptions(driver: XCUITestDriver): StartupRetryOptions {
let startupRetries =
driver.opts.wdaStartupRetries ||
(driver.isRealDevice() ? WDA_REAL_DEV_STARTUP_RETRIES : WDA_SIM_STARTUP_RETRIES);
const startupRetryInterval = driver.opts.wdaStartupRetryInterval || WDA_STARTUP_RETRY_INTERVAL;
if (isXcodebuildNeeded(driver.opts)) {
driver.log.debug(
`Trying to start WebDriverAgent ${startupRetries} times with ${startupRetryInterval}ms interval`,
);
if (
!util.hasValue(driver.opts.wdaStartupRetries) &&
!util.hasValue(driver.opts.wdaStartupRetryInterval)
) {
driver.log.debug(
`These values can be customized by changing wdaStartupRetries/wdaStartupRetryInterval capabilities`,
);
}
} else {
driver.log.debug(
`Trying to start WebDriverAgent once since at least one of ${CAP_NAMES_NO_XCODEBUILD_REQUIRED} capabilities is provided`,
);
startupRetries = 1;
}
return {startupRetries, startupRetryInterval};
}
async function runStartupWithRetries(
driver: XCUITestDriver,
startupRetries: number,
startupRetryInterval: number,
): Promise<void> {
let shortCircuitError: InstanceType<typeof errors.TimeoutError> | null = null;
let retryCount = 0;
await retryInterval(startupRetries, startupRetryInterval, async () => {
driver.logEvent('wdaStartAttempted');
if (retryCount > 0) {
driver.log.info(`Retrying WDA startup (${retryCount + 1} of ${startupRetries})`);
}
try {
await launchOnce(driver);
} catch (err) {
retryCount++;
await handleLaunchFailure(driver, err);
}
shortCircuitError = await establishProxySession(driver);
if (shortCircuitError) {
return;
}
await finalizeSuccessfulStartup(driver);
});
if (shortCircuitError) {
throw shortCircuitError;
}
}
async function launchOnce(driver: XCUITestDriver): Promise<void> {
if (driver.opts.usePreinstalledWDA) {
await preparePreinstalled(driver);
}
if (!driver.sessionId) {
throw new Error('Session ID is required but was not set');
}
driver.cachedWdaStatus = await driver.wda.launch(driver.sessionId);
}
async function handleLaunchFailure(driver: XCUITestDriver, err: unknown): Promise<void> {
driver.logEvent('wdaStartFailed');
const cause = err instanceof Error ? err : new Error(String(err));
driver.log.debug(cause.stack);
let errorMsg = `Unable to launch WebDriverAgent. Original error: ${cause.message}`;
if (driver.isRealDevice()) {
errorMsg += `. Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}`;
}
if (driver.opts.usePreinstalledWDA) {
try {
await driver.wda.quit();
} catch {}
errorMsg =
`Unable to launch WebDriverAgent. Original error: ${cause.message}. ` +
`Make sure the application ${driver.wda.bundleIdForXctest} exists and it is launchable.`;
if (driver.isRealDevice()) {
errorMsg += ` ${WDA_REAL_DEV_TUTORIAL_URL} may help to complete the preparation.`;
}
throw new Error(errorMsg, {cause: err});
}
await quitAndThrow(driver, errorMsg);
}
async function establishProxySession(
driver: XCUITestDriver,
): Promise<InstanceType<typeof errors.TimeoutError> | null> {
driver.proxyReqRes = driver.wda.proxyReqRes.bind(driver.wda);
driver.jwpProxyActive = true;
try {
driver.logEvent('wdaSessionAttempted');
driver.log.debug('Sending createSession command to WDA');
driver.cachedWdaStatus =
driver.cachedWdaStatus || (await driver.proxyCommand('/status', 'GET'));
await createWdaSession(driver, driver.opts.bundleId, driver.opts.processArguments);
driver.logEvent('wdaSessionStarted');
return null;
} catch (err) {
driver.logEvent('wdaSessionFailed');
if (err instanceof errors.TimeoutError) {
driver.log.debug(err.stack);
return err;
}
const cause = err instanceof Error ? err : new Error(String(err));
driver.log.debug(cause.stack);
let errorMsg = `Unable to start WebDriverAgent session. Original error: ${cause.message}`;
if (driver.isRealDevice() && cause.message?.includes('xcodebuild')) {
errorMsg += ` Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}.`;
}
throw new Error(errorMsg, {cause: err});
}
}
async function finalizeSuccessfulStartup(driver: XCUITestDriver): Promise<void> {
if (driver.opts.clearSystemFiles && isXcodebuildNeeded(driver.opts)) {
await markSystemFilesForCleanup(() => driver.wda.retrieveDerivedDataPath());
}
if (driver.cachedWdaStatus?.build) {
driver.log.info(`WebDriverAgent version: '${driver.cachedWdaStatus.build.version}'`);
} else {
driver.log.warn(
`WebDriverAgent does not provide any version information. ` +
`This might indicate either a custom or an outdated build.`,
);
}
driver.wda.fullyStarted = true;
driver.logEvent('wdaStarted');
}
async function createWdaSession(
driver: XCUITestDriver,
bundleId?: string,
processArguments?: any,
): Promise<void> {
const args = processArguments ? structuredClone(processArguments.args) || [] : [];
if (!Array.isArray(args)) {
throw new Error(
`processArguments.args capability is expected to be an array. ` +
`${JSON.stringify(args)} is given instead`,
);
}
const env = processArguments ? structuredClone(processArguments.env) || {} : {};
if (!isPlainObject(env)) {
throw new Error(
`processArguments.env capability is expected to be a dictionary. ` +
`${JSON.stringify(env)} is given instead`,
);
}
if (util.hasValue(driver.opts.language)) {
args.push('-AppleLanguages', `(${driver.opts.language})`);
args.push('-NSLanguages', `(${driver.opts.language})`);
}
if (util.hasValue(driver.opts.locale)) {
args.push('-AppleLocale', driver.opts.locale);
}
if (driver.opts.noReset) {
if (driver.opts.shouldTerminateApp == null) {
driver.opts.shouldTerminateApp = false;
}
if (driver.opts.forceAppLaunch == null) {
driver.opts.forceAppLaunch = false;
}
}
if (util.hasValue(driver.opts.appTimeZone)) {
// https://developer.apple.com/forums/thread/86951?answerId=263395022#263395022
env.TZ = driver.opts.appTimeZone;
}
const wdaCaps: StringRecord = {
bundleId: driver.opts.autoLaunch === false ? undefined : bundleId,
arguments: args,
environment: env,
eventloopIdleDelaySec: driver.opts.wdaEventloopIdleDelay ?? 0,
shouldWaitForQuiescence: true,
maxTypingFrequency: driver.opts.maxTypingFrequency ?? 60,
shouldUseSingletonTestManager: driver.opts.shouldUseSingletonTestManager ?? true,
waitForIdleTimeout: driver.opts.waitForIdleTimeout,
shouldUseCompactResponses: (driver.opts as StringRecord).shouldUseCompactResponses,
elementResponseFields: (driver.opts as StringRecord).elementResponseFields,
disableAutomaticScreenshots: driver.opts.disableAutomaticScreenshots,
shouldTerminateApp: driver.opts.shouldTerminateApp ?? true,
forceAppLaunch: driver.opts.forceAppLaunch ?? true,
appLaunchStateTimeoutSec: driver.opts.appLaunchStateTimeoutSec,
useNativeCachingStrategy: driver.opts.useNativeCachingStrategy ?? true,
forceSimulatorSoftwareKeyboardPresence:
driver.opts.forceSimulatorSoftwareKeyboardPresence ??
(driver.opts.connectHardwareKeyboard === true ? false : true),
};
if (driver.opts.autoAcceptAlerts) {
wdaCaps.defaultAlertAction = 'accept';
} else if (driver.opts.autoDismissAlerts) {
wdaCaps.defaultAlertAction = 'dismiss';
}
if (driver.opts.initialDeeplinkUrl) {
driver.log.info(`The deeplink URL will be set to ${driver.opts.initialDeeplinkUrl}`);
wdaCaps.initialUrl = driver.opts.initialDeeplinkUrl;
}
const timer = new timing.Timer().start();
await driver.proxyCommand('/session', 'POST', {
capabilities: {
firstMatch: [wdaCaps],
alwaysMatch: {},
},
});
driver.log.info(`WDA session startup took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
}
async function quitAndThrow(driver: XCUITestDriver, msg: string): Promise<never> {
driver.log.debug(msg);
if (!isXcodebuildNeeded(driver.opts)) {
driver.log.debug(
`Not quitting WebDriverAgent since at least one of ${CAP_NAMES_NO_XCODEBUILD_REQUIRED} capabilities is provided`,
);
throw new Error(msg);
}
driver.log.info('Quitting WebDriverAgent');
await driver.wda.quit();
throw new Error(msg);
}