appium
Version:
Automation for Apps.
655 lines • 27.6 kB
JavaScript
;
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