UNPKG

kuben-appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

352 lines (331 loc) 16 kB
import _ from 'lodash'; import { system, fs, tempDir, mkdirp, zip } from 'appium-support'; import path from 'path'; import { iosCommands } from 'kuben-appium-ios-driver'; import log from '../logger'; import { exec } from 'teen_process'; import { addMedia, getAppContainer } from 'kuben-node-simctl'; const CONTAINER_PATH_MARKER = '@'; // https://regex101.com/r/PLdB0G/2 const CONTAINER_PATH_PATTERN = new RegExp(`^${CONTAINER_PATH_MARKER}([^/]+)/(.+)`); const CONTAINER_TYPE_SEPARATOR = ':'; let commands = iosCommands.file; async function verifyIFusePresence () { if (!await fs.which('ifuse')) { log.errorAndThrow(`'ifuse' tool is required to be installed on the machine. ` + `Install it using 'brew cask install osxfuse && brew install ifuse' or check ` + `if it is available in PATH environment variable if the tool is already installed. ` + `Current PATH value: ${process.env.PATH}`); } } async function mountDevice (device, iFuseArgs) { log.debug(`Starting ifuse with args '${iFuseArgs}'...`); try { await exec('ifuse', iFuseArgs); } catch (e) { log.errorAndThrow(`Cannot mount the media folder of the device with UDID ${device.udid}. ` + `Make sure osxfuse plugin has necessary permissions in System Preferences->Security & Privacy. ` + `Error code: ${e.code}; stderr output: ${e.stderr}`); } } function verifyIsSubPath (originalPath, root) { const normalizedRoot = path.normalize(root); const normalizedPath = path.normalize(path.dirname(originalPath)); if (!normalizedPath.startsWith(normalizedRoot)) { log.errorAndThrow(`'${normalizedPath}' is expected to be a subpath of '${normalizedRoot}'`); } } /** * Parses the actual path and the bundle identifier from the given path string * * @param {string} remotePath - The given path string. The string should * match `CONTAINER_PATH_PATTERN` regexp, otherwise an error is going * to be thrown. A valid string example: `@bundle.identifier:container_type/relative_path_in_container` * @param {Function|string} containerRootSupplier - Either a string, that contains * full path to the mount root for real devices or a function, which accepts two parameters * (bundle identifier and optional container type) and returns full path to container * root folder on the local file system, for Simulator * @returns {Array<string>} - An array where the first item is the parsed bundle * identifier and the second one is the absolute full path of the item on the local * file system */ async function parseContainerPath (remotePath, containerRootSupplier) { const match = CONTAINER_PATH_PATTERN.exec(remotePath); if (!match) { log.errorAndThrow(`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`); } let [, bundleId, relativePath] = match; let containerType = 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); log.debug(`Parsed container type: ${containerType}`); bundleId = bundleId.substring(0, typeSeparatorPos); } const containerRoot = _.isFunction(containerRootSupplier) ? await containerRootSupplier(bundleId, containerType) : containerRootSupplier; const resultPath = path.posix.resolve(containerRoot, relativePath); verifyIsSubPath(resultPath, containerRoot); return [bundleId, resultPath]; } /** * Save the given base64 data chunk as a binary file on the Simulator under test. * * @param {Object} device - The device object, which represents the device under test. * This object is expected to have the `udid` property containing the * valid device ID. * @param {string} remotePath - The remote path on the device. This variable can be prefixed with * bundle id, so then the file will be uploaded to the corresponding * application container instead of the default media folder, for example * '@com.myapp.bla:data/RelativePathInContainer/111.png'. The '@' character at the * beginning of the argument is mandatory in such case. The colon at the end of bundle identifier * is optional and is used to distinguish the container type. * Possible values there are 'app', 'data', 'groups', '<A specific App Group container>'. * The default value is 'app'. * The relative folder path is ignored if the file is going to be uploaded * to the default media folder and only the file name is considered important. * @param {string} base64Data - Base-64 encoded content of the file to be uploaded. */ async function pushFileToSimulator (device, remotePath, base64Data) { const buffer = Buffer.from(base64Data, 'base64'); if (CONTAINER_PATH_PATTERN.test(remotePath)) { const [bundleId, dstPath] = await parseContainerPath(remotePath, async (appBundle, containerType) => await getAppContainer(device.udid, appBundle, null, containerType)); log.info(`Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `Will put the data into '${dstPath}'`); if (!await fs.exists(path.dirname(dstPath))) { 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 addMedia(device.udid, dstPath); } finally { await fs.rimraf(dstFolder); } } /** * Save the given base64 data chunk as a binary file on the device under test. * ifuse/osxfuse should be installed and configured on the target machine in order * for this function to work properly. Read https://github.com/libimobiledevice/ifuse * and https://github.com/osxfuse/osxfuse/wiki/FAQ for more details. * * @param {Object} device - The device object, which represents the device under test. * This object is expected to have the `udid` property containing the * valid device ID. * @param {string} remotePath - The remote path on the device. This variable can be prefixed with * bundle id, so then the file will be uploaded to the corresponding * application container instead of the default media folder, for example * '@com.myapp.bla/RelativePathInContainer/111.png'. The '@' character at the * beginning of the argument is mandatory in such case. * @param {string} base64Data - Base-64 encoded content of the file to be uploaded. */ async function pushFileToRealDevice (device, remotePath, base64Data) { await verifyIFusePresence(); const mntRoot = await tempDir.openDir(); let isUnmountSuccessful = true; try { let dstPath = path.resolve(mntRoot, remotePath); let ifuseArgs = ['-u', device.udid, mntRoot]; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const [bundleId, pathInContainer] = await parseContainerPath(remotePath, mntRoot); dstPath = pathInContainer; log.info(`Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `Will put the data into '${dstPath}'`); ifuseArgs = ['-u', device.udid, '--container', bundleId, mntRoot]; } else { verifyIsSubPath(dstPath, mntRoot); } await mountDevice(device, ifuseArgs); isUnmountSuccessful = false; try { if (!await fs.exists(path.dirname(dstPath))) { log.debug(`The destination folder '${path.dirname(dstPath)}' does not exist. Creating...`); await mkdirp(path.dirname(dstPath)); } await fs.writeFile(dstPath, Buffer.from(base64Data, 'base64')); } finally { await exec('umount', [mntRoot]); isUnmountSuccessful = true; } } finally { if (isUnmountSuccessful) { await fs.rimraf(mntRoot); } else { log.warn(`Umount has failed, so not removing '${mntRoot}'`); } } } /** * 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 {Object} device - The device object, which represents the device under test. * This object is expected to have the `udid` property containing the * valid device ID. * @param {string} 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 {boolean} isFile - Whether the destination item is a file or a folder * @returns {string} Base-64 encoded content of the file. */ async function pullFromSimulator (device, remotePath, isFile) { let pathOnServer; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const [bundleId, dstPath] = await parseContainerPath(remotePath, async (appBundle, containerType) => await getAppContainer(device.udid, appBundle, null, containerType)); 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); log.info(`Got the full item path: ${pathOnServer}`); } if (!await fs.exists(pathOnServer)) { log.errorAndThrow(`The remote ${isFile ? 'file' : 'folder'} at '${pathOnServer}' does not exist`); } const buffer = isFile ? await fs.readFile(pathOnServer) : await zip.toInMemoryZip(pathOnServer); return Buffer.from(buffer).toString('base64'); } /** * 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 {Object} device - The device object, which represents the device under test. * This object is expected to have the `udid` property containing the * valid device ID. * @param {string} 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, for example * '@com.myapp.bla/RelativePathInContainer/111.png'. The '@' character at the * beginning of the argument is mandatory in such case. * @param {boolean} isFile - Whether the destination item is a file or a folder * @return {string} Base-64 encoded content of the remote file */ async function pullFromRealDevice (device, remotePath, isFile) { await verifyIFusePresence(); const mntRoot = await tempDir.openDir(); let isUnmountSuccessful = true; try { let dstPath = path.resolve(mntRoot, remotePath); let ifuseArgs = ['-u', device.udid, mntRoot]; if (CONTAINER_PATH_PATTERN.test(remotePath)) { const [bundleId, pathInContainer] = await parseContainerPath(remotePath, mntRoot); dstPath = pathInContainer; log.info(`Parsed bundle identifier '${bundleId}' from '${remotePath}'. ` + `Will get the data from '${dstPath}'`); ifuseArgs = ['-u', device.udid, '--container', bundleId, mntRoot]; } else { verifyIsSubPath(dstPath, mntRoot); } await mountDevice(device, ifuseArgs); isUnmountSuccessful = false; try { if (!await fs.exists(dstPath)) { log.errorAndThrow(`The remote ${isFile ? 'file' : 'folder'} at '${dstPath}' does not exist`); } const buffer = isFile ? await fs.readFile(dstPath) : await zip.toInMemoryZip(dstPath); return Buffer.from(buffer).toString('base64'); } finally { await exec('umount', [mntRoot]); isUnmountSuccessful = true; } } finally { if (isUnmountSuccessful) { await fs.rimraf(mntRoot); } else { log.warn(`Umount has failed, so not removing '${mntRoot}'`); } } } commands.pushFile = async function (remotePath, base64Data) { if (remotePath.endsWith('/')) { log.errorAndThrow(`It is expected that remote path points to a file and not to a folder. ` + `'${remotePath}' is given instead`); } if (_.isArray(base64Data)) { // some clients (ahem) java, send a byte array encoding utf8 characters // instead of a string, which would be infinitely better! base64Data = Buffer.from(base64Data).toString('utf8'); } return this.isSimulator() ? await pushFileToSimulator(this.opts.device, remotePath, base64Data) : await pushFileToRealDevice(this.opts.device, remotePath, base64Data); }; commands.pullFile = async function (remotePath) { if (remotePath.endsWith('/')) { log.errorAndThrow(`It is expected that remote path points to a file and not to a folder. ` + `'${remotePath}' is given instead`); } return this.isSimulator() ? await pullFromSimulator(this.opts.device, remotePath, true) : await pullFromRealDevice(this.opts.device, remotePath, true); }; commands.getSimFileFullPath = async function (remotePath) { let basePath = this.opts.device.getDir(); let appName = null; if (this.opts.app) { let appNameRegex = new RegExp(`\\${path.sep}([\\w-]+\\.app)`); let appNameMatches = appNameRegex.exec(this.opts.app); if (appNameMatches) { appName = appNameMatches[1]; } } // de-absolutize the path if (system.isWindows()) { if (remotePath.indexof('://') === 1) { remotePath = remotePath.slice(4); } } else { if (remotePath.indexOf('/') === 0) { remotePath = remotePath.slice(1); } } if (remotePath.indexOf(appName) === 0) { let findPath = basePath; if (this.opts.platformVersion >= 8) { // the .app file appears in /Containers/Data and /Containers/Bundle both. We only want /Bundle findPath = path.resolve(basePath, 'Containers', 'Bundle'); } findPath = findPath.replace(/\s/g, '\\ '); let { stdout } = await exec('find', [findPath, '-name', appName]); let appRoot = stdout.replace(/\n$/, ''); let subPath = remotePath.substring(appName.length + 1); let fullPath = path.resolve(appRoot, subPath); log.debug(`Finding app-relative file: '${fullPath}'`); return fullPath; } else { let fullPath = path.resolve(basePath, remotePath); log.debug(`Finding sim-relative file: ${fullPath}`); return fullPath; } }; commands.pullFolder = async function (remotePath) { if (!remotePath.endsWith('/')) { remotePath = `${remotePath}/`; } return this.isSimulator() ? await pullFromSimulator(this.opts.device, remotePath, false) : await pullFromRealDevice(this.opts.device, remotePath, false); }; export { commands }; export default commands;