appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
286 lines (259 loc) • 9.91 kB
text/typescript
import {node} from 'appium/support';
import path from 'node:path';
import {readFileSync} from 'node:fs';
import type {AppiumLogger} from '@appium/types';
import type * as RemoteXPCModule from 'appium-ios-remotexpc';
import {isDeviceListedInUsbmux} from './usbmux-utils';
export type RemoteXPCEsmModule = typeof RemoteXPCModule;
export type RemoteXPCServices = RemoteXPCEsmModule['Services'];
export type RemoteXPCTestRunner = RemoteXPCEsmModule['XCTestRunner'];
/**
* Whether the given error means RemoteXPC tunnel infrastructure is unavailable.
*/
export function isTunnelAvailabilityError(err: unknown): boolean {
if (!err) {
return false;
}
const name = (err as any)?.name ?? (err as any)?.constructor?.name;
return name === 'TunnelAvailabilityError';
}
/**
* Full ESM namespace after a successful `import('appium-ios-remotexpc')` (e.g. **XCTestAttachment**).
* Set together with {@link cachedRemoteXPCServices}.
*/
let cachedRemoteXPCFullModule: RemoteXPCEsmModule | null = null;
/**
* Cached RemoteXPC Services module
*/
let cachedRemoteXPCServices: RemoteXPCServices | null = null;
/**
* Set when **appium-ios-remotexpc** resolution failed in a way that is unlikely to succeed on
* retry in the same process (package not installed). Transient import errors do not set this.
*/
let remoteXpcModuleUnavailable = false;
/** Stored only when {@link remoteXpcModuleUnavailable} is set (missing-package case). */
let lastRemoteXpcImportError: Error | null = null;
/**
* Most recent failed optional `import('appium-ios-remotexpc')` from {@link tryGetRemoteXPCServices}
* (including full-module backfill). Cleared when an optional load succeeds.
*/
let lastTryGetRemoteXPCImportError: Error | null = null;
/**
* Cached XCTestRunner class
*/
let cachedXCTestRunnerClass: RemoteXPCTestRunner | null = null;
/**
* Module root and version cached at initialization
*/
const {moduleRoot, remoteXpcVersion} = fetchInstallInfo();
/**
* Get the RemoteXPC Services module dynamically
*
* This helper centralizes the import of appium-ios-remotexpc to:
* - Provide consistent error handling across all services
* - Give helpful installation instructions when the module is missing
*
* @returns The Services export from appium-ios-remotexpc
* @throws {Error} If the module cannot be imported
*/
export async function getRemoteXPCServices(): Promise<RemoteXPCServices> {
if (cachedRemoteXPCServices) {
if (!cachedRemoteXPCFullModule) {
try {
cachedRemoteXPCFullModule = (await import('appium-ios-remotexpc')) as RemoteXPCEsmModule;
} catch (err) {
throwRemoteXPCImportError(err as Error);
}
}
lastTryGetRemoteXPCImportError = null;
return cachedRemoteXPCServices;
}
if (remoteXpcModuleUnavailable && lastRemoteXpcImportError) {
throwRemoteXPCImportError(lastRemoteXpcImportError);
}
try {
const remotexpcModule = (await import('appium-ios-remotexpc')) as RemoteXPCEsmModule;
cachedRemoteXPCFullModule = remotexpcModule;
cachedRemoteXPCServices = remotexpcModule.Services;
lastTryGetRemoteXPCImportError = null;
return cachedRemoteXPCServices;
} catch (err) {
throwRemoteXPCImportError(err as Error);
}
}
/**
* Try to load appium-ios-remotexpc without throwing (e.g. for optional features).
* Successful loads share the same cache as {@link getRemoteXPCServices}.
*
* If the package is **not installed** (resolution error for **appium-ios-remotexpc**), subsequent
* calls return `null` without re-importing. Other import failures are recorded via
* {@link getLastRemoteXPCOptionalImportError} and **do not** permanently disable retries.
*/
export async function tryGetRemoteXPCServices(): Promise<RemoteXPCServices | null> {
if (cachedRemoteXPCServices) {
if (!cachedRemoteXPCFullModule) {
try {
cachedRemoteXPCFullModule = (await import('appium-ios-remotexpc')) as RemoteXPCEsmModule;
lastTryGetRemoteXPCImportError = null;
} catch (err) {
lastTryGetRemoteXPCImportError = err as Error;
/* ignore: tryGetRemoteXPCModule may still return null for XCTestAttachment callers */
}
} else {
lastTryGetRemoteXPCImportError = null;
}
return cachedRemoteXPCServices;
}
if (remoteXpcModuleUnavailable) {
return null;
}
try {
const remotexpcModule = (await import('appium-ios-remotexpc')) as RemoteXPCEsmModule;
cachedRemoteXPCFullModule = remotexpcModule;
cachedRemoteXPCServices = remotexpcModule.Services;
lastTryGetRemoteXPCImportError = null;
return cachedRemoteXPCServices;
} catch (err) {
const error = err as Error;
lastTryGetRemoteXPCImportError = error;
if (isAppiumIosRemotexpcPackageMissingError(error)) {
lastRemoteXpcImportError = error;
remoteXpcModuleUnavailable = true;
}
return null;
}
}
/**
* Whether {@link tryGetRemoteXPCServices} has determined that **appium-ios-remotexpc** is not
* installed (same process will not retry optional import).
*/
export function isRemoteXPCOptionalDependencyMissing(): boolean {
return remoteXpcModuleUnavailable;
}
/**
* Last error from an optional RemoteXPC `import()`, including transient failures. Cleared when a
* load succeeds. When {@link isRemoteXPCOptionalDependencyMissing} is `true`, this matches the
* stored missing-package error.
*/
export function getLastRemoteXPCOptionalImportError(): Error | null {
return lastTryGetRemoteXPCImportError;
}
/**
* Full **appium-ios-remotexpc** module after a successful optional load (same `import()` as
* {@link tryGetRemoteXPCServices}). Returns `null` if the package is missing or failed to load.
*/
export async function tryGetRemoteXPCModule(): Promise<RemoteXPCEsmModule | null> {
await tryGetRemoteXPCServices();
return cachedRemoteXPCFullModule;
}
/**
* Optional load of **appium-ios-remotexpc** (shared cache) plus the USBMUX vs tunnel branch hint:
* whether `udid` appears in the usbmux device list. Used by lockdown and port forwarding so they
* do not duplicate `import()` + {@link isDeviceListedInUsbmux}.
*
* @returns `null` if the module is not available; otherwise the module and whether to use the
* USBMUX-oriented APIs (`createLockdownServiceByUDID`, `connectViaUsbmux`, …).
*/
export async function tryGetRemoteXPCUsbMuxStrategy(
udid: string,
log: AppiumLogger,
): Promise<{remotexpc: RemoteXPCEsmModule; useUsbMuxPath: boolean} | null> {
const remotexpc = await tryGetRemoteXPCModule();
if (!remotexpc) {
return null;
}
const useUsbMuxPath = await isDeviceListedInUsbmux(remotexpc, udid, log);
return {remotexpc, useUsbMuxPath};
}
/**
* Get the XCTestRunner class dynamically from appium-ios-remotexpc
*
* @returns The XCTestRunner class
* @throws {Error} If the module cannot be imported
*/
export async function getXCTestRunnerClass(): Promise<RemoteXPCTestRunner> {
if (cachedXCTestRunnerClass) {
return cachedXCTestRunnerClass;
}
await getRemoteXPCServices();
const remotexpcModule = cachedRemoteXPCFullModule;
if (!remotexpcModule) {
throw new Error(
'appium-ios-remotexpc loaded Services but full module cache is missing; cannot load XCTestRunner.',
);
}
try {
const XCTestRunnerClass = remotexpcModule.XCTestRunner;
if (typeof XCTestRunnerClass !== 'function') {
throw new Error(
'XCTestRunner is not exported from appium-ios-remotexpc. ' +
'The installed version may be incompatible.',
);
}
cachedXCTestRunnerClass = XCTestRunnerClass;
return cachedXCTestRunnerClass;
} catch (err) {
throw new Error(
'Failed to import XCTestRunner from appium-ios-remotexpc. ' +
`Original error: ${(err as Error).message}`,
{cause: err},
);
}
}
/**
* Whether `err` indicates the **appium-ios-remotexpc** package is not installed / not resolvable,
* as opposed to a transient or corrupt-install failure that may succeed on a later attempt.
*/
function isAppiumIosRemotexpcPackageMissingError(err: Error): boolean {
const msg = err.message;
return (
(msg.includes('Cannot find module') && msg.includes('appium-ios-remotexpc')) ||
(msg.includes('Cannot find package') && msg.includes('appium-ios-remotexpc'))
);
}
function throwRemoteXPCImportError(err: Error): never {
if (err.message.includes('Cannot find module')) {
let errorMessage =
'Failed to import appium-ios-remotexpc module. ' +
'This module is required for iOS 18 and above device operations.';
if (moduleRoot && remoteXpcVersion) {
errorMessage +=
' Please install it by running: ' +
`cd "${moduleRoot}" && npm install "appium-ios-remotexpc@${remoteXpcVersion}".`;
}
errorMessage += ` Original error: ${err.message}`;
throw new Error(errorMessage);
}
throw new Error(
'Failed to import appium-ios-remotexpc module. ' +
'This module is required for iOS 18 and above device operations. ' +
`Original error: ${err.message}`,
);
}
/**
* Fetch module root and appium-ios-remotexpc version from package.json
*
* @returns Object containing moduleRoot and remoteXpcVersion
*/
function fetchInstallInfo(): {
moduleRoot: string | undefined;
remoteXpcVersion: string | undefined;
} {
try {
const root = node.getModuleRootSync('appium-xcuitest-driver', __filename);
if (root) {
const packageJsonPath = path.join(root, 'package.json');
const packageJsonContent = readFileSync(packageJsonPath, 'utf8');
if (packageJsonContent) {
const packageJson = JSON.parse(packageJsonContent);
return {
moduleRoot: root,
remoteXpcVersion: packageJson.optionalDependencies?.['appium-ios-remotexpc'],
};
}
}
} catch {
// Error messages will skip install hints
}
return {moduleRoot: undefined, remoteXpcVersion: undefined};
}