mirror of
https://github.com/appium/appium.git
synced 2026-10-29 04:36:39 +08:00
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import {util} from '@appium/support';
|
|
import type {Capabilities, Constraints, NSCapabilities, StandardCapabilities, W3CCapabilities} from '@appium/types';
|
|
import type {KeyAsString, MergeExclusive} from 'type-fest';
|
|
|
|
import {errors} from '../protocol/errors';
|
|
import {omit, pickBy} from '../utils';
|
|
import {log} from './logger';
|
|
import {validator} from './validation';
|
|
|
|
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 (primary[name] !== undefined) {
|
|
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 (!util.isPlainObject(caps)) {
|
|
throw new errors.InvalidArgumentError(`must be a JSON object`);
|
|
}
|
|
|
|
// Remove the 'presence' constraint if we're not checking for it
|
|
constraints = Object.fromEntries(
|
|
Object.entries(constraints ?? {}).map(([key, constraint]) => {
|
|
if (skipPresenceConstraint) {
|
|
return [key, omit(constraint as Record<string, unknown>, 'presence')];
|
|
}
|
|
if ((constraint as {presence?: unknown}).presence === true) {
|
|
return [
|
|
key,
|
|
{
|
|
...omit(constraint as Record<string, unknown>, 'presence'),
|
|
presence: {allowEmpty: false},
|
|
},
|
|
];
|
|
}
|
|
return [key, constraint];
|
|
}),
|
|
) as C;
|
|
|
|
const validationErrors = validator.validate(
|
|
pickBy(caps, (value) => util.hasValue(value)),
|
|
constraints,
|
|
);
|
|
|
|
if (validationErrors) {
|
|
const message: string[] = [];
|
|
for (const [attribute, reasons] of Object.entries(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 capKeys = Object.keys(caps);
|
|
const prefixedCaps = capKeys.filter((cap) => String(cap).startsWith(APPIUM_VENDOR_PREFIX));
|
|
const nonPrefixedCaps = capKeys.filter((cap) => !String(cap).startsWith(APPIUM_VENDOR_PREFIX));
|
|
|
|
// initialize this with the k/v pairs of the non-prefixed caps
|
|
const strippedCaps: Record<string, unknown> = Object.fromEntries(
|
|
nonPrefixedCaps.map((cap) => [cap, caps[cap as keyof typeof caps]]),
|
|
) 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 (strippedCaps[strippedCapName] == null) {
|
|
strippedCaps[strippedCapName] = caps[prefixedCap as keyof typeof caps];
|
|
} else {
|
|
log.warn(
|
|
`Ignoring capability '${prefixedCap}=${caps[prefixedCap as keyof typeof caps]}' and ` +
|
|
`using capability '${strippedCapName}=${strippedCaps[strippedCapName]}'`,
|
|
);
|
|
}
|
|
} else {
|
|
strippedCaps[strippedCapName] = caps[prefixedCap as keyof typeof caps];
|
|
}
|
|
}
|
|
|
|
// 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 as Capabilities<C>;
|
|
}
|
|
|
|
/**
|
|
* 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 util.uniq(
|
|
[alwaysMatch, ...firstMatch].reduce<string[]>(
|
|
(unprefixedCaps, caps) => [
|
|
...unprefixedCaps,
|
|
...Object.keys(caps).filter((cap) => !cap.includes(':') && !isStandardCap(cap)),
|
|
],
|
|
[],
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (!util.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 (!Array.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 (!util.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 = Object.fromEntries(
|
|
Object.entries(constraints ?? {}).filter(([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 = strippedAllFirstMatchCaps
|
|
.map((firstMatchCaps) => {
|
|
try {
|
|
// Validate firstMatch caps
|
|
return shouldValidateCaps ? validateCaps(firstMatchCaps, filteredConstraints) : firstMatchCaps;
|
|
} catch (e) {
|
|
validationErrors.push((e as Error).message);
|
|
}
|
|
})
|
|
.filter(Boolean) 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 as Error).message);
|
|
validationErrors.push((err as Error).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 (Array.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 (!util.isPlainObject(appiumOptions)) {
|
|
throw new errors.SessionNotCreatedError(`The ${PREFIXED_APPIUM_OPTS_CAP} capability must be an object`);
|
|
}
|
|
if (util.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 (typeof capName !== 'string') {
|
|
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: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(appiumOptions as Record<string, unknown>)) {
|
|
const verifiedKey = verifyIfAcceptable(key);
|
|
const finalKey = shouldAddVendorPrefix(verifiedKey) ? `${APPIUM_VENDOR_PREFIX}${verifiedKey}` : verifiedKey;
|
|
preprocessedOptions[finalKey] = value;
|
|
}
|
|
// warn if we are going to overwrite any keys on the base caps object
|
|
const overwrittenKeys = Object.keys(obj).filter((key) => key in 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)}`,
|
|
);
|
|
}
|
|
const restObj = omit(obj, PREFIXED_APPIUM_OPTS_CAP) as NSCapabilities<C>;
|
|
return structuredClone({
|
|
...restObj,
|
|
...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 (util.isPlainObject(alwaysMatch)) {
|
|
result.alwaysMatch = promoteAppiumOptionsForObject(alwaysMatch);
|
|
} else if ('alwaysMatch' in originalCaps) {
|
|
result.alwaysMatch = alwaysMatch;
|
|
}
|
|
if (Array.isArray(firstMatch)) {
|
|
result.firstMatch = firstMatch.map(promoteAppiumOptionsForObject);
|
|
} else if ('firstMatch' in originalCaps) {
|
|
result.firstMatch = firstMatch;
|
|
}
|
|
return result;
|
|
}
|