appium-mac2-driver
Version:
XCTest-based Appium driver for macOS apps automation
453 lines • 19.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WDAMacServer = exports.WDAMacProxy = void 0;
const lodash_1 = __importDefault(require("lodash"));
const path_1 = __importDefault(require("path"));
const url_1 = __importDefault(require("url"));
const axios_1 = __importDefault(require("axios"));
const bluebird_1 = __importDefault(require("bluebird"));
const driver_1 = require("appium/driver");
const support_1 = require("appium/support");
const strongbox_1 = require("@appium/strongbox");
const teen_process_1 = require("teen_process");
const asyncbox_1 = require("asyncbox");
const portscanner_1 = require("portscanner");
const child_process_1 = require("child_process");
const utils_1 = require("./utils");
const log = support_1.logger.getLogger('WebDriverAgentMac');
const DEFAULT_WDA_ROOT = path_1.default.resolve((0, utils_1.getModuleRoot)(), 'WebDriverAgentMac');
const WDA_PROJECT_NAME = 'WebDriverAgentMac.xcodeproj';
const WDA_PROJECT = (wdaRoot = DEFAULT_WDA_ROOT) => path_1.default.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_1.default.join('.appium', 'webdriveragent_mac', 'upgrade.time');
const RECENT_MODULE_VERSION_ITEM_NAME = 'recentWdaModuleVersion';
async function cleanupObsoleteProcesses() {
if (!lodash_1.default.isEmpty(RUNNING_PROCESS_IDS)) {
log.debug(`Cleaning up ${RUNNING_PROCESS_IDS.length} obsolete ` +
support_1.util.pluralize('process', RUNNING_PROCESS_IDS.length, false));
try {
await (0, teen_process_1.exec)('kill', ['-9', ...RUNNING_PROCESS_IDS]);
}
catch { }
lodash_1.default.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
}
process.once('exit', () => {
if (!lodash_1.default.isEmpty(RUNNING_PROCESS_IDS)) {
try {
(0, child_process_1.execSync)(`kill -9 ${RUNNING_PROCESS_IDS.join(' ')}`);
}
catch { }
lodash_1.default.pullAll(RUNNING_PROCESS_IDS, RUNNING_PROCESS_IDS);
}
});
class WDAMacProxy extends driver_1.JWProxy {
async proxyCommand(url, method, body = null) {
if (this.didProcessExit) {
throw new driver_1.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);
}
}
exports.WDAMacProxy = WDAMacProxy;
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 (0, utils_1.listChildrenProcessIds)(this.pid)) : [];
}
async cleanupProjectIfFresh() {
const packageInfo = JSON.parse(await support_1.fs.readFile(path_1.default.join((0, utils_1.getModuleRoot)(), 'package.json'), 'utf8'));
const box = (0, strongbox_1.strongbox)(packageInfo.name);
let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME);
if (!boxItem) {
const timestampPath = path_1.default.resolve(process.env.HOME ?? '', RECENT_UPGRADE_TIMESTAMP_PATH);
if (await support_1.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 = support_1.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 (support_1.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 (0, teen_process_1.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 (lodash_1.default.isBoolean(showServerLogs) && this.showServerLogs !== showServerLogs
|| lodash_1.default.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 support_1.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 support_1.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 (0, portscanner_1.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 support_1.timing.Timer().start();
try {
await axios_1.default.delete(`http://${this.host}:${this.port}/`, {
timeout: 5000,
});
// Give the server some time to finish and stop listening
await bluebird_1.default.delay(500);
await (0, asyncbox_1.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 teen_process_1.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 = lodash_1.default.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} ${support_1.util.quote(args)}`);
await this.proc.start(0);
return true;
}
async stop() {
if (!this.isRunning) {
return;
}
const childrenPids = await this.listChildrenPids();
if (!lodash_1.default.isEmpty(childrenPids)) {
try {
await (0, teen_process_1.exec)('kill', childrenPids);
}
catch { }
}
await this.proc?.stop('SIGTERM', 3000);
}
async kill() {
if (!this.isRunning) {
return;
}
const childrenPids = await this.listChildrenPids();
if (!lodash_1.default.isEmpty(childrenPids)) {
try {
await (0, teen_process_1.exec)('kill', ['-9', ...childrenPids]);
}
catch { }
}
try {
await this.proc?.stop('SIGKILL');
}
catch { }
}
}
class WDAMacServer {
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_1.default.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 (lodash_1.default.isString(protocol)) {
scheme = protocol.split(':')[0];
}
return {
scheme,
host: hostname ?? DEFAULT_SYSTEM_HOST,
port: lodash_1.default.isEmpty(port) ? DEFAULT_SYSTEM_PORT : lodash_1.default.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 support_1.timing.Timer().start();
try {
await (0, asyncbox_1.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 lodash_1.default.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}`);
}
}
}
}
exports.WDAMacServer = WDAMacServer;
const WDA_MAC_SERVER = new WDAMacServer();
exports.default = WDA_MAC_SERVER;
/**
* @typedef {Object} SessionOptions
* @property {string} [reqBasePath]
*/
//# sourceMappingURL=wda-mac.js.map