UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

442 lines (388 loc) 14.9 kB
import {util} from '@appium/support'; import { BASE_DESIRED_CAP_CONSTRAINTS, type AppiumServer, type BaseDriverCapConstraints, type Capabilities, type Constraints, type DefaultCreateSessionResult, type Driver, type DriverCaps, type DriverData, type MultiSessionData, type ServerArgs, type StringRecord, type W3CDriverCaps, type InitialOpts, type DefaultDeleteSessionResult, type SingularSessionData, type SessionCapabilities, } from '@appium/types'; import B from 'bluebird'; import _ from 'lodash'; import {fixCaps, isW3cCaps} from '../helpers/capabilities'; import {calcSignature} from '../helpers/session'; import {DELETE_SESSION_COMMAND, determineProtocol, errors} from '../protocol'; import {processCapabilities, validateCaps} from './capabilities'; import {DriverCore} from './core'; import * as helpers from './helpers'; const EVENT_SESSION_INIT = 'newSessionRequested'; const EVENT_SESSION_START = 'newSessionStarted'; const EVENT_SESSION_QUIT_START = 'quitSessionRequested'; const EVENT_SESSION_QUIT_DONE = 'quitSessionFinished'; const ON_UNEXPECTED_SHUTDOWN_EVENT = 'onUnexpectedShutdown'; export class BaseDriver< const C extends Constraints, CArgs extends StringRecord = StringRecord, Settings extends StringRecord = StringRecord, CreateResult = DefaultCreateSessionResult<C>, DeleteResult = DefaultDeleteSessionResult, SessionData extends StringRecord = StringRecord, > extends DriverCore<C, Settings> implements Driver<C, CArgs, Settings, CreateResult, DeleteResult, SessionData> { cliArgs: CArgs & ServerArgs; caps: DriverCaps<C>; originalCaps: W3CDriverCaps<C>; desiredCapConstraints: C; server?: AppiumServer; serverHost?: string; serverPort?: number; serverPath?: string; constructor(opts: InitialOpts, shouldValidateCaps = true) { super(opts, shouldValidateCaps); this.caps = {} as DriverCaps<C>; this.cliArgs = {} as CArgs & ServerArgs; } /** * Contains the base constraints plus whatever the subclass wants to add. * * Subclasses _shouldn't_ need to use this. If you need to use this, please create * an issue: * @see {@link https://github.com/appium/appium/issues/new} */ protected get _desiredCapConstraints(): Readonly<BaseDriverCapConstraints & C> { return Object.freeze(_.merge({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints)); } /** * This is the main command handler for the driver. It wraps command * execution with timeout logic, checking that we have a valid session, * and ensuring that we execute commands one at a time. This method is called * by MJSONWP's express router. */ async executeCommand<T = unknown>(cmd: string, ...args: any[]): Promise<T> { // get start time for this command, and log in special cases const startTime = Date.now(); if (cmd === 'createSession') { // If creating a session determine if W3C or MJSONWP protocol was requested and remember the choice this.protocol = determineProtocol(args); this.logEvent(EVENT_SESSION_INIT); } else if (cmd === DELETE_SESSION_COMMAND) { this.logEvent(EVENT_SESSION_QUIT_START); } // if we had a command timer running, clear it now that we're starting // a new command and so don't want to time out await this.clearNewCommandTimeout(); if (this.shutdownUnexpectedly) { throw new errors.NoSuchDriverError('The driver was unexpectedly shut down!'); } // If we don't have this command, it must not be implemented if (!this[cmd]) { await this.startNewCommandTimeout(); throw new errors.NotYetImplementedError(); } const runCommandPromise = async () => { let unexpectedShutdownRejecter: ((error?: any) => void) | null = null; let unexpectedShutdownResolver: ((x?: unknown) => void) | null = null; let wasSessionShutdownUnexpectedly = false; const onUnexpectedShutdown = (e: Error) => { wasSessionShutdownUnexpectedly = true; unexpectedShutdownRejecter?.(e); }; try { return await B.race([ this[cmd](...args), // This promise is needed to monitor if the session has been // shut down unexpectedly while the command was running new B((resolve, reject) => { unexpectedShutdownResolver = resolve; unexpectedShutdownRejecter = reject; this.eventEmitter.once(ON_UNEXPECTED_SHUTDOWN_EVENT, onUnexpectedShutdown); }) ]); } finally { if (unexpectedShutdownRejecter && unexpectedShutdownResolver) { // This is needed to prevent memory leaks this.eventEmitter.removeListener(ON_UNEXPECTED_SHUTDOWN_EVENT, onUnexpectedShutdown); unexpectedShutdownRejecter = null; // @ts-ignore typescript cannot understand this unexpectedShutdownResolver?.(); unexpectedShutdownResolver = null; } // if we have set a new command timeout (which is the default), start a // timer once we've finished executing this command. If we don't clear // the timer (which is done when a new command comes in), we will trigger // automatic session deletion in this.onCommandTimeout. Of course we don't // want to trigger the timer when the user is shutting down the session // intentionally if (!wasSessionShutdownUnexpectedly && this.isCommandsQueueEnabled && cmd !== DELETE_SESSION_COMMAND) { // resetting existing timeout await this.startNewCommandTimeout(); } } }; const synchronizationKey = BaseDriver.name; // eslint-disable-next-line dot-notation const commandsQueueLen: number = this.commandsQueueGuard['queues']?.[synchronizationKey]?.length ?? 0; if (this.isCommandsQueueEnabled && commandsQueueLen > 0) { this.log.debug( `Scheduling the '${cmd}' command to the ${this.constructor.name} commands queue. ` + `${util.pluralize('queue item', commandsQueueLen, true)} ${commandsQueueLen === 1 ? 'is' : 'are'} ` + `already waiting for execution.` ); } const res = this.isCommandsQueueEnabled ? await this.commandsQueueGuard.acquire(synchronizationKey, runCommandPromise) : await runCommandPromise(); // log timing information about this command const endTime = Date.now(); this._eventHistory.commands.push({cmd, startTime, endTime}); if (cmd === 'createSession') { this.logEvent(EVENT_SESSION_START); } else if (cmd === DELETE_SESSION_COMMAND) { this.logEvent(EVENT_SESSION_QUIT_DONE); } return res; } async startUnexpectedShutdown( err: Error = new errors.NoSuchDriverError('The driver was unexpectedly shut down!'), ) { this.eventEmitter.emit(ON_UNEXPECTED_SHUTDOWN_EVENT, err); // allow others to listen for this this.shutdownUnexpectedly = true; try { if (this.sessionId !== null) { await this.deleteSession(this.sessionId); } } finally { this.shutdownUnexpectedly = false; } } async startNewCommandTimeout() { // make sure there are no rogue timeouts await this.clearNewCommandTimeout(); // if command timeout is 0, it is disabled if (!this.newCommandTimeoutMs) return; // eslint-disable-line curly this.noCommandTimer = setTimeout(async () => { this.log.warn( `Shutting down because we waited ` + `${this.newCommandTimeoutMs / 1000.0} seconds for a command`, ); const errorMessage = `New Command Timeout of ` + `${this.newCommandTimeoutMs / 1000.0} seconds ` + `expired. Try customizing the timeout using the ` + `'newCommandTimeout' desired capability`; await this.startUnexpectedShutdown(new Error(errorMessage)); }, this.newCommandTimeoutMs); } assignServer(server: AppiumServer, host: string, port: number, path: string) { this.server = server; this.serverHost = host; this.serverPort = port; this.serverPath = path; } /* * Restart the session with the original caps, * preserving the timeout config. */ async reset() { this.log.debug('Resetting app mid-session'); this.log.debug('Running generic full reset'); // preserving state const currentConfig = {}; for (const property of [ 'implicitWaitMs', 'newCommandTimeoutMs', 'sessionId', 'resetOnUnexpectedShutdown', ]) { currentConfig[property] = this[property]; } try { if (this.sessionId !== null) { await this.deleteSession(this.sessionId); } this.log.debug('Restarting app'); await this.createSession(this.originalCaps); } finally { // always restore state. for (const [key, value] of _.toPairs(currentConfig)) { this[key] = value; } } await this.clearNewCommandTimeout(); } /** * * Historically the first two arguments were reserved for JSONWP capabilities. * Appium 2 has dropped the support of these, so now we only accept capability * objects in W3C format and thus allow any of the three arguments to represent * the latter. */ async createSession( w3cCapabilities1: W3CDriverCaps<C>, w3cCapabilities2?: W3CDriverCaps<C>, w3cCapabilities?: W3CDriverCaps<C>, // eslint-disable-next-line @typescript-eslint/no-unused-vars driverData?: DriverData[], ): Promise<CreateResult> { if (this.sessionId !== null) { throw new errors.SessionNotCreatedError( 'Cannot create a new session while one is in progress', ); } this.log.debug(); const originalCaps = _.cloneDeep( [w3cCapabilities, w3cCapabilities1, w3cCapabilities2].find(isW3cCaps), ); if (!originalCaps) { throw new errors.SessionNotCreatedError( 'Appium only supports W3C-style capability objects. ' + 'Your client is sending an older capabilities format. Please update your client library.', ); } this.setProtocolW3C(); this.originalCaps = originalCaps; this.log.debug( `Creating session with W3C capabilities: ${JSON.stringify(originalCaps, null, 2)}`, ); let caps: DriverCaps<C>; try { caps = processCapabilities( originalCaps, this._desiredCapConstraints, this.shouldValidateCaps, ) as DriverCaps<C>; caps = fixCaps(caps, this._desiredCapConstraints, this.log) as DriverCaps<C>; } catch (e) { throw new errors.SessionNotCreatedError(e.message); } this.validateDesiredCaps(caps); this.sessionId = util.uuidV4(); this.caps = caps; // merge caps onto opts so we don't need to worry about what's where this.opts = {..._.cloneDeep(this.initialOpts), ...this.caps}; // deal with resets // some people like to do weird things by setting noReset and fullReset // both to true, but this is misguided and strange, so error here instead if (this.opts.noReset && this.opts.fullReset) { throw new Error( "The 'noReset' and 'fullReset' capabilities are mutually " + 'exclusive and should not both be set to true. You ' + "probably meant to just use 'fullReset' on its own", ); } if (this.opts.noReset === true) { this.opts.fullReset = false; } if (this.opts.fullReset === true) { this.opts.noReset = false; } this.opts.fastReset = !this.opts.fullReset && !this.opts.noReset; this.opts.skipUninstall = this.opts.fastReset || this.opts.noReset; // Prevents empty string caps so we don't need to test it everywhere if (typeof this.opts.app === 'string' && this.opts.app.trim() === '') { delete this.opts.app; } if (!_.isUndefined(this.caps.newCommandTimeout)) { this.newCommandTimeoutMs = (this.caps.newCommandTimeout as number) * 1000; } this._log.prefix = helpers.generateDriverLogPrefix(this); this.log.updateAsyncContext({ sessionId: this.sessionId, sessionSignature: calcSignature(this.sessionId), }); this.log.info(`Session created with session id: ${this.sessionId}`); return [this.sessionId, caps] as CreateResult; } async getSessions() { const ret: MultiSessionData<C>[] = []; if (this.sessionId) { ret.push({ id: this.sessionId, capabilities: this.caps, }); } return ret; } /** * Returns capabilities for the session and event history (if applicable) * @deprecated Use {@linkcode ISessionCommands.getAppiumSessionCapabilities} instead for getting the capabilities. * Use {@linkcode EventCommands.getLogEvents} instead to get the event history. */ async getSession() { return ( this.caps.eventTimings ? {...this.caps, events: this.eventHistory} : this.caps ) as SingularSessionData<C, SessionData>; } /** * Returns capabilities for the session */ async getAppiumSessionCapabilities(): Promise<SessionCapabilities<C>> { return {capabilities: this.caps}; } // eslint-disable-next-line @typescript-eslint/no-unused-vars async deleteSession(sessionId?: string | null) { await this.clearNewCommandTimeout(); if (this.isCommandsQueueEnabled && this.commandsQueueGuard.isBusy()) { // simple hack to release pending commands if they exist // @ts-expect-error private API const queues = this.commandsQueueGuard.queues; for (const key of _.keys(queues)) { queues[key] = []; } } this.sessionId = null; } logExtraCaps(caps: Capabilities<C>) { const extraCaps = _.difference(_.keys(caps), _.keys(this._desiredCapConstraints)); if (extraCaps.length) { this.log.warn(`The following provided capabilities were not recognized by this driver:`); for (const cap of extraCaps) { this.log.warn(` ${cap}`); } } } validateDesiredCaps(caps: any): caps is DriverCaps<C> { if (!this.shouldValidateCaps) { return true; } try { validateCaps(caps, this._desiredCapConstraints); } catch (e) { throw this.log.errorWithException( new errors.SessionNotCreatedError( `The desiredCapabilities object was not valid for the ` + `following reason(s): ${e.message}`, ), ); } this.logExtraCaps(caps); return true; } async updateSettings(newSettings: Settings) { if (!this.settings) { throw this.log.errorWithException('Cannot update settings; settings object not found'); } return await this.settings.update(newSettings); } async getSettings() { if (!this.settings) { throw this.log.errorWithException('Cannot get settings; settings object not found'); } return this.settings.getSettings(); } } export * from './commands'; export default BaseDriver;