appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
531 lines (479 loc) • 17.2 kB
text/typescript
import _ from 'lodash';
import path from 'node:path';
import url from 'node:url';
import axios from 'axios';
import B from 'bluebird';
import {JWProxy, errors} from 'appium/driver';
import {fs, logger, util, timing} from 'appium/support';
import {strongbox} from '@appium/strongbox';
import {SubProcess, exec} from 'teen_process';
import {waitForCondition} from 'asyncbox';
import {checkPortStatus} from 'portscanner';
import {execSync} from 'node:child_process';
import type {HTTPMethod, HTTPBody, ProxyResponse, ProxyOptions} from '@appium/types';
import {listChildrenProcessIds, getModuleRoot} from './utils';
const log = logger.getLogger('WebDriverAgentMac');
const DEFAULT_WDA_ROOT = path.resolve(getModuleRoot(), 'WebDriverAgentMac');
const WDA_PROJECT_NAME = 'WebDriverAgentMac.xcodeproj';
const WDA_PROJECT = (wdaRoot = DEFAULT_WDA_ROOT) => path.resolve(wdaRoot, WDA_PROJECT_NAME);
const RUNNER_SCHEME = 'WebDriverAgentRunner';
const DISABLE_STORE_ARG = 'COMPILER_INDEX_STORE_ENABLE=NO';
const XCODEBUILD = 'xcodebuild';
const STARTUP_TIMEOUT_MS = 120000;
const DEFAULT_SYSTEM_PORT = 10100;
const DEFAULT_SYSTEM_HOST = '127.0.0.1';
const DEFAULT_SHOW_SERVER_LOGS = false;
const RUNNING_PROCESS_IDS: (string | number)[] = [];
const RECENT_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent_mac', 'upgrade.time');
const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion';
export interface SessionOptions {
reqBasePath?: string;
}
export class WDAMacProxy extends JWProxy {
public didProcessExit: boolean = false;
override async proxyCommand(
url: string,
method: HTTPMethod,
body: HTTPBody = null,
): Promise<[ProxyResponse, HTTPBody]> {
if (this.didProcessExit) {
throw new errors.InvalidContextError(
`'${method} ${url}' cannot be proxied to Mac2 Driver server because ` +
'its process is not running (probably crashed). Check the Appium log for more details',
);
}
return await super.proxyCommand(url, method, body);
}
}
class WDAMacProcess {
public port: number = DEFAULT_SYSTEM_PORT;
public host: string = DEFAULT_SYSTEM_HOST;
public bootstrapRoot: string = DEFAULT_WDA_ROOT;
public proc: SubProcess | null = null;
private _showServerLogs: boolean = DEFAULT_SHOW_SERVER_LOGS;
get isRunning(): boolean {
return !!this.proc?.isRunning;
}
get pid(): number | null {
return this.isRunning && this.proc ? this.proc.pid : null;
}
async listChildrenPids(): Promise<string[]> {
return this.pid ? await listChildrenProcessIds(this.pid) : [];
}
async cleanupProjectIfFresh(): Promise<void> {
const packageInfo = JSON.parse(
await fs.readFile(path.join(getModuleRoot(), 'package.json'), 'utf8'),
) as {name: string; version: string};
const box = strongbox(packageInfo.name);
let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME);
if (!boxItem) {
const timestampPath = path.resolve(process.env.HOME ?? '', RECENT_UPGRADE_TIMESTAMP_PATH);
if (await fs.exists(timestampPath)) {
// TODO: It is probably a bit ugly to hardcode the recent version string,
// TODO: however 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, '1.5.4');
} catch (e: any) {
log.warn(`The actual module version cannot be persisted: ${e.message}`);
return;
}
} else {
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) {
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) {
log.warn(`The persisted module version string has been damaged: ${e.message}`);
log.info(
`Updating it to '${packageInfo.version}' assuming the project cleanup is not needed`,
);
await boxItem.write(packageInfo.version);
return;
}
if (util.compareVersions(recentModuleVersion, '>=', packageInfo.version)) {
log.info(
`WebDriverAgent does not need a cleanup. The project sources are up to date ` +
`(${recentModuleVersion} >= ${packageInfo.version})`,
);
return;
}
try {
log.info('Performing project cleanup');
const args = ['clean', '-project', WDA_PROJECT(this.bootstrapRoot), '-scheme', RUNNER_SCHEME];
await exec(XCODEBUILD, args, {cwd: this.bootstrapRoot});
await boxItem.write(packageInfo.version);
} catch (e: any) {
log.warn(`Cannot perform project cleanup. Original error: ${e.stderr || e.message}`);
}
}
async init(opts: WDAMacProcessInitOptions = {}): Promise<boolean> {
if (this.isRunning && this.hasSameOpts(opts)) {
return false;
}
this._showServerLogs = opts.showServerLogs ?? this._showServerLogs;
this.port = opts.systemPort ?? this.port;
this.host = opts.systemHost ?? this.host;
this.bootstrapRoot = opts.bootstrapRoot ?? this.bootstrapRoot;
log.debug(`Using bootstrap root: ${this.bootstrapRoot}`);
if (!(await fs.exists(WDA_PROJECT(this.bootstrapRoot)))) {
throw new Error(
`${WDA_PROJECT_NAME} does not exist at '${WDA_PROJECT(this.bootstrapRoot)}'. ` +
`Was 'bootstrapRoot' set to a proper value?`,
);
}
await this.kill();
await cleanupObsoleteProcesses();
let xcodebuild: string;
try {
xcodebuild = await fs.which(XCODEBUILD);
} catch {
throw new Error(
`${XCODEBUILD} binary cannot be found in PATH. ` +
`Please make sure that Xcode is installed on your system`,
);
}
log.debug(`Using ${XCODEBUILD} binary at '${xcodebuild}'`);
await this.cleanupProjectIfFresh();
log.debug(`Using ${this.host} as server host`);
log.debug(`Using port ${this.port}`);
const isPortBusy = async (): Promise<boolean> =>
(await checkPortStatus(this.port, this.host)) === 'open';
if (await isPortBusy()) {
log.warn(
`The port #${this.port} at ${this.host} is busy. ` +
`Assuming it is an obsolete WDA server instance and ` +
`trying to terminate it in order to start a new one`,
);
const timer = new timing.Timer().start();
try {
await axios.delete(`http://${this.host}:${this.port}/`, {
timeout: 5000,
});
// Give the server some time to finish and stop listening
await B.delay(500);
await waitForCondition(async () => !(await isPortBusy()), {
waitMs: 3000,
intervalMs: 100,
});
} catch (e: any) {
log.warn(
`Did not know how to terminate the process at ${this.host}:${this.port}: ${e.message}. ` +
`Perhaps, it is not a WDA server, which is hogging the port?`,
);
throw new Error(
`The port #${this.port} at ${this.host} is busy. ` +
`Consider setting 'systemPort' capability to another free port number and/or ` +
`make sure previous driver sessions have been closed properly.`,
);
}
log.info(
`The previously running WDA server has been successfully terminated after ` +
`${Math.round(timer.getDuration().asMilliSeconds)}ms`,
);
}
const args = [
'build-for-testing',
'test-without-building',
'-project',
WDA_PROJECT(this.bootstrapRoot),
'-scheme',
RUNNER_SCHEME,
DISABLE_STORE_ARG,
];
const env = Object.assign({}, process.env, {
USE_PORT: `${this.port}`,
USE_HOST: this.host,
});
this.proc = new SubProcess(xcodebuild, args, {
cwd: this.bootstrapRoot,
env,
});
if (!this._showServerLogs) {
log.info(
`Mac2Driver host process logging is disabled. ` +
`All the ${XCODEBUILD} output is going to be suppressed. ` +
`Set the 'showServerLogs' capability to 'true' if this is an undesired behavior`,
);
}
this.proc.on('output', (stdout, stderr) => {
if (!this._showServerLogs) {
return;
}
const line = _.trim(stdout || stderr);
if (line) {
log.debug(`[${XCODEBUILD}] ${line}`);
}
});
this.proc.on('exit', (code, signal) => {
log.info(`Mac2Driver host process has exited with code ${code}, signal ${signal}`);
});
log.info(`Starting Mac2Driver host process: ${XCODEBUILD} ${util.quote(args)}`);
await this.proc.start(0);
return true;
}
async stop(): Promise<void> {
if (!this.isRunning) {
return;
}
const childrenPids = await this.listChildrenPids();
if (!_.isEmpty(childrenPids)) {
try {
await exec('kill', childrenPids);
} catch {}
}
await this.proc?.stop('SIGTERM', 3000);
}
async kill(): Promise<void> {
if (!this.isRunning) {
return;
}
const childrenPids = await this.listChildrenPids();
if (!_.isEmpty(childrenPids)) {
try {
await exec('kill', ['-9', ...childrenPids]);
} catch {}
}
try {
await this.proc?.stop('SIGKILL');
} catch {}
}
private hasSameOpts(opts: WDAMacProcessInitOptions): boolean {
const {showServerLogs, systemPort, systemHost, bootstrapRoot} = opts;
if (
(_.isBoolean(showServerLogs) && this._showServerLogs !== showServerLogs) ||
(_.isNil(showServerLogs) && this._showServerLogs !== DEFAULT_SHOW_SERVER_LOGS)
) {
return false;
}
if (
(systemPort && this.port !== systemPort) ||
(!systemPort && this.port !== DEFAULT_SYSTEM_PORT)
) {
return false;
}
if (
(systemHost && this.host !== systemHost) ||
(!systemHost && this.host !== DEFAULT_SYSTEM_HOST)
) {
return false;
}
if (
(bootstrapRoot && this.bootstrapRoot !== bootstrapRoot) ||
(!bootstrapRoot && this.bootstrapRoot !== DEFAULT_WDA_ROOT)
) {
return false;
}
return true;
}
}
export class WDAMacServer {
private _proxy: WDAMacProxy | null = null;
private _process: WDAMacProcess | null = null;
private _serverStartupTimeoutMs: number = STARTUP_TIMEOUT_MS;
private _isProxyingToRemoteServer: boolean = false;
get proxy(): WDAMacProxy {
if (!this._proxy) {
throw new Error('Proxy is not initialized. Did you call startSession()?');
}
return this._proxy;
}
async startSession(caps: StartSessionCapabilities, opts: SessionOptions = {}): Promise<void> {
this._serverStartupTimeoutMs = caps.serverStartupTimeout ?? this._serverStartupTimeoutMs;
this._isProxyingToRemoteServer = !!caps.webDriverAgentMacUrl;
let wasProcessInitNecessary: boolean;
if (this._isProxyingToRemoteServer) {
if (this._process) {
await this._process.kill();
await cleanupObsoleteProcesses();
this._process = null;
}
wasProcessInitNecessary = false;
} else {
if (!this._process) {
this._process = new WDAMacProcess();
}
wasProcessInitNecessary = await this._process.init(caps);
}
if (wasProcessInitNecessary || this._isProxyingToRemoteServer || !this._proxy) {
const {scheme, host, port, path} = this.parseProxyProperties(caps);
const proxyOpts: ProxyOptions = {
scheme,
server: host,
port,
base: path,
keepAlive: true,
};
if (caps.reqBasePath) {
proxyOpts.reqBasePath = opts.reqBasePath;
}
this._proxy = new WDAMacProxy(proxyOpts);
this._proxy.didProcessExit = false;
if (this._process?.proc) {
this._process.proc.on('exit', () => {
if (this._proxy) {
this._proxy.didProcessExit = true;
}
});
}
const timer = new timing.Timer().start();
try {
await waitForCondition(async () => await this.isProxyReady(), {
waitMs: this._serverStartupTimeoutMs,
intervalMs: 1000,
});
} catch (e: any) {
if (this._process?.isRunning) {
// avoid "frozen" processes,
await this._process.kill();
}
if (/Condition unmet/.test(e.message)) {
const msg = this._isProxyingToRemoteServer
? `No response from '${scheme}://${host}:${port}${path}' within ${this._serverStartupTimeoutMs}ms timeout.` +
`Please make sure the remote server is running and accessible by Appium`
: `Mac2Driver server is not listening within ${this._serverStartupTimeoutMs}ms timeout. ` +
`Try to increase the value of 'serverStartupTimeout' capability, check the server logs ` +
`and make sure the ${XCODEBUILD} host process could be started manually from a terminal`;
throw new Error(msg);
}
throw e;
}
if (this._process) {
const pid = this._process.pid;
const childrenPids = await this._process.listChildrenPids();
if (pid !== null) {
RUNNING_PROCESS_IDS.push(...childrenPids, pid);
this._process.proc?.on('exit', () => void _.pull(RUNNING_PROCESS_IDS, pid));
}
log.info(
`The host process is ready within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
}
} else {
log.info('The host process has already been listening. Proceeding with session creation');
}
await this._proxy.command('/session', 'POST', {
capabilities: {
firstMatch: [{}],
alwaysMatch: caps,
},
});
}
async stopSession(): Promise<void> {
if (!this._isProxyingToRemoteServer && !this._process?.isRunning) {
log.info(`Mac2Driver session cannot be stopped, because the server is not running`);
return;
}
if (this._proxy?.sessionId) {
try {
await this._proxy.command(`/session/${this._proxy.sessionId}`, 'DELETE');
} catch (e: any) {
log.info(`Mac2Driver session cannot be deleted. Original error: ${e.message}`);
}
}
}
private async isProxyReady(throwOnExit = true): Promise<boolean> {
if (!this._proxy) {
return false;
}
try {
await this._proxy.command('/status', 'GET');
return true;
} catch (err: any) {
if (throwOnExit && this._proxy.didProcessExit) {
throw new Error(err.message);
}
return false;
}
}
/**
* Returns proxy information where WDAMacServer proxy to.
*
* @param caps - The capabilities in the session.
* @return ProxyProperties
* @throws Error if 'webDriverAgentMacUrl' had invalid URL
*/
private parseProxyProperties(caps: StartSessionCapabilities): ProxyProperties {
let scheme = 'http';
if (!caps.webDriverAgentMacUrl) {
return {
scheme,
host: this._process?.host ?? caps.systemHost ?? DEFAULT_SYSTEM_HOST,
port: this._process?.port ?? caps.systemPort ?? DEFAULT_SYSTEM_PORT,
path: '',
};
}
let parsedUrl: url.URL;
try {
parsedUrl = new url.URL(caps.webDriverAgentMacUrl);
} catch (e: any) {
throw new Error(
`webDriverAgentMacUrl, '${caps.webDriverAgentMacUrl}', ` +
`in the capabilities is invalid. ${e.message}`,
);
}
const {protocol, hostname, port, pathname} = parsedUrl;
if (_.isString(protocol)) {
scheme = protocol.split(':')[0];
}
return {
scheme,
host: hostname ?? DEFAULT_SYSTEM_HOST,
port: _.isEmpty(port) ? DEFAULT_SYSTEM_PORT : _.parseInt(port),
path: pathname === '/' ? '' : pathname,
};
}
}
export const WDA_MAC_SERVER = new WDAMacServer();
// Private functions
async function cleanupObsoleteProcesses(): Promise<void> {
if (!_.isEmpty(RUNNING_PROCESS_IDS)) {
log.debug(
`Cleaning up ${RUNNING_PROCESS_IDS.length} obsolete ` +
util.pluralize('process', RUNNING_PROCESS_IDS.length, false),
);
try {
await exec('kill', ['-9', ...RUNNING_PROCESS_IDS.map(String)]);
} catch {}
_.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
}
process.once('exit', () => {
if (!_.isEmpty(RUNNING_PROCESS_IDS)) {
try {
execSync(`kill -9 ${RUNNING_PROCESS_IDS.map(String).join(' ')}`);
} catch {}
_.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
});
// Private type definitions
interface WDAMacProcessInitOptions {
showServerLogs?: boolean;
systemPort?: number;
systemHost?: string;
bootstrapRoot?: string;
}
interface ProxyProperties {
scheme: string;
host: string;
port: number;
path: string;
}
interface StartSessionCapabilities {
webDriverAgentMacUrl?: string;
systemHost?: string;
systemPort?: number;
serverStartupTimeout?: number;
reqBasePath?: string;
[key: string]: unknown;
}