mirror of
https://github.com/appium/appium.git
synced 2026-10-29 12:41:58 +08:00
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
import {logger, util} from '@appium/support';
|
|
import {
|
|
type AppiumServer,
|
|
BASE_DESIRED_CAP_CONSTRAINTS,
|
|
type BaseDriverCapConstraints,
|
|
type Capabilities,
|
|
type Constraints,
|
|
type DefaultCreateSessionResult,
|
|
type DefaultDeleteSessionResult,
|
|
type Driver,
|
|
type DriverCaps,
|
|
type InitialOpts,
|
|
type LegacyCreateSessionArgs,
|
|
type ServerArgs,
|
|
type SessionCapabilities,
|
|
type SingularSessionData,
|
|
type StringRecord,
|
|
type W3CDriverCaps,
|
|
} from '@appium/types';
|
|
import type AsyncLock from 'async-lock';
|
|
|
|
import {fixCaps, isW3cCaps} from '../helpers/capabilities';
|
|
import {resolveExecuteExtensionName} from '../helpers/extension-command-name';
|
|
import {getLevenshteinSuggestion} from '../helpers/levenshtein-match';
|
|
import {calcSignature} from '../helpers/session';
|
|
import {DELETE_SESSION_COMMAND, determineProtocol, errors} from '../protocol';
|
|
import {mergePlainObjects} from '../utils';
|
|
import {processCapabilities, validateCaps} from './capabilities';
|
|
import {clearBidiSubscriptions} from './commands/bidi-subscriptions';
|
|
import {DriverCore} from './core';
|
|
import * as helpers from './helpers';
|
|
|
|
type CommandInvoker<C extends Constraints> = BaseDriver<C> & Record<string, ((...args: any[]) => any) | undefined>;
|
|
|
|
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(
|
|
mergePlainObjects({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints) as BaseDriverCapConstraints & C,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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!');
|
|
}
|
|
|
|
const invoker = this as unknown as CommandInvoker<C>;
|
|
const command = invoker[cmd];
|
|
// If we don't have this command, it must not be implemented
|
|
if (!command) {
|
|
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 Promise.race([
|
|
command.call(this, ...args),
|
|
// This promise is needed to monitor if the session has been
|
|
// shut down unexpectedly while the command was running
|
|
new Promise((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;
|
|
const commandsQueueGuard = this.commandsQueueGuard as AsyncLock & {
|
|
queues?: Record<string, unknown[]>;
|
|
};
|
|
const commandsQueueLen: number = 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 (typeof firstArg === 'string' && firstArg.trim().length > 0) {
|
|
return resolveExecuteExtensionName.call(this as BaseDriver<Constraints>, 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 = {
|
|
implicitWaitMs: this.implicitWaitMs,
|
|
newCommandTimeoutMs: this.newCommandTimeoutMs,
|
|
sessionId: this.sessionId,
|
|
shutdownUnexpectedly: this.shutdownUnexpectedly,
|
|
};
|
|
|
|
try {
|
|
if (this.sessionId !== null) {
|
|
await this.deleteSession(this.sessionId);
|
|
}
|
|
this.log.debug('Restarting app');
|
|
await this.createSession(this.originalCaps);
|
|
} finally {
|
|
// always restore state.
|
|
Object.assign(this, currentConfig);
|
|
}
|
|
await this.clearNewCommandTimeout();
|
|
}
|
|
|
|
/**
|
|
* Start a new automation session.
|
|
*
|
|
* @param w3cCapabilities - the new session capabilities in W3C format
|
|
*/
|
|
async createSession(w3cCapabilities: W3CDriverCaps<C>): Promise<CreateResult>;
|
|
/**
|
|
* @deprecated Historically the first three arguments were reserved for JSONWP capabilities.
|
|
* Appium 2 dropped support for that protocol; these positions are now intended to carry the
|
|
* same W3C capabilities value, and if they differ, which one wins is unspecified. Use the
|
|
* single-argument overload of {@linkcode createSession} instead. The `driverData` parameter is
|
|
* also deprecated; use {@linkcode IAppiumIpc} for cross-session coordination instead.
|
|
*/
|
|
async createSession(...legacyArgs: LegacyCreateSessionArgs<C>): Promise<CreateResult>;
|
|
async createSession(...legacyArgs: LegacyCreateSessionArgs<C>): Promise<CreateResult> {
|
|
const [w3cCapabilities1, w3cCapabilities2, w3cCapabilities] = legacyArgs;
|
|
if (this.sessionId !== null) {
|
|
throw new errors.SessionNotCreatedError('Cannot create a new session while one is in progress');
|
|
}
|
|
|
|
this.log.debug();
|
|
|
|
const originalCaps = structuredClone([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: %s',
|
|
logger.markSensitive(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) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
throw new errors.SessionNotCreatedError(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 = {...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 (this.caps.newCommandTimeout !== undefined) {
|
|
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};
|
|
}
|
|
|
|
/**
|
|
* Stop the current automation session.
|
|
*/
|
|
// 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 Object.keys(queues)) {
|
|
queues[key] = [];
|
|
}
|
|
}
|
|
this.sessionId = null;
|
|
clearBidiSubscriptions(this);
|
|
}
|
|
|
|
logExtraCaps(caps: Capabilities<C>) {
|
|
const knownCaps = Object.keys(this._desiredCapConstraints);
|
|
const knownCapsSet = new Set(knownCaps);
|
|
const extraCaps = Object.keys(caps).filter((cap) => !knownCapsSet.has(cap));
|
|
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) {
|
|
const capError = e instanceof Error ? e : new Error(String(e));
|
|
throw this.log.errorWithException(
|
|
new errors.SessionNotCreatedError(
|
|
`Session capabilities were not valid for the ` + `following reason(s): ${capError.message}`,
|
|
capError,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|