UNPKG

appium

Version:

Automation for Apps.

655 lines 27.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ExtensionConfig = exports.INSTALL_TYPES = exports.INSTALL_TYPE_DEV = exports.INSTALL_TYPE_GIT = exports.INSTALL_TYPE_GITHUB = exports.INSTALL_TYPE_LOCAL = exports.INSTALL_TYPE_NPM = void 0; exports.resolveEsmEntryPoint = resolveEsmEntryPoint; const support_1 = require("@appium/support"); const bluebird_1 = __importDefault(require("bluebird")); const lodash_1 = __importDefault(require("lodash")); const node_path_1 = __importDefault(require("node:path")); const resolve_from_1 = __importDefault(require("resolve-from")); const semver_1 = require("semver"); const extension_1 = require("../cli/extension"); const config_1 = require("../config"); const logger_1 = __importDefault(require("../logger")); const schema_1 = require("../schema/schema"); const node_url_1 = require("node:url"); const DEFAULT_ENTRY_POINT = 'index.js'; /** * "npm" install type * Used when extension was installed by npm package name * @remarks _All_ extensions are installed _by_ `npm`, but only this one means the package name was * used to specify it */ exports.INSTALL_TYPE_NPM = 'npm'; /** * "local" install type * Used when extension was installed from a local path */ exports.INSTALL_TYPE_LOCAL = 'local'; /** * "github" install type * Used when extension was installed via GitHub URL */ exports.INSTALL_TYPE_GITHUB = 'github'; /** * "git" install type * Used when extensions was installed via Git URL */ exports.INSTALL_TYPE_GIT = 'git'; /** * "dev" install type * Used when automatically detected as a working copy */ exports.INSTALL_TYPE_DEV = 'dev'; /** @type {Set<InstallType>} */ exports.INSTALL_TYPES = new Set([ exports.INSTALL_TYPE_GIT, exports.INSTALL_TYPE_GITHUB, exports.INSTALL_TYPE_LOCAL, exports.INSTALL_TYPE_NPM, exports.INSTALL_TYPE_DEV, ]); /** * This class is abstract. It should not be instantiated directly. * * Subclasses should provide the generic parameter to implement. * @template {ExtensionType} ExtType */ class ExtensionConfig { /** * The type of extension this class is responsible for. * @type {ExtType} */ extensionType; /** * Manifest data for the extensions of this type. * * This data should _not_ be written to by anything but {@linkcode Manifest}. * @type {Readonly<ExtRecord<ExtType>>} */ installedExtensions; /** @type {import('@appium/types').AppiumLogger} */ log; /** @type {Manifest} */ manifest; /** * @type {import('../cli/extension-command').ExtensionList<ExtType>|undefined} */ #listDataCache; /** * @protected * @param {ExtType} extensionType - Type of extension * @param {Manifest} manifest - `Manifest` instance */ constructor(extensionType, manifest) { this.extensionType = extensionType; this.installedExtensions = manifest.getExtensionData(extensionType); this.manifest = manifest; } get manifestPath() { return this.manifest.manifestPath; } get appiumHome() { return this.manifest.appiumHome; } /** * Returns a list of errors for a given extension. * * @param {ExtName<ExtType>} extName * @param {ExtManifest<ExtType>} extManifest * @returns {ExtManifestProblem[]} */ getProblems(extName, extManifest) { return [ ...this.getGenericConfigProblems(extManifest, extName), ...this.getConfigProblems(extManifest, extName), ...this.getSchemaProblems(extManifest, extName), ]; } /** * Returns a list of warnings for a given extension. * * @param {ExtName<ExtType>} extName * @param {ExtManifest<ExtType>} extManifest * @returns {Promise<string[]>} */ async getWarnings(extName, extManifest) { const [genericConfigWarnings, configWarnings] = await bluebird_1.default.all([ this.getGenericConfigWarnings(extManifest, extName), this.getConfigWarnings(extManifest, extName), ]); return [...genericConfigWarnings, ...configWarnings]; } /** * Returns a list of extension-type-specific issues. To be implemented by subclasses. * @abstract * @param {ExtManifest<ExtType>} extManifest * @param {ExtName<ExtType>} extName * @returns {Promise<string[]>} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async getConfigWarnings(extManifest, extName) { return []; } /** * * @param {Map<ExtName<ExtType>,ExtManifestProblem[]>} [errorMap] * @param {Map<ExtName<ExtType>,string[]>} [warningMap] */ getValidationResultSummaries(errorMap = new Map(), warningMap = new Map()) { /** * Array of computed strings * @type {string[]} */ const errorSummaries = []; for (const [extName, problems] of errorMap.entries()) { if (lodash_1.default.isEmpty(problems)) { continue; } // remove this extension from the list since it's not valid errorSummaries.push(`${this.extensionType} "${extName}" had ${support_1.util.pluralize('error', problems.length)} and will not be available:`); for (const problem of problems) { errorSummaries.push(` - ${problem.err} (Actual value: ` + `${JSON.stringify(problem.val)})`); } } /** @type {string[]} */ const warningSummaries = []; for (const [extName, warnings] of warningMap.entries()) { if (lodash_1.default.isEmpty(warnings)) { continue; } const extTypeText = lodash_1.default.capitalize(this.extensionType); const problemEnumerationText = support_1.util.pluralize('potential problem', warnings.length, true); warningSummaries.push(`${extTypeText} "${extName}" has ${problemEnumerationText}: `); for (const warning of warnings) { warningSummaries.push(` - ${warning}`); } } return { errorSummaries, warningSummaries }; } /** * Checks extensions for problems. To be called by subclasses' `validate` method. * * Errors and warnings will be displayed to the user. * * This method mutates `exts`. * * @protected * @param {ExtRecord<ExtType>} exts - Lookup of extension names to {@linkcode ExtManifest} objects * @returns {Promise<ExtRecord<ExtType>>} The same lookup, but picking only error-free extensions */ async _validate(exts) { /** * Lookup of extension names to {@linkcode ExtManifestProblem ExtManifestProblems} * @type {Map<ExtName<ExtType>,ExtManifestProblem[]>} */ const errorMap = new Map(); /** * Lookup of extension names to warnings. * @type {Map<ExtName<ExtType>,string[]>} */ const warningMap = new Map(); for (const [extName, extManifest] of lodash_1.default.toPairs(exts)) { const [errors, warnings] = await bluebird_1.default.all([ this.getProblems(extName, extManifest), this.getWarnings(extName, extManifest), ]); if (errors.length) { delete exts[extName]; } errorMap.set(extName, errors); warningMap.set(extName, warnings); } const { errorSummaries, warningSummaries } = this.getValidationResultSummaries(errorMap, warningMap); if (!lodash_1.default.isEmpty(errorSummaries)) { logger_1.default.error(`Appium encountered ${support_1.util.pluralize('error', errorMap.size, true)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`); for (const summary of errorSummaries) { logger_1.default.error(summary); } } else if (!lodash_1.default.isEmpty(warningSummaries)) { // only display warnings if there are no errors! logger_1.default.warn(`Appium encountered ${support_1.util.pluralize('warning', warningMap.size, true)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`); for (const summary of warningSummaries) { logger_1.default.warn(summary); } } return exts; } /** * Retrieves listing data for extensions via command class. * * This is an expensive operation, so the result is cached. Currently, there is no * use case for invalidating the cache. * @protected * @returns {Promise<import('../cli/extension-command').ExtensionList<ExtType>>} */ async getListData() { if (this.#listDataCache) { return this.#listDataCache; } const CommandClass = /** @type {ExtCommand<ExtType>} */ (extension_1.commandClasses[this.extensionType]); const cmd = new CommandClass({ config: this, json: true }); const listData = await cmd.list({ showInstalled: true, showUpdates: true }); this.#listDataCache = listData; return listData; } /** * Returns a list of warnings for a particular extension. * * By definition, a non-empty list of warnings does _not_ imply the extension cannot be loaded, * but it may not work as expected or otherwise throw an exception at runtime. * * @param {ExtManifest<ExtType>} extManifest * @param {ExtName<ExtType>} extName * @returns {Promise<string[]>} */ async getGenericConfigWarnings(extManifest, extName) { const { appiumVersion, installSpec, installType, pkgName } = extManifest; const warnings = []; const invalidFields = []; if (!lodash_1.default.isString(installSpec)) { invalidFields.push('installSpec'); } if (!exports.INSTALL_TYPES.has(installType)) { invalidFields.push('installType'); } const extTypeText = lodash_1.default.capitalize(this.extensionType); if (invalidFields.length) { const invalidFieldsEnumerationText = support_1.util.pluralize('invalid or missing field', invalidFields.length, true); const invalidFieldsText = invalidFields.map((field) => `"${field}"`).join(', '); warnings.push(`${extTypeText} "${extName}" (package \`${pkgName}\`) has ${invalidFieldsEnumerationText} (${invalidFieldsText}) in \`extensions.yaml\`; ` + `this may cause upgrades done via the \`appium\` CLI tool to fail. Please reinstall with \`appium ${this.extensionType} uninstall ` + `${extName}\` and \`appium ${this.extensionType} install ${extName}\` to attempt a fix.`); } /** * Helps concatenate warning messages related to peer dependencies * @param {string} reason * @returns string */ const createPeerWarning = (reason) => `${extTypeText} "${extName}" (package \`${pkgName}\`) may be incompatible with the current version of Appium (v${config_1.APPIUM_VER}) due to ${reason}`; if (lodash_1.default.isString(appiumVersion) && !(0, semver_1.satisfies)(config_1.APPIUM_VER, appiumVersion)) { const listData = await this.getListData(); const extListData = /** @type {import('../cli/extension-command').ExtensionListData<ExtType>} */ (listData[extName]); if (extListData?.installed) { const { updateVersion, upToDate } = extListData; if (!upToDate && updateVersion) { warnings.push(createPeerWarning(`its peer dependency on Appium ${appiumVersion}. Try to upgrade \`${pkgName}\` to v${updateVersion} or newer.`)); } else { warnings.push(createPeerWarning(`its peer dependency on Appium ${appiumVersion}. Please install a compatible version of the ${lodash_1.default.toLower(extTypeText)}.`)); } } } else if (!lodash_1.default.isString(appiumVersion)) { const listData = await this.getListData(); const extListData = /** @type {import('../cli/extension-command').InstalledExtensionListData<ExtType>} */ (listData[extName]); if (!extListData?.upToDate && extListData?.updateVersion) { warnings.push(createPeerWarning(`an invalid or missing peer dependency on Appium. A newer version of \`${pkgName}\` is available; ` + `please attempt to upgrade "${extName}" to v${extListData.updateVersion} or newer.`)); } else { warnings.push(createPeerWarning(`an invalid or missing peer dependency on Appium. ` + `Please ask the developer of \`${pkgName}\` to add a peer dependency on \`^appium@${config_1.APPIUM_VER}\`.`)); } } return warnings; } /** * Returns list of unrecoverable errors (if any) for the given extension _if_ it has a `schema` property. * * @param {ExtManifest<ExtType>} extManifest - Extension data (from manifest) * @param {ExtName<ExtType>} extName - Extension name (from manifest) * @returns {ExtManifestProblem[]} */ getSchemaProblems(extManifest, extName) { /** @type {ExtManifestProblem[]} */ const problems = []; const { schema: argSchemaPath } = extManifest; if (ExtensionConfig.extDataHasSchema(extManifest)) { if (lodash_1.default.isString(argSchemaPath)) { if ((0, schema_1.isAllowedSchemaFileExtension)(argSchemaPath)) { try { this.readExtensionSchema(extName, extManifest); } catch (err) { problems.push({ err: `Unable to register schema at path ${argSchemaPath}; ${err.message}`, val: argSchemaPath, }); } } else { problems.push({ err: `Schema file has unsupported extension. Allowed: ${[ ...schema_1.ALLOWED_SCHEMA_EXTENSIONS, ].join(', ')}`, val: argSchemaPath, }); } } else if (lodash_1.default.isPlainObject(argSchemaPath)) { try { this.readExtensionSchema(extName, extManifest); } catch (err) { problems.push({ err: `Unable to register embedded schema; ${err.message}`, val: argSchemaPath, }); } } else { problems.push({ err: 'Incorrectly formatted schema field; must be a path to a schema file or a schema object.', val: argSchemaPath, }); } } return problems; } /** * Return a list of generic unrecoverable errors for the given extension * @param {ExtManifest<ExtType>} extManifest - Extension data (from manifest) * @param {ExtName<ExtType>} extName - Extension name (from manifest) * @returns {ExtManifestProblem[]} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getGenericConfigProblems(extManifest, extName) { const { version, pkgName, mainClass } = extManifest; const problems = []; if (!lodash_1.default.isString(version)) { problems.push({ err: `Invalid or missing \`version\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`, val: version, }); } if (!lodash_1.default.isString(pkgName)) { problems.push({ err: `Invalid or missing \`name\` field in my \`package.json\` and/or \`extensions.yaml\` (must be a string)`, val: pkgName, }); } if (!lodash_1.default.isString(mainClass)) { problems.push({ err: `Invalid or missing \`appium.mainClass\` field in my \`package.json\` and/or \`mainClass\` field in \`extensions.yaml\` (must be a string)`, val: mainClass, }); } return problems; } /** * @abstract * @param {ExtManifest<ExtType>} extManifest * @param {ExtName<ExtType>} extName * @returns {ExtManifestProblem[]} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getConfigProblems(extManifest, extName) { // should override this method if special validation is necessary for this extension type return []; } /** * @param {string} extName * @param {ExtManifest<ExtType>} extManifest * @param {ExtensionConfigMutationOpts} opts * @returns {Promise<void>} */ async addExtension(extName, extManifest, { write = true } = {}) { this.manifest.setExtension(this.extensionType, extName, extManifest); if (write) { await this.manifest.write(); } } /** * @param {ExtName<ExtType>} extName * @param {ExtManifest<ExtType>} extManifest * @param {ExtensionConfigMutationOpts} opts * @returns {Promise<void>} */ async updateExtension(extName, extManifest, { write = true } = {}) { this.manifest.setExtension(this.extensionType, extName, { ...this.installedExtensions[extName], ...extManifest, }); if (write) { await this.manifest.write(); } } /** * Remove an extension from the list of installed extensions, and optionally avoid a write to the manifest file. * * @param {ExtName<ExtType>} extName * @param {ExtensionConfigMutationOpts} opts * @returns {Promise<void>} */ async removeExtension(extName, { write = true } = {}) { this.manifest.deleteExtension(this.extensionType, extName); if (write) { await this.manifest.write(); } } /** * @param {ExtName<ExtType>[]} [activeNames] * @returns {void} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars print(activeNames) { if (lodash_1.default.isEmpty(this.installedExtensions)) { logger_1.default.info(`No ${this.extensionType}s have been installed in ${this.appiumHome}. Use the "appium ${this.extensionType}" ` + 'command to install the one(s) you want to use.'); return; } logger_1.default.info(`Available ${this.extensionType}s:`); for (const [extName, extManifest] of /** @type {[string, ExtManifest<ExtType>][]} */ (lodash_1.default.toPairs(this.installedExtensions))) { logger_1.default.info(` - ${this.extensionDesc(extName, extManifest)}`); } } /** * Returns a string describing the extension. Subclasses must implement. * @param {ExtName<ExtType>} extName - Extension name * @param {ExtManifest<ExtType>} extManifest - Extension data * @returns {string} * @abstract */ // eslint-disable-next-line @typescript-eslint/no-unused-vars extensionDesc(extName, extManifest) { throw new Error('This must be implemented in a subclass'); } /** * Returns--with reasonable accuracy--the path on disk to the extension. * * If `installPath` is present in the manifest, then it is used; otherwise we just guess. * @param {keyof typeof this.installedExtensions} extName * @returns {string} */ getInstallPath(extName) { return (this.installedExtensions[extName]?.installPath ?? node_path_1.default.join(this.appiumHome, 'node_modules', this.installedExtensions[extName].pkgName)); } /** * * @param {ExtName<ExtType>} extName * @returns {Promise<[string, string]>} */ async _resolveExtension(extName) { const { mainClass } = this.installedExtensions[extName]; const moduleRoot = this.getInstallPath(extName); const packageJsonPath = node_path_1.default.join(moduleRoot, 'package.json'); let extensionManifest; try { extensionManifest = JSON.parse(await support_1.fs.readFile(packageJsonPath, 'utf8')); } catch (e) { throw new ReferenceError(`Could not read the ${this.extensionType} manifest at ${packageJsonPath}: ${e.message}`); } /** @type {string | undefined} */ let entryPointRelativePath; try { if (extensionManifest.type === 'module' && extensionManifest.exports) { entryPointRelativePath = resolveEsmEntryPoint(extensionManifest.exports); } entryPointRelativePath = entryPointRelativePath ?? extensionManifest.main ?? DEFAULT_ENTRY_POINT; } catch (e) { throw new ReferenceError(`Could not find the ${this.extensionType} installed at ${moduleRoot}: ${e.message}`); } const entryPointFullPath = node_path_1.default.resolve(moduleRoot, /** @type {string} */ (entryPointRelativePath)); if (!await support_1.fs.exists(entryPointFullPath)) { throw new ReferenceError(`Cannot find a valid ${this.extensionType} main entry point in '${packageJsonPath}'. ` + `Assumed entry point: '${entryPointFullPath}'`); } // note: this will only reload the entry point if (process.env.APPIUM_RELOAD_EXTENSIONS && require.cache[entryPointFullPath]) { logger_1.default.debug(`Removing ${entryPointFullPath} from require cache`); delete require.cache[entryPointFullPath]; } return [entryPointFullPath, mainClass]; } /** * Loads extension asynchronously and returns its main class (constructor) * * @param {ExtName<ExtType>} extName * @returns {Promise<ExtClass<ExtType>>} */ async requireAsync(extName) { const [reqPath, mainClass] = await this._resolveExtension(extName); logger_1.default.debug(`Requiring ${this.extensionType} at ${reqPath}`); // https://github.com/nodejs/node/issues/31710 const importPath = support_1.system.isWindows() ? (0, node_url_1.pathToFileURL)(reqPath).href : reqPath; const MainClass = (await import(importPath))[mainClass]; if (!MainClass) { throw new ReferenceError(`Could not find a class named "${mainClass}" exported by ${this.extensionType} "${extName}"`); } return MainClass; } /** * @param {string} extName * @returns {boolean} */ isInstalled(extName) { return extName in this.installedExtensions; } /** * Intended to be called by corresponding instance methods of subclass. * @private * @template {ExtensionType} ExtType * @param {string} appiumHome * @param {ExtType} extType * @param {ExtName<ExtType>} extName - Extension name (unique to its type) * @param {ExtManifestWithSchema<ExtType>} extManifest - Extension config * @returns {import('ajv').SchemaObject|undefined} */ static _readExtensionSchema(appiumHome, extType, extName, extManifest) { const { pkgName, schema: argSchemaPath } = extManifest; if (!argSchemaPath) { throw new TypeError(`No \`schema\` property found in config for ${extType} ${pkgName} -- why is this function being called?`); } let moduleObject; if (lodash_1.default.isString(argSchemaPath)) { const schemaPath = (0, resolve_from_1.default)(appiumHome, node_path_1.default.join(pkgName, argSchemaPath)); moduleObject = require(schemaPath); } else { moduleObject = argSchemaPath; } // this sucks. default exports should be destroyed const schema = moduleObject.__esModule ? moduleObject.default : moduleObject; (0, schema_1.registerSchema)(extType, extName, schema); return schema; } /** * Returns `true` if a specific {@link ExtManifest} object has a `schema` prop. * The {@link ExtManifest} object becomes a {@link ExtManifestWithSchema} object. * @template {ExtensionType} ExtType * @param {ExtManifest<ExtType>} extManifest * @returns {extManifest is ExtManifestWithSchema<ExtType>} */ static extDataHasSchema(extManifest) { return lodash_1.default.isString(extManifest?.schema) || lodash_1.default.isObject(extManifest?.schema); } /** * If an extension provides a schema, this will load the schema and attempt to * register it with the schema registrar. * @param {ExtName<ExtType>} extName - Name of extension * @param {ExtManifestWithSchema<ExtType>} extManifest - Extension data * @returns {import('ajv').SchemaObject|undefined} */ readExtensionSchema(extName, extManifest) { return ExtensionConfig._readExtensionSchema(this.appiumHome, this.extensionType, extName, extManifest); } } exports.ExtensionConfig = ExtensionConfig; /** * https://nodejs.org/api/packages.html#package-entry-points * * @param {any} exportsValue * @returns {string | undefined} */ function resolveEsmEntryPoint(exportsValue) { if (lodash_1.default.isString(exportsValue) && exportsValue) { return exportsValue; } if (!lodash_1.default.isPlainObject(exportsValue)) { return; } for (const key of ['.', 'import']) { if (exportsValue[key]) { return resolveEsmEntryPoint(exportsValue[key]); } } } /** * An issue with the {@linkcode ExtManifest} for a particular extension. * * The existance of such an object implies that the extension cannot be loaded. * @typedef ExtManifestProblem * @property {string} err - Error message * @property {any} val - Associated value */ /** * An optional logging function provided to an {@link ExtensionConfig} subclass. * @callback ExtensionLogFn * @param {...any} args * @returns {void} */ /** * @typedef {import('@appium/types').ExtensionType} ExtensionType * @typedef {import('./manifest').Manifest} Manifest * @typedef {import('appium/types').InstallType} InstallType */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtManifest<ExtType>} ExtManifest */ /** * @template {ExtensionType} ExtType * @typedef {ExtManifest<ExtType> & {schema: NonNullable<ExtManifest<ExtType>['schema']>}} ExtManifestWithSchema */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtName<ExtType>} ExtName */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtClass<ExtType>} ExtClass */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtRecord<ExtType>} ExtRecord */ /** * @template {ExtensionType} ExtType * @typedef {import('../cli/extension').ExtCommand<ExtType>} ExtCommand */ /** * Options for various methods in {@link ExtensionConfig} * @typedef ExtensionConfigMutationOpts * @property {boolean} [write=true] Whether or not to write the manifest to disk after a mutation operation */ //# sourceMappingURL=extension-config.js.map