appium
Version:
Automation for Apps.
767 lines (706 loc) • 24.6 kB
JavaScript
import {util, fs, system} from '@appium/support';
import B from 'bluebird';
import _ from 'lodash';
import path from 'path';
import resolveFrom from 'resolve-from';
import {satisfies} from 'semver';
import {commandClasses} from '../cli/extension';
import {APPIUM_VER} from '../config';
import log from '../logger';
import {
ALLOWED_SCHEMA_EXTENSIONS,
isAllowedSchemaFileExtension,
registerSchema,
} from '../schema/schema';
import { pathToFileURL } from '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
*/
export const INSTALL_TYPE_NPM = 'npm';
/**
* "local" install type
* Used when extension was installed from a local path
*/
export const INSTALL_TYPE_LOCAL = 'local';
/**
* "github" install type
* Used when extension was installed via GitHub URL
*/
export const INSTALL_TYPE_GITHUB = 'github';
/**
* "git" install type
* Used when extensions was installed via Git URL
*/
export const INSTALL_TYPE_GIT = 'git';
/**
* "dev" install type
* Used when automatically detected as a working copy
*/
export const INSTALL_TYPE_DEV = 'dev';
/** @type {Set<InstallType>} */
export const INSTALL_TYPES = new Set([
INSTALL_TYPE_GIT,
INSTALL_TYPE_GITHUB,
INSTALL_TYPE_LOCAL,
INSTALL_TYPE_NPM,
INSTALL_TYPE_DEV,
]);
/**
* This class is abstract. It should not be instantiated directly.
*
* Subclasses should provide the generic parameter to implement.
* @template {ExtensionType} ExtType
*/
export 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 B.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 (_.isEmpty(problems)) {
continue;
}
// remove this extension from the list since it's not valid
errorSummaries.push(
`${this.extensionType} "${extName}" had ${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 (_.isEmpty(warnings)) {
continue;
}
const extTypeText = _.capitalize(this.extensionType);
const problemEnumerationText = 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 _.toPairs(exts)) {
const [errors, warnings] = await B.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 (!_.isEmpty(errorSummaries)) {
log.error(
`Appium encountered ${util.pluralize('error', errorMap.size, true)} while validating ${
this.extensionType
}s found in manifest ${this.manifestPath}`
);
for (const summary of errorSummaries) {
log.error(summary);
}
} else if (!_.isEmpty(warningSummaries)) {
// only display warnings if there are no errors!
log.warn(
`Appium encountered ${util.pluralize(
'warning',
warningMap.size,
true
)} while validating ${this.extensionType}s found in manifest ${this.manifestPath}`
);
for (const summary of warningSummaries) {
log.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>} */ (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 (!_.isString(installSpec)) {
invalidFields.push('installSpec');
}
if (!INSTALL_TYPES.has(installType)) {
invalidFields.push('installType');
}
const extTypeText = _.capitalize(this.extensionType);
if (invalidFields.length) {
const invalidFieldsEnumerationText = 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${APPIUM_VER}) due to ${reason}`;
if (_.isString(appiumVersion) && !satisfies(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 ${_.toLower(extTypeText)}.`
)
);
}
}
} else if (!_.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@${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 (_.isString(argSchemaPath)) {
if (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: ${[
...ALLOWED_SCHEMA_EXTENSIONS,
].join(', ')}`,
val: argSchemaPath,
});
}
} else if (_.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 (!_.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 (!_.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 (!_.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 (_.isEmpty(this.installedExtensions)) {
log.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;
}
log.info(`Available ${this.extensionType}s:`);
for (const [extName, extManifest] of /** @type {[string, ExtManifest<ExtType>][]} */ (
_.toPairs(this.installedExtensions)
)) {
log.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 ??
path.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 = path.join(moduleRoot, 'package.json');
let extensionManifest;
try {
extensionManifest = JSON.parse(await 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 = path.resolve(moduleRoot, /** @type {string} */ (entryPointRelativePath));
if (!await 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]) {
log.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);
log.debug(`Requiring ${this.extensionType} at ${reqPath}`);
// https://github.com/nodejs/node/issues/31710
const importPath = system.isWindows() ? 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 (_.isString(argSchemaPath)) {
const schemaPath = resolveFrom(appiumHome, path.join(pkgName, argSchemaPath));
moduleObject = require(schemaPath);
} else {
moduleObject = argSchemaPath;
}
// this sucks. default exports should be destroyed
const schema = moduleObject.__esModule ? moduleObject.default : moduleObject;
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 _.isString(extManifest?.schema) || _.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
);
}
}
/**
* https://nodejs.org/api/packages.html#package-entry-points
*
* @param {any} exportsValue
* @returns {string | undefined}
*/
export function resolveEsmEntryPoint(exportsValue) {
if (_.isString(exportsValue) && exportsValue) {
return exportsValue;
}
if (!_.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
*/