appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
337 lines (307 loc) • 10.8 kB
text/typescript
import {getSimulator, type Simulator, type LocalizationOptions} from 'appium-ios-simulator';
import {Simctl} from 'node-simctl';
import {resetTestProcesses} from 'appium-webdriveragent';
import _ from 'lodash';
import {util, timing} from 'appium/support';
import {UDID_AUTO, normalizePlatformName} from '../utils';
import {buildSafariPreferences} from '../app-utils';
import type { XCUITestDriver } from '../driver';
import type { DeviceInfo } from 'node-simctl';
const APPIUM_SIM_PREFIX = 'appiumTest';
/**
* Create a new simulator with `appiumTest-` prefix and return the object.
*
* @returns Simulator object associated with the udid passed in.
*/
export async function createSim(this: XCUITestDriver): Promise<Simulator> {
const {simulatorDevicesSetPath: devicesSetPath, deviceName, platformVersion} = this.opts;
const platform = normalizePlatformName(this.opts.platformName);
const simctl = new Simctl({devicesSetPath});
if (!deviceName) {
let deviceNames: string[] = [];
try {
const devices = platformVersion
? await simctl.getDevices(platformVersion, platform)
: await simctl.getDevices(null, platform);
const nameMapper = (device: DeviceInfo) => device.name;
deviceNames = Array.isArray(devices)
? devices.map(nameMapper)
: _.flatMap(_.values(devices)).map(nameMapper);
} catch {}
throw new Error(
`'deviceName' must be provided in order to create a new Simulator for ${platform} platform. ` +
`Currently available device names: ${deviceNames}`,
);
}
if (!platformVersion) {
throw new Error(`'platformVersion' is required.`);
}
const simName = `${APPIUM_SIM_PREFIX}-${util.uuidV4().toUpperCase()}-${deviceName}`;
this.log.debug(`Creating a temporary Simulator device '${simName}'`);
const udid = await simctl.createDevice(simName, deviceName, platformVersion, {platform});
return await getSimulator(udid, {
platform,
checkExistence: false,
devicesSetPath,
// @ts-ignore This is ok
logger: this.log,
});
}
/**
* Get an existing simulator matching the provided capabilities.
*
* @returns The matched Simulator instance or `null` if no matching device is found.
*/
export async function getExistingSim(this: XCUITestDriver): Promise<Simulator | null> {
const {
platformVersion,
deviceName,
udid,
simulatorDevicesSetPath: devicesSetPath,
platformName,
} = this.opts;
const platform = normalizePlatformName(platformName);
const selectSim = async (dev: { udid: string; platform: string; }): Promise<Simulator> =>
await getSimulator(dev.udid, {
platform,
checkExistence: false,
devicesSetPath,
// @ts-ignore This is ok
logger: this.log,
});
const simctl = new Simctl({devicesSetPath});
let devicesMap: Record<string, any[]> | undefined;
if (udid && _.toLower(udid) !== UDID_AUTO) {
this.log.debug(`Looking for an existing Simulator with UDID '${udid}'`);
devicesMap = await simctl.getDevices(null, platform);
for (const device of _.flatMap(_.values(devicesMap))) {
if (device.udid === udid) {
return await selectSim(device);
}
}
return null;
}
if (!platformVersion) {
this.log.debug(
`Provide 'platformVersion' capability if you prefer an existing Simulator to be selected`,
);
return null;
}
const devices =
devicesMap?.[platformVersion] ?? (await simctl.getDevices(platformVersion, platform));
this.log.debug(
`Looking for an existing Simulator with platformName: ${platform}, ` +
`platformVersion: ${platformVersion}, deviceName: ${deviceName}`,
);
for (const device of devices) {
if ((deviceName && device.name === deviceName) || !deviceName) {
if (!deviceName) {
this.log.debug(
`The 'deviceName' capability value is empty. ` +
`Selecting the first matching device '${device.name}' having the ` +
`'platformVersion' set to ${platformVersion}`,
);
}
return await selectSim(device);
}
}
return null;
}
/**
* Shutdown simulator
*/
export async function shutdownSimulator(this: XCUITestDriver): Promise<void> {
const device = this.device as Simulator;
// stop XCTest processes if running to avoid unexpected side effects
await resetTestProcesses(device.udid, true);
await device.shutdown();
}
/**
* Run simulator reset
* @param enforceSimulatorShutdown Whether to enforce simulator shutdown
*/
export async function runSimulatorReset(
this: XCUITestDriver,
enforceSimulatorShutdown: boolean = false
): Promise<void> {
const {
noReset,
fullReset,
keychainsExcludePatterns,
keepKeyChains,
bundleId,
app,
browserName,
} = this.opts;
if (noReset && !fullReset) {
// noReset === true && fullReset === false
this.log.debug('Reset: noReset is on. Leaving simulator as is');
return;
}
const device = this.device as Simulator;
if (!this.device) {
this.log.debug('Reset: no device available. Skipping');
return;
}
if (fullReset) {
this.log.debug('Reset: fullReset is on. Cleaning simulator');
await shutdownSimulator.bind(this)();
const isKeychainsBackupSuccessful = (keychainsExcludePatterns || keepKeyChains) && (await device.backupKeychains());
await device.clean();
if (isKeychainsBackupSuccessful) {
await device.restoreKeychains(keychainsExcludePatterns?.split(',')?.map(_.trim) || []);
this.log.info(`Successfully restored keychains after full reset`);
} else if (keychainsExcludePatterns || keepKeyChains) {
this.log.warn(
'Cannot restore keychains after full reset, because ' +
'the backup operation did not succeed',
);
}
} else if (bundleId) {
// fastReset or noReset
// Terminate the app under test if it is still running on Simulator
try {
await device.terminateApp(bundleId);
} catch {
this.log.warn(`Reset: failed to terminate Simulator application with id "${bundleId}"`);
}
if (app) {
this.log.info('Not scrubbing third party app in anticipation of uninstall');
} else {
const isSafari = _.toLower(browserName) === 'safari';
try {
if (isSafari) {
await device.scrubSafari(true);
} else {
await device.scrubApp(bundleId);
}
} catch (err) {
this.log.debug((err as Error).stack);
this.log.warn((err as Error).message);
this.log.warn(
`Reset: could not scrub ${
isSafari ? 'Safari browser' : 'application with id "' + this.opts.bundleId + '"'
}. ` + `Leaving as is.`,
);
}
}
if (enforceSimulatorShutdown && (await device.isRunning())) {
await shutdownSimulator.bind(this)();
}
}
}
/**
* Install app to simulator
* @param app The app to the path
* @param bundleId The bundle id to ensure it is already installed and uninstall it
* @param opts Install options
*/
export async function installToSimulator(
this: XCUITestDriver,
app: string,
bundleId?: string,
opts: SimulatorInstallOptions = {},
): Promise<void> {
if (!app) {
this.log.debug('No app path is given. Nothing to install.');
return;
}
const {skipUninstall, newSimulator = false} = opts;
const device = this.device as Simulator;
if (!skipUninstall && !newSimulator && bundleId && (await device.isAppInstalled(bundleId))) {
this.log.debug(`Reset requested. Removing app with id '${bundleId}' from the device`);
await device.removeApp(bundleId);
}
this.log.debug(`Installing '${app}' on Simulator with UUID '${device.udid}'...`);
const timer = new timing.Timer().start();
await device.installApp(app);
this.log.info(`The app has been successfully installed in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`);
}
/**
* Shutdown other simulators
*/
export async function shutdownOtherSimulators(this: XCUITestDriver): Promise<void> {
const device = this.device as Simulator;
const simctl = new Simctl({
devicesSetPath: device.devicesSetPath,
});
const allDevices = _.flatMap(_.values(await simctl.getDevices()));
const otherBootedDevices = allDevices
.filter(({udid, state}) => udid !== device.udid && state === 'Booted');
if (_.isEmpty(otherBootedDevices)) {
this.log.info('No other running simulators have been detected');
return;
}
this.log.info(
`Detected ${util.pluralize(
'other running Simulator',
otherBootedDevices.length,
true
)}. Shutting them down...`,
);
for (const {udid} of otherBootedDevices) {
// It is necessary to stop the corresponding xcodebuild process before killing
// the simulator, otherwise it will be automatically restarted
await resetTestProcesses(udid, true);
simctl.udid = udid;
await simctl.shutdownDevice();
}
}
/**
* Configures Safari options based on the given session capabilities
*
* @returns true if any preferences have been updated
*/
export async function setSafariPrefs(this: XCUITestDriver): Promise<boolean> {
const prefs = buildSafariPreferences(this.opts);
if (_.isEmpty(prefs)) {
return false;
}
this.log.debug(`About to update Safari preferences: ${JSON.stringify(prefs)}`);
await (this.device as Simulator).updateSafariSettings(prefs);
return true;
}
/**
* Changes Simulator localization preferences
*
* @returns True if preferences were changed
*/
export async function setLocalizationPrefs(this: XCUITestDriver): Promise<boolean> {
const {language, locale, calendarFormat, skipSyncUiDialogTranslation} = this.opts;
const l10nConfig: LocalizationOptions = {};
if (language) {
l10nConfig.language = {name: language, skipSyncUiDialogTranslation };
}
if (locale) {
l10nConfig.locale = {name: locale};
if (calendarFormat) {
l10nConfig.locale.calendar = calendarFormat;
}
}
if (_.isEmpty(l10nConfig)) {
return false;
}
this.log.debug(`About to update localization preferences: ${JSON.stringify(l10nConfig)}`);
await (this.device as Simulator).configureLocalization(l10nConfig);
return true;
}
//#region Type Definitions
export interface SimulatorLookupOptions {
/** The name of the device to lookup */
deviceName?: string;
/** The platform version string */
platformVersion: string;
/** The full path to the simulator devices set */
simulatorDevicesSetPath?: string;
/** Simulator udid */
udid?: string;
/** The name of the current platform */
platformName?: string;
}
export interface SimulatorInstallOptions {
/** Whether to skip app uninstall before installing it */
skipUninstall?: boolean;
/** Whether the simulator is brand new */
newSimulator?: boolean;
}
//#endregion