appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
488 lines (433 loc) • 16 kB
JavaScript
import _ from 'lodash';
import path from 'path';
import url from '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 'child_process';
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 = [];
const RECENT_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent_mac', 'upgrade.time');
const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion';
async function cleanupObsoleteProcesses () {
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]);
} catch {}
_.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
}
process.once('exit', () => {
if (!_.isEmpty(RUNNING_PROCESS_IDS)) {
try {
execSync(`kill -9 ${RUNNING_PROCESS_IDS.join(' ')}`);
} catch {}
_.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
});
export class WDAMacProxy extends JWProxy {
/** @type {boolean|undefined} */
didProcessExit;
async proxyCommand (url, method, body = null) {
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 {
constructor () {
this.showServerLogs = DEFAULT_SHOW_SERVER_LOGS;
this.port = DEFAULT_SYSTEM_PORT;
this.host = DEFAULT_SYSTEM_HOST;
this.bootstrapRoot = DEFAULT_WDA_ROOT;
this.proc = null;
}
get isRunning () {
return !!(this.proc?.isRunning);
}
get pid () {
return this.isRunning && this.proc ? this.proc.pid : null;
}
async listChildrenPids () {
return this.pid ? (await listChildrenProcessIds(this.pid)) : [];
}
async cleanupProjectIfFresh () {
const packageInfo = JSON.parse(await fs.readFile(path.join(getModuleRoot(), '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 ?? '', RECENT_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, '1.5.4');
} catch (e) {
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) {
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) {
log.warn(`The persisted module version string has been damaged: ${e.message}`);
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)) {
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) {
log.warn(`Cannot perform project cleanup. Original error: ${e.stderr || e.message}`);
}
}
hasSameOpts ({ showServerLogs, systemPort, systemHost, bootstrapRoot }) {
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;
}
async init (opts = {}) {
// @ts-ignore TODO: Make opts typed
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;
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 () => (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) {
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 () {
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 () {
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 {}
}
}
export class WDAMacServer {
/** @type {WDAMacProxy} */
proxy;
constructor () {
this.process = null;
this.serverStartupTimeoutMs = STARTUP_TIMEOUT_MS;
// @ts-ignore this is ok
this.proxy = null;
// To handle if the WDAMac server is proxying requests to a remote WDAMac app instance
this.isProxyingToRemoteServer = false;
}
async isProxyReady (throwOnExit = true) {
if (!this.proxy) {
return false;
}
try {
await this.proxy.command('/status', 'GET');
return true;
} catch (err) {
if (throwOnExit && this.proxy.didProcessExit) {
throw new Error(err.message);
}
return false;
}
}
/**
* @typedef {Object} ProxyProperties
*
* @property {string} scheme - The scheme proxy to.
* @property {string} host - The host name proxy to.
* @property {number} port - The port number proxy to.
* @property {string} path - The path proxy to.
*/
/**
* Returns proxy information where WDAMacServer proxy to.
*
* @param {Object} caps - The capabilities in the session.
* @return {ProxyProperties}
* @throws Error if 'webDriverAgentMacUrl' had invalid URL
*/
parseProxyProperties (caps) {
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;
try {
parsedUrl = new url.URL(caps.webDriverAgentMacUrl);
} catch (e) {
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
};
}
/**
*
* @param {import('@appium/types').StringRecord} caps
* @param {SessionOptions} [opts={}]
*/
async startSession (caps, opts = {}) {
this.serverStartupTimeoutMs = caps.serverStartupTimeout ?? this.serverStartupTimeoutMs;
this.isProxyingToRemoteServer = !!caps.webDriverAgentMacUrl;
let wasProcessInitNecessary;
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 = {
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) {
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();
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 () {
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) {
log.info(`Mac2Driver session cannot be deleted. Original error: ${e.message}`);
}
}
}
}
const WDA_MAC_SERVER = new WDAMacServer();
export default WDA_MAC_SERVER;
/**
* @typedef {Object} SessionOptions
* @property {string} [reqBasePath]
*/