UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

344 lines (329 loc) 11.9 kB
import _ from 'lodash'; import B, {TimeoutError} from 'bluebird'; import {fs, tempDir, mkdirp, zip, util, timing} from 'appium/support'; import path from 'path'; import log from './logger'; export const IO_TIMEOUT_MS = 4 * 60 * 1000; // Mobile devices use NAND memory modules for the storage, // and the parallelism there is not as performant as on regular SSDs const MAX_IO_CHUNK_SIZE = 8; /** * Retrieve a file from a real device * * @param {any} afcService Apple File Client service instance from * 'appium-ios-device' module * @param {string} remotePath Relative path to the file on the device * @returns {Promise<Buffer>} The file content as a buffer */ export async function pullFile(afcService, remotePath) { const stream = await afcService.createReadStream(remotePath, {autoDestroy: true}); const pullPromise = new B((resolve, reject) => { stream.on('close', resolve); stream.on('error', reject); }).timeout(IO_TIMEOUT_MS); const buffers = []; stream.on('data', (data) => buffers.push(data)); await pullPromise; return Buffer.concat(buffers); } /** * Checks a presence of a local folder. * * @param {string} folderPath Full path to the local folder * @returns {Promise<boolean>} True if the folder exists and is actually a folder */ async function folderExists(folderPath) { try { return (await fs.stat(folderPath)).isDirectory(); } catch { return false; } } /** * Retrieve a folder from a real device * * @param {any} afcService Apple File Client service instance from * 'appium-ios-device' module * @param {string} remoteRootPath Relative path to the folder on the device * @returns {Promise<Buffer>} The folder content as a zipped base64-encoded buffer */ export async function pullFolder(afcService, remoteRootPath) { const tmpFolder = await tempDir.openDir(); try { let localTopItem = null; let countFilesSuccess = 0; let countFilesFail = 0; let countFolders = 0; const pullPromises = []; await afcService.walkDir(remoteRootPath, true, async (remotePath, isDir) => { const localPath = path.join(tmpFolder, remotePath); const dirname = isDir ? localPath : path.dirname(localPath); if (!(await folderExists(dirname))) { await mkdirp(dirname); } if (!localTopItem || localPath.split(path.sep).length < localTopItem.split(path.sep).length) { localTopItem = localPath; } if (isDir) { ++countFolders; return; } const readStream = await afcService.createReadStream(remotePath, {autoDestroy: true}); const writeStream = fs.createWriteStream(localPath, {autoClose: true}); pullPromises.push( new B((resolve) => { writeStream.on('close', () => { ++countFilesSuccess; resolve(); }); const onStreamingError = (e) => { readStream.unpipe(writeStream); log.warn( `Cannot pull '${remotePath}' to '${localPath}'. ` + `The file will be skipped. Original error: ${e.message}`, ); ++countFilesFail; resolve(); }; writeStream.on('error', onStreamingError); readStream.on('error', onStreamingError); }).timeout(IO_TIMEOUT_MS), ); readStream.pipe(writeStream); if (pullPromises.length >= MAX_IO_CHUNK_SIZE) { await B.any(pullPromises); } _.remove(pullPromises, (p) => p.isFulfilled()); }); // Wait for the rest of files to be pulled if (!_.isEmpty(pullPromises)) { await B.all(pullPromises); } log.info( `Pulled ${util.pluralize('file', countFilesSuccess, true)} out of ` + `${countFilesSuccess + countFilesFail} and ${util.pluralize( 'folder', countFolders, true, )} ` + `from '${remoteRootPath}'`, ); return await zip.toInMemoryZip(localTopItem ? path.dirname(localTopItem) : tmpFolder, { encodeToBase64: true, }); } finally { await fs.rimraf(tmpFolder); } } /** * Creates remote folder path recursively. Noop if the given path * already exists * * @param {any} afcService Apple File Client service instance from * 'appium-ios-device' module * @param {string} remoteRoot The relative path to the remote folder structure * to be created */ async function remoteMkdirp(afcService, remoteRoot) { if (remoteRoot === '.' || remoteRoot === '/') { return; } try { await afcService.listDirectory(remoteRoot); return; } catch { // This means that the directory is missing and we got an object not found error. // Therefore, we are going to the parent await remoteMkdirp(afcService, path.dirname(remoteRoot)); } await afcService.createDirectory(remoteRoot); } /** * @typedef {Object} PushFileOptions * @property {number} [timeoutMs=240000] The maximum count of milliceconds to wait until * file push is completed. Cannot be lower than 60000ms */ /** * Pushes a file to a real device * * @param {any} afcService afcService Apple File Client service instance from * 'appium-ios-device' module * @param {string|Buffer} localPathOrPayload Either full path to the source file * or a buffer payload to be written into the remote destination * @param {string} remotePath Relative path to the file on the device. The remote * folder structure is created automatically if necessary. * @param {PushFileOptions} [opts={}] */ export async function pushFile (afcService, localPathOrPayload, remotePath, opts = {}) { const { timeoutMs = IO_TIMEOUT_MS, } = opts; const timer = new timing.Timer().start(); await remoteMkdirp(afcService, path.dirname(remotePath)); const source = Buffer.isBuffer(localPathOrPayload) ? localPathOrPayload : fs.createReadStream(localPathOrPayload, {autoClose: true}); const writeStream = await afcService.createWriteStream(remotePath, { autoDestroy: true, }); writeStream.on('finish', writeStream.destroy); let pushError = null; const filePushPromise = new B((resolve, reject) => { writeStream.on('close', () => { if (pushError) { reject(pushError); } else { resolve(); } }); const onStreamError = (e) => { if (!Buffer.isBuffer(source)) { source.unpipe(writeStream); } log.debug(e); pushError = e; }; writeStream.on('error', onStreamError); if (!Buffer.isBuffer(source)) { source.on('error', onStreamError); } }); if (Buffer.isBuffer(source)) { writeStream.write(source); writeStream.end(); } else { source.pipe(writeStream); } await filePushPromise.timeout(Math.max(timeoutMs, 60000)); const fileSize = Buffer.isBuffer(localPathOrPayload) ? localPathOrPayload.length : (await fs.stat(localPathOrPayload)).size; log.debug( `Successfully pushed the file payload (${util.toReadableSizeString(fileSize)}) ` + `to the remote location '${remotePath}' in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` ); }; /** * @typedef {Object} PushFolderOptions * * @property {number} [timeoutMs=240000] The maximum timeout to wait until a * single file is copied * @property {boolean} [enableParallelPush=false] Whether to push files in parallel. * This usually gives better performance, but might sometimes be less stable. */ /** * Pushes a folder to a real device * * @param {any} afcService Apple File Client service instance from * 'appium-ios-device' module * @param {string} srcRootPath The full path to the source folder * @param {string} dstRootPath The relative path to the destination folder. The folder * will be deleted if already exists. * @param {PushFolderOptions} opts */ export async function pushFolder(afcService, srcRootPath, dstRootPath, opts = {}) { const {timeoutMs = IO_TIMEOUT_MS, enableParallelPush = false} = opts; const timer = new timing.Timer().start(); const allItems = /** @type {import('path-scurry').Path[]} */ (/** @type {unknown} */ ( await fs.glob('**', { cwd: srcRootPath, withFileTypes: true, })) ); log.debug(`Successfully scanned the tree structure of '${srcRootPath}'`); // top-level folders go first /** @type {string[]} */ const foldersToPush = allItems .filter((x) => x.isDirectory()) .map((x) => x.relative()) .sort((a, b) => a.split(path.sep).length - b.split(path.sep).length); // larger files go first /** @type {string[]} */ const filesToPush = allItems .filter((x) => !x.isDirectory()) .sort((a, b) => (b.size ?? 0) - (a.size ?? 0)) .map((x) => x.relative()); log.debug( `Got ${util.pluralize('folder', foldersToPush.length, true)} and ` + `${util.pluralize('file', filesToPush.length, true)} to push`, ); // create the folder structure first try { await afcService.deleteDirectory(dstRootPath); } catch {} await afcService.createDirectory(dstRootPath); for (const relativeFolderPath of foldersToPush) { // createDirectory does not accept folder names ending with a path separator const absoluteFolderPath = _.trimEnd(path.join(dstRootPath, relativeFolderPath), path.sep); if (absoluteFolderPath) { await afcService.createDirectory(absoluteFolderPath); } } // do not forget about the root folder log.debug( `Successfully created the remote folder structure ` + `(${util.pluralize('item', foldersToPush.length + 1, true)})`, ); const _pushFile = async (/** @type {string} */ relativePath) => { const absoluteSourcePath = path.join(srcRootPath, relativePath); const readStream = fs.createReadStream(absoluteSourcePath, {autoClose: true}); const absoluteDestinationPath = path.join(dstRootPath, relativePath); const writeStream = await afcService.createWriteStream(absoluteDestinationPath, { autoDestroy: true, }); writeStream.on('finish', writeStream.destroy); let pushError = null; const filePushPromise = new B((resolve, reject) => { writeStream.on('close', () => { if (pushError) { reject(pushError); } else { resolve(); } }); const onStreamError = (e) => { readStream.unpipe(writeStream); log.debug(e); pushError = e; }; writeStream.on('error', onStreamError); readStream.on('error', onStreamError); }); readStream.pipe(writeStream); await filePushPromise.timeout(Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000)); }; if (enableParallelPush) { log.debug(`Proceeding to parallel files push (max ${MAX_IO_CHUNK_SIZE} writers)`); const pushPromises = []; for (const relativeFilePath of filesToPush) { pushPromises.push(B.resolve(_pushFile(relativeFilePath))); // keep the push queue filled if (pushPromises.length >= MAX_IO_CHUNK_SIZE) { await B.any(pushPromises); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new TimeoutError(`Timed out after ${elapsedMs} ms`); } } _.remove(pushPromises, (p) => p.isFulfilled()); } if (!_.isEmpty(pushPromises)) { // handle the rest of push promises await B.all(pushPromises).timeout(Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000)); } } else { log.debug(`Proceeding to serial files push`); for (const relativeFilePath of filesToPush) { await _pushFile(relativeFilePath); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new TimeoutError(`Timed out after ${elapsedMs} ms`); } } } log.debug( `Successfully pushed ${util.pluralize('folder', foldersToPush.length, true)} ` + `and ${util.pluralize('file', filesToPush.length, true)} ` + `within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`, ); }