UNPKG

appium-webdriveragent

Version:
731 lines (666 loc) 24.7 kB
import {waitForCondition} from 'asyncbox'; import path from 'node:path'; import {JWProxy} from '@appium/base-driver'; import {fs, util} from '@appium/support'; import type {AppiumLogger, StringRecord} from '@appium/types'; import {log as defaultLogger} from './logger'; import {NoSessionProxy} from './no-session-proxy'; import { getWDAUpgradeTimestamp, resetTestProcesses, getPIDsListeningOnPort, BOOTSTRAP_PATH, } from './utils'; import {XcodeBuild} from './xcodebuild'; import AsyncLock from 'async-lock'; import {exec} from 'teen_process'; import { WDA_RUNNER_BUNDLE_ID, WDA_BASE_URL, WDA_UPGRADE_TIMESTAMP_PATH, DEFAULT_TEST_BUNDLE_SUFFIX, } from './constants'; import {strongbox} from '@appium/strongbox'; import type { WebDriverAgentArgs, AppleDevice, XcodeBuildSettings, RetrieveBuildSettingsOptions, } from './types'; import type {Simctl} from 'node-simctl'; import type {Devicectl} from 'node-devicectl'; const WDA_LAUNCH_TIMEOUT = 60 * 1000; const WDA_AGENT_PORT = 8100; const SHARED_RESOURCES_GUARD = new AsyncLock(); const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion'; const URL_PROTOCOL_SEPARATOR = '://'; export class WebDriverAgent { bootstrapPath!: string; agentPath!: string; readonly args: WebDriverAgentArgs; readonly device: AppleDevice; readonly platformVersion?: string; readonly platformName?: string; readonly iosSdkVersion?: string; readonly host?: string; readonly isRealDevice: boolean; readonly wdaRemotePort: number; readonly wdaBaseUrl: string; readonly wdaBindingIP?: string; webDriverAgentUrl?: string; started: boolean; updatedWDABundleId?: string; noSessionProxy?: NoSessionProxy; jwproxy?: JWProxy; proxyReqRes?: any; private readonly log: AppiumLogger; private readonly wdaLocalPort?: number; private readonly prebuildWDA?: boolean; private readonly wdaConnectionTimeout?: number; private readonly useXctestrunFile?: boolean; private readonly usePrebuiltWDA?: boolean; private readonly mjpegServerPort?: number; private readonly wdaLaunchTimeout: number; private readonly usePreinstalledWDA?: boolean; private readonly updatedWDABundleIdSuffix: string; private _xcodebuild?: XcodeBuild | null; private _url?: URL; /** * Creates a new WebDriverAgent instance. * @param args - Configuration arguments for WebDriverAgent * @param log - Optional logger instance */ constructor(args: WebDriverAgentArgs, log: AppiumLogger | null = null) { this.args = {...args}; this.log = log ?? defaultLogger; this.device = args.device; this.platformVersion = args.platformVersion; this.platformName = args.platformName; this.iosSdkVersion = args.iosSdkVersion; this.host = args.host; this.isRealDevice = !!args.realDevice; this.setWDAPaths(args.bootstrapPath, args.agentPath); this.wdaLocalPort = args.wdaLocalPort; this.wdaRemotePort = ((this.isRealDevice ? args.wdaRemotePort : null) ?? args.wdaLocalPort) || WDA_AGENT_PORT; this.wdaBaseUrl = args.wdaBaseUrl || WDA_BASE_URL; this.wdaBindingIP = args.wdaBindingIP; this.prebuildWDA = args.prebuildWDA; // this.args.webDriverAgentUrl guiarantees the capabilities acually // gave 'appium:webDriverAgentUrl' but 'this.webDriverAgentUrl' // could be used for caching WDA with xcodebuild. this.webDriverAgentUrl = args.webDriverAgentUrl; this.started = false; this.wdaConnectionTimeout = args.wdaConnectionTimeout; this.useXctestrunFile = args.useXctestrunFile; this.usePrebuiltWDA = args.usePrebuiltWDA; this.mjpegServerPort = args.mjpegServerPort; this.updatedWDABundleId = args.updatedWDABundleId; this.wdaLaunchTimeout = args.wdaLaunchTimeout || WDA_LAUNCH_TIMEOUT; this.usePreinstalledWDA = args.usePreinstalledWDA; this.updatedWDABundleIdSuffix = args.updatedWDABundleIdSuffix ?? DEFAULT_TEST_BUNDLE_SUFFIX; this._xcodebuild = this.canSkipXcodebuild ? null : new XcodeBuild( this.device, { platformVersion: this.platformVersion, platformName: this.platformName, iosSdkVersion: this.iosSdkVersion, agentPath: this.agentPath, bootstrapPath: this.bootstrapPath, realDevice: this.isRealDevice, showXcodeLog: args.showXcodeLog, xcodeConfigFile: args.xcodeConfigFile, xcodeOrgId: args.xcodeOrgId, xcodeSigningId: args.xcodeSigningId, keychainPath: args.keychainPath, keychainPassword: args.keychainPassword, useSimpleBuildTest: args.useSimpleBuildTest, usePrebuiltWDA: args.usePrebuiltWDA, updatedWDABundleId: this.updatedWDABundleId, launchTimeout: this.wdaLaunchTimeout, wdaRemotePort: this.wdaRemotePort, wdaBindingIP: this.wdaBindingIP, useXctestrunFile: this.useXctestrunFile, derivedDataPath: args.derivedDataPath, mjpegServerPort: this.mjpegServerPort, allowProvisioningDeviceRegistration: args.allowProvisioningDeviceRegistration, resultBundlePath: args.resultBundlePath, resultBundleVersion: args.resultBundleVersion, }, this.log, ); } /** * Return true if the session does not need xcodebuild. * @returns Whether the session needs/has xcodebuild. */ get canSkipXcodebuild(): boolean { // Use this.args.webDriverAgentUrl to guarantee // the capabilities set gave the `appium:webDriverAgentUrl`. return this.usePreinstalledWDA || !!this.args.webDriverAgentUrl; } /** * Get the xcodebuild instance. Throws if not initialized. * @returns The XcodeBuild instance * @throws Error if xcodebuild is not available */ get xcodebuild(): XcodeBuild { if (!this._xcodebuild) { throw new Error('xcodebuild is not available'); } return this._xcodebuild; } /** * Return bundle id for WebDriverAgent to launch the WDA. * The primary usage is with 'this.usePreinstalledWDA'. * It adds `.xctrunner` as suffix by default but 'this.updatedWDABundleIdSuffix' * lets skip it. * * @returns Bundle ID for Xctest. */ get bundleIdForXctest(): string { return `${this.updatedWDABundleId ? this.updatedWDABundleId : WDA_RUNNER_BUNDLE_ID}${this.updatedWDABundleIdSuffix}`; } /** * Gets the base path for the WebDriverAgent URL. * @returns The base path (empty string if root path) */ get basePath(): string { if (this.url.pathname === '/') { return ''; } return this.url.pathname || ''; } /** * Gets the WebDriverAgent URL. * Constructs the URL from webDriverAgentUrl if provided, otherwise * builds it from wdaBaseUrl, wdaBindingIP, and wdaLocalPort. * @returns The parsed URL object */ get url(): URL { if (!this._url) { if (this.webDriverAgentUrl) { this._url = this.toUrl(this.webDriverAgentUrl); } else { const port = this.wdaLocalPort || WDA_AGENT_PORT; const parsedBaseUrl = this.toUrl(this.wdaBaseUrl || WDA_BASE_URL); this._url = new URL( `${parsedBaseUrl.protocol}//${this.wdaBindingIP || parsedBaseUrl.hostname}:${port}`, ); } } return this._url; } /** * Gets whether WebDriverAgent has fully started. * @returns `true` if WDA has started, `false` otherwise */ get fullyStarted(): boolean { return this.started; } /** * Sets whether WebDriverAgent has fully started. * @param started - `true` if WDA has started, `false` otherwise */ set fullyStarted(started: boolean) { this.started = started ?? false; } /** * Sets the WebDriverAgent URL. * @param _url - The URL string to parse and set */ set url(_url: string) { this._url = this.toUrl(_url); } /** * Cleans up obsolete cached processes from previous WDA sessions * that are listening on the same port but belong to different devices. */ async cleanupObsoleteProcesses(): Promise<void> { const obsoletePids = await getPIDsListeningOnPort( this.url.port as string, (cmdLine) => cmdLine.includes('/WebDriverAgentRunner') && !cmdLine.toLowerCase().includes(this.device.udid.toLowerCase()), ); if (obsoletePids.length === 0) { this.log.debug( `No obsolete cached processes from previous WDA sessions ` + `listening on port ${this.url.port} have been found`, ); return; } this.log.info( `Detected ${obsoletePids.length} obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} ` + `from previous WDA sessions. Cleaning them up`, ); try { await exec('kill', obsoletePids); } catch (e: any) { this.log.warn( `Failed to kill obsolete cached process${obsoletePids.length === 1 ? '' : 'es'} '${obsoletePids}'. ` + `Original error: ${e.message}`, ); } } /** } /** * Return current running WDA's status like below after launching WDA * { * "state": "success", * "os": { * "name": "iOS", * "version": "11.4", * "sdkVersion": "11.3" * }, * "ios": { * "simulatorVersion": "11.4", * "ip": "172.254.99.34" * }, * "build": { * "time": "Jun 24 2018 17:08:21", * "productBundleIdentifier": "com.facebook.WebDriverAgentRunner" * } * } * * @param sessionId Launch WDA and establish the session with this sessionId */ async launch(sessionId: string): Promise<StringRecord | null> { if (this.webDriverAgentUrl) { this.log.info(`Using provided WebdriverAgent at '${this.webDriverAgentUrl}'`); this.url = this.webDriverAgentUrl; this.setupProxies(sessionId); return await this.getStatus(); } if (this.usePreinstalledWDA) { return await this.launchWithPreinstalledWDA(sessionId); } this.log.info('Launching WebDriverAgent on the device'); this.setupProxies(sessionId); if (!this.useXctestrunFile && !(await fs.exists(this.agentPath))) { throw new Error( `Trying to use WebDriverAgent project at '${this.agentPath}' but the ` + 'file does not exist', ); } // useXctestrunFile and usePrebuiltWDA use existing dependencies // It depends on user side if (this.useXctestrunFile || this.usePrebuiltWDA) { this.log.info('Skipped WDA project cleanup according to the provided capabilities'); } else { const synchronizationKey = path.normalize(this.bootstrapPath); await SHARED_RESOURCES_GUARD.acquire( synchronizationKey, async () => await this._cleanupProjectIfFresh(), ); } // We need to provide WDA local port, because it might be occupied await resetTestProcesses(this.device.udid, !this.isRealDevice); if (!this.noSessionProxy) { throw new Error('noSessionProxy is not available'); } await this.xcodebuild.init(this.noSessionProxy); // Start the xcodebuild process if (this.prebuildWDA) { await this.xcodebuild.prebuild(); } return (await this.xcodebuild.start()) as StringRecord | null; } /** * Checks if the WebDriverAgent source is fresh by verifying * that required resource files exist. * @returns `true` if source is fresh (all required files exist), `false` otherwise */ async isSourceFresh(): Promise<boolean> { const existsPromises = ['Resources', path.join('Resources', 'WebDriverAgent.bundle')].map( (subPath) => fs.exists(path.resolve(this.bootstrapPath, subPath)), ); return (await Promise.all(existsPromises)).every((v) => v === true); } /** * Stops the WebDriverAgent session and cleans up resources. * Handles both preinstalled WDA and xcodebuild-based sessions. */ async quit(): Promise<void> { if (this.usePreinstalledWDA) { this.log.info('Stopping the XCTest session'); try { if (this.device.simctl) { await this.device.simctl.terminateApp(this.bundleIdForXctest); } else if (this.device.devicectl) { await this.device.devicectl.terminateApp(this.bundleIdForXctest); } } catch (e: any) { this.log.warn(e.message); } } else if (!this.args.webDriverAgentUrl) { this.log.info('Shutting down sub-processes'); if (this._xcodebuild) { await this.xcodebuild.quit(); } } else { this.log.debug( 'Stopping neither xcodebuild nor XCTest session since WDA lifecycle is not managed by this driver', ); } if (this.jwproxy) { this.jwproxy.sessionId = null; } this.started = false; if (!this.args.webDriverAgentUrl) { // if we populated the url ourselves (during `setupCaching` call, for instance) // then clean that up. If the url was supplied, we want to keep it this.webDriverAgentUrl = undefined; } } /** * Retrieves Xcode build settings. * @param options - Optional scheme, SDK, configuration, or destination * @returns Build settings, or `undefined` if xcodebuild is skipped or settings cannot be determined */ async retrieveBuildSettings( options?: RetrieveBuildSettingsOptions, ): Promise<XcodeBuildSettings | undefined> { if (this.canSkipXcodebuild) { return; } return await this.xcodebuild.retrieveBuildSettings(options); } /** * @deprecated Use {@link retrieveBuildSettings} instead. Will be removed in a future release. * @returns The derived data path, or `undefined` if xcodebuild is skipped */ async retrieveDerivedDataPath(): Promise<string | undefined> { if (this.canSkipXcodebuild) { return; } return await this.xcodebuild.retrieveDerivedDataPath(); } /** * Reuse running WDA if it has the same bundle id with updatedWDABundleId. * Or reuse it if it has the default id without updatedWDABundleId. * * @returns The WDA URL used for caching on success, or `undefined` if caching was skipped. */ async setupCaching(): Promise<string | undefined> { const status = await this.getStatus(0); if (!status || !status.build) { this.log.debug('WDA is currently not running. There is nothing to cache'); return undefined; } const {productBundleIdentifier, upgradedAt} = status.build as any; // for real device if ( util.hasValue(productBundleIdentifier) && util.hasValue(this.updatedWDABundleId) && this.updatedWDABundleId !== productBundleIdentifier ) { this.log.info( `Will not reuse running WDA since it has different bundle id. The actual value is '${productBundleIdentifier}'.`, ); return undefined; } // for simulator if ( util.hasValue(productBundleIdentifier) && !util.hasValue(this.updatedWDABundleId) && WDA_RUNNER_BUNDLE_ID !== productBundleIdentifier ) { this.log.info( `Will not reuse running WDA since its bundle id is not equal to the default value ${WDA_RUNNER_BUNDLE_ID}`, ); return undefined; } const actualUpgradeTimestamp = await getWDAUpgradeTimestamp(); this.log.debug(`Upgrade timestamp of the currently bundled WDA: ${actualUpgradeTimestamp}`); this.log.debug(`Upgrade timestamp of the WDA on the device: ${upgradedAt}`); if ( actualUpgradeTimestamp && upgradedAt && `${actualUpgradeTimestamp}`.toLowerCase() !== `${upgradedAt}`.toLowerCase() ) { this.log.info( 'Will not reuse running WDA since it has different version in comparison to the one ' + `which is bundled with appium-xcuitest-driver module (${actualUpgradeTimestamp} != ${upgradedAt})`, ); return undefined; } const cachedUrl = this.url.href; const message = util.hasValue(productBundleIdentifier) ? `Will reuse previously cached WDA instance at '${cachedUrl}' with '${productBundleIdentifier}'` : `Will reuse previously cached WDA instance at '${cachedUrl}'`; this.log.info( `${message}. Set the wdaLocalPort capability to a value different from ${this.url.port} if this is an undesired behavior.`, ); this.webDriverAgentUrl = cachedUrl; return cachedUrl; } private setupProxies(sessionId: string): void { const proxyOpts: any = { log: this.log, server: this.url.hostname ?? undefined, port: parseInt(this.url.port ?? '', 10) || undefined, base: this.basePath, timeout: this.wdaConnectionTimeout, keepAlive: true, scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http', headers: this.args.extraRequestHeaders, }; if (this.args.reqBasePath) { proxyOpts.reqBasePath = this.args.reqBasePath; } this.jwproxy = new JWProxy(proxyOpts); this.jwproxy.sessionId = sessionId; this.proxyReqRes = this.jwproxy.proxyReqRes.bind(this.jwproxy); this.noSessionProxy = new NoSessionProxy(proxyOpts); } private toUrl(value: string): URL { // Treat values without `://` as host/path inputs and normalize to http. if (!value.includes(URL_PROTOCOL_SEPARATOR)) { return new URL(`http://${value}`); } try { return new URL(value); } catch { throw new Error(`Invalid URL: ${value}`); } } private setWDAPaths(bootstrapPath?: string, agentPath?: string): void { // allow the user to specify a place for WDA. This is undocumented and // only here for the purposes of testing development of WDA this.bootstrapPath = bootstrapPath || BOOTSTRAP_PATH; this.log.info(`Using WDA path: '${this.bootstrapPath}'`); // for backward compatibility we need to be able to specify agentPath too this.agentPath = agentPath || path.resolve(this.bootstrapPath, 'WebDriverAgent.xcodeproj'); this.log.info(`Using WDA agent: '${this.agentPath}'`); } /** * Return current running WDA's status like below * { * "state": "success", * "os": { * "name": "iOS", * "version": "11.4", * "sdkVersion": "11.3" * }, * "ios": { * "simulatorVersion": "11.4", * "ip": "172.254.99.34" * }, * "build": { * "time": "Jun 24 2018 17:08:21", * "productBundleIdentifier": "com.facebook.WebDriverAgentRunner" * } * } * * @param timeoutMs If zero or negative, returns immediately. Otherwise, waits up to timeoutMs. */ private async getStatus(timeoutMs: number = 0): Promise<StringRecord | null> { const noSessionProxy = new NoSessionProxy({ scheme: this.url.protocol ? this.url.protocol.replace(':', '') : 'http', server: this.url.hostname ?? undefined, port: parseInt(this.url.port ?? '', 10) || undefined, base: this.basePath, timeout: 3000, headers: this.args.extraRequestHeaders, }); const sendGetStatus = async () => (await noSessionProxy.command('/status', 'GET')) as StringRecord; if (timeoutMs == null || timeoutMs <= 0) { try { return await sendGetStatus(); } catch (err: any) { this.log.debug( `WDA is not listening at '${this.url.href}'. Original error:: ${err.message}`, ); return null; } } let lastError: any = null; let status: StringRecord | null = null; try { await waitForCondition( async () => { try { status = await sendGetStatus(); return true; } catch (err) { lastError = err; } return false; }, { waitMs: timeoutMs, intervalMs: 300, }, ); } catch (err: any) { this.log.debug( `Failed to get the status endpoint in ${timeoutMs} ms. ` + `The last error while accessing ${this.url.href}: ${lastError}. Original error:: ${err.message}.`, ); throw new Error(`WDA was not ready in ${timeoutMs} ms.`, {cause: err}); } return status; } private async _cleanupProjectIfFresh(): Promise<void> { if (this.canSkipXcodebuild) { return; } const packageInfo = JSON.parse( await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8'), ); const box = strongbox(packageInfo.name); let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME); if (!boxItem) { const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH); if (await fs.exists(timestampPath)) { // TODO: It is probably a bit ugly to hardcode the recent version string, // TODO: hovewer it should do the job as a temporary transition trick // TODO: to switch from a hardcoded file path to the strongbox usage. try { boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0'); } catch (e: any) { this.log.warn(`The actual module version cannot be persisted: ${e.message}`); return; } } else { this.log.info( 'There is no need to perform the project cleanup. A fresh install has been detected', ); try { await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version); } catch (e: any) { this.log.warn(`The actual module version cannot be persisted: ${e.message}`); } return; } } let recentModuleVersion = await boxItem.read(); try { recentModuleVersion = util.coerceVersion(recentModuleVersion, true); } catch (e: any) { this.log.warn(`The persisted module version string has been damaged: ${e.message}`); this.log.info( `Updating it to '${packageInfo.version}' assuming the project clenup is not needed`, ); await boxItem.write(packageInfo.version); return; } if (util.compareVersions(recentModuleVersion, '>=', packageInfo.version)) { this.log.info( `WebDriverAgent does not need a cleanup. The project sources are up to date ` + `(${recentModuleVersion} >= ${packageInfo.version})`, ); return; } this.log.info( `Cleaning up the WebDriverAgent project after the module upgrade has happened ` + `(${recentModuleVersion} < ${packageInfo.version})`, ); try { await this.xcodebuild.cleanProject(); await boxItem.write(packageInfo.version); } catch (e: any) { this.log.warn(`Cannot perform WebDriverAgent project cleanup. Original error: ${e.message}`); } } /** * Launch WDA with preinstalled package with 'xcrun devicectl device process launch'. * The WDA package must be prepared properly like published via * https://github.com/appium/WebDriverAgent/releases * with proper sign for this case. * * @param opts launching WDA with devicectl command options. */ private async _launchViaDevicectl( opts: {env?: Record<string, string | number>} = {}, ): Promise<void> { const {env} = opts; await (this.device.devicectl as Devicectl).launchApp(this.bundleIdForXctest, { env, terminateExisting: true, }); } /** * Launch WDA with preinstalled package without xcodebuild. * @param sessionId Launch WDA and establish the session with this sessionId */ private async launchWithPreinstalledWDA(sessionId: string): Promise<StringRecord | null> { const xctestEnv: Record<string, string | number> = { USE_PORT: this.wdaLocalPort || WDA_AGENT_PORT, WDA_PRODUCT_BUNDLE_IDENTIFIER: this.bundleIdForXctest, }; if (this.mjpegServerPort) { xctestEnv.MJPEG_SERVER_PORT = this.mjpegServerPort; } if (this.wdaBindingIP) { xctestEnv.USE_IP = this.wdaBindingIP; } this.log.info('Launching WebDriverAgent on the device without xcodebuild'); if (this.isRealDevice) { await this._launchViaDevicectl({env: xctestEnv}); } else { await (this.device.simctl as Simctl).exec('launch', { args: ['--terminate-running-process', this.device.udid, this.bundleIdForXctest], env: xctestEnv, }); } this.setupProxies(sessionId); let status: StringRecord | null; try { status = await this.getStatus(this.wdaLaunchTimeout); } catch { throw new Error( `Failed to start the preinstalled WebDriverAgent in ${this.wdaLaunchTimeout} ms. ` + `The WebDriverAgent might not be properly built or the device might be locked. ` + `The 'appium:wdaLaunchTimeout' capability modifies the timeout.`, ); } this.started = true; return status; } }