UNPKG

appium-geckodriver

Version:

Appium driver for Gecko-based browsers and web views

312 lines (279 loc) 9.25 kB
import os from 'node:os'; import path from 'node:path'; import {JWProxy, errors} from 'appium/driver'; import {fs, util, system} from 'appium/support'; import {SubProcess} from 'teen_process'; import {waitForCondition} from 'asyncbox'; import {findAPortNotInUse} from 'portscanner'; import {execSync} from 'node:child_process'; import type {AppiumLogger, StringRecord, HTTPMethod, HTTPBody} from '@appium/types'; import {VERBOSITY} from './constants'; const GD_BINARY = `geckodriver${system.isWindows() ? '.exe' : ''}`; const STARTUP_TIMEOUT_MS = 10000; // 10 seconds const GECKO_PORT_RANGE: [number, number] = [5200, 5300]; const GECKO_SERVER_GUARD = util.getLockFileGuard( path.resolve(os.tmpdir(), 'gecko_server_guard.lock'), {timeout: 5, tryRecovery: true}, ); const DEFAULT_MARIONETTE_PORT = 2828; export const GECKO_SERVER_HOST = '127.0.0.1'; export interface SessionOptions { reqBasePath?: string; } class GeckoDriverProcess { private readonly noReset?: boolean; private readonly verbosity?: string; private readonly androidStorage?: string; private readonly marionettePort?: number; private readonly geckodriverExecutable?: string; private _port?: number; private readonly log: AppiumLogger; private _proc: SubProcess | null = null; constructor(log: AppiumLogger, opts: StringRecord = {}) { this.noReset = opts.noReset; this.verbosity = opts.verbosity; this.androidStorage = opts.androidStorage; this.marionettePort = opts.marionettePort; this.geckodriverExecutable = opts.geckodriverExecutable; this._port = opts.systemPort; this.log = log; } get isRunning(): boolean { return !!this._proc?.isRunning; } get port(): number | undefined { return this._port; } get proc(): SubProcess | null { return this._proc; } async init(): Promise<void> { if (this.isRunning) { return; } if (!this._port) { await GECKO_SERVER_GUARD(async () => { const [startPort, endPort] = GECKO_PORT_RANGE; try { this._port = await findAPortNotInUse(startPort, endPort); } catch { throw new Error( `Cannot find any free port in range ${startPort}..${endPort}. ` + `Double check the processes that are locking ports within this range and terminate ` + `these which are not needed anymore or set any free port number to the 'systemPort' capability`, ); } }); } const driverBin = await this.resolveGeckodriverBinary(); const args: string[] = []; /* #region Options */ switch (this.verbosity?.toLowerCase()) { case VERBOSITY.DEBUG: args.push('-v'); break; case VERBOSITY.TRACE: args.push('-vv'); break; } if (this.noReset) { args.push('--connect-existing'); // https://firefox-source-docs.mozilla.org/testing/geckodriver/Flags.html#code-connect-existing-code if (this.marionettePort == null) { this.log.info( `'marionettePort' capability value is not provided while 'noReset' is enabled`, ); this.log.info( `Assigning 'marionettePort' to the default value (${DEFAULT_MARIONETTE_PORT})`, ); } args.push('--marionette-port', `${this.marionettePort ?? DEFAULT_MARIONETTE_PORT}`); } else if (this.marionettePort != null) { args.push('--marionette-port', `${this.marionettePort}`); } /* #endregion */ args.push('-p', `${this._port}`); if (this.androidStorage) { args.push('--android-storage', this.androidStorage); } this._proc = new SubProcess(driverBin, args); this._proc.on('output', (stdout, stderr) => { const line = (stdout || stderr).trim(); if (line) { this.log.debug(`[${GD_BINARY}] ${line}`); } }); this._proc.on('exit', (code, signal) => { this.log.info(`${GD_BINARY} has exited with code ${code}, signal ${signal}`); }); this.log.info(`Starting '${driverBin}' with args ${JSON.stringify(args)}`); await this._proc.start(0); } async stop(): Promise<void> { if (this.isRunning) { await this._proc?.stop('SIGTERM'); } } async kill(): Promise<void> { if (this.isRunning) { try { await this._proc?.stop('SIGKILL'); } catch {} } } private async resolveGeckodriverBinary(): Promise<string> { if (this.geckodriverExecutable) { if (!(await fs.exists(this.geckodriverExecutable))) { throw new Error( `The custom geckodriver binary at '${this.geckodriverExecutable}' cannot be found. ` + `Make sure the path is correct and accessible to the Appium server process`, ); } return this.geckodriverExecutable; } try { return await fs.which(GD_BINARY); } catch { throw new Error( `${GD_BINARY} binary cannot be found in PATH. ` + `Please make sure it is present on your system`, ); } } } export class GeckoProxy extends JWProxy { didProcessExit?: boolean; override async proxyCommand(url: string, method: HTTPMethod, body: HTTPBody = null) { if (this.didProcessExit) { throw new errors.InvalidContextError( `'${method} ${url}' cannot be proxied to Gecko Driver server because ` + 'its process is not running (probably crashed). Check the Appium log for more details', ); } return await super.proxyCommand(url, method, body); } } const RUNNING_PROCESS_IDS: (number | undefined)[] = []; const removeRunningProcessId = (pid: number): void => { let idx = RUNNING_PROCESS_IDS.indexOf(pid); while (idx >= 0) { RUNNING_PROCESS_IDS.splice(idx, 1); idx = RUNNING_PROCESS_IDS.indexOf(pid); } }; process.once('exit', () => { if (RUNNING_PROCESS_IDS.length === 0) { return; } const command = system.isWindows() ? 'taskkill.exe ' + RUNNING_PROCESS_IDS.filter((pid): pid is number => pid !== undefined) .map((pid) => `/PID ${pid}`) .join(' ') : `kill ${RUNNING_PROCESS_IDS.filter((pid): pid is number => pid !== undefined).join(' ')}`; try { execSync(command); } catch {} }); export class GeckoDriverServer { private _proxy: GeckoProxy | null = null; private readonly _process: GeckoDriverProcess; private readonly log: AppiumLogger; constructor(log: AppiumLogger, caps: StringRecord) { this._process = new GeckoDriverProcess(log, caps); this.log = log; this._proxy = null; } get proxy(): GeckoProxy { if (!this._proxy) { throw new Error('Gecko proxy is not initialized'); } return this._proxy; } get isRunning(): boolean { return !!this._process?.isRunning; } async start(geckoCaps: StringRecord, opts: SessionOptions = {}): Promise<StringRecord> { await this._process.init(); const proxyOpts: any = { server: GECKO_SERVER_HOST, port: this._process.port, log: this.log, base: '', keepAlive: true, }; if (opts.reqBasePath) { proxyOpts.reqBasePath = opts.reqBasePath; } this._proxy = new GeckoProxy(proxyOpts); this._proxy.didProcessExit = false; this._process?.proc?.on('exit', () => { if (this._proxy) { this._proxy.didProcessExit = true; } }); try { await waitForCondition( async () => { try { await this._proxy?.command('/status', 'GET'); return true; } catch (err: any) { if (this._proxy?.didProcessExit) { throw new Error(err.message, {cause: err}); } return false; } }, { waitMs: STARTUP_TIMEOUT_MS, intervalMs: 1000, }, ); } catch (e: any) { if (this._process.isRunning) { // avoid "frozen" processes, await this._process.kill(); } if (/Condition unmet/.test(e.message)) { throw new Error( `Gecko Driver server is not listening within ${STARTUP_TIMEOUT_MS}ms timeout. ` + `Make sure it could be started manually from a terminal`, {cause: e}, ); } throw e; } const pid = this._process.proc?.pid; if (pid) { RUNNING_PROCESS_IDS.push(pid); this._process.proc?.on('exit', () => removeRunningProcessId(pid)); } return (await this._proxy.command('/session', 'POST', { capabilities: { firstMatch: [{}], alwaysMatch: geckoCaps, }, })) as StringRecord; } async stop(): Promise<void> { if (!this.isRunning) { this.log.info(`Gecko Driver 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) { this.log.info(`Gecko Driver session cannot be deleted. Original error: ${e.message}`); } } try { await this._process.stop(); } catch (e: any) { this.log.warn(`Gecko Driver process cannot be stopped (${e.message}). Killing it forcefully`); await this._process.kill(); } } } export default GeckoDriverServer;