appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
453 lines • 18.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DeviceConnectionsFactory = void 0;
const node_net_1 = __importDefault(require("node:net"));
const appium_ios_device_1 = require("appium-ios-device");
const support_1 = require("appium/support");
const asyncbox_1 = require("asyncbox");
const portscanner_1 = require("portscanner");
const utils_1 = require("../utils");
const remote_xpc_1 = require("./remote-xpc");
const LOCALHOST = '127.0.0.1';
const TERMINATION_SIGNALS = ['SIGINT', 'SIGTERM'];
const terminationCallbacks = new Set();
const PORT_CLOSE_TIMEOUT = 15 * 1000; // 15 seconds
const SPLITTER = ':';
/** Holds a slot in the shared SIGINT/SIGTERM dispatch; {@link dispose} unregisters the callback. */
class TerminationSubscription {
unsubscribe = null;
subscribe(onTerminate) {
this.dispose();
this.unsubscribe = registerTerminationCallback(onTerminate);
}
dispose() {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
}
}
class LegacyPortForwarder {
udid;
localport;
deviceport;
log;
localServer = null;
termination = new TerminationSubscription();
constructor(udid, localport, deviceport, log) {
this.udid = udid;
this.localport = localport;
this.deviceport = deviceport;
this.log = log;
}
async start() {
if (this.localServer) {
return;
}
this.localServer = node_net_1.default.createServer(async (localSocket) => {
let remoteSocket;
try {
// We can only connect to the remote socket after the local socket connection succeeds
remoteSocket = await appium_ios_device_1.utilities.connectPort(this.udid, this.deviceport);
}
catch (e) {
this.log.debug(e.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) => this.log.debug(e));
localSocket.once('end', destroyCommChannel);
localSocket.once('close', () => {
destroyCommChannel();
remoteSocket.destroy();
});
localSocket.on('error', (e) => this.log.warn(e.message));
localSocket.pipe(remoteSocket);
remoteSocket.pipe(localSocket);
});
const listeningPromise = new Promise((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) => this.log.warn(e.message));
this.localServer.once('close', (e) => {
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() {
this.termination.dispose();
this._closeLocalServer();
}
_closeLocalServer() {
if (!this.localServer) {
return;
}
this.log.debug(`Closing the connection`);
this.localServer.close();
this.localServer = null;
}
}
class RemotexpcPortForwarder {
forwarder;
log;
localPort;
devicePort;
termination = new TerminationSubscription();
constructor(forwarder, log, localPort, devicePort) {
this.forwarder = forwarder;
this.log = log;
this.localPort = localPort;
this.devicePort = devicePort;
}
async start() {
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() {
this.termination.dispose();
try {
await this.forwarder.stop();
}
finally {
this.cleanupForwarder();
}
}
cleanupForwarder() {
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);
}
adjustSocketOptions(socket) {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 30_000);
}
onClientConnected = (socket) => {
this.log.debug(`RemoteXPC downstream socket connected (local ${this.localPort} -> device ${this.devicePort})`);
this.adjustSocketOptions(socket);
};
onUpstreamConnected = (socket) => {
this.log.debug(`RemoteXPC upstream socket connected (local ${this.localPort} -> device ${this.devicePort})`);
this.adjustSocketOptions(socket);
};
onUpstreamConnectError = (err) => {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(`RemoteXPC upstream connect error (local ${this.localPort} -> device ${this.devicePort}): ${msg}`);
};
onClientDisconnected = (_socket, err) => {
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})`);
}
};
onUpstreamDisconnected = (_socket, err) => {
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). */
_scheduleEmergencyStop() {
void (async () => {
try {
await this.forwarder.stop();
}
catch (err) {
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}
*/
class DeviceConnectionsFactory {
log;
/** Shared across factory instances so parallel sessions coordinate the same local ports. */
static _connectionsMapping = {};
/**
* @param log - Logger for this factory
*/
constructor(log) {
this.log = log;
}
/**
* 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 = null, port = null, strict = false) {
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` is used when `usePortForwarding` is true
* (eligible RemoteXPC sessions may use tunnel/usbmux forwarding; otherwise legacy 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, port, options = {}) {
if (!udid || !port) {
this._warnMissingRequestConnectionParams(udid, port);
return;
}
const { usePortForwarding, devicePort } = 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 (!(0, utils_1.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), options.remoteXPCFacade ?? null);
}
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 = null, port = null) {
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 ((0, utils_1.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}`);
}
_warnMissingRequestConnectionParams(udid, port) {
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');
}
}
async _ensureForwardingPortIsFree(port, connectionsOnPort) {
let isPortBusy = (await (0, portscanner_1.checkPortStatus)(port, LOCALHOST)) === 'open';
if (isPortBusy) {
this.log.warn(`Port #${port} is busy. Did you quit the previous driver session(s) properly?`);
if (!(0, utils_1.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 support_1.timing.Timer().start();
try {
await (0, asyncbox_1.waitForCondition)(async () => {
try {
if ((await (0, portscanner_1.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.`);
}
}
async _startAndRegisterPortForwarder(currentKey, udid, port, devicePort, remoteXPCFacade) {
const portForwarder = await this._createPortForwarder(udid, port, devicePort, remoteXPCFacade);
try {
await portForwarder.start();
DeviceConnectionsFactory._connectionsMapping[currentKey] = { portForwarder };
}
catch (e) {
try {
await portForwarder.stop();
}
catch (e1) {
this.log.debug(e1);
}
throw e;
}
}
_udidAsToken(udid) {
return `${support_1.util.hasValue(udid) ? udid : ''}${SPLITTER}`;
}
_portAsToken(port) {
return `${SPLITTER}${support_1.util.hasValue(port) ? port : ''}`;
}
_toKey(udid = null, port = null) {
return `${support_1.util.hasValue(udid) ? udid : ''}${SPLITTER}${support_1.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).
*/
async _releaseProxiedConnections(connectionKeys) {
const jobs = connectionKeys
.map((key) => {
const forwarder = DeviceConnectionsFactory._connectionsMapping[key]?.portForwarder;
return forwarder ? { key, forwarder } : null;
})
.filter((job) => 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;
}));
}
async _createPortForwarder(udid, localPort, devicePort, remoteXPCFacade) {
if (!remoteXPCFacade?.eligible) {
return new LegacyPortForwarder(udid, localPort, devicePort, this.log);
}
try {
const forwarder = await remoteXPCFacade.createDevicePortForwarder(localPort, devicePort);
return new RemotexpcPortForwarder(forwarder, this.log, localPort, devicePort);
}
catch (err) {
if (!(0, remote_xpc_1.isRemoteXPCUnavailableError)(err)) {
throw err;
}
this.log.debug('RemoteXPC port forwarding is not available. Using appium-ios-device port forwarding fallback.');
return new LegacyPortForwarder(udid, localPort, devicePort, this.log);
}
}
}
exports.DeviceConnectionsFactory = DeviceConnectionsFactory;
function dispatchProcessTermination() {
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) {
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);
}
}
};
}
//# sourceMappingURL=device-connections-factory.js.map