UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

293 lines (272 loc) 9.76 kB
import B from 'bluebird'; import {logger} from 'appium/support'; import _ from 'lodash'; import {errors} from 'appium/driver'; import type {XCUITestDriver} from '../driver'; import type {XCTestResult, RunXCTestResult} from './types'; import type {StringRecord} from '@appium/types'; import type IDB from 'appium-idb'; const XCTEST_TIMEOUT = 360000; // 60 minute timeout const xctestLog = logger.getLogger('XCTest'); /** * Asserts that IDB is present and that launchWithIDB was used. * * @param opts - Opts object from the driver instance * @returns The IDB instance * @throws {Error} If IDB is not available or launchWithIDB is not enabled */ export function assertIDB(this: XCUITestDriver, opts: XCUITestDriver['opts']): IDB { const device = this.device as any; if (!device?.idb || !opts.launchWithIDB) { throw new Error( `To use XCTest runner, IDB (https://github.com/facebook/idb) must be installed ` + `and sessions must be run with the "launchWithIDB" capability`, ); } return device.idb; } /** * Parse the stdout of XC test log. * * @param stdout - A line of standard out from `idb xctest run ...` * @returns The final output of the XCTest run */ export function parseXCTestStdout(stdout: string): XCTestResult[] | string[] { // Parses a 'key' into JSON format function parseKey(name: string): string { const words = name.split(' '); let out = ''; for (const word of words) { out += word.substr(0, 1).toUpperCase() + word.substr(1); } return out.substr(0, 1).toLowerCase() + out.substr(1); } // Parses a 'value' into JSON format function parseValue(value: string): any { value = value || ''; switch (value.toLowerCase()) { case 'true': return true; case 'false': return false; case '': return null; default: break; } if (!isNaN(Number(value))) { if (!_.isString(value)) { return 0; } else if (value.indexOf('.') > 0) { return parseFloat(value); } return parseInt(value, 10); } return value; } if (!stdout) { return []; } // Parse each line into an array const lines = stdout.trim().split('\n'); // One single string, just return the string if (lines.length === 1 && !lines[0].includes('|')) { return [lines[0]]; } const results: XCTestResult[] = []; for (const line of lines) { // The properties are split up by pipes and each property // has the format "Some Key : Some Value" const properties = line.split('|'); // Parse each property const output: any = {}; let entryIndex = 0; for (const prop of properties) { if (entryIndex === 0) { // The first property only contains one string that contains // the test name (e.g.: 'XCTesterAppUITests - XCTesterAppUITests.XCTesterAppUITests/testExample') output.testName = prop.trim(); } else if (prop.trim().startsWith('Location')) { // The Location property has a value that comes after 'Location' without colon. // e.g. Location /path/to/XCTesterAppUITests/XCTesterAppUITests.swift:36 output.location = prop.substring(prop.indexOf('Location') + 8).trim(); } else { const [key, value] = prop.split(':'); output[parseKey(key.trim())] = parseValue(value ? value.trim() : ''); } entryIndex++; } // keep backward compatibility // old pattern: XCTesterAppUITests - XCTesterAppUITests.XCTesterAppUITests/testExample | Passed: True | Crashed: False | Duration: 1.485 | Failure message: | Location :0 // latest pattern: XCTesterAppUITests - XCTesterAppUITests.XCTesterAppUITests/testExample | Status: passed | Duration: 1.9255789518356323 if (!output.passed) { output.passed = output.status === 'passed'; output.crashed = output.status === 'crashed'; } else if (!output.status) { if (output.passed) { output.status = 'passed'; } else if (output.crashed) { output.status = 'crashed'; } else { output.status = 'failed'; } } // Add this line to the results results.push(output); } return results; } /** * Error thrown when XCTest subprocess returns non-zero exit code. */ export interface XCUITestError extends Error { code: number; signal?: string; result?: XCTestResult[]; } /** * Run a native XCTest script. * * Launches a subprocess that runs the XC Test and blocks until it is completed. Parses the stdout of the process and returns its result as an array. * * **Facebook's [IDB](https://github.com/facebook/idb) tool is required** to run such tests; see [the idb docs](https://fbidb.io/docs/test-execution/) for reference. * * @param testRunnerBundleId - Test app bundle (e.g.: `io.appium.XCTesterAppUITests.xctrunner`) * @param appUnderTestBundleId - App-under-test bundle * @param xcTestBundleId - XCTest bundle ID * @param args - Launch arguments to start the test with (see [reference documentation](https://developer.apple.com/documentation/xctest/xcuiapplication/1500477-launcharguments)) * @param testType - XC test type * @param env - Environment variables passed to test * @param timeout - Timeout (in ms) for session completion * @returns The array of test results * @throws {XCUITestError} Error thrown if subprocess returns non-zero exit code */ export async function mobileRunXCTest( this: XCUITestDriver, testRunnerBundleId: string, appUnderTestBundleId: string, xcTestBundleId: string, args: string[] = [], testType: 'app' | 'ui' | 'logic' = 'ui', env?: StringRecord, timeout = XCTEST_TIMEOUT, ): Promise<RunXCTestResult> { const subproc = await assertIDB.call(this, this.opts).runXCUITest( testRunnerBundleId, appUnderTestBundleId, xcTestBundleId, {env, args, testType}, ); return await new B((resolve, reject) => { let mostRecentLogObject: XCTestResult[] | string[] | null = null; let xctestTimeout: NodeJS.Timeout | undefined; let lastErrorMessage: string | null = null; if (timeout > 0) { xctestTimeout = setTimeout( () => reject( new errors.TimeoutError( `Timed out after '${timeout}ms' waiting for XCTest to complete`, ), ), timeout, ); } subproc.on('output', (stdout: string, stderr: string) => { if (stdout) { try { mostRecentLogObject = parseXCTestStdout(stdout); } catch (err: any) { // Fails if log parsing fails. // This is in case IDB changes the way that logs are formatted and // it breaks 'parseXCTestStdout'. If that happens we still want the process // to finish this.log.warn(`Failed to parse logs from test output: '${stdout}'`); this.log.debug(err.stack); } } if (stderr) { lastErrorMessage = stderr; xctestLog.error(stderr); } if (stdout) { xctestLog.info(stdout); } }); subproc.on('exit', (code: number | null, signal: string | null) => { if (xctestTimeout) { clearTimeout(xctestTimeout); } if (code !== 0) { const err = new Error(lastErrorMessage || String(mostRecentLogObject)) as XCUITestError; err.code = code ?? -1; if (signal != null) { err.signal = signal; } if (mostRecentLogObject) { err.result = mostRecentLogObject as XCTestResult[]; } return reject(err); } resolve({ code: code ?? 0, signal: signal ?? null, results: mostRecentLogObject as XCTestResult[], passed: true, }); }); }); } /** * Installs an XCTest bundle to the device under test. * * **Facebook's [IDB](https://github.com/facebook/idb) tool is required** for this command to work. * * @param xctestApp - Path of the XCTest app (URL or filename with extension `.app`) */ export async function mobileInstallXCTestBundle( this: XCUITestDriver, xctestApp: string, ): Promise<void> { if (!_.isString(xctestApp)) { throw new errors.InvalidArgumentError( `'xctestApp' is a required parameter for 'installXCTestBundle' and ` + `must be a string. Found '${xctestApp}'`, ); } xctestLog.info(`Installing bundle '${xctestApp}'`); const idb = assertIDB.call(this, this.opts); const res = await this.helpers.configureApp(xctestApp, '.xctest'); await idb.installXCTestBundle(res); } /** * List XCTest bundles that are installed on the device. * * **Facebook's [IDB](https://github.com/facebook/idb) tool is required** for this command to work. * * @returns List of XCTest bundles (e.g.: `XCTesterAppUITests.XCTesterAppUITests/testLaunchPerformance`) */ export async function mobileListXCTestBundles(this: XCUITestDriver): Promise<string[]> { return await assertIDB.call(this, this.opts).listXCTestBundles(); } /** * List XCTests in a test bundle. * * **Facebook's [IDB](https://github.com/facebook/idb) tool is required** for this command to work. * * @param bundle - Bundle ID of the XCTest * @returns The list of xctests in the test bundle (e.g., `['XCTesterAppUITests.XCTesterAppUITests/testExample', 'XCTesterAppUITests.XCTesterAppUITests/testLaunchPerformance']`) */ export async function mobileListXCTestsInTestBundle( this: XCUITestDriver, bundle: string, ): Promise<string[]> { if (!_.isString(bundle)) { throw new errors.InvalidArgumentError( `'bundle' is a required parameter for 'listXCTestsInTestBundle' and ` + `must be a string. Found '${bundle}'`, ); } const idb = assertIDB.call(this, this.opts); return await idb.listXCTestsInTestBundle(bundle); }