UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

450 lines 18.4 kB
"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