appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
604 lines (549 loc) • 20 kB
text/typescript
import {isEmpty} from '../utils';
import net from 'node:net';
import {util, timing} from 'appium/support';
import {utilities} from 'appium-ios-device';
import {checkPortStatus} from 'portscanner';
import {waitForCondition} from 'asyncbox';
import type {AppiumLogger} from '@appium/types';
import type {DevicePortForwarder, TunnelEndpoint} from 'appium-ios-remotexpc';
import {
getLastRemoteXPCOptionalImportError,
tryGetRemoteXPCUsbMuxStrategy,
wrapRemoteXPCConnectionError,
} from './remotexpc-utils';
import {isIos18OrNewerPlatform} from '../commands/helpers';
import type {Socket} from 'node:net';
const LOCALHOST = '127.0.0.1';
const TERMINATION_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
const terminationCallbacks = new Set<() => void>();
const PORT_CLOSE_TIMEOUT = 15 * 1000; // 15 seconds
const SPLITTER = ':';
interface ConnectionMapping {
[key: string]: {
portForwarder?: PortForwarder;
};
}
/** Options for {@link DeviceConnectionsFactory.requestConnection}. */
interface RequestConnectionOptions {
usePortForwarding?: boolean;
devicePort?: number | null;
platformVersion?: string | null;
}
interface PortForwarder {
start(): Promise<void>;
stop(): Promise<void>;
}
/** Holds a slot in the shared SIGINT/SIGTERM dispatch; {@link dispose} unregisters the callback. */
class TerminationSubscription {
private unsubscribe: (() => void) | null = null;
subscribe(onTerminate: () => void): void {
this.dispose();
this.unsubscribe = registerTerminationCallback(onTerminate);
}
dispose(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
}
class LegacyPortForwarder implements PortForwarder {
private localServer: net.Server | null = null;
private readonly termination = new TerminationSubscription();
constructor(
private readonly udid: string,
private readonly localport: number,
private readonly deviceport: number,
private readonly log: AppiumLogger,
) {}
async start(): Promise<void> {
if (this.localServer) {
return;
}
this.localServer = net.createServer(async (localSocket: net.Socket) => {
let remoteSocket: any;
try {
// We can only connect to the remote socket after the local socket connection succeeds
remoteSocket = await utilities.connectPort(this.udid, this.deviceport);
} catch (e) {
this.log.debug((e as Error).message);
localSocket.destroy();
return;
}
const destroyCommChannel = () => {
remoteSocket.unpipe(localSocket);
localSocket.unpipe(remoteSocket);
};
remoteSocket.once('close', () => {
destroyCommChannel();
localSocket.destroy();
});
// not all remote socket errors are critical for the user
remoteSocket.on('error', (e: Error) => this.log.debug(e));
localSocket.once('end', destroyCommChannel);
localSocket.once('close', () => {
destroyCommChannel();
remoteSocket.destroy();
});
localSocket.on('error', (e: Error) => this.log.warn(e.message));
localSocket.pipe(remoteSocket);
remoteSocket.pipe(localSocket);
});
const listeningPromise = new Promise<void>((resolve, reject) => {
if (this.localServer) {
this.localServer.once('listening', resolve);
this.localServer.once('error', reject);
} else {
reject(new Error('Local server is not initialized'));
}
});
this.localServer.listen(this.localport);
try {
await listeningPromise;
} catch (e) {
this.localServer = null;
throw e;
}
this.localServer.on('error', (e: Error) => this.log.warn(e.message));
this.localServer.once('close', (e?: Error) => {
if (e) {
this.log.info(`The connection has been closed with error ${e.message}`);
} else {
this.log.info(`The connection has been closed`);
}
this.localServer = null;
});
this.termination.subscribe(() => this._closeLocalServer());
}
async stop(): Promise<void> {
this.termination.dispose();
this._closeLocalServer();
}
private _closeLocalServer(): void {
if (!this.localServer) {
return;
}
this.log.debug(`Closing the connection`);
this.localServer.close();
this.localServer = null;
}
}
class RemotexpcPortForwarder implements PortForwarder {
private readonly termination = new TerminationSubscription();
constructor(
private readonly forwarder: DevicePortForwarder,
private readonly log: AppiumLogger,
private readonly localPort: number,
private readonly devicePort: number,
) {}
async start(): Promise<void> {
this.forwarder.on('upstreamConnectError', this.onUpstreamConnectError);
this.forwarder.on('clientDisconnected', this.onClientDisconnected);
this.forwarder.on('upstreamDisconnected', this.onUpstreamDisconnected);
this.forwarder.on('clientConnected', this.onClientConnected);
this.forwarder.on('upstreamConnected', this.onUpstreamConnected);
try {
await this.forwarder.start();
} catch (e) {
this.cleanupForwarder();
throw e;
}
this.termination.subscribe(() => this._scheduleEmergencyStop());
}
async stop(): Promise<void> {
this.termination.dispose();
try {
await this.forwarder.stop();
} finally {
this.cleanupForwarder();
}
}
private cleanupForwarder(): void {
this.forwarder.off('upstreamConnectError', this.onUpstreamConnectError);
this.forwarder.off('clientDisconnected', this.onClientDisconnected);
this.forwarder.off('upstreamDisconnected', this.onUpstreamDisconnected);
this.forwarder.off('clientConnected', this.onClientConnected);
this.forwarder.off('upstreamConnected', this.onUpstreamConnected);
}
private adjustSocketOptions(socket: Socket): void {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 30_000);
}
private readonly onClientConnected = (socket: Socket) => {
this.log.debug(
`RemoteXPC downstream socket connected (local ${this.localPort} -> device ${this.devicePort})`,
);
this.adjustSocketOptions(socket);
};
private readonly onUpstreamConnected = (socket: Socket) => {
this.log.debug(
`RemoteXPC upstream socket connected (local ${this.localPort} -> device ${this.devicePort})`,
);
this.adjustSocketOptions(socket);
};
private readonly onUpstreamConnectError = (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(
`RemoteXPC upstream connect error (local ${this.localPort} -> device ${this.devicePort}): ${msg}`,
);
};
private readonly onClientDisconnected = (_socket: Socket, err?: Error): void => {
if (err) {
this.log.warn(
`RemoteXPC downstream socket error (local ${this.localPort} -> device ${this.devicePort}): ${err.message}`,
);
} else {
this.log.debug(
`RemoteXPC downstream socket disconnected (local ${this.localPort} -> device ${this.devicePort})`,
);
}
};
private readonly onUpstreamDisconnected = (_socket: Socket, err?: Error): void => {
if (err) {
this.log.warn(
`RemoteXPC upstream socket error (local ${this.localPort} -> device ${this.devicePort}): ${err.message}`,
);
} else {
this.log.debug(
`RemoteXPC upstream socket disconnected (local ${this.localPort} -> device ${this.devicePort})`,
);
}
};
/** Best-effort stop when the process receives SIGINT/SIGTERM (errors are logged, not thrown). */
private _scheduleEmergencyStop(): void {
void (async () => {
try {
await this.forwarder.stop();
} catch (err: unknown) {
this.log.debug(err);
}
})();
}
}
/**
* Manages cached local device connections and optional TCP port forwarding to real iOS devices.
*
* Forwarding may use `appium-ios-remotexpc` on iOS 18+ (usbmux or tunnel) with fallback to
* `appium-ios-device` {@linkcode utilities.connectPort}
*/
export class DeviceConnectionsFactory {
/** Shared across factory instances so parallel sessions coordinate the same local ports. */
private static _connectionsMapping: ConnectionMapping = {};
/**
* @param log - Logger for this factory
*/
constructor(private readonly log: AppiumLogger) {}
/**
* Lists cache keys (`udid:localPort`) matching the given filters.
*
* @param udid - If set, only keys whose UDID prefix matches
* @param port - If set, only keys whose local port suffix matches
* @param strict - If true and both `udid` and `port` are set, only the exact `udid:port` key;
* otherwise keys matching either filter are included
* @returns Matching connection keys (empty if both `udid` and `port` are omitted)
*/
listConnections(
udid: string | null = null,
port: number | null = null,
strict: boolean = false,
): string[] {
if (!udid && !port) {
return [];
}
// `this._connectionMapping` keys have format `udid:port`
// the `strict` argument enforces to match keys having both `udid` and `port`
// if they are defined
// while in non-strict mode keys having any of these are going to be matched
return Object.keys(DeviceConnectionsFactory._connectionsMapping).filter((key) =>
strict && udid && port
? key === this._toKey(udid, port)
: (udid && key.startsWith(this._udidAsToken(udid))) ||
(port && key.endsWith(this._portAsToken(port))),
);
}
/**
* Registers a connection for the device, optionally starting local TCP forwarding to `devicePort`.
*
* When `usePortForwarding` is true, ensures the local port is free (may release stale forwarders)
* then starts forwarding. When false, only records an empty cache entry for the key.
*
* @param udid - Device UDID
* @param port - Local port on the host
* @param options - Forwarding options; `devicePort` and `platformVersion` are used when
* `usePortForwarding` is true (iOS 18+ may use RemoteXPC; otherwise legacy forwarding applies)
* @throws If `usePortForwarding` is true but `devicePort` is not an integer
* @throws If the local port is still in use after attempting cleanup
*/
async requestConnection(
udid?: string | null,
port?: number | null,
options: RequestConnectionOptions = {},
): Promise<void> {
if (!udid || !port) {
this._warnMissingRequestConnectionParams(udid, port);
return;
}
const {usePortForwarding, devicePort, platformVersion} = options;
this.log.info(
`Requesting connection for device ${udid} on local port ${port}` +
(devicePort ? `, device port ${devicePort}` : ''),
);
this.log.debug(
`Cached connections count: ${Object.keys(DeviceConnectionsFactory._connectionsMapping).length}`,
);
const connectionsOnPort = this.listConnections(null, port);
if (!isEmpty(connectionsOnPort)) {
this.log.info(
`Found cached connections on port #${port}: ${JSON.stringify(connectionsOnPort)}`,
);
}
if (usePortForwarding) {
await this._ensureForwardingPortIsFree(port, connectionsOnPort);
}
const currentKey = this._toKey(udid, port);
if (usePortForwarding) {
if (!Number.isInteger(devicePort)) {
throw new Error('devicePort is required when usePortForwarding is true');
}
await this._startAndRegisterPortForwarder(
currentKey,
udid,
port,
Number(devicePort),
platformVersion,
);
} else {
DeviceConnectionsFactory._connectionsMapping[currentKey] = {};
}
this.log.info(`Successfully requested the connection for ${currentKey}`);
}
/**
* Removes matching entries from the cache and stops any associated port forwarders.
*
* @param udid - If set, only connections for this device; use with `port` for a single exact key
* @param port - If set, only connections on this local port
*/
async releaseConnection(udid: string | null = null, port: number | null = null): Promise<void> {
if (!udid && !port) {
this.log.warn(
'Neither device UDID nor local port is set. ' +
'Did not know how to release the connection',
);
return;
}
this.log.info(
`Releasing connections for ${udid || 'any'} device on ${port || 'any'} port number`,
);
const keys = this.listConnections(udid, port, true);
if (isEmpty(keys)) {
this.log.info('No cached connections have been found');
return;
}
this.log.info(`Found cached connections to release: ${JSON.stringify(keys)}`);
await this._releaseProxiedConnections(keys);
for (const key of keys) {
delete DeviceConnectionsFactory._connectionsMapping[key];
}
this.log.debug(
`Cached connections count: ${Object.keys(DeviceConnectionsFactory._connectionsMapping).length}`,
);
}
private _warnMissingRequestConnectionParams(
udid: string | null | undefined,
port: number | null | undefined,
): void {
this.log.warn('Did not know how to request the connection:');
if (!udid) {
this.log.warn('- Device UDID is unset');
}
if (!port) {
this.log.warn('- The local port number is unset');
}
}
private async _ensureForwardingPortIsFree(
port: number,
connectionsOnPort: string[],
): Promise<void> {
let isPortBusy = (await checkPortStatus(port, LOCALHOST)) === 'open';
if (isPortBusy) {
this.log.warn(`Port #${port} is busy. Did you quit the previous driver session(s) properly?`);
if (!isEmpty(connectionsOnPort)) {
this.log.info('Trying to release the port');
for (const key of await this._releaseProxiedConnections(connectionsOnPort)) {
delete DeviceConnectionsFactory._connectionsMapping[key];
}
const timer = new timing.Timer().start();
try {
await waitForCondition(
async () => {
try {
if ((await checkPortStatus(port, LOCALHOST)) !== 'open') {
this.log.info(
`Port #${port} has been successfully released after ` +
`${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
isPortBusy = false;
return true;
}
} catch {}
return false;
},
{
waitMs: PORT_CLOSE_TIMEOUT,
intervalMs: 300,
},
);
} catch {
this.log.warn(
`Did not know how to release port #${port} in ` +
`${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
}
}
}
if (isPortBusy) {
throw new Error(
`The port #${port} is occupied by an other process. ` +
`You can either quit that process or select another free port.`,
);
}
}
private async _startAndRegisterPortForwarder(
currentKey: string,
udid: string,
port: number,
devicePort: number,
platformVersion: string | null | undefined,
): Promise<void> {
const portForwarder = await this._createPortForwarder(udid, port, devicePort, platformVersion);
try {
await portForwarder.start();
DeviceConnectionsFactory._connectionsMapping[currentKey] = {portForwarder};
} catch (e) {
try {
await portForwarder.stop();
} catch (e1) {
this.log.debug(e1);
}
throw e;
}
}
private _udidAsToken(udid?: string | null): string {
return `${util.hasValue(udid) ? udid : ''}${SPLITTER}`;
}
private _portAsToken(port?: number | null): string {
return `${SPLITTER}${util.hasValue(port) ? port : ''}`;
}
private _toKey(udid: string | null = null, port: number | null = null): string {
return `${util.hasValue(udid) ? udid : ''}${SPLITTER}${util.hasValue(port) ? port : ''}`;
}
/**
* Stops forwarders for the given keys. Snapshots `portForwarder` references before any `await`
* so `releaseConnection` can delete mapping entries only after stops complete (no stale reads).
*/
private async _releaseProxiedConnections(connectionKeys: string[]): Promise<string[]> {
const jobs = connectionKeys
.map((key) => {
const forwarder = DeviceConnectionsFactory._connectionsMapping[key]?.portForwarder;
return forwarder ? {key, forwarder} : null;
})
.filter((job): job is {key: string; forwarder: PortForwarder} => job != null);
return Promise.all(
jobs.map(async ({key, forwarder}) => {
this.log.info(`Releasing the listener for '${key}'`);
try {
await forwarder.stop();
} catch (e) {
this.log.debug(e);
}
return key;
}),
);
}
private async _createPortForwarder(
udid: string,
localPort: number,
devicePort: number,
platformVersion?: string | null,
): Promise<PortForwarder> {
if (!isIos18OrNewerPlatform(platformVersion)) {
this.log.debug(
`Device '${udid}' is running iOS below 18 (platformVersion='${platformVersion ?? 'unknown'}'). ` +
`Using appium-ios-device port forwarding fallback.`,
);
return new LegacyPortForwarder(udid, localPort, devicePort, this.log);
}
const resolved = await tryGetRemoteXPCUsbMuxStrategy(udid, this.log);
if (!resolved) {
this.log.debug(
`appium-ios-remotexpc is unavailable. Falling back to appium-ios-device port forwarding. ` +
`Original error: ${getLastRemoteXPCOptionalImportError()?.message ?? 'unknown'}`,
);
return new LegacyPortForwarder(udid, localPort, devicePort, this.log);
}
const {remotexpc, useUsbMuxPath} = resolved;
if (useUsbMuxPath) {
this.log.debug(`Using appium-ios-remotexpc usbmux strategy for '${udid}'`);
return new RemotexpcPortForwarder(
new remotexpc.DevicePortForwarder(localPort, devicePort, {
primaryConnector: () => remotexpc.connectViaUsbmux(udid, devicePort),
}),
this.log,
localPort,
devicePort,
);
}
// We cannot use the legacy fallback past this point as the device is not accessible via USB/local usbmux
let tunnelConnection: TunnelEndpoint;
try {
tunnelConnection = await remotexpc.Services.getTunnelForDevice(udid);
} catch (err) {
throw wrapRemoteXPCConnectionError(
err,
`Cannot create port forwarder via RemoteXPC tunnel for '${udid}'`,
);
}
const tunnelHost = tunnelConnection.host;
this.log.debug(
`Using appium-ios-remotexpc tunnel strategy for '${udid}' through '${tunnelHost}'`,
);
return new RemotexpcPortForwarder(
new remotexpc.DevicePortForwarder(localPort, devicePort, {
primaryConnector: () => remotexpc.connectViaTunnel(tunnelHost, devicePort),
}),
this.log,
localPort,
devicePort,
);
}
}
function dispatchProcessTermination(): void {
for (const fn of [...terminationCallbacks]) {
try {
fn();
} catch {
// isolate callbacks so one failure does not skip the rest
}
}
}
/**
* Registers a callback to run on SIGINT/SIGTERM. Uses one shared listener per signal for the
* whole process; returns an unsubscribe that removes this callback from the dispatch set.
*/
function registerTerminationCallback(onTerminate: () => void): () => void {
terminationCallbacks.add(onTerminate);
if (terminationCallbacks.size === 1) {
for (const sig of TERMINATION_SIGNALS) {
process.on(sig, dispatchProcessTermination);
}
}
return () => {
terminationCallbacks.delete(onTerminate);
if (terminationCallbacks.size === 0) {
for (const sig of TERMINATION_SIGNALS) {
process.off(sig, dispatchProcessTermination);
}
}
};
}