appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
450 lines • 18.4 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.AfcClient = void 0;
const node_stream_1 = require("node:stream");
const promises_1 = require("node:stream/promises");
const node_path_1 = __importDefault(require("node:path"));
const support_1 = require("appium/support");
const appium_ios_device_1 = require("appium-ios-device");
const remotexpc_utils_1 = require("./remotexpc-utils");
const logger_1 = require("../logger");
const real_device_management_1 = require("./real-device-management");
const utils_1 = require("../utils");
/**
* Unified AFC Client
*
* Provides a unified interface for file operations on iOS devices,
* automatically handling the differences between iOS < 18 (appium-ios-device)
* and iOS 18 and above (appium-ios-remotexpc).
*/
class AfcClient {
service;
_isRemoteXPC;
constructor(service, isRemoteXPC = false) {
this.service = service;
this._isRemoteXPC = isRemoteXPC;
}
/**
* Check if this client is using RemoteXPC
*/
get isRemoteXPC() {
return this._isRemoteXPC;
}
/**
* Get service as RemoteXPC AFC service
*/
get remoteXPCAfcService() {
return this.service;
}
/**
* Get service as iOS Device AFC service
*/
get iosDeviceAfcService() {
return this.service;
}
/**
* Create an AFC client for device
*
* @param udid - Device UDID
* @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
* @returns AFC client instance
*/
static async createForDevice(udid, useRemoteXPC) {
if (useRemoteXPC) {
// Best-practice pattern (matches go-ios `defer rsd.Close()` and
// pymobiledevice3 single-RSD-per-session): perform exactly one RSD
// probe via `startAfcService`, which discovers the AFC port, closes
// its discovery RSD eagerly, and returns a self-contained AfcService
// bound to its own per-service TCP socket. Opening an extra RSD here
// would only race with the one inside `startAfcService` and trigger
// an ECONNRESET from the on-device `remoted` daemon.
try {
const Services = await (0, remotexpc_utils_1.getRemoteXPCServices)();
const afcService = await Services.startAfcService(udid);
return new AfcClient(afcService, true);
}
catch (err) {
logger_1.log.error(`Failed to create AFC client via RemoteXPC: ${err.message}, falling back to appium-ios-device`);
}
}
const afcService = await appium_ios_device_1.services.startAfcService(udid);
return new AfcClient(afcService);
}
/**
* Create an AFC client for app container access
*
* @param udid - Device UDID
* @param bundleId - App bundle identifier
* @param useRemoteXPC - Whether to use remotexpc (use isIos18OrNewer(opts) to determine)
* @param options - Optional configuration for container access
* @returns AFC client instance
*/
static async createForApp(udid, bundleId, useRemoteXPC, options) {
const { containerType = null, skipDocumentsCheck = false } = options ?? {};
const isDocuments = !skipDocumentsCheck && containerType?.toLowerCase() === 'documents';
if (useRemoteXPC) {
// Best-practice pattern (matches go-ios `defer rsd.Close()`): one RSD
// probe via `startHouseArrestService`, vend the AFC service, then
// release the discovery RSD eagerly. The vended AfcService has its
// own dedicated socket so it does not need the discovery RSD to
// remain open. Avoids the prior pattern of overlapping RSD probes
// that triggered ECONNRESETs from the on-device `remoted` daemon.
let houseArrestRemoteXPC;
try {
const Services = await (0, remotexpc_utils_1.getRemoteXPCServices)();
const result = await Services.startHouseArrestService(udid);
houseArrestRemoteXPC = result.remoteXPC;
const afcService = isDocuments
? await result.houseArrestService.vendDocuments(bundleId)
: await result.houseArrestService.vendContainer(bundleId);
return new AfcClient(afcService, true);
}
catch (err) {
logger_1.log.error(`Failed to create AFC client via RemoteXPC: ${err.message}, falling back to appium-ios-device`);
}
finally {
if (houseArrestRemoteXPC) {
try {
await houseArrestRemoteXPC.close();
}
catch {
// ignore cleanup errors
}
}
}
}
const houseArrestService = await appium_ios_device_1.services.startHouseArrestService(udid);
const afcService = isDocuments
? await houseArrestService.vendDocuments(bundleId)
: await houseArrestService.vendContainer(bundleId);
return new AfcClient(afcService);
}
/**
* Check if a path is a directory
*/
async isDirectory(path) {
if (this.isRemoteXPC) {
return await this.remoteXPCAfcService.isdir(path);
}
const fileInfo = await this.iosDeviceAfcService.getFileInfo(path);
return fileInfo.isDirectory();
}
/**
* List directory contents
*/
async listDirectory(path) {
if (this.isRemoteXPC) {
return await this.remoteXPCAfcService.listdir(path);
}
return await this.iosDeviceAfcService.listDirectory(path);
}
/**
* Create a directory
*/
async createDirectory(path) {
if (this.isRemoteXPC) {
await this.remoteXPCAfcService.mkdir(path);
}
else {
await this.iosDeviceAfcService.createDirectory(path);
}
}
/**
* Delete a directory or file
*/
async deleteDirectory(path) {
if (this.isRemoteXPC) {
await this.remoteXPCAfcService.rm(path, true);
}
else {
await this.iosDeviceAfcService.deleteDirectory(path);
}
}
/**
* Get file contents as a buffer
*/
async getFileContents(path) {
if (this.isRemoteXPC) {
return await this.remoteXPCAfcService.getFileContents(path);
}
// For ios-device, use stream-based approach
const stream = await this.iosDeviceAfcService.createReadStream(path, {
autoDestroy: true,
});
const buffers = [];
return new Promise((resolve, reject) => {
stream.on('data', (data) => buffers.push(data));
stream.on('end', () => resolve(Buffer.concat(buffers)));
stream.on('error', reject);
});
}
/**
* Set file contents from a buffer
*/
async setFileContents(path, data) {
if (this.isRemoteXPC) {
await this.remoteXPCAfcService.setFileContents(path, data);
return;
}
// For ios-device, convert buffer to stream and use writeFromStream
const bufferStream = node_stream_1.Readable.from([data]);
return await this.writeFromStream(path, bufferStream);
}
/**
* Write file contents from a readable stream
*/
async writeFromStream(path, stream) {
if (this.isRemoteXPC) {
await this.remoteXPCAfcService.writeFromStream(path, stream);
return;
}
const writeStream = await this.iosDeviceAfcService.createWriteStream(path, {
autoDestroy: true,
});
writeStream.on('finish', () => {
if (typeof writeStream.destroy === 'function') {
writeStream.destroy();
}
});
return new Promise((resolve, reject) => {
writeStream.on('close', resolve);
const onError = (e) => {
stream.unpipe(writeStream);
reject(e);
};
writeStream.on('error', onError);
stream.on('error', onError);
stream.pipe(writeStream);
});
}
/**
* Pull files/folders from device to local filesystem.
* Uses the appropriate mechanism (walkDir for ios-device, pull for remotexpc).
*
* @param remotePath - Remote path on the device (file or directory)
* @param localPath - Local destination path
* @param options - Pull options (recursive, overwrite, onEntry)
*/
async pull(remotePath, localPath, options = {}) {
if (this.isRemoteXPC) {
// RemoteXPC expects 'callback' property, so map onEntry -> callback
const remoteXpcOptions = {
...options,
callback: options.onEntry,
};
delete remoteXpcOptions.onEntry;
await this.remoteXPCAfcService.pull(remotePath, localPath, remoteXpcOptions);
}
else {
await this.pullWithWalkDir(remotePath, localPath, options);
}
}
/**
* Close the AFC service connection
*/
async close() {
this.service.close();
}
/**
* Create a read stream for a file (internal use only).
*/
async createReadStream(remotePath, options) {
if (this.isRemoteXPC) {
// Use readToStream which returns a streaming Readable
return await this.remoteXPCAfcService.readToStream(remotePath);
}
return await this.iosDeviceAfcService.createReadStream(remotePath, options);
}
/**
* Internal implementation of pull for ios-device using walkDir.
* Walks the remote directory tree and pulls files to local filesystem.
*/
async pullWithWalkDir(remotePath, localPath, options) {
const { recursive = false, overwrite = true, onEntry } = options;
if (!(await this.isDirectory(remotePath))) {
await this.pullWalkDirSingleFile(remotePath, localPath, overwrite, onEntry);
return;
}
if (!recursive) {
throw new Error(`Cannot pull directory '${remotePath}' without recursive option. Set recursive: true to pull directories.`);
}
const localRootDir = await this.prepareWalkPullDirectoryRoot(remotePath, localPath, onEntry);
const activePulls = [];
const pullRejections = [];
const waitForPullSlot = this.createBoundedPullSlotWaiter(activePulls);
const ctx = {
remoteTreeRoot: remotePath,
localRootDir,
overwrite,
onEntry,
activePulls,
pullRejections,
waitForPullSlot,
};
await this.iosDeviceAfcService.walkDir(remotePath, true, async (entryPath, isDirectory) => await this.processWalkDirPullEntry(ctx, entryPath, isDirectory));
// Rejects still in `activePulls` surface via `Promise.all`. Pulls already spliced out after a
// failure are not in that array; their reasons were pushed once in `pull.catch` and surface here.
if (activePulls.length > 0) {
await Promise.all(activePulls);
}
if (pullRejections.length > 0) {
const [first, ...rest] = pullRejections;
throw rest.length === 0
? first
: new AggregateError(pullRejections, `${support_1.util.pluralize('pull', pullRejections.length, true)} failed`);
}
}
/** Pull a single remote file when walkDir target is not a directory. */
async pullWalkDirSingleFile(remotePath, localPath, overwrite, onEntry) {
const localFilePath = (await this.isLocalDirectory(localPath))
? node_path_1.default.join(localPath, node_path_1.default.posix.basename(remotePath))
: localPath;
await this.checkOverwrite(localFilePath, overwrite);
await this.pullSingleFile(remotePath, localFilePath);
if (onEntry) {
await onEntry(remotePath, localFilePath, false);
}
}
/** Creates local root folder and notifies onEntry for the directory root. */
async prepareWalkPullDirectoryRoot(remotePath, localPath, onEntry) {
const localDstIsDirectory = await this.isLocalDirectory(localPath);
const localRootDir = localDstIsDirectory
? node_path_1.default.join(localPath, node_path_1.default.posix.basename(remotePath))
: localPath;
await support_1.fs.mkdirp(localRootDir);
if (onEntry) {
await onEntry(remotePath, localRootDir, true);
}
return localRootDir;
}
/**
* Returns a waiter that blocks until fewer than MAX_IO_CHUNK_SIZE pulls are in flight.
* Uses Promise.race over in-flight pull completions to free a slot.
*/
createBoundedPullSlotWaiter(activePulls) {
return async () => {
while (activePulls.length >= real_device_management_1.MAX_IO_CHUNK_SIZE) {
const indexed = [];
for (let i = 0; i < activePulls.length; i++) {
indexed.push(racePullCompletionIndex(activePulls[i], i));
}
const doneIndex = await Promise.race(indexed);
// The raced pull has already settled; removing it from the pool is bookkeeping only.
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- false positive: splice drops a completed task reference
activePulls.splice(doneIndex, 1);
}
};
}
async processWalkDirPullEntry(ctx, entryPath, isDirectory) {
const { remoteTreeRoot, localRootDir, overwrite, onEntry, activePulls, pullRejections, waitForPullSlot, } = ctx;
const relativePath = entryPath.startsWith(remoteTreeRoot + '/')
? entryPath.slice(remoteTreeRoot.length + 1)
: entryPath.slice(remoteTreeRoot.length);
const localEntryPath = node_path_1.default.join(localRootDir, relativePath);
if (isDirectory) {
await support_1.fs.mkdirp(localEntryPath);
if (onEntry) {
await onEntry(entryPath, localEntryPath, true);
}
return;
}
await this.checkOverwrite(localEntryPath, overwrite);
await support_1.fs.mkdirp(node_path_1.default.dirname(localEntryPath));
await waitForPullSlot();
const pull = this.pullRemoteFileToLocalViaStreams(entryPath, localEntryPath, onEntry);
activePulls.push(pull);
// eslint-disable-next-line promise/prefer-await-to-then
void pull.catch((reason) => {
pullRejections.push(reason);
});
}
/**
* Pull one remote file to a local path using streams (ios-device AFC only).
* Resolves when the write stream closes or when streaming is skipped after an error.
*/
async pullRemoteFileToLocalViaStreams(entryPath, localEntryPath, onEntry) {
const readStream = await this.iosDeviceAfcService.createReadStream(entryPath, {
autoDestroy: true,
});
const writeStream = support_1.fs.createWriteStream(localEntryPath, { autoClose: true });
await (0, utils_1.withTimeout)(new Promise((resolve) => {
writeStream.on('close', async () => {
if (onEntry) {
try {
await onEntry(entryPath, localEntryPath, false);
}
catch (err) {
logger_1.log.warn(`onEntry callback failed for '${entryPath}': ${err.message}`);
}
}
resolve();
});
const onStreamingError = (e) => {
readStream.unpipe(writeStream);
logger_1.log.warn(`Cannot pull '${entryPath}' to '${localEntryPath}'. ` +
`The file will be skipped. Original error: ${e.message}`);
resolve();
};
writeStream.on('error', onStreamingError);
readStream.on('error', onStreamingError);
readStream.pipe(writeStream);
}), real_device_management_1.IO_TIMEOUT_MS);
}
/**
* Check if local file exists and should not be overwritten.
* Throws an error if the file exists and overwrite is false.
*
* @param localPath - Local file path to check
* @param overwrite - Whether to allow overwriting existing files
*/
async checkOverwrite(localPath, overwrite) {
if (!overwrite && (await support_1.fs.exists(localPath))) {
throw new Error(`Local file already exists: ${localPath}`);
}
}
/**
* Pull a single file from device to local filesystem using streams.
* This method only works for ios-device.
*
* @param remotePath - Remote file path
* @param localPath - Local destination path
*/
async pullSingleFile(remotePath, localPath) {
const readStream = await this.iosDeviceAfcService.createReadStream(remotePath, {
autoDestroy: true,
});
const writeStream = support_1.fs.createWriteStream(localPath, { autoClose: true });
await (0, promises_1.pipeline)(readStream, writeStream);
}
/**
* Check if a local path exists and is a directory.
*/
async isLocalDirectory(localPath) {
try {
const stats = await support_1.fs.stat(localPath);
return stats.isDirectory();
}
catch {
return false;
}
}
}
exports.AfcClient = AfcClient;
/**
* Resolves with slot index `i` when pull `p` settles. Each pull records at most one rejection via a
* per-pull `.catch` when enqueued; this helper only swallows errors so the slot waiter can splice
* `activePulls` without throwing.
*/
async function racePullCompletionIndex(p, i) {
try {
await p;
}
catch {
// Failed pull still frees a concurrency slot; rejection is already handled on `p`.
}
return i;
}
//# sourceMappingURL=afc-client.js.map