import os from 'node:os' import fs from 'node:fs' import fsp from 'node:fs/promises' import url from 'node:url' import path from 'node:path' import { spawn, type ChildProcessByStdio } from 'node:child_process' import { type Readable } from 'node:stream' import { promisify } from 'node:util' import logger from '@wdio/logger' import getPort from 'get-port' import { resolve } from 'import-meta-resolve' import { isCloudCapability } from '@wdio/config' import { SevereServiceError } from 'webdriverio' import type { Services, Capabilities, Options } from '@wdio/types' import { isAppiumCapability } from '@wdio/utils' import { getFilePath, formatCliArgs } from './utils.js' import type { AppiumServerArguments, AppiumServiceConfig } from './types.js' import treeKill from 'tree-kill' const log = logger('@wdio/appium-service') const DEFAULT_APPIUM_PORT = 4723 const DEFAULT_LOG_FILENAME = 'wdio-appium.log' const DEFAULT_CONNECTION = { protocol: 'http', hostname: '127.0.0.1', path: '/' } const APPIUM_START_TIMEOUT = 30 * 1000 export default class AppiumLauncher implements Services.ServiceInstance { private readonly _logPath?: string private readonly _appiumCliArgs: string[] = [] private readonly _args: AppiumServerArguments private _process?: ChildProcessByStdio private _isShuttingDown: boolean = false constructor( private _options: AppiumServiceConfig, private _capabilities: Capabilities.TestrunnerCapabilities, private _config?: Options.Testrunner ) { this._args = { basePath: DEFAULT_CONNECTION.path, ...(this._options.args || {}) } this._logPath = _options.logPath || this._config?.outputDir } private async _getCommand(command?: string) { /** * Explicitly set node as command and appium * module path as it's first argument if it's not defined */ if (!command) { command = 'node' this._appiumCliArgs.unshift(await AppiumLauncher._getAppiumCommand()) } /** * Windows needs to be started through `cmd` and the command needs to be an arg */ if (os.platform() === 'win32') { this._appiumCliArgs.unshift('/c', command) command = 'cmd' } return command } /** * update capability connection options to connect * to Appium server */ private _setCapabilities(port: number) { let capabilityWasUpdated = false /** * Multiremote sessions */ if (!Array.isArray(this._capabilities)) { for (const [, capability] of Object.entries(this._capabilities)) { const cap = (capability.capabilities as Capabilities.W3CCapabilities) || capability const c = (cap as Capabilities.W3CCapabilities).alwaysMatch || cap if (!isCloudCapability(c) && isAppiumCapability(c)) { capabilityWasUpdated = true Object.assign( capability, DEFAULT_CONNECTION, { path: this._args.basePath, port }, { ...capability } ) } } return capabilityWasUpdated } this._capabilities.forEach((cap) => { const w3cCap = cap as Capabilities.W3CCapabilities /** * Parallel Multiremote */ if (Object.values(cap).length > 0 && Object.values(cap).every(c => typeof c === 'object' && c.capabilities)) { Object.values(cap).forEach(c => { const capability = (c.capabilities as Capabilities.W3CCapabilities).alwaysMatch || (c.capabilities as Capabilities.W3CCapabilities) || c if (!isCloudCapability(capability) && isAppiumCapability(capability)) { capabilityWasUpdated = true Object.assign( c, DEFAULT_CONNECTION, { path: this._args.basePath, port }, { ...c } ) } } ) } else if (!isCloudCapability(w3cCap.alwaysMatch || cap) && isAppiumCapability(w3cCap.alwaysMatch || cap)) { capabilityWasUpdated = true Object.assign( cap, DEFAULT_CONNECTION, { path: this._args.basePath, port }, { ...cap } ) } }) return capabilityWasUpdated } async onPrepare() { /** * Throws an error if `this._options.args` is defined and is an array. * @throws {Error} If `this._options.args` is an array. */ if (Array.isArray(this._options.args)) { throw new Error('Args should be an object') } /** * Use port from service option or get a random port */ this._args.port = typeof this._args.port === 'number' ? this._args.port : await getPort({ port: DEFAULT_APPIUM_PORT }) /** * only actually start Appium server if we detect a capability that * would indicate a that a local Appium session is needed */ const capabilityWasUpdated = this._setCapabilities(this._args.port) if (!capabilityWasUpdated) { log.warn('Could not identify any capability that indicates a local Appium session, skipping Appium launch') return } /** * Append cli arguments */ this._appiumCliArgs.push(...formatCliArgs({ ...this._args })) /** * start Appium */ const command = await this._getCommand(this._options.command) this._process = await this._startAppium(command, this._appiumCliArgs) if (this._logPath) { this._redirectLogStream(this._logPath) } else { log.info('Appium logs written to stdout') this._process.stdout.on('data', this.#logStdout) this._process.stderr.on('data', this.#logStderr) } } #logStdout = (data: Buffer) => { log.debug(data.toString()) } #logStderr = (data: Buffer) => { log.warn(data.toString()) } private promisifiedTreeKill = promisify(treeKill) async onComplete() { this._isShuttingDown = true /** * Kill appium and all process' spawned from it */ if (this._process && this._process.pid) { /** * remove stdio event listener */ this._process.stdout.off('data', this.#logStdout) this._process.stderr.off('data', this.#logStderr) /** * Ensure all child processes are also killed */ log.info('Killing entire Appium tree') try { // First attempt with SIGTERM await this.promisifiedTreeKill(this._process.pid, 'SIGTERM') .catch(async (err) => { log.warn('SIGTERM failed, attempting SIGKILL:', err) // If SIGTERM fails, try SIGKILL await this.promisifiedTreeKill(this._process!.pid!, 'SIGKILL') }) log.info('Process and its children successfully terminated') } catch (err) { log.error('Failed to kill Appium process tree:', err) try { this._process.kill('SIGKILL') log.info('Killed main process directly') } catch (e) { log.error('Failed to kill process directly:', e) } } } } private _startAppium(command: string, args: Array, timeout = APPIUM_START_TIMEOUT) { log.info(`Will spawn Appium process: ${command} ${args.join(' ')}`) const appiumProcess: ChildProcessByStdio = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) // just for validate the first error let errorCaptured = false // to set a timeout for the promise let timeoutId: NodeJS.Timeout // to store the first error message let error: string return new Promise>((resolve, reject) => { let outputBuffer = '' /** * set timeout for promise. If Appium does not start within given timeout, * e.g. if the port is already in use, reject the promise. */ timeoutId = setTimeout(() => { rejectOnce(new Error('Timeout: Appium did not start within expected time')) }, timeout) /** * reject promise if Appium does not start within given timeout, * e.g. if the port is already in use * * @param err - error to reject with */ const rejectOnce = (err: Error) => { if (!errorCaptured) { errorCaptured = true clearTimeout(timeoutId) reject(err) } } /** * only capture first error to print it in case Appium failed to start. */ const onErrorMessage = (data: Buffer) => { /** * filter 'Debugger attached' message as it is not an error */ error = data.toString() || 'Appium exited without unknown error message' if (!data.toString().includes('Debugger attached')) { log.error(error) } rejectOnce(new Error(error)) } const onStdout = (data: Buffer) => { outputBuffer += data.toString() if (outputBuffer.includes('Appium REST http interface listener started')) { outputBuffer = '' log.info(`Appium started with ID: ${appiumProcess.pid}`) clearTimeout(timeoutId) appiumProcess.stdout.off('data', onStdout) appiumProcess.stderr.off('data', onErrorMessage) resolve(appiumProcess) } } appiumProcess.stdout.on('data', onStdout) appiumProcess.stderr.once('data', onErrorMessage) appiumProcess.once('exit', (exitCode: number) => { if (this._isShuttingDown) { return } let errorMessage = `Appium exited before timeout (exit code: ${exitCode})` if (exitCode === 2) { errorMessage += '\n' + (error?.toString() || 'Check that you don\'t already have a running Appium service.') } else if (errorCaptured) { errorMessage += `\n${error?.toString()}` } if (exitCode !== 0) { log.error(errorMessage) } rejectOnce(new Error(errorMessage)) }) }) } private async _redirectLogStream(logPath: string) { if (!this._process){ throw Error('No Appium process to redirect log stream') } const logFile = getFilePath(logPath, DEFAULT_LOG_FILENAME) // ensure file & directory exists await fsp.mkdir(path.dirname(logFile), { recursive: true }) log.debug(`Appium logs written to: ${logFile}`) const logStream = fs.createWriteStream(logFile, { flags: 'w' }) this._process.stdout.pipe(logStream) this._process.stderr.pipe(logStream) } private static async _getAppiumCommand (command = 'appium') { try { const entryPath = await resolve(command, import.meta.url) return url.fileURLToPath(entryPath) } catch (err) { const errorMessage = ( 'Appium is not installed locally. Please install via e.g. `npm i --save-dev appium`.\n' + 'If you use globally installed appium please add: `appium: { command: \'appium\' }`\n' + 'to your wdio.conf.js!\n\n' + (err as Error).stack ) log.error(errorMessage) throw new SevereServiceError(errorMessage) } } }