@appium/base-driver
Version:
Base driver class for Appium drivers
426 lines (387 loc) • 14.6 kB
text/typescript
import type {
Constraints,
NSCapabilities,
Capabilities,
W3CCapabilities,
StandardCapabilities,
} from '@appium/types';
import type {
KeyAsString,
MergeExclusive,
} from 'type-fest';
import _ from 'lodash';
import {validator} from './validation';
import {util} from '@appium/support';
import {log} from './logger';
import {errors} from '../protocol/errors';
export const APPIUM_VENDOR_PREFIX = 'appium:';
export const PREFIXED_APPIUM_OPTS_CAP = `${APPIUM_VENDOR_PREFIX}options`;
export type ParsedCaps<C extends Constraints> = {
allFirstMatchCaps: NSCapabilities<C>[];
validatedFirstMatchCaps: Capabilities<C>[];
requiredCaps: NSCapabilities<C>;
matchedCaps: Capabilities<C> | null;
validationErrors: string[];
};
export type ValidateCapsOpts = {
/** if true, skip the presence constraint */
skipPresenceConstraint?: boolean;
};
/**
* Takes primary caps object and merges it into a secondary caps object.
*
* @see https://www.w3.org/TR/webdriver/#dfn-merging-capabilities)
*/
export function mergeCaps<
T extends Constraints,
U extends Constraints,
Primary extends Capabilities<T>,
Secondary extends Capabilities<U>
>(
primary: Primary | undefined = {} as Primary,
secondary: Secondary | undefined = {} as Secondary
): MergeExclusive<Primary, Secondary> {
const result = ({
...primary,
}) as MergeExclusive<Primary, Secondary>;
for (const [name, value] of Object.entries(secondary)) {
// Overwriting is not allowed. Primary and secondary must have different properties (w3c rule 4.4)
if (!_.isUndefined(primary[name])) {
throw new errors.InvalidArgumentError(
`property '${name}' should not exist on both primary (${JSON.stringify(
primary
)}) and secondary (${JSON.stringify(secondary)}) object`
);
}
result[name as keyof typeof result] = value;
}
return result;
}
/**
* Validates caps against a set of constraints
*/
export function validateCaps<C extends Constraints>(
caps: Capabilities<C>,
constraints: C | undefined = {} as C,
opts: ValidateCapsOpts | undefined = {}
): Capabilities<C> {
const {skipPresenceConstraint} = opts;
if (!_.isPlainObject(caps)) {
throw new errors.InvalidArgumentError(`must be a JSON object`);
}
// Remove the 'presence' constraint if we're not checking for it
constraints = (
_.mapValues(
constraints,
skipPresenceConstraint
? /** @param {Constraint} constraint */
(constraint) => _.omit(constraint, 'presence')
: /** @param {Constraint} constraint */
(constraint) => {
if (constraint.presence === true) {
return {..._.omit(constraint, 'presence'), presence: {allowEmpty: false}};
}
return constraint;
}
)
) as C;
const validationErrors = validator.validate(_.pickBy(caps, util.hasValue), constraints);
if (validationErrors) {
const message: string[] = [];
for (const [attribute, reasons] of _.toPairs(validationErrors)) {
for (const reason of (reasons as string[])) {
message.push(`'${attribute}' ${reason}`);
}
}
throw new errors.InvalidArgumentError(message.join('; '));
}
// Return caps
return caps;
}
/**
* Standard, non-prefixed capabilities
* @see https://www.w3.org/TR/webdriver2/#dfn-table-of-standard-capabilities)
*/
export const STANDARD_CAPS = Object.freeze(
new Set(
([
'browserName',
'browserVersion',
'platformName',
'acceptInsecureCerts',
'pageLoadStrategy',
'proxy',
'setWindowRect',
'timeouts',
'strictFileInteractability',
'unhandledPromptBehavior',
'userAgent',
'webSocketUrl',
]) as KeyAsString<StandardCapabilities>[]
)
);
const STANDARD_CAPS_LOWER = new Set([...STANDARD_CAPS].map((cap) => cap.toLowerCase()));
/**
* @param cap - Capability name (any casing)
* @returns Whether the name is a W3C standard capability
*/
export function isStandardCap(cap: string): boolean {
return STANDARD_CAPS_LOWER.has(cap.toLowerCase());
}
/**
* If the 'appium:' prefix was provided and it's a valid capability, strip out the prefix
* @see https://www.w3.org/TR/webdriver/#dfn-extension-capabilities
* @internal
*/
export function stripAppiumPrefixes<C extends Constraints>(caps: NSCapabilities<C>): Capabilities<C> {
// split into prefixed and non-prefixed.
// non-prefixed should be standard caps at this point
const [prefixedCaps, nonPrefixedCaps] = _.partition(_.keys(caps), (cap) =>
String(cap).startsWith(APPIUM_VENDOR_PREFIX)
);
// initialize this with the k/v pairs of the non-prefixed caps
const strippedCaps = (_.pick(caps, nonPrefixedCaps)) as Capabilities<C>;
const badPrefixedCaps: string[] = [];
// Strip out the 'appium:' prefix
for (const prefixedCap of prefixedCaps) {
const strippedCapName = prefixedCap.substring(APPIUM_VENDOR_PREFIX.length) as KeyAsString<Capabilities<C>>;
// If it's standard capability that was prefixed, add it to an array of incorrectly prefixed capabilities
if (isStandardCap(strippedCapName)) {
badPrefixedCaps.push(strippedCapName);
if (_.isNil(strippedCaps[strippedCapName])) {
strippedCaps[strippedCapName] = caps[prefixedCap];
} else {
log.warn(
`Ignoring capability '${prefixedCap}=${caps[prefixedCap]}' and ` +
`using capability '${strippedCapName}=${strippedCaps[strippedCapName]}'`
);
}
} else {
strippedCaps[strippedCapName] = caps[prefixedCap];
}
}
// If we found standard caps that were incorrectly prefixed, throw an exception (e.g.: don't accept 'appium:platformName', only accept just 'platformName')
if (badPrefixedCaps.length > 0) {
log.warn(
`The capabilities ${JSON.stringify(
badPrefixedCaps
)} are standard capabilities and do not require "appium:" prefix`
);
}
return strippedCaps;
}
/**
* Get an array of all the unprefixed caps that are being used in 'alwaysMatch' and all of the 'firstMatch' object
*/
export function findNonPrefixedCaps<C extends Constraints>(
{
alwaysMatch = {},
firstMatch = []
}: W3CCapabilities<C>
): string[] {
return _.chain([alwaysMatch, ...firstMatch])
.reduce(
(unprefixedCaps, caps) => [
...unprefixedCaps,
...Object.keys(caps).filter((cap) => !cap.includes(':') && !isStandardCap(cap)),
],
[]
)
.uniq()
.value();
}
/**
* Parse capabilities
* @see https://www.w3.org/TR/webdriver/#processing-capabilities
*/
export function parseCaps<C extends Constraints>(
caps: W3CCapabilities<C>,
constraints: C | undefined = {} as C,
shouldValidateCaps: boolean | undefined = true
): ParsedCaps<C> {
// If capabilities request is not an object, return error (#1.1)
if (!_.isPlainObject(caps)) {
throw new errors.InvalidArgumentError(
'The capabilities argument was not valid for the following reason(s): "capabilities" must be a JSON object.'
);
}
// Let 'requiredCaps' be property named 'alwaysMatch' from capabilities request (#2)
// and 'allFirstMatchCaps' be property named 'firstMatch' from capabilities request (#3)
const {
alwaysMatch: requiredCaps = {} as NSCapabilities<C>, // If 'requiredCaps' is undefined, set it to an empty JSON object (#2.1)
firstMatch: allFirstMatchCaps = [{}] as NSCapabilities<C>[], // If 'firstMatch' is undefined set it to a singleton list with one empty object (#3.1)
} = caps;
// Reject 'firstMatch' argument if it's not an array (#3.2)
if (!_.isArray(allFirstMatchCaps)) {
throw new errors.InvalidArgumentError(
'The capabilities.firstMatch argument was not valid for the following reason(s): "capabilities.firstMatch" must be a JSON array or undefined'
);
}
// If an empty array as provided, we'll be forgiving and make it an array of one empty object
// In the future, reject 'firstMatch' argument if its array did not have one or more entries (#3.2)
if (allFirstMatchCaps.length === 0) {
log.warn(
`The firstMatch array in the given capabilities has no entries. Adding an empty entry for now, ` +
`but it will require one or more entries as W3C spec.`
);
allFirstMatchCaps.push({});
}
// Check for non-prefixed, non-standard capabilities and log warnings if they are found
const nonPrefixedCaps = findNonPrefixedCaps(caps);
if (!_.isEmpty(nonPrefixedCaps)) {
throw new errors.InvalidArgumentError(
`All non-standard capabilities should have a vendor prefix. The following capabilities did not have one: ${nonPrefixedCaps}`
);
}
// Strip out the 'appium:' prefix from all
let strippedRequiredCaps = stripAppiumPrefixes(requiredCaps);
const strippedAllFirstMatchCaps: Capabilities<C>[] = allFirstMatchCaps.map(stripAppiumPrefixes);
// Validate the requiredCaps. But don't validate 'presence' because if that constraint fails on 'alwaysMatch' it could still pass on one of the 'firstMatch' keys
if (shouldValidateCaps) {
strippedRequiredCaps = validateCaps(strippedRequiredCaps, constraints, {
skipPresenceConstraint: true,
});
}
// Remove the 'presence' constraint for any keys that are already present in 'requiredCaps'
// since we know that this constraint has already passed
const filteredConstraints = _.omitBy(constraints, (_, key) => key in strippedRequiredCaps) as C;
// Validate all of the first match capabilities and return an array with only the valid caps (see spec #5)
const validationErrors: string[] = [];
const validatedFirstMatchCaps = _.compact(
strippedAllFirstMatchCaps.map((firstMatchCaps) => {
try {
// Validate firstMatch caps
return shouldValidateCaps
? validateCaps(firstMatchCaps, filteredConstraints)
: firstMatchCaps;
} catch (e) {
validationErrors.push(e.message);
}
})
) as Capabilities<C>[];
/**
* Try to merge requiredCaps with first match capabilities, break once it finds its first match
* (see spec #6)
*/
let matchedCaps: ParsedCaps<C>['matchedCaps'] = null;
for (const firstMatchCaps of validatedFirstMatchCaps) {
try {
matchedCaps = mergeCaps(strippedRequiredCaps, firstMatchCaps) as ParsedCaps<C>['matchedCaps'];
if (matchedCaps) {
break;
}
} catch (err) {
log.warn(err.message);
validationErrors.push(err.message);
}
}
// Returns variables for testing purposes
return {
requiredCaps,
allFirstMatchCaps,
validatedFirstMatchCaps,
matchedCaps,
validationErrors,
};
}
/**
* Calls parseCaps and just returns the matchedCaps variable
*/
export function processCapabilities<
C extends Constraints,
W3CCaps extends W3CCapabilities<C>
>(
w3cCaps: W3CCaps,
constraints: C | undefined = {} as C,
shouldValidateCaps: boolean | undefined = true
): Capabilities<C> {
const {matchedCaps, validationErrors} = parseCaps(w3cCaps, constraints, shouldValidateCaps);
// If we found an error throw an exception
if (!util.hasValue(matchedCaps)) {
if (_.isArray(w3cCaps.firstMatch) && w3cCaps.firstMatch.length > 1) {
// If there was more than one 'firstMatch' cap, indicate that we couldn't find a matching capabilities set and show all the errors
throw new errors.InvalidArgumentError(
`Could not find matching capabilities from ${JSON.stringify(
w3cCaps
)}:\n ${validationErrors.join('\n')}`
);
} else {
// Otherwise, just show the singular error message
throw new errors.InvalidArgumentError(validationErrors[0]);
}
}
return (matchedCaps ?? {}) as Capabilities<C>;
}
/**
* Return a copy of a "bare" (single-level, non-W3C) capabilities object which has taken everything
* within the 'appium:options' capability and promoted it to the top level.
*/
export function promoteAppiumOptionsForObject<C extends Constraints>(obj: NSCapabilities<C>): NSCapabilities<C> {
const appiumOptions = obj[PREFIXED_APPIUM_OPTS_CAP];
if (!appiumOptions) {
return obj;
}
if (!_.isPlainObject(appiumOptions)) {
throw new errors.SessionNotCreatedError(
`The ${PREFIXED_APPIUM_OPTS_CAP} capability must be an object`
);
}
if (_.isEmpty(appiumOptions)) {
return obj;
}
log.debug(
`Found ${PREFIXED_APPIUM_OPTS_CAP} capability present; will promote items inside to caps`
);
/**
* @param {string} capName
*/
const shouldAddVendorPrefix = (capName: string) => !capName.startsWith(APPIUM_VENDOR_PREFIX);
const verifyIfAcceptable = (capName: string) => {
if (!_.isString(capName)) {
throw new errors.SessionNotCreatedError(
`Capability names in ${PREFIXED_APPIUM_OPTS_CAP} must be strings. '${capName}' is unexpected`
);
}
if (isStandardCap(capName)) {
throw new errors.SessionNotCreatedError(
`${PREFIXED_APPIUM_OPTS_CAP} must only contain vendor-specific capabilities. '${capName}' is unexpected`
);
}
return capName;
};
const preprocessedOptions = _(appiumOptions)
.mapKeys((value, key: string) => verifyIfAcceptable(key))
.mapKeys((value, key: string) => (shouldAddVendorPrefix(key) ? `${APPIUM_VENDOR_PREFIX}${key}` : key))
.value();
// warn if we are going to overwrite any keys on the base caps object
const overwrittenKeys = _.intersection(Object.keys(obj), Object.keys(preprocessedOptions));
if (overwrittenKeys.length > 0) {
log.warn(
`Found capabilities inside ${PREFIXED_APPIUM_OPTS_CAP} that will overwrite ` +
`capabilities at the top level: ${JSON.stringify(overwrittenKeys)}`
);
}
return _.cloneDeep({
..._.omit(obj, PREFIXED_APPIUM_OPTS_CAP) as NSCapabilities<C>,
...preprocessedOptions,
});
}
/**
* Return a copy of a capabilities object which has taken everything within the 'options'
* capability and promoted it to the top level.
*/
export function promoteAppiumOptions<C extends Constraints>(originalCaps: W3CCapabilities<C>): W3CCapabilities<C> {
const result = {} as W3CCapabilities<C>;
const {alwaysMatch, firstMatch} = originalCaps;
if (_.isPlainObject(alwaysMatch)) {
result.alwaysMatch = promoteAppiumOptionsForObject(alwaysMatch);
} else if ('alwaysMatch' in originalCaps) {
result.alwaysMatch = alwaysMatch;
}
if (_.isArray(firstMatch)) {
result.firstMatch = firstMatch.map(promoteAppiumOptionsForObject);
} else if ('firstMatch' in originalCaps) {
result.firstMatch = firstMatch;
}
return result;
}