UNPKG

appium

Version:

Automation for Apps.

726 lines (656 loc) 22.2 kB
import {Ajv} from 'ajv'; import addFormats from 'ajv-formats'; import _ from 'lodash'; import path from 'node:path'; import {DRIVER_TYPE, PLUGIN_TYPE} from '../constants'; import {AppiumConfigJsonSchema} from '@appium/schema'; import {APPIUM_CONFIG_SCHEMA_ID, ArgSpec, SERVER_PROP_NAME} from './arg-spec'; import {keywords} from './keywords'; /** * Key/value pairs go in... but they don't come out. * * @template K,V * @extends {Map<K,V>} */ export class RoachHotelMap extends Map { /** * @param {K} key * @param {V} value */ set(key, value) { if (this.has(key)) { throw new Error(`${key} is already set`); } return super.set(key, value); } /** * @param {K} key */ // eslint-disable-next-line @typescript-eslint/no-unused-vars delete(key) { return false; } clear() { throw new Error(`Cannot clear RoachHotelMap`); } } /** * Extensions that an extension schema file can have. */ export const ALLOWED_SCHEMA_EXTENSIONS = Object.freeze( new Set(/** @type {AllowedSchemaExtension[]} */ (['.json', '.js', '.cjs'])) ); const SCHEMA_KEY = '$schema'; /** * A wrapper around Ajv and schema-related functions. * * Should have been named Highlander, because _there can only be one_ */ class AppiumSchema { /** * A mapping of unique argument IDs to their corresponding {@link ArgSpec}s. * * An "argument" is a CLI argument or a config property. * * Used to provide easy lookups of argument metadata when converting between different representations of those arguments. * @type {RoachHotelMap<string,ArgSpec>} */ #argSpecs = new RoachHotelMap(); /** * A map of extension types to extension names to schema objects. * * This data structure is used to ensure there are no naming conflicts. The schemas * are stored here in memory until the instance is _finalized_. * @type {Record<ExtensionType,Map<string,SchemaObject>>} */ #registeredSchemas = {[DRIVER_TYPE]: new Map(), [PLUGIN_TYPE]: new Map()}; /** * Ajv instance * * @type {Ajv} */ #ajv; /** * Singleton instance. * @type {AppiumSchema} */ static #instance; /** * Lookup of schema IDs to finalized schemas. * * This does not include references, but rather the root schemas themselves. * @type {Record<string,StrictSchemaObject>?} */ #finalizedSchemas = null; /** * Initializes Ajv, adds standard formats and our custom keywords. * @see https://npm.im/ajv-formats * @private */ constructor() { this.#ajv = AppiumSchema._instantiateAjv(); } /** * Factory function for {@link AppiumSchema} instances. * * Returns a singleton instance if one exists, otherwise creates a new one. * Binds public methods to the instance. * @returns {AppiumSchema} */ static create() { if (!AppiumSchema.#instance) { const instance = new AppiumSchema(); AppiumSchema.#instance = instance; _.bindAll(instance, [ 'finalize', 'flatten', 'getAllArgSpecs', 'getArgSpec', 'getDefaults', 'getDefaultsForExtension', 'getSchema', 'hasArgSpec', 'isFinalized', 'registerSchema', 'hasRegisteredSchema', 'reset', 'validate', ]); } return AppiumSchema.#instance; } /** * Returns `true` if a schema has been registered using given extension type and name. * * This does not depend on whether or not the instance has been _finalized_. * @param {ExtensionType} extType - Extension type * @param {string} extName - Name * @returns {boolean} If registered */ hasRegisteredSchema(extType, extName) { return this.#registeredSchemas[extType].has(extName); } /** * Return `true` if {@link AppiumSchema.finalize finalize} has been called * successfully and {@link AppiumSchema.reset reset} has not been called since. * @returns {boolean} If finalized */ isFinalized() { return Boolean(this.#finalizedSchemas); } getAllArgSpecs() { return this.#argSpecs; } /** * Call this when no more schemas will be registered. * * This does three things: * 1. It combines all schemas from extensions into the Appium config schema, * then adds the result to the `Ajv` instance. * 2. It adds schemas for _each_ argument/property for validation purposes. * The CLI uses these schemas to validate specific arguments. * 3. The schemas are validated against JSON schema draft-07 (which is the * only one supported at this time) * * Any method in this instance that needs to interact with the `Ajv` instance * will throw if this method has not been called. * * If the instance has already been finalized, this is a no-op. * @public * @throws {Error} If the schema is not valid * @returns {Readonly<Record<string,StrictSchemaObject>>} Record of schema IDs to full schema objects */ finalize() { if (this.isFinalized()) { return /** @type {Record<string,StrictSchemaObject>} */ (this.#finalizedSchemas); } const ajv = this.#ajv; // Ajv will _mutate_ the schema, so we need to clone it. const baseSchema = _.cloneDeep(AppiumConfigJsonSchema); /** * * @param {SchemaObject} schema * @param {ExtensionType} [extType] * @param {string} [extName] */ const addArgSpecs = (schema, extType, extName) => { for (let [propName, propSchema] of Object.entries(schema)) { const argSpec = ArgSpec.create(propName, { dest: propSchema.appiumCliDest, defaultValue: propSchema.default, extType, extName, }); const {arg} = argSpec; this.#argSpecs.set(arg, argSpec); } }; addArgSpecs(_.omit(baseSchema.properties.server.properties, [DRIVER_TYPE, PLUGIN_TYPE])); /** * @type {Record<string,StrictSchemaObject>} */ const finalizedSchemas = {}; const finalSchema = _.reduce( this.#registeredSchemas, /** * @param {typeof baseSchema} baseSchema * @param {Map<string,SchemaObject>} extensionSchemas * @param {ExtensionType} extType */ (baseSchema, extensionSchemas, extType) => { extensionSchemas.forEach((schema, extName) => { const $ref = ArgSpec.toSchemaBaseRef(extType, extName); schema.$id = $ref; schema.additionalProperties = false; // this makes `schema` become a `StrictSchemaObject` baseSchema.properties.server.properties[extType].properties[extName] = { $ref, $comment: extName, }; ajv.validateSchema(schema, true); addArgSpecs(schema.properties, extType, extName); ajv.addSchema(schema, $ref); finalizedSchemas[$ref] = /** @type {StrictSchemaObject} */ (schema); }); return baseSchema; }, baseSchema ); ajv.addSchema(finalSchema, APPIUM_CONFIG_SCHEMA_ID); finalizedSchemas[APPIUM_CONFIG_SCHEMA_ID] = finalSchema; ajv.validateSchema(finalSchema, true); this.#finalizedSchemas = finalizedSchemas; return Object.freeze(finalizedSchemas); } /** * Configures and creates an Ajv instance. * @private * @returns {Ajv} */ static _instantiateAjv() { const ajv = addFormats( new Ajv({ // without this not much validation actually happens allErrors: true, }) ); // add custom keywords to ajv. see schema-keywords.js _.forEach(keywords, (keyword) => { ajv.addKeyword(keyword); }); return ajv; } /** * Resets this instance to its original state. * * - Removes all added schemas from the `Ajv` instance * - Resets the map of {@link ArgSpec ArgSpecs} * - Resets the map of registered schemas * - Sets the {@link AppiumSchema._finalized _finalized} flag to `false` * * If you need to call {@link AppiumSchema.finalize} again, you'll want to call this first. * @returns {void} */ reset() { for (const schemaId of Object.keys(this.#finalizedSchemas ?? {})) { this.#ajv.removeSchema(schemaId); } this.#argSpecs = new RoachHotelMap(); this.#registeredSchemas = { [DRIVER_TYPE]: new Map(), [PLUGIN_TYPE]: new Map(), }; this.#finalizedSchemas = null; // Ajv seems to have an over-eager cache, so we have to dump the object entirely. this.#ajv = AppiumSchema._instantiateAjv(); } /** * Registers a schema from an extension. * * This is "fail-fast" in that the schema will immediately be validated against JSON schema draft-07 _or_ whatever the value of the schema's `$schema` prop is. * * Does _not_ add the schema to the `ajv` instance (this is done by {@link AppiumSchema.finalize}). * @param {ExtensionType} extType - Extension type * @param {string} extName - Unique extension name for `type` * @param {SchemaObject} schema - Schema object * @throws {SchemaNameConflictError} If the schema is an invalid * @returns {void} */ registerSchema(extType, extName, schema) { if (!(extType && extName) || _.isUndefined(schema)) { throw new TypeError('Expected extension type, extension name, and a defined schema'); } if (!AppiumSchema.isSupportedSchemaType(schema)) { throw new SchemaUnsupportedSchemaError(schema, extType, extName); } const normalizedExtName = _.kebabCase(extName); if (this.hasRegisteredSchema(extType, normalizedExtName)) { if (_.isEqual(this.#registeredSchemas[extType].get(normalizedExtName), schema)) { return; } throw new SchemaNameConflictError(extType, extName); } this.#ajv.validateSchema(schema, true); this.#registeredSchemas[extType].set(normalizedExtName, schema); } /** * Returns a {@link ArgSpec} for the given argument name. * @param {string} name - CLI argument name * @param {ExtensionType} [extType] - Extension type * @param {string} [extName] - Extension name * @returns {ArgSpec|undefined} ArgSpec or `undefined` if not found */ getArgSpec(name, extType, extName) { return this.#argSpecs.get(ArgSpec.toArg(name, extType, extName)); } /** * Returns `true` if the instance knows about an argument by the given `name`. * @param {string} name - CLI argument name * @param {ExtensionType} [extType] - Extension type * @param {string} [extName] - Extension name * @returns {boolean} `true` if such an {@link ArgSpec} exists */ hasArgSpec(name, extType, extName) { return this.#argSpecs.has(ArgSpec.toArg(name, extType, extName)); } /** * Returns a `Record` of argument "dest" strings to default values. * * The "dest" string is the property name in object returned by * `argparse.ArgumentParser['parse_args']`. * @template {boolean|undefined} Flattened * @param {Flattened} [flatten=true] - If `true`, flattens the returned object * using "keypath"-style keys of the format `<extType>.<extName>.<argName>`. * Otherwise, returns a nested object using `extType` and `extName` as * properties. Base arguments (server arguments) are always at the top level. * @returns {DefaultValues<Flattened>} */ getDefaults(flatten = /** @type {Flattened} */ (true)) { if (!this.isFinalized()) { throw new SchemaFinalizationError(); } /** * @private * @callback DefaultReducer * @param {DefaultValues<Flattened>} defaults * @param {ArgSpec} argSpec * @returns {DefaultValues<Flattened>} */ /** @type {DefaultReducer} */ const reducer = flatten ? (defaults, {defaultValue, dest}) => { if (!_.isUndefined(defaultValue)) { defaults[dest] = defaultValue; } return defaults; } : (defaults, {defaultValue, dest}) => { if (!_.isUndefined(defaultValue)) { _.set(defaults, dest, defaultValue); } return defaults; }; /** @type {DefaultValues<Flattened>} */ const retval = {}; return [...this.#argSpecs.values()].reduce(reducer, retval); } /** * Returns a flattened Record of defaults for a specific extension. Keys will * be of format `<argName>`. * @param {ExtensionType} extType - Extension type * @param {string} extName - Extension name * @returns {Record<string,ArgSpecDefaultValue>} */ getDefaultsForExtension(extType, extName) { if (!this.isFinalized()) { throw new SchemaFinalizationError(); } const specs = [...this.#argSpecs.values()].filter( (spec) => spec.extType === extType && spec.extName === extName ); return specs.reduce((defaults, {defaultValue, rawDest}) => { if (!_.isUndefined(defaultValue)) { defaults[rawDest] = defaultValue; } return defaults; }, {}); } /** * Flatten schema into an array of `SchemaObject`s and associated * {@link ArgSpec ArgSpecs}. * * Converts nested extension schemas to keys based on the extension type and * name. Used when translating to `argparse` options or getting the list of * default values (see {@link AppiumSchema.getDefaults}) for CLI or otherwise. * * The return value is an intermediate representation used by `cli-args` * module's `toParserArgs`, which converts the finalized schema to parameters * used by `argparse`. * @throws If {@link AppiumSchema.finalize} has not been called yet. * @returns {FlattenedSchema} */ flatten() { const schema = this.getSchema(); /** @type { {properties: SchemaObject, prefix: string[]}[] } */ const stack = [{properties: schema.properties, prefix: []}]; /** @type {FlattenedSchema} */ const flattened = []; // this bit is a recursive algorithm rewritten as a for loop. // when we find something we want to traverse, we add it to `stack` for (const {properties, prefix} of stack) { const pairs = _.toPairs(properties); for (const [key, value] of pairs) { if (key === SCHEMA_KEY) { continue; } const {properties, $ref} = value; if (properties) { stack.push({ properties, prefix: key === SERVER_PROP_NAME ? [] : [...prefix, key], }); } else if ($ref) { let refSchema; try { refSchema = this.getSchema($ref); } catch { // this can happen if an extension schema supplies a $ref to a non-existent schema throw new SchemaUnknownSchemaError($ref); } const {normalizedExtName} = ArgSpec.extensionInfoFromRootSchemaId($ref); if (!normalizedExtName) { /* istanbul ignore next */ throw new ReferenceError( `Could not determine extension name from schema ID ${$ref}. This is a bug.` ); } stack.push({ properties: refSchema.properties, prefix: [...prefix, key, normalizedExtName], }); } else if (key !== DRIVER_TYPE && key !== PLUGIN_TYPE) { const [extType, extName] = prefix; const argSpec = this.getArgSpec(key, /** @type {ExtensionType} */ (extType), extName); if (!argSpec) { /* istanbul ignore next */ throw new ReferenceError( `Unknown argument with key ${key}, extType ${extType} and extName ${extName}. This is a bug.` ); } flattened.push({schema: _.cloneDeep(value), argSpec}); } } } return flattened; } /** * Retrieves the schema itself * @public * @param {string} [ref] - Schema ID * @throws If the schema has not yet been finalized * @returns {SchemaObject} */ getSchema(ref = APPIUM_CONFIG_SCHEMA_ID) { return /** @type {SchemaObject} */ (this._getValidator(ref).schema); } /** * Retrieves schema validator function from Ajv * @param {string} [id] - Schema ID * @private * @returns {import('ajv').ValidateFunction} */ _getValidator(id = APPIUM_CONFIG_SCHEMA_ID) { const validator = this.#ajv.getSchema(id); if (!validator) { if (id === APPIUM_CONFIG_SCHEMA_ID) { throw new SchemaFinalizationError(); } else { throw new SchemaUnknownSchemaError(id); } } return validator; } /** * Given an object, validates it against the Appium config schema. * If errors occur, the returned array will be non-empty. * @param {any} value - The value (hopefully an object) to validate against the schema * @param {string} [ref] - Schema ID or ref. * @public * @returns {import('ajv').ErrorObject[]} Array of errors, if any. */ validate(value, ref = APPIUM_CONFIG_SCHEMA_ID) { const validator = this._getValidator(ref); return !validator(value) && _.isArray(validator.errors) ? [...validator.errors] : []; } /** * Returns `true` if `filename`'s file extension is allowed (in {@link ALLOWED_SCHEMA_EXTENSIONS}). * @param {import('type-fest').LiteralUnion<AllowedSchemaExtension, string>} filename * @returns {boolean} */ static isAllowedSchemaFileExtension(filename) { return ALLOWED_SCHEMA_EXTENSIONS.has( /** @type {AllowedSchemaExtension} */ (path.extname(filename)) ); } /** * Returns `true` if `schema` is a plain object with a non-true `$async` property. * @param {any} schema - Schema to check * @returns {schema is SchemaObject} */ static isSupportedSchemaType(schema) { return _.isPlainObject(schema) && schema.$async !== true; } } /** * Thrown when the {@link AppiumSchema} instance has not yet been finalized, but * the method called requires it. */ export class SchemaFinalizationError extends Error { /** * @type {Readonly<string>} */ code = 'APPIUMERR_SCHEMA_FINALIZATION'; constructor() { super('Schema not yet finalized; `finalize()` must be called first.'); } } /** * Thrown when a "unique" schema ID conflicts with an existing schema ID. * * This is likely going to be caused by attempting to register the same schema twice. */ export class SchemaNameConflictError extends Error { /** * @type {Readonly<string>} */ code = 'APPIUMERR_SCHEMA_NAME_CONFLICT'; /** * @type {Readonly<{extType: ExtensionType, extName: string}>} */ data; /** * @param {ExtensionType} extType * @param {string} extName */ constructor(extType, extName) { super(`Name for ${extType} schema "${extName}" conflicts with an existing schema`); this.data = {extType, extName}; } } /** * Thrown when a schema ID was expected, but it doesn't exist on the {@link Ajv} instance. */ export class SchemaUnknownSchemaError extends ReferenceError { /** * @type {Readonly<string>} */ code = 'APPIUMERR_SCHEMA_UNKNOWN_SCHEMA'; /** * @type {Readonly<{schemaId: string}>} */ data; /** * @param {string} schemaId */ constructor(schemaId) { super(`Unknown schema: "${schemaId}"`); this.data = {schemaId}; } } /** * Thrown when a schema is provided, but it's of an unsupported type. * * "Valid" schemas which are unsupported include boolean schemas and async schemas * (having a `true` `$async` property). */ export class SchemaUnsupportedSchemaError extends TypeError { /** * @type {Readonly<string>} */ code = 'APPIUMERR_SCHEMA_UNSUPPORTED_SCHEMA'; /** * @type {Readonly<{schema: any, extType: ExtensionType, extName: string}>} */ data; /** * @param {any} schema * @param {ExtensionType} extType * @param {string} extName */ constructor(schema, extType, extName) { // https://github.com/Microsoft/TypeScript/issues/8277 super( (() => { let msg = `Unsupported schema from ${extType} "${extName}":`; if (_.isBoolean(schema)) { return `${msg} schema cannot be a boolean`; } if (_.isPlainObject(schema)) { if (schema.$async) { return `${msg} schema cannot be an async schema`; } /* istanbul ignore next */ throw new TypeError( `schema IS supported; this error should not be thrown (this is a bug). value of schema: ${JSON.stringify( schema )}` ); } return `${msg} schema must be a plain object without a true "$async" property`; })() ); this.data = {schema, extType, extName}; } } const appiumSchema = AppiumSchema.create(); export const { registerSchema, getAllArgSpecs, getArgSpec, hasArgSpec, isFinalized, finalize: finalizeSchema, reset: resetSchema, validate, getSchema, flatten: flattenSchema, getDefaults: getDefaultsForSchema, getDefaultsForExtension, } = appiumSchema; export const {isAllowedSchemaFileExtension} = AppiumSchema; /** * Appium only supports schemas that are plain objects; not arrays. * @typedef {import('ajv').SchemaObject & {[key: number]: never}} SchemaObject */ /** * @typedef {import('@appium/types').ExtensionType} ExtensionType */ /** * An object having property `additionalProperties: false` * @typedef StrictProp * @property {false} additionalProperties */ /** * A {@link SchemaObject} with `additionalProperties: false` * @typedef {SchemaObject & StrictProp} StrictSchemaObject */ /** * A list of schemas associated with properties and their corresponding {@link ArgSpec} objects. * * Intermediate data structure used when converting the entire schema down to CLI arguments. * @typedef { {schema: SchemaObject, argSpec: ArgSpec}[] } FlattenedSchema */ /** * @typedef {ArgSpec['defaultValue']} ArgSpecDefaultValue */ /** * e.g. `{driver: {foo: 'bar'}}` where `foo` is the arg name and `bar` is the default value. * @typedef {Record<string,Record<string,ArgSpecDefaultValue>>} NestedArgSpecDefaultValue */ /** * Helper type for the return value of {@link AppiumSchema.getDefaults} * @template {boolean|undefined} Flattened * @typedef {Record<string,Flattened extends true ? ArgSpecDefaultValue : ArgSpecDefaultValue | NestedArgSpecDefaultValue>} DefaultValues */ /** * @typedef {'.json'|'.js'|'.cjs'} AllowedSchemaExtension */