appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
507 lines (453 loc) • 17.5 kB
text/typescript
import {resolveExecutablePath} from './utils';
import {doctor, fs, node} from 'appium/support';
import axios from 'axios';
import type {IDoctorCheck, AppiumLogger, DoctorCheckResult} from '@appium/types';
import '@colors/colors';
import {exec, SubProcess} from 'teen_process';
import memoize from 'lodash/memoize';
export class OptionalSimulatorCheck implements IDoctorCheck {
static readonly SUPPORTED_SIMULATOR_PLATFORMS: SimulatorPlatform[] = [
{
displayName: 'iOS',
name: 'iphonesimulator',
},
{
displayName: 'tvOS',
name: 'appletvsimulator',
},
];
log!: AppiumLogger;
async diagnose(): Promise<DoctorCheckResult> {
try {
// https://github.com/appium/appium/issues/12093#issuecomment-459358120
await exec('xcrun', ['simctl', 'help']);
} catch (err: any) {
return doctor.nokOptional(
`Testing on Simulator is not possible. Cannot run 'xcrun simctl': ${
err?.stderr || (err as Error).message
}`,
);
}
const sdks = await this._listInstalledSdks();
for (const {displayName, name} of OptionalSimulatorCheck.SUPPORTED_SIMULATOR_PLATFORMS) {
const errorPrefix = `Testing on ${displayName} Simulator is not possible`;
if (!sdks.some(({platform}) => platform === name)) {
return doctor.nokOptional(`${errorPrefix}: SDK is not installed`);
}
}
return doctor.okOptional(
`The following Simulator SDKs are installed:\n` +
sdks
.filter(({platform}) =>
OptionalSimulatorCheck.SUPPORTED_SIMULATOR_PLATFORMS.some(
({name}) => name === platform,
),
)
.map(({displayName}) => `\t→ ${displayName}`)
.join('\n'),
);
}
async fix(): Promise<string> {
return `Install the desired Simulator SDK from Xcode's Settings -> Components`;
}
hasAutofix(): boolean {
return false;
}
isOptional(): boolean {
return true;
}
private async _listInstalledSdks(): Promise<InstalledSdk[]> {
const {stdout} = await exec('xcodebuild', ['-json', '-showsdks']);
return JSON.parse(stdout);
}
}
export const optionalSimulatorCheck = new OptionalSimulatorCheck();
export class OptionalApplesimutilsCommandCheck implements IDoctorCheck {
static readonly README_LINK =
'https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/execute-methods.md#mobile-setpermission';
log!: AppiumLogger;
async diagnose(): Promise<DoctorCheckResult> {
const applesimutilsPath = await resolveExecutablePath('applesimutils');
return applesimutilsPath
? doctor.okOptional(`applesimutils is installed at: ${applesimutilsPath}`)
: doctor.nokOptional('applesimutils are not installed');
}
async fix(): Promise<string> {
return `Why ${'applesimutils'.bold} is needed and how to install it: ${OptionalApplesimutilsCommandCheck.README_LINK}`;
}
hasAutofix(): boolean {
return false;
}
isOptional(): boolean {
return true;
}
}
export const optionalApplesimutilsCheck = new OptionalApplesimutilsCommandCheck();
export class OptionalFfmpegCheck implements IDoctorCheck {
static readonly FFMPEG_BINARY = 'ffmpeg';
static readonly FFMPEG_INSTALL_LINK = 'https://www.ffmpeg.org/download.html';
log!: AppiumLogger;
async diagnose(): Promise<DoctorCheckResult> {
const ffmpegPath = await resolveExecutablePath(OptionalFfmpegCheck.FFMPEG_BINARY);
return ffmpegPath
? doctor.okOptional(`${OptionalFfmpegCheck.FFMPEG_BINARY} exists at '${ffmpegPath}'`)
: doctor.nokOptional(`${OptionalFfmpegCheck.FFMPEG_BINARY} cannot be found`);
}
async fix(): Promise<string> {
return (
`${`${OptionalFfmpegCheck.FFMPEG_BINARY}`.bold} is used to capture screen recordings from the device under test. ` +
`Please read ${OptionalFfmpegCheck.FFMPEG_INSTALL_LINK}.`
);
}
hasAutofix(): boolean {
return false;
}
isOptional(): boolean {
return true;
}
}
export const optionalFfmpegCheck = new OptionalFfmpegCheck();
const REMOTE_XPC_PACKAGE_NAME = 'appium-ios-remotexpc';
const isRemoteXpcDependencyAvailable = memoize(
async function ensureRemoteXpcDependencyAvailable(): Promise<boolean> {
try {
// We only care that the module can be imported; we don't need to use it here.
await import(REMOTE_XPC_PACKAGE_NAME);
return true;
} catch {
return false;
}
},
);
const getXcuitestDriverRoot = memoize(function getXcuitestDriverRoot(): string | null {
return node.getModuleRootSync('appium-xcuitest-driver', __filename);
});
export class OptionalIosRemoteXpcDependencyCheck implements IDoctorCheck {
static readonly README_LINK = 'https://github.com/appium/appium-ios-remotexpc';
log!: AppiumLogger;
async diagnose(): Promise<DoctorCheckResult> {
const available = await isRemoteXpcDependencyAvailable();
if (available) {
return doctor.okOptional(
`${REMOTE_XPC_PACKAGE_NAME} is installed and can be imported. ` +
`Remote XPC-based features are available for real devices (iOS/tvOS 18+).`,
);
}
return doctor.nokOptional(
`${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` +
`Install it as an optional dependency if you plan to use Remote XPC-based features ` +
`on real devices (iOS/tvOS 18+). Tests may still run without it, but some ` +
`advanced functionality might not work or be unavailable.`,
);
}
async fix(): Promise<string> {
const driverRoot = getXcuitestDriverRoot();
const locationHint = driverRoot ? `cd "${driverRoot}"; ` : '';
return (
`${`${REMOTE_XPC_PACKAGE_NAME}`.bold} provides Remote XPC communication ` +
`and tunneling support for real devices (iOS/tvOS 18+). ` +
`Run '${locationHint}npm install ${REMOTE_XPC_PACKAGE_NAME}'. ` +
`For more information, see ${OptionalIosRemoteXpcDependencyCheck.README_LINK}.`
);
}
hasAutofix(): boolean {
return false;
}
isOptional(): boolean {
return true;
}
}
export const optionalIosRemoteXpcDependencyCheck = new OptionalIosRemoteXpcDependencyCheck();
const TUNNEL_SCRIPT_TIMEOUT_MS = 5000;
const API_READY_PATTERN = /:\d+\/remotexpc\/tunnels/;
export class OptionalTunnelAvailabilityCheck implements IDoctorCheck {
static readonly README_LINK = 'https://github.com/appium/appium-ios-tuntap';
static readonly TUNNEL_CREATION_COMMAND = 'sudo appium driver run xcuitest tunnel-creation';
log!: AppiumLogger;
async diagnose(): Promise<DoctorCheckResult> {
const remoteXpcAvailable = await isRemoteXpcDependencyAvailable();
if (!remoteXpcAvailable) {
return doctor.nokOptional(
`Remote XPC tunnel availability cannot be checked because ` +
`${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` +
`Install it first using the '${REMOTE_XPC_PACKAGE_NAME}' optional check.`,
);
}
const platform = process.platform;
if (platform !== 'darwin' && platform !== 'linux') {
return doctor.okOptional(
`Tunnel availability status cannot be automatically verified on platform '${platform}'.`,
);
}
const candidatePorts = await this._getListeningTcpPorts();
if (candidatePorts.length > 0) {
const registryResult = await this._probeTunnelRegistry(candidatePorts);
if (registryResult) {
return registryResult;
}
}
return await this._runTunnelCreationScript();
}
async fix(): Promise<string> {
return (
`The Remote XPC tunnel infrastructure is used for IPv6 tunneling when testing against real ` +
`devices (iOS/tvOS 18+). ` +
`To explicitly start or verify tunnels when needed, run ` +
`'${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' with sudo/root privileges. ` +
`See ${OptionalTunnelAvailabilityCheck.README_LINK} for more details about tunnel usage.`
);
}
hasAutofix(): boolean {
return false;
}
isOptional(): boolean {
return true;
}
/**
* Returns listening TCP ports. Uses pure Node on Linux (/proc/net/tcp, tcp6); uses netstat on macOS.
*/
private async _getListeningTcpPorts(): Promise<number[]> {
if (process.platform === 'linux') {
return await this._getListeningTcpPortsLinux();
}
if (process.platform === 'darwin') {
return await this._getListeningTcpPortsDarwin();
}
return [];
}
/**
* Linux: parse /proc/net/tcp and /proc/net/tcp6 (pure Node, no exec). State 0A = LISTEN.
*/
private async _getListeningTcpPortsLinux(): Promise<number[]> {
const ports = new Set<number>();
const files = ['/proc/net/tcp', '/proc/net/tcp6'] as const;
for (const file of files) {
try {
const raw = await fs.readFile(file, 'utf8');
const lines = raw.split('\n');
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].trim().split(/\s+/);
if (parts.length < 4) {
continue;
}
const state = parts[3];
if (state !== '0A') {
continue; // 0A = LISTEN
}
const localAddr = parts[1];
const colon = localAddr.lastIndexOf(':');
if (colon === -1) {
continue;
}
const portHex = localAddr.slice(colon + 1);
const port = Number.parseInt(portHex, 16);
if (Number.isInteger(port) && port > 0 && port <= 65535) {
ports.add(port);
}
}
} catch {
// File missing or unreadable (e.g. not Linux or permissions)
}
}
return Array.from(ports);
}
/**
* macOS: netstat -anv -p tcp (Node has no API for system-wide listening ports).
*/
private async _getListeningTcpPortsDarwin(): Promise<number[]> {
try {
const {stdout} = await exec('netstat', ['-anv', '-p', 'tcp']);
const ports = new Set<number>();
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed || !trimmed.toLowerCase().startsWith('tcp')) {
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length < 4) {
continue;
}
const portMatch = /\.(\d+)$/.exec(parts[3]);
if (!portMatch) {
continue;
}
const port = Number.parseInt(portMatch[1], 10);
if (Number.isInteger(port) && port > 0) {
ports.add(port);
}
}
return Array.from(ports);
} catch {
return [];
}
}
/**
* Probes candidate ports for the tunnel registry API in parallel; resolves as soon as any succeed, else null.
*/
private async _probeTunnelRegistry(ports: number[]): Promise<DoctorCheckResult | null> {
if (ports.length === 0) {
return null;
}
return await new Promise<DoctorCheckResult | null>((resolve) => {
let settled = false;
let remaining = ports.length;
const maybeResolveNull = () => {
remaining -= 1;
if (!settled && remaining === 0) {
settled = true;
resolve(null);
}
};
for (const port of ports) {
void (async () => {
try {
const res = await axios.get(`http://127.0.0.1:${port}/remotexpc/tunnels`, {
timeout: 1000,
validateStatus: (status) => status === 200,
});
const data = res.data as any;
if (!settled && data != null && typeof data === 'object' && data.status === 'OK') {
settled = true;
resolve(
doctor.okOptional(
`Detected an active Remote XPC tunnel registry process on port ${port}. ` +
`The Remote XPC tunnel infrastructure appears to be available, so Remote XPC-based ` +
`features for real devices (iOS/tvOS 18+) should be available.`,
),
);
return;
}
} catch {
// Ignore individual probe failures; we'll resolve to null only if all fail.
}
if (!settled) {
maybeResolveNull();
}
})();
}
});
}
/**
* Runs the tunnel-creation driver script as a subprocess to avoid blocking doctor if the script hangs.
* Waits for exit, TUNNEL_SCRIPT_TIMEOUT_MS (5s), or output string indicating registry is up;
* then evaluates or stops the process.
*/
private async _runTunnelCreationScript(): Promise<DoctorCheckResult> {
const homeCwd = process.env.HOME || process.cwd();
const driverRoot = getXcuitestDriverRoot();
let combinedOutput = '';
let resolveApiReady: () => void;
const apiReadyPromise = new Promise<{reason: 'api'}>((resolve) => {
resolveApiReady = () => resolve({reason: 'api'});
});
const sub =
driverRoot != null
? new SubProcess(process.execPath, ['./scripts/tunnel-creation.mjs'], {cwd: driverRoot})
: new SubProcess('appium', ['driver', 'run', 'xcuitest', 'tunnel-creation'], {
cwd: homeCwd,
});
const appendLine = (line: string) => {
combinedOutput += line + '\n';
if (API_READY_PATTERN.test(line)) {
resolveApiReady();
}
};
sub.on('line-stdout', appendLine);
sub.on('line-stderr', appendLine);
const exitPromise = new Promise<{reason: 'exit'; code?: number; signal?: string}>((resolve) => {
sub.once('exit', (code, signal) => resolve({reason: 'exit', code, signal}));
});
const timeoutPromise = new Promise<{reason: 'timeout'}>((resolve) => {
setTimeout(() => resolve({reason: 'timeout'}), TUNNEL_SCRIPT_TIMEOUT_MS);
});
try {
await sub.start(0);
} catch (err) {
const message = ((err as any).stderr || (err as Error).message || '').toString();
return doctor.nokOptional(
`Could not start '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}'. ` +
`Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work or be unavailable. ` +
`Details: ${message}`,
);
}
const winner = await Promise.race([exitPromise, timeoutPromise, apiReadyPromise]);
if (winner.reason === 'exit') {
const code = (winner as {reason: 'exit'; code?: number; signal?: string}).code;
return this._evaluateTunnelScriptOutput(combinedOutput.trim(), code);
}
if (sub.isRunning) {
try {
await sub.stop('SIGTERM', 500);
} catch {
// Subprocess did not exit within 500ms; force-kill so doctor process can exit
if (sub.pid != null) {
try {
process.kill(sub.pid, 'SIGKILL');
} catch {
// ignore if already gone
}
}
}
}
return doctor.okOptional(
`The tunnel script was started; the registry was detected or the check timed out. ` +
`Tunnel infrastructure for real devices (iOS/tvOS 18+) should be available when run with sufficient privileges.`,
);
}
/**
* Interprets tunnel-creation script stdout+stderr and optional exit code; returns the appropriate doctor result.
* Output pattern matches take priority over a non-zero exit code.
*/
private _evaluateTunnelScriptOutput(
combinedOutput: string,
exitCode?: number | null,
): DoctorCheckResult {
if (/No devices found/i.test(combinedOutput)) {
return doctor.okOptional(
`The Remote XPC tunnel-creation script can be invoked via '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}', ` +
`but no real devices are currently connected.`,
);
}
if (/must be run as root|operation not permitted|permission denied/i.test(combinedOutput)) {
return doctor.okOptional(
`The tunnel-creation script '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' is available, ` +
`but did not run with elevated privileges (e.g. root check or TUN/TAP creation). ` +
`This is expected when not running with sudo/root. ` +
`When you actually need Remote XPC-based functionality for real devices (iOS/tvOS 18+), ` +
`run the same command with sufficient privileges to establish the tunnel.`,
);
}
if (exitCode != null && exitCode !== 0) {
return doctor.nokOptional(
`The tunnel script exited with code ${exitCode}. ` +
`Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work. ` +
(combinedOutput ? `Output:\n${combinedOutput}` : ''),
);
}
return doctor.okOptional(
`Successfully ran '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}'. ` +
`The Remote XPC tunnel infrastructure should be available for creating tunnels.` +
(combinedOutput ? `\nLast output:\n${combinedOutput}` : ''),
);
}
}
export const optionalTunnelAvailabilityCheck = new OptionalTunnelAvailabilityCheck();
interface SimulatorPlatform {
displayName: string;
name: string;
}
interface InstalledSdk {
buildID?: string;
canonicalName: string;
displayName: string;
isBaseSdk: boolean;
platform: string;
platformPath: string;
platformVersion: string;
productBuildVersion?: string;
productCopyright?: string;
productName?: string;
productVersion?: string;
sdkPath: string;
sdkVersion: string;
}