UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

492 lines (464 loc) 20.7 kB
import _ from 'lodash'; import {fs, tempDir, mkdirp, zip, util} from 'appium/support'; import path from 'path'; import {services} from 'appium-ios-device'; import { pullFile as realDevicePullFile, pullFolder as realDevicePullFolder, pushFile as realDevicePushFile, } from '../device/real-device-management'; import {errors} from 'appium/driver'; import type {Simulator} from 'appium-ios-simulator'; import type {XCUITestDriver} from '../driver'; import type {ContainerObject, ContainerRootSupplier} from './types'; const CONTAINER_PATH_MARKER = '@'; // https://regex101.com/r/PLdB0G/2 const CONTAINER_PATH_PATTERN = new RegExp(`^${CONTAINER_PATH_MARKER}([^/]+)/(.*)`); const CONTAINER_TYPE_SEPARATOR = ':'; const CONTAINER_DOCUMENTS_PATH = 'Documents'; const OBJECT_NOT_FOUND_ERROR_MESSAGE = 'OBJECT_NOT_FOUND'; /** * Parses the actual path and the bundle identifier from the given path string. * * @param remotePath - Path string matching `CONTAINER_PATH_PATTERN`, e.g. `@bundle.id:container/relative/path` * @param containerRootSupplier - Container root path supplier or explicit root */ export async function parseContainerPath( this: XCUITestDriver, remotePath: string, containerRootSupplier?: ContainerRootSupplier | string, ): Promise<ContainerObject> { const match = CONTAINER_PATH_PATTERN.exec(remotePath); if (!match) { throw new Error( `It is expected that package identifier ` + `starts with '${CONTAINER_PATH_MARKER}' and is separated from the ` + `relative path with a single slash. '${remotePath}' is given instead`, ); } const [, bundleIdMatch, relativePath] = match; let bundleId = bundleIdMatch; let containerType: string | null = null; const typeSeparatorPos = bundleId.indexOf(CONTAINER_TYPE_SEPARATOR); // We only consider container type exists if its length is greater than zero // not counting the colon if (typeSeparatorPos > 0 && typeSeparatorPos < bundleId.length - 1) { containerType = bundleId.substring(typeSeparatorPos + 1); this.log.debug(`Parsed container type: ${containerType}`); bundleId = bundleId.substring(0, typeSeparatorPos); } if (_.isNil(containerRootSupplier)) { const pathInContainer = relativePath; return {bundleId, pathInContainer, containerType}; } const containerRoot = _.isFunction(containerRootSupplier) ? await containerRootSupplier(bundleId, containerType) : containerRootSupplier; const pathInContainer = path.posix.resolve(containerRoot, relativePath); verifyIsSubPath(pathInContainer, containerRoot); return {bundleId, pathInContainer, containerType}; } /** * Pushes the given data to a file on the remote device. * * @param remotePath The full path to the remote file or * a file inside a package bundle. Check the documentation on * `pushFileToRealDevice` and `pushFileToSimulator` for more information * on acceptable values. * @param base64Data Base64 encoded data to be written to the * remote file. The remote file will be silently overridden if it already exists. * @throws {Error} If there was an error while pushing the data */ export async function pushFile( this: XCUITestDriver, remotePath: string, base64Data: string | number[] | Buffer, ): Promise<void> { if (remotePath.endsWith('/')) { throw new errors.InvalidArgumentError( `It is expected that remote path points to a file and not to a folder. ` + `'${remotePath}' is given instead`, ); } let b64StringData: string; if (_.isArray(base64Data)) { // some clients (ahem) java, send a byte array encoding utf8 characters // instead of a string, which would be infinitely better! b64StringData = Buffer.from(base64Data).toString('utf8'); } else if (Buffer.isBuffer(base64Data)) { b64StringData = base64Data.toString('utf8'); } else { b64StringData = base64Data as string; } return this.isSimulator() ? await pushFileToSimulator.bind(this)(remotePath, b64StringData) : await pushFileToRealDevice.bind(this)(remotePath, b64StringData); } /** * Pushes the given data to a file on the remote device. * * @param remotePath - The full path to the remote file * or a specially formatted path, which points to an item inside an app bundle. * @param payload - Base64-encoded content of the file to be pushed. */ export async function mobilePushFile( this: XCUITestDriver, remotePath: string, payload: string, ): Promise<void> { return await this.pushFile(remotePath, payload); } /** * Pulls a remote file from the device. * * @param remotePath The full path to the remote file * or a specially formatted path, which points to an item inside app bundle. * See the documentation for `pullFromRealDevice` and `pullFromSimulator` * to get more information on acceptable values. * @returns Base64 encoded content of the pulled file * @throws {Error} If the pull operation failed */ export async function pullFile(this: XCUITestDriver, remotePath: string): Promise<string> { if (remotePath.endsWith('/')) { throw new errors.InvalidArgumentError( `It is expected that remote path points to a file and not to a folder. ` + `'${remotePath}' is given instead`, ); } return this.isSimulator() ? await pullFromSimulator.bind(this)(remotePath, true) : await pullFromRealDevice.bind(this)(remotePath, true); } /** * Pulls a remote file from the device. * * @param remotePath - The full path to the remote file * or a specially formatted path, which points to an item inside app bundle. See the documentation for `pullFromRealDevice` and `pullFromSimulator` to get more information on acceptable values. * @returns The same as in `pullFile` */ export async function mobilePullFile(this: XCUITestDriver, remotePath: string): Promise<string> { return await this.pullFile(remotePath); } /** * Delete a remote folder from the device. * * @param remotePath - The full path to the remote folder or a specially formatted path, which points to an item inside app bundle. See the documentation for `pullFromRealDevice` and `pullFromSimulator` to get more information on acceptable values. * @returns Nothing */ export async function mobileDeleteFolder(this: XCUITestDriver, remotePath: string): Promise<void> { if (!remotePath.endsWith('/')) { remotePath = `${remotePath}/`; } await deleteFileOrFolder.bind(this)(remotePath); } /** * Delete a remote file from the device. * * @param remotePath - The full path to the remote file or a specially formatted path, which points to an item inside app bundle. See the documentation for `pullFromRealDevice` and `pullFromSimulator` to get more information on acceptable values. * @returns Nothing */ export async function mobileDeleteFile(this: XCUITestDriver, remotePath: string): Promise<void> { if (remotePath.endsWith('/')) { throw new errors.InvalidArgumentError( `It is expected that remote path points to a file and not to a folder. ` + `'${remotePath}' is given instead`, ); } await deleteFileOrFolder.bind(this)(remotePath); } /** * Pulls the whole folder from the remote device * * @param remotePath The full path to a folder on the * remote device or a folder inside an application bundle * @returns Zipped and base64-encoded content of the folder * @throws {Error} If there was a failure while getting the folder content */ export async function pullFolder(this: XCUITestDriver, remotePath: string): Promise<string> { if (!remotePath.endsWith('/')) { remotePath = `${remotePath}/`; } return this.isSimulator() ? await pullFromSimulator.bind(this)(remotePath, false) : await pullFromRealDevice.bind(this)(remotePath, false); } /** * Pulls the whole folder from the device under test. * * @param remotePath - The full path to the remote folder * @returns The same as `pullFolder` */ export async function mobilePullFolder(this: XCUITestDriver, remotePath: string): Promise<string> { return await this.pullFolder(remotePath); } function verifyIsSubPath(originalPath: string, root: string): void { const normalizedRoot = path.normalize(root); const normalizedPath = path.normalize(path.dirname(originalPath)); // If originalPath is root, `/`, originalPath should equal to normalizedRoot if (normalizedRoot !== originalPath && !normalizedPath.startsWith(normalizedRoot)) { throw new Error(`'${normalizedPath}' is expected to be a subpath of '${normalizedRoot}'`); } } async function createAfcClient( this: XCUITestDriver, bundleId?: string | null, containerType?: string | null, ): Promise<any> { const udid = this.device.udid as string; if (!bundleId) { return await services.startAfcService(udid); } const service = await services.startHouseArrestService(udid); const { skipDocumentsContainerCheck = false, } = await this.settings.getSettings(); if (skipDocumentsContainerCheck) { return service.vendContainer(bundleId); } return isDocumentsContainer(containerType) ? await service.vendDocuments(bundleId) : await service.vendContainer(bundleId); } function isDocumentsContainer(containerType?: string | null): boolean { return _.toLower(containerType ?? '') === _.toLower(CONTAINER_DOCUMENTS_PATH); } async function createService( this: XCUITestDriver, remotePath: string, ): Promise<{service: any; relativePath: string}> { if (CONTAINER_PATH_PATTERN.test(remotePath)) { const {bundleId, pathInContainer, containerType} = await parseContainerPath.bind(this)(remotePath); const service = await createAfcClient.bind(this)(bundleId, containerType); const relativePath = isDocumentsContainer(containerType) ? path.join(CONTAINER_DOCUMENTS_PATH, pathInContainer) : pathInContainer; return {service, relativePath}; } else { const service = await createAfcClient.bind(this)(); return {service, relativePath: remotePath}; } } /** * Save the given base64 data chunk as a binary file on the Simulator under test. * * @param remotePath - Remote path on the simulator. Supports bundle-id-prefixed format * (e.g. `@com.myapp.bla:data/path/in/container/file.png`) to target * application containers; otherwise uploads to the default media folder. * @param base64Data - Base-64 encoded content of the file to be uploaded. */ async function pushFileToSimulator( this: XCUITestDriver, remotePath: string, base64Data: string, ): Promise<void> { const buffer = Buffer.from(base64Data, 'base64'); const device = this.device as Simulator; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const {bundleId, pathInContainer: dstPath} = await parseContainerPath.bind(this)( remotePath, async (appBundle, containerType) => await device.simctl.getAppContainer(appBundle, containerType), ); this.log.info( `Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `Will put the data into '${dstPath}'`, ); if (!(await fs.exists(path.dirname(dstPath)))) { this.log.debug(`The destination folder '${path.dirname(dstPath)}' does not exist. Creating...`); await mkdirp(path.dirname(dstPath)); } await fs.writeFile(dstPath, buffer); return; } const dstFolder = await tempDir.openDir(); const dstPath = path.resolve(dstFolder, path.basename(remotePath)); try { await fs.writeFile(dstPath, buffer); await device.simctl.addMedia(dstPath); } finally { await fs.rimraf(dstFolder); } } /** * Save the given base64 data chunk as a binary file on a real device. * * @param remotePath - Remote path on the device. Supports the same bundle-id-prefixed * format as simulator uploads (e.g. `@com.myapp.bla:documents/file.png`) * to target application containers; otherwise defaults to media folder. * @param base64Data - Base-64 encoded content of the file to be uploaded. */ async function pushFileToRealDevice( this: XCUITestDriver, remotePath: string, base64Data: string, ): Promise<void> { const {service, relativePath} = await createService.bind(this)(remotePath); try { await realDevicePushFile(service, Buffer.from(base64Data, 'base64'), relativePath); } catch (e) { this.log.debug(e.stack); throw new Error(`Could not push the file to '${remotePath}'. Original error: ${e.message}`); } finally { service.close(); } } async function deleteFileOrFolder(this: XCUITestDriver, remotePath: string): Promise<void> { return this.isSimulator() ? await deleteFromSimulator.bind(this)(remotePath) : await deleteFromRealDevice.bind(this)(remotePath); } /** * Get the content of given file or folder from iOS Simulator and return it as base-64 encoded string. * Folder content is recursively packed into a zip archive. * * @param remotePath - The path to a file or a folder, which exists in the corresponding application * container on Simulator. Use * `@<app_bundle_id>:<optional_container_type>/<path_to_the_file_or_folder_inside_container>` * format to pull a file or a folder from an application container of the given type. * Possible container types are `app`, `data`, `groups`, `<A specific App Group container>`. * The default type is `app`. * @param isFile - Whether the destination item is a file or a folder * @returns Base-64 encoded content of the file. */ async function pullFromSimulator( this: XCUITestDriver, remotePath: string, isFile: boolean, ): Promise<string> { let pathOnServer; const device = this.device as Simulator; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const {bundleId, pathInContainer: dstPath} = await parseContainerPath.bind(this)( remotePath, async (appBundle, containerType) => await device.simctl.getAppContainer(appBundle, containerType), ); this.log.info( `Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `Will get the data from '${dstPath}'`, ); pathOnServer = dstPath; } else { const simRoot = device.getDir(); pathOnServer = path.posix.join(simRoot, remotePath); verifyIsSubPath(pathOnServer, simRoot); this.log.info(`Got the full item path: ${pathOnServer}`); } if (!(await fs.exists(pathOnServer))) { throw this.log.errorWithException( `The remote ${isFile ? 'file' : 'folder'} at '${pathOnServer}' does not exist`, ); } const buffer = isFile ? await util.toInMemoryBase64(pathOnServer) : await zip.toInMemoryZip(pathOnServer, {encodeToBase64: true}); return buffer.toString(); } /** * Get the content of given file or folder from the real device under test and return it as base-64 encoded string. * Folder content is recursively packed into a zip archive. * * @param remotePath - The path to an existing remote file on the device. This variable can be prefixed with * bundle id, so then the file will be downloaded from the corresponding * application container instead of the default media folder. Use * `@<app_bundle_id>:<optional_container_type>/<path_to_the_file_or_folder_inside_container>` * format to pull a file or a folder from an application container of the given type. * The only supported container type is 'documents'. If the container type is not set * explicitly for a bundle id, then the default application container is going to be mounted * (aka --container ifuse argument) * e.g. If `@com.myapp.bla:documents/111.png` is provided, * `On My iPhone/<app name>` in Files app will be mounted in the host machine. * `On My iPhone/<app name>/111.png` will be pulled into the mounted host machine * and Appium returns the data as base64-encoded string to client. * `@com.myapp.bla:documents/` means `On My iPhone/<app name>`. * @param isFile - Whether the destination item is a file or a folder * @returns Base-64 encoded content of the remote file */ async function pullFromRealDevice( this: XCUITestDriver, remotePath: string, isFile: boolean, ): Promise<string> { const {service, relativePath} = await createService.bind(this)(remotePath); try { const fileInfo = await service.getFileInfo(relativePath); if (isFile && fileInfo.isDirectory()) { throw new Error(`The requested path is not a file. Path: '${remotePath}'`); } if (!isFile && !fileInfo.isDirectory()) { throw new Error(`The requested path is not a folder. Path: '${remotePath}'`); } return fileInfo.isFile() ? (await realDevicePullFile(service, relativePath)).toString('base64') : (await realDevicePullFolder(service, relativePath)).toString(); } finally { service.close(); } } /** * Remove the file or folder from the device * * @param remotePath - The path to a file or a folder, which exists in the corresponding application * container on Simulator. Use * `@<app_bundle_id>:<optional_container_type>/<path_to_the_file_or_folder_inside_container>` * format to pull a file or a folder from an application container of the given type. * Possible container types are 'app', 'data', 'groups', '<A specific App Group container>'. * The default type is 'app'. * @returns Nothing */ async function deleteFromSimulator(this: XCUITestDriver, remotePath: string): Promise<void> { let pathOnServer: string; const device = this.device as Simulator; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const {bundleId, pathInContainer: dstPath} = await parseContainerPath.bind(this)( remotePath, async (appBundle, containerType) => await device.simctl.getAppContainer(appBundle, containerType), ); this.log.info( `Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `'${dstPath}' will be deleted`, ); pathOnServer = dstPath; } else { const simRoot = device.getDir(); pathOnServer = path.posix.join(simRoot, remotePath); verifyIsSubPath(pathOnServer, simRoot); this.log.info(`Got the full path: ${pathOnServer}`); } if (!(await fs.exists(pathOnServer))) { throw new errors.InvalidArgumentError(`The remote path at '${pathOnServer}' does not exist`); } await fs.rimraf(pathOnServer); } /** * Remove the file or folder from the device * * @param remotePath - The path to an existing remote file on the device. This variable can be prefixed with * bundle id, so then the file will be downloaded from the corresponding * application container instead of the default media folder. Use * `@<app_bundle_id>:<optional_container_type>/<path_to_the_file_or_folder_inside_container>` * format to pull a file or a folder from an application container of the given type. * The only supported container type is 'documents'. If the container type is not set * explicitly for a bundle id, then the default application container is going to be mounted * (aka --container ifuse argument) * e.g. If `@com.myapp.bla:documents/111.png` is provided, * `On My iPhone/<app name>` in Files app will be mounted in the host machine. * `On My iPhone/<app name>/111.png` will be pulled into the mounted host machine * and Appium returns the data as base64-encoded string to client. * `@com.myapp.bla:documents/` means `On My iPhone/<app name>`. * @returns Nothing */ async function deleteFromRealDevice(this: XCUITestDriver, remotePath: string): Promise<void> { const {service, relativePath} = await createService.bind(this)(remotePath); try { await service.deleteDirectory(relativePath); } catch (e) { if (e.message.includes(OBJECT_NOT_FOUND_ERROR_MESSAGE)) { throw new Error(`Path '${remotePath}' does not exist on the device`); } throw e; } finally { service.close(); } }