appium-ios-simulator
Version:
iOS Simulator interface for Appium.
184 lines (169 loc) • 6.29 kB
text/typescript
import { log } from './logger';
import _ from 'lodash';
import { exec } from 'teen_process';
import { waitForCondition } from 'asyncbox';
import { getVersion } from 'appium-xcode';
import type { XcodeVersion } from 'appium-xcode';
import path from 'path';
import { Simctl } from 'node-simctl';
import type { StringRecord } from '@appium/types';
// it's a hack needed to stub getDevices in tests
import * as utilsModule from './utils';
const DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS = 30000;
export const SAFARI_STARTUP_TIMEOUT_MS = 25 * 1000;
export const MOBILE_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
export const SIMULATOR_APP_NAME = 'Simulator.app';
export const MIN_SUPPORTED_XCODE_VERSION = 14;
/**
* @param appName - The application name to kill.
* @param forceKill - Whether to force kill the process.
* @returns Promise that resolves to 0 on success.
*/
async function pkill(appName: string, forceKill: boolean = false): Promise<number> {
const args = forceKill ? ['-9'] : [];
args.push('-x', appName);
try {
await exec('pkill', args);
return 0;
} catch (err: any) {
// pgrep/pkill exit codes:
// 0 One or more processes were matched.
// 1 No processes were matched.
// 2 Invalid options were specified on the command line.
// 3 An internal error occurred.
if (!_.isUndefined(err.code)) {
throw new Error(`Cannot forcefully terminate ${appName}. pkill error code: ${err.code}`);
}
log.error(`Received unexpected error while trying to kill ${appName}: ${err.message}`);
throw err;
}
}
/**
* @param timeout - Timeout in milliseconds (default: DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS).
* @returns Promise that resolves when all simulators are killed.
*/
export async function killAllSimulators(timeout: number = DEFAULT_SIM_SHUTDOWN_TIMEOUT_MS): Promise<void> {
log.debug('Killing all iOS Simulators');
const xcodeVersion = await getVersion(true);
if (_.isString(xcodeVersion)) {
return;
}
const appName = path.parse(SIMULATOR_APP_NAME).name;
const version = xcodeVersion as XcodeVersion;
// later versions are slower to close
timeout = timeout * (version.major >= 8 ? 2 : 1);
try {
await exec('xcrun', ['simctl', 'shutdown', version.major > 8 ? 'all' : 'booted'], {timeout});
} catch {}
const pids: string[] = [];
try {
const {stdout} = await exec('pgrep', ['-f', `${appName}.app/Contents/MacOS/`]);
if (stdout.trim()) {
pids.push(...(stdout.trim().split(/\s+/)));
}
} catch (e: any) {
if (e.code === 1) {
log.debug(`${appName} is not running. Continuing...`);
return;
}
if (_.isEmpty(pids)) {
log.warn(`pgrep error ${e.code} while detecting whether ${appName} is running. Trying to kill anyway.`);
}
}
if (!_.isEmpty(pids)) {
log.debug(`Killing processes: ${pids.join(', ')}`);
try {
await exec('kill', ['-9', ...(pids.map((pid) => `${pid}`))]);
} catch {}
}
log.debug(`Using pkill to kill application: ${appName}`);
try {
await pkill(appName, true);
} catch {}
// wait for all the devices to be shutdown before Continuing
// but only print out the failed ones when they are actually fully failed
let remainingDevices: string[] = [];
async function allSimsAreDown(): Promise<boolean> {
remainingDevices = [];
const devicesRecord = await utilsModule.getDevices();
const devices = _.flatten(_.values(devicesRecord));
return _.every(devices, (sim: any) => {
const state = sim.state.toLowerCase();
const done = ['shutdown', 'unavailable', 'disconnected'].includes(state);
if (!done) {
remainingDevices.push(`${sim.name} (${sim.sdk}, udid: ${sim.udid}) is still in state '${state}'`);
}
return done;
});
}
try {
await waitForCondition(allSimsAreDown, {
waitMs: timeout,
intervalMs: 200
});
} catch (err) {
if (remainingDevices.length > 0) {
log.warn(`The following devices are still not in the correct state after ${timeout} ms:`);
for (const device of remainingDevices) {
log.warn(` ${device}`);
}
}
throw err;
}
}
export interface SimulatorInfoOptions {
devicesSetPath?: string | null;
}
/**
* @param udid - The simulator UDID.
* @param opts - Options including devicesSetPath.
* @returns Promise that resolves to simulator info or undefined if not found.
*/
export async function getSimulatorInfo(udid: string, opts: SimulatorInfoOptions = {}): Promise<any> {
const {
devicesSetPath
} = opts;
// see the README for github.com/appium/node-simctl for example output of getDevices()
const devices = _.toPairs(await utilsModule.getDevices({devicesSetPath}))
.map((pair) => pair[1])
.reduce((a, b) => a.concat(b), []);
return _.find(devices, (sim: any) => sim.udid === udid);
}
/**
* @param udid - The simulator UDID.
* @returns Promise that resolves to true if simulator exists, false otherwise.
*/
export async function simExists(udid: string): Promise<boolean> {
return !!(await getSimulatorInfo(udid));
}
/**
* @returns Promise that resolves to the developer root path.
*/
export async function getDeveloperRoot(): Promise<string> {
const {stdout} = await exec('xcode-select', ['-p']);
return stdout.trim();
}
/**
* Asserts that the Xcode version meets the minimum supported version requirement.
*
* @template V - The Xcode version type.
* @param xcodeVersion - The Xcode version to check.
* @returns The same Xcode version if it meets the requirement.
* @throws {Error} If the Xcode version is below the minimum supported version.
*/
export function assertXcodeVersion<V extends XcodeVersion>(xcodeVersion: V): V {
if (xcodeVersion.major < MIN_SUPPORTED_XCODE_VERSION) {
throw new Error(
`Tried to use an iOS simulator with xcode version ${xcodeVersion.versionString} but only Xcode version ` +
`${MIN_SUPPORTED_XCODE_VERSION} and up are supported`
);
}
return xcodeVersion;
}
/**
* @param simctlOpts - Optional simctl options
* @returns Promise that resolves to a record of devices grouped by SDK version
*/
export async function getDevices(simctlOpts?: StringRecord): Promise<Record<string, any[]>> {
return await new Simctl(simctlOpts).getDevices();
}