UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

453 lines (397 loc) 15.4 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 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 {getLevenshteinSuggestion} from '../helpers/levenshtein-match'; 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'; import {resolveExecuteExtensionName} from '../helpers/extension-command-name'; 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?.(); } // 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(); if (this.clarifyCommandName) { cmd = this.clarifyCommandName(cmd, args); } 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; } clarifyCommandName(cmd: string, args: string[]): string { if (cmd === 'execute') { const firstArg = args?.[0]; if (_.isString(firstArg) && firstArg.trim().length > 0) { return resolveExecuteExtensionName.call(this, firstArg); } } return cmd; } 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.sessionCreationTimestampMs = Date.now(); 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; } /** * Returns capabilities for the session and event history (if applicable) * @deprecated Use {@linkcode 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 knownCaps = _.keys(this._desiredCapConstraints); const extraCaps = _.difference(_.keys(caps), knownCaps); if (extraCaps.length) { this.log.warn(`The following provided capabilities were not recognized by this driver:`); for (const cap of extraCaps) { const suggestion = getLevenshteinSuggestion(cap, knownCaps); this.log.warn( suggestion ? ` ${cap} (did you mean '${suggestion}'?)` : ` ${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( `Session capabilities were not valid for the ` + `following reason(s): ${e.message}`, e ) ); } 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;