UNPKG

appium

Version:

Automation for Apps.

1,000 lines 47.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ExtensionCommand = void 0; const bluebird_1 = __importDefault(require("bluebird")); const lodash_1 = __importDefault(require("lodash")); const path_1 = __importDefault(require("path")); const support_1 = require("@appium/support"); const utils_1 = require("./utils"); const extension_config_1 = require("../extension/extension-config"); const teen_process_1 = require("teen_process"); const package_changed_1 = require("../extension/package-changed"); const child_process_1 = require("child_process"); const node_util_1 = require("node:util"); const url_1 = require("url"); const doctor_1 = require("../doctor/doctor"); const utils_2 = require("../utils"); const semver = __importStar(require("semver")); const UPDATE_ALL = 'installed'; class NotUpdatableError extends Error { } class NoUpdatesAvailableError extends Error { } /** * Omits `driverName`/`pluginName` props from the receipt to make a {@linkcode ExtManifest} * @template {ExtensionType} ExtType * @param {ExtInstallReceipt<ExtType>} receipt * @returns {ExtManifest<ExtType>} */ function receiptToManifest(receipt) { return /** @type {ExtManifest<ExtType>} */ (lodash_1.default.omit(receipt, 'driverName', 'pluginName')); } /** * Fetches the remote extension version requirements * * @param {string} pkgName Extension name * @param {string} [pkgVer] Extension version (if not provided then the latest is assumed) * @returns {Promise<[string, string|null]>} */ async function getRemoteExtensionVersionReq(pkgName, pkgVer) { const allDeps = await support_1.npm.getPackageInfo(`${pkgName}${pkgVer ? `@${pkgVer}` : ``}`, ['peerDependencies', 'dependencies']); const requiredVersionPair = lodash_1.default.flatMap(lodash_1.default.values(allDeps).map(lodash_1.default.toPairs)) .find(([name]) => name === 'appium'); return [utils_2.npmPackage.version, requiredVersionPair ? requiredVersionPair[1] : null]; } /** * @template {ExtensionType} ExtType */ class ExtensionCliCommand { /** * Build an ExtensionCommand * @param {ExtensionCommandOptions<ExtType>} opts */ constructor({ config, json }) { this.config = config; this.log = new support_1.console.CliConsole({ jsonMode: json }); this.isJsonOutput = Boolean(json); } /** * `driver` or `plugin`, depending on the `ExtensionConfig`. */ get type() { return this.config.extensionType; } /** * Logs a message and returns an {@linkcode Error} to throw. * * For TS to understand that a function throws an exception, it must actually throw an exception-- * in other words, _calling_ a function which is guaranteed to throw an exception is not enough-- * nor is something like `@returns {never}` which does not imply a thrown exception. * @param {string} message * @protected * @throws {Error} */ _createFatalError(message) { return new Error(this.log.decorate(message, 'error')); } /** * Take a CLI parse and run an extension command based on its type * * @param {object} args - a key/value object with CLI flags and values * @return {Promise<object>} the result of the specific command which is executed */ async execute(args) { const cmd = args[`${this.type}Command`]; if (!lodash_1.default.isFunction(this[cmd])) { throw this._createFatalError(`Cannot handle ${this.type} command ${cmd}`); } const executeCmd = this[cmd].bind(this); return await executeCmd(args); } /** * List extensions * @template {ExtensionType} ExtType * @param {ListOptions} opts * @return {Promise<ExtensionList<ExtType>>} map of extension names to extension data */ async list({ showInstalled, showUpdates, verbose = false }) { let lsMsg = `Listing ${showInstalled ? 'installed' : 'available'} ${this.type}s`; if (verbose) { lsMsg += ' (verbose mode)'; } const installedNames = Object.keys(this.config.installedExtensions); const knownNames = Object.keys(this.knownExtensions); const listData = [...installedNames, ...knownNames].reduce((acc, name) => { if (!acc[name]) { if (installedNames.includes(name)) { acc[name] = { ... /** @type {Partial<ExtManifest<ExtType>>} */(this.config.installedExtensions[name]), installed: true, }; } else if (!showInstalled) { acc[name] = /** @type {ExtensionListData<ExtType>} */ ({ pkgName: this.knownExtensions[name], installed: false, }); } } return acc; }, /** @type {ExtensionList<ExtType>} */ ({})); // if we want to show whether updates are available, put that behind a spinner await (0, utils_1.spinWith)(this.isJsonOutput, lsMsg, async () => { if (!showUpdates) { return; } for (const [ext, data] of lodash_1.default.toPairs(listData)) { if (!data.installed || data.installType !== extension_config_1.INSTALL_TYPE_NPM) { // don't need to check for updates on exts that aren't installed // also don't need to check for updates on non-npm exts continue; } try { const updates = await this.checkForExtensionUpdate(ext); data.updateVersion = updates.safeUpdate; data.unsafeUpdateVersion = updates.unsafeUpdate; data.upToDate = updates.safeUpdate === null && updates.unsafeUpdate === null; } catch (e) { data.updateError = e.message; } } }); /** * Type guard to narrow "installed" extensions, which have more data * @param {any} data * @returns {data is InstalledExtensionListData<ExtType>} */ const extIsInstalled = (data) => Boolean(data.installed); // if we're just getting the data, short circuit return here since we don't need to do any // formatting logic if (this.isJsonOutput) { return listData; } if (verbose) { this.log.log((0, node_util_1.inspect)(listData, { colors: true, depth: null })); return listData; } for (const [name, data] of lodash_1.default.toPairs(listData)) { let installTxt = ' [not installed]'.grey; let updateTxt = ''; let upToDateTxt = ''; let unsafeUpdateTxt = ''; if (extIsInstalled(data)) { const { installType, installSpec, updateVersion, unsafeUpdateVersion, version, upToDate, updateError, } = data; let typeTxt; switch (installType) { case extension_config_1.INSTALL_TYPE_GIT: case extension_config_1.INSTALL_TYPE_GITHUB: typeTxt = `(cloned from ${installSpec})`.yellow; break; case extension_config_1.INSTALL_TYPE_LOCAL: typeTxt = `(linked from ${installSpec})`.magenta; break; case extension_config_1.INSTALL_TYPE_DEV: typeTxt = '(dev mode)'; break; default: typeTxt = '(npm)'; } installTxt = `@${version.yellow} ${('[installed ' + typeTxt + ']').green}`; if (showUpdates) { if (updateError) { updateTxt = ` [Cannot check for updates: ${updateError}]`.red; } else { if (updateVersion) { updateTxt = ` [${updateVersion} available]`.magenta; } if (upToDate) { upToDateTxt = ` [Up to date]`.green; } if (unsafeUpdateVersion) { unsafeUpdateTxt = ` [${unsafeUpdateVersion} available (potentially unsafe)]`.cyan; } } } } this.log.log(`- ${name.yellow}${installTxt}${updateTxt}${upToDateTxt}${unsafeUpdateTxt}`); } return listData; } /** * Checks whether the given extension is compatible with the currently installed server * * @param {InstallViaNpmArgs} installViaNpmOpts * @returns {Promise<void>} */ async _checkInstallCompatibility({ installSpec, pkgName, pkgVer, installType }) { if (extension_config_1.INSTALL_TYPE_NPM !== installType) { return; } await (0, utils_1.spinWith)(this.isJsonOutput, `Checking if '${pkgName}' is compatible`, async () => { const [serverVersion, extVersionRequirement] = await getRemoteExtensionVersionReq(pkgName, pkgVer); if (serverVersion && extVersionRequirement && !semver.satisfies(serverVersion, extVersionRequirement)) { throw this._createFatalError(`'${installSpec}' cannot be installed because the server version it requires (${extVersionRequirement}) ` + `does not meet the currently installed one (${serverVersion}). Please install ` + `a compatible server version first.`); } }); } /** * Install an extension * * @param {InstallOpts} opts * @return {Promise<ExtRecord<ExtType>>} map of all installed extension names to extension data */ async _install({ installSpec, installType, packageName }) { /** @type {ExtInstallReceipt<ExtType>} */ let receipt; if (packageName && [extension_config_1.INSTALL_TYPE_LOCAL, extension_config_1.INSTALL_TYPE_NPM].includes(installType)) { throw this._createFatalError(`When using --source=${installType}, cannot also use --package`); } if (!packageName && [extension_config_1.INSTALL_TYPE_GIT, extension_config_1.INSTALL_TYPE_GITHUB].includes(installType)) { throw this._createFatalError(`When using --source=${installType}, must also use --package`); } /** * @type {InstallViaNpmArgs} */ let installViaNpmOpts; /** * The probable (?) name of the extension derived from the install spec. * * If using a local install type, this will remain empty. * @type {string} */ let probableExtName = ''; // depending on `installType`, build the options to pass into `installViaNpm` if (installType === extension_config_1.INSTALL_TYPE_GITHUB) { if (installSpec.split('/').length !== 2) { throw this._createFatalError(`Github ${this.type} spec ${installSpec} appeared to be invalid; ` + 'it should be of the form <org>/<repo>'); } installViaNpmOpts = { installSpec, installType, pkgName: /** @type {string} */ (packageName), }; probableExtName = /** @type {string} */ (packageName); } else if (installType === extension_config_1.INSTALL_TYPE_GIT) { // git urls can have '.git' at the end, but this is not necessary and would complicate the // way we download and name directories, so we can just remove it installSpec = installSpec.replace(/\.git$/, ''); installViaNpmOpts = { installSpec, installType, pkgName: /** @type {string} */ (packageName), }; probableExtName = /** @type {string} */ (packageName); } else { let pkgName, pkgVer; if (installType === extension_config_1.INSTALL_TYPE_LOCAL) { pkgName = path_1.default.isAbsolute(installSpec) ? installSpec : path_1.default.resolve(installSpec); } else { // at this point we have either an npm package or an appium verified extension // name or a local path. both of which will be installed via npm. // extensions installed via npm can include versions or tags after the '@' // sign, so check for that. We also need to be careful that package names themselves can // contain the '@' symbol, as in `npm install @appium/fake-driver@1.2.0` let name; const splits = installSpec.split('@'); if (installSpec[0] === '@') { // this is the case where we have an npm org included in the package name [name, pkgVer] = [`@${splits[1]}`, splits[2]]; } else { // this is the case without an npm org [name, pkgVer] = splits; } if (installType === extension_config_1.INSTALL_TYPE_NPM) { // if we're installing a named package from npm, we don't need to check // against the appium extension list; just use the installSpec as is pkgName = name; } else { // if we're installing a named appium driver (like 'xcuitest') we need to // dereference the actual npm package ('appiupm-xcuitest-driver'), so // check it exists and get the correct package const knownNames = Object.keys(this.knownExtensions); if (!lodash_1.default.includes(knownNames, name)) { const msg = `Could not resolve ${this.type}; are you sure it's in the list ` + `of supported ${this.type}s? ${JSON.stringify(knownNames)}`; throw this._createFatalError(msg); } probableExtName = name; pkgName = this.knownExtensions[name]; // given that we'll use the install type in the driver json, store it as // 'npm' now installType = extension_config_1.INSTALL_TYPE_NPM; } } installViaNpmOpts = { installSpec, pkgName, pkgVer, installType }; } // fail fast here if we can if (probableExtName && this.config.isInstalled(probableExtName)) { throw this._createFatalError(`A ${this.type} named "${probableExtName}" is already installed. ` + `Did you mean to update? Run "appium ${this.type} update". See ` + `installed ${this.type}s with "appium ${this.type} list --installed".`); } await this._checkInstallCompatibility(installViaNpmOpts); receipt = await this.installViaNpm(installViaNpmOpts); // this _should_ be the same as `probablyExtName` as the one derived above unless // install type is local. /** @type {string} */ const extName = receipt[ /** @type {string} */(`${this.type}Name`)]; // check _a second time_ with the more-accurate extName if (this.config.isInstalled(extName)) { throw this._createFatalError(`A ${this.type} named "${extName}" is already installed. ` + `Did you mean to update? Run "appium ${this.type} update". See ` + `installed ${this.type}s with "appium ${this.type} list --installed".`); } // this field does not exist as such in the manifest (it's used as a property name instead) // so that's why it's being removed here. /** @type {ExtManifest<ExtType>} */ const extManifest = receiptToManifest(receipt); const [errors, warnings] = await bluebird_1.default.all([ this.config.getProblems(extName, extManifest), this.config.getWarnings(extName, extManifest), ]); const errorMap = new Map([[extName, errors]]); const warningMap = new Map([[extName, warnings]]); const { errorSummaries, warningSummaries } = this.config.getValidationResultSummaries(errorMap, warningMap); if (!lodash_1.default.isEmpty(errorSummaries)) { throw this._createFatalError(errorSummaries.join('\n')); } // note that we won't show any warnings if there were errors. if (!lodash_1.default.isEmpty(warningSummaries)) { this.log.warn(warningSummaries.join('\n')); } await this.config.addExtension(extName, extManifest); // update the hash if we've changed the local `package.json` if (await support_1.env.hasAppiumDependency(this.config.appiumHome)) { await (0, package_changed_1.packageDidChange)(this.config.appiumHome); } // log info for the user this.log.info(this.getPostInstallText({ extName, extData: receipt })); return this.config.installedExtensions; } /** * Install an extension via NPM * * @param {InstallViaNpmArgs} args * @returns {Promise<ExtInstallReceipt<ExtType>>} */ async installViaNpm({ installSpec, pkgName, pkgVer, installType }) { const msg = `Installing '${installSpec}'`; // the string used for installation is either <name>@<ver> in the case of a standard NPM // package, or whatever the user sent in otherwise. const installStr = installType === extension_config_1.INSTALL_TYPE_NPM ? `${pkgName}${pkgVer ? `@${pkgVer}` : ''}` : installSpec; try { const { pkg, path } = await (0, utils_1.spinWith)(this.isJsonOutput, msg, async () => { const { pkg, installPath: path } = await support_1.npm.installPackage(this.config.appiumHome, installStr, { pkgName, installType, }); this.validatePackageJson(pkg, installSpec); return { pkg, path }; }); return this.getInstallationReceipt({ pkg, installPath: path, installType, installSpec, }); } catch (err) { throw this._createFatalError(`Encountered an error when installing package: ${err.message}`); } } /** * Get the text which should be displayed to the user after an extension has been installed. This * is designed to be overridden by drivers/plugins with their own particular text. * * @param {ExtensionArgs} args * @returns {string} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getPostInstallText(args) { throw this._createFatalError('Must be implemented in final class'); } /** * Once a package is installed on-disk, this gathers some necessary metadata for validation. * * @param {GetInstallationReceiptOpts<ExtType>} opts * @returns {ExtInstallReceipt<ExtType>} */ getInstallationReceipt({ pkg, installPath, installType, installSpec }) { const { appium, name, version, peerDependencies } = pkg; const strVersion = /** @type {string} */ (version); /** @type {import('appium/types').InternalMetadata} */ const internal = { pkgName: /** @type {string} */ (name), version: strVersion, installType, installSpec, installPath, appiumVersion: peerDependencies?.appium, }; /** @type {ExtMetadata<ExtType>} */ const extMetadata = appium; return { ...internal, ...extMetadata, }; } /** * Validates the _required_ root fields of an extension's `package.json` file. * * These required fields are: * - `name` * - `version` * - `appium` * @param {import('type-fest').PackageJson} pkg - `package.json` of extension * @param {string} installSpec - Extension name/spec * @throws {ReferenceError} If `package.json` has a missing or invalid field * @returns {pkg is ExtPackageJson<ExtType>} */ validatePackageJson(pkg, installSpec) { const { appium, name, version } = /** @type {ExtPackageJson<ExtType>} */ (pkg); /** * * @param {string} field * @returns {ReferenceError} */ const createMissingFieldError = (field) => new ReferenceError(`${this.type} "${installSpec}" invalid; missing a \`${field}\` field of its \`package.json\``); if (!name) { throw createMissingFieldError('name'); } if (!version) { throw createMissingFieldError('version'); } if (!appium) { throw createMissingFieldError('appium'); } this.validateExtensionFields(appium, installSpec); return true; } /** * For any `package.json` fields which a particular type of extension requires, validate the * presence and form of those fields on the `package.json` data, throwing an error if anything is * amiss. * * @param {ExtMetadata<ExtType>} extMetadata - the data in the "appium" field of `package.json` for an extension * @param {string} installSpec - Extension name/spec */ // eslint-disable-next-line @typescript-eslint/no-unused-vars validateExtensionFields(extMetadata, installSpec) { throw this._createFatalError('Must be implemented in final class'); } /** * Uninstall an extension. * * First tries to do this via `npm uninstall`, but if that fails, just `rm -rf`'s the extension dir. * * Will only remove the extension from the manifest if it has been successfully removed. * * @param {UninstallOpts} opts * @return {Promise<ExtRecord<ExtType>>} map of all installed extension names to extension data (without the extension just uninstalled) */ async _uninstall({ installSpec }) { if (!this.config.isInstalled(installSpec)) { throw this._createFatalError(`Can't uninstall ${this.type} '${installSpec}'; it is not installed`); } const extRecord = this.config.installedExtensions[installSpec]; if (extRecord.installType === extension_config_1.INSTALL_TYPE_DEV) { this.log.warn(`Cannot uninstall ${this.type} "${installSpec}" because it is in development!`); return this.config.installedExtensions; } const pkgName = extRecord.pkgName; await (0, utils_1.spinWith)(this.isJsonOutput, `Uninstalling ${this.type} '${installSpec}'`, async () => { await support_1.npm.uninstallPackage(this.config.appiumHome, pkgName); }); await this.config.removeExtension(installSpec); this.log.ok(`Successfully uninstalled ${this.type} '${installSpec}'`.green); return this.config.installedExtensions; } /** * Attempt to update one or more drivers using NPM * * @param {ExtensionUpdateOpts} updateSpec * @return {Promise<ExtensionUpdateResult>} */ async _update({ installSpec, unsafe }) { const shouldUpdateAll = installSpec === UPDATE_ALL; // if we're specifically requesting an update for an extension, make sure it's installed if (!shouldUpdateAll && !this.config.isInstalled(installSpec)) { throw this._createFatalError(`The ${this.type} "${installSpec}" was not installed, so can't be updated`); } const extsToUpdate = shouldUpdateAll ? Object.keys(this.config.installedExtensions) : [installSpec]; // 'errors' will have ext names as keys and error objects as values /** @type {Record<string,Error>} */ const errors = {}; // 'updates' will have ext names as keys and update objects as values, where an update // object is of the form {from: versionString, to: versionString} /** @type {Record<string,UpdateReport>} */ const updates = {}; for (const e of extsToUpdate) { try { await (0, utils_1.spinWith)(this.isJsonOutput, `Checking if ${this.type} '${e}' is updatable`, () => { if (this.config.installedExtensions[e].installType !== extension_config_1.INSTALL_TYPE_NPM) { throw new NotUpdatableError(); } }); const update = await (0, utils_1.spinWith)(this.isJsonOutput, `Checking if ${this.type} '${e}' needs an update`, async () => { const update = await this.checkForExtensionUpdate(e); if (!(update.safeUpdate || update.unsafeUpdate)) { throw new NoUpdatesAvailableError(); } return update; }); if (!unsafe && !update.safeUpdate) { throw this._createFatalError(`The ${this.type} '${e}' has a major revision update ` + `(${update.current} => ${update.unsafeUpdate}), which could include ` + `breaking changes. If you want to apply this update, re-run with --unsafe`); } const updateVer = unsafe && update.unsafeUpdate ? update.unsafeUpdate : update.safeUpdate; await (0, utils_1.spinWith)(this.isJsonOutput, `Updating ${this.type} '${e}' from ${update.current} to ${updateVer}`, async () => await this.updateExtension(e, updateVer)); // if we're doing a safe update, but an unsafe update is also available, let the user know if (!unsafe && update.unsafeUpdate) { const newMajorUpdateMsg = `A newer major version ${update.unsafeUpdate} ` + `is available for ${this.type} '${e}', which could include breaking changes. ` + `If you want to apply this update, re-run with --unsafe`; this.log.info(newMajorUpdateMsg.yellow); } updates[e] = { from: update.current, to: updateVer }; } catch (err) { errors[e] = err; } } this.log.info('Update report:'); for (const [e, update] of lodash_1.default.toPairs(updates)) { this.log.ok(` - ${this.type} ${e} updated: ${update.from} => ${update.to}`.green); } for (const [e, err] of lodash_1.default.toPairs(errors)) { if (err instanceof NotUpdatableError) { this.log.warn(` - '${e}' was not installed via npm, so we could not check ` + `for updates`.yellow); } else if (err instanceof NoUpdatesAvailableError) { this.log.info(` - '${e}' had no updates available`.yellow); } else { // otherwise, make it pop with red! this.log.error(` - '${e}' failed to update: ${err}`.red); } } return { updates, errors }; } /** * Given an extension name, figure out what its highest possible version upgrade is, and also the * highest possible safe upgrade. * * @param {string} ext - name of extension * @return {Promise<PossibleUpdates>} */ async checkForExtensionUpdate(ext) { // TODO decide how we want to handle beta versions? // this is a helper method, 'ext' is assumed to already be installed here, and of the npm // install type const { version, pkgName } = this.config.installedExtensions[ext]; /** @type {string?} */ let unsafeUpdate = await support_1.npm.getLatestVersion(this.config.appiumHome, pkgName); let safeUpdate = await support_1.npm.getLatestSafeUpgradeVersion(this.config.appiumHome, pkgName, version); if (unsafeUpdate !== null && !support_1.util.compareVersions(unsafeUpdate, '>', version)) { // the latest version is not greater than the current version, so there's no possible update unsafeUpdate = null; safeUpdate = null; } if (unsafeUpdate && unsafeUpdate === safeUpdate) { // the latest update is the same as the safe update, which means it's not actually unsafe unsafeUpdate = null; } if (safeUpdate && !support_1.util.compareVersions(safeUpdate, '>', version)) { // even the safe update is not later than the current, so it is not actually an update safeUpdate = null; } return { current: version, safeUpdate, unsafeUpdate }; } /** * Actually update an extension installed by NPM, using the NPM cli. And update the installation * manifest. * * @param {string} installSpec - name of extension to update * @param {string} version - version string identifier to update extension to * @returns {Promise<void>} */ async updateExtension(installSpec, version) { const { pkgName, installType } = this.config.installedExtensions[installSpec]; const extData = await this.installViaNpm({ installSpec, installType, pkgName, pkgVer: version, }); delete extData[ /** @type {string} */(`${this.type}Name`)]; await this.config.updateExtension(installSpec, extData); } /** * Just wraps {@linkcode child_process.spawn} with some default options * * @param {string} cwd - CWD * @param {string} script - Path to script * @param {string[]} args - Extra args for script * @param {import('child_process').SpawnOptions} opts - Options * @returns {import('node:child_process').ChildProcess} */ _runUnbuffered(cwd, script, args = [], opts = {}) { return (0, child_process_1.spawn)(process.execPath, [script, ...args], { cwd, stdio: 'inherit', ...opts, }); } /** * Runs doctor checks for the given extension. * * @param {DoctorOptions} opts * @returns {Promise<number>} The amount of Doctor checks that were * successfully loaded and executed for the given extension * @throws {Error} If any of the mandatory Doctor checks fails. */ async _doctor({ installSpec }) { if (!this.config.isInstalled(installSpec)) { throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`); } const moduleRoot = this.config.getInstallPath(installSpec); const packageJsonPath = path_1.default.join(moduleRoot, 'package.json'); if (!await support_1.fs.exists(packageJsonPath)) { throw this._createFatalError(`No package.json could be found for "${installSpec}" ${this.type}`); } let doctorSpec; try { doctorSpec = JSON.parse(await support_1.fs.readFile(packageJsonPath, 'utf8')).appium?.doctor; } catch (e) { throw this._createFatalError(`The manifest at '${packageJsonPath}' cannot be parsed: ${e.message}`); } if (!doctorSpec) { this.log.info(`The ${this.type} "${installSpec}" does not export any doctor checks`); return 0; } if (!lodash_1.default.isPlainObject(doctorSpec) || !lodash_1.default.isArray(doctorSpec.checks)) { throw this._createFatalError(`The 'doctor' entry in the package manifest '${packageJsonPath}' must be a proper object ` + `containing the 'checks' key with the array of script paths`); } const paths = doctorSpec.checks.map((/** @type {string} */ p) => { const scriptPath = path_1.default.resolve(moduleRoot, p); if (!path_1.default.normalize(scriptPath).startsWith(path_1.default.normalize(moduleRoot))) { this.log.error(`The doctor check script '${p}' from the package manifest '${packageJsonPath}' must be located ` + `in the '${moduleRoot}' root folder. It will be skipped`); return null; } return scriptPath; }).filter(Boolean); /** @type {Promise[]} */ const loadChecksPromises = []; for (const p of paths) { const promise = (async () => { // https://github.com/nodejs/node/issues/31710 const scriptPath = support_1.system.isWindows() ? (0, url_1.pathToFileURL)(p).href : p; try { return await import(scriptPath); } catch (e) { this.log.warn(`Unable to load doctor checks from '${p}': ${e.message}`); } })(); loadChecksPromises.push(promise); } const isDoctorCheck = (/** @type {any} */ x) => ['diagnose', 'fix', 'hasAutofix', 'isOptional'].every((method) => lodash_1.default.isFunction(x?.[method])); /** @type {import('@appium/types').IDoctorCheck[]} */ const checks = lodash_1.default.flatMap((await bluebird_1.default.all(loadChecksPromises)).filter(Boolean).map(lodash_1.default.toPairs)) .map(([, value]) => value) .filter(isDoctorCheck); if (lodash_1.default.isEmpty(checks)) { this.log.info(`The ${this.type} "${installSpec}" exports no valid doctor checks`); return 0; } this.log.debug(`Running ${support_1.util.pluralize('doctor check', checks.length, true)} ` + `for the "${installSpec}" ${this.type}`); const exitCode = await new doctor_1.Doctor(checks).run(); if (exitCode !== doctor_1.EXIT_CODE.SUCCESS) { throw this._createFatalError('Treatment required'); } return checks.length; } /** * Runs a script cached inside the `scripts` field under `appium` * inside of the extension's `package.json` file. Will throw * an error if the driver/plugin does not contain a `scripts` field * underneath the `appium` field in its `package.json`, if the * `scripts` field is not a plain object, or if the `scriptName` is * not found within `scripts` object. * * @param {RunOptions} opts * @return {Promise<RunOutput>} */ async _run({ installSpec, scriptName, extraArgs = [], bufferOutput = false }) { if (!this.config.isInstalled(installSpec)) { throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`); } const extConfig = this.config.installedExtensions[installSpec]; // note: TS cannot understand that _.has() is a type guard if (!('scripts' in extConfig)) { throw this._createFatalError(`The ${this.type} named '${installSpec}' does not contain the ` + `"scripts" field underneath the "appium" field in its package.json`); } const extScripts = extConfig.scripts; if (!extScripts || !lodash_1.default.isPlainObject(extScripts)) { throw this._createFatalError(`The ${this.type} named '${installSpec}' "scripts" field must be a plain object`); } if (!scriptName) { const allScripts = lodash_1.default.toPairs(extScripts); const root = this.config.getInstallPath(installSpec); const existingScripts = await bluebird_1.default.filter(allScripts, async ([, p]) => await support_1.fs.exists(path_1.default.join(root, p))); if (lodash_1.default.isEmpty(existingScripts)) { this.log.info(`The ${this.type} named '${installSpec}' does not contain any scripts`); } else { this.log.info(`The ${this.type} named '${installSpec}' contains ` + `${support_1.util.pluralize('script', existingScripts.length, true)}:`); existingScripts.forEach(([name]) => this.log.info(` - ${name}`)); } this.log.ok(`Successfully retrieved the list of scripts`.green); return {}; } if (!(scriptName in /** @type {Record<string,string>} */ (extScripts))) { throw this._createFatalError(`The ${this.type} named '${installSpec}' does not support the script: '${scriptName}'`); } const scriptPath = extScripts[scriptName]; const moduleRoot = this.config.getInstallPath(installSpec); const normalizedScriptPath = path_1.default.normalize(path_1.default.resolve(moduleRoot, scriptPath)); if (!normalizedScriptPath.startsWith(path_1.default.normalize(moduleRoot))) { throw this._createFatalError(`The '${scriptPath}' script must be located in the '${moduleRoot}' folder`); } if (bufferOutput) { const runner = new teen_process_1.SubProcess(process.execPath, [scriptPath, ...extraArgs], { cwd: moduleRoot, }); const output = new utils_1.RingBuffer(50); runner.on('stream-line', (line) => { output.enqueue(line); this.log.log(line); }); await runner.start(0); try { await runner.join(); this.log.ok(`${scriptName} successfully ran`.green); return { output: output.getBuff() }; } catch (err) { this.log.error(`Encountered an error when running '${scriptName}': ${err.message}`.red); return { error: err.message, output: output.getBuff() }; } } try { await new bluebird_1.default((resolve, reject) => { this._runUnbuffered(moduleRoot, scriptPath, extraArgs) .on('error', (err) => { // generally this is of the "I can't find the script" variety. // this is a developer bug: the extension is pointing to a script that is not where the // developer said it would be (in `appium.scripts` of the extension's `package.json`) reject(err); }) .on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Script "${scriptName}" exited with code ${code}`)); } }); }); this.log.ok(`${scriptName} successfully ran`.green); return {}; } catch (err) { this.log.error(`Encountered an error when running '${scriptName}': ${err.message}`.red); return { error: err.message }; } } } exports.ExtensionCommand = ExtensionCliCommand; exports.default = ExtensionCliCommand; /** * Options for the {@linkcode ExtensionCliCommand} constructor * @template {ExtensionType} ExtType * @typedef ExtensionCommandOptions * @property {ExtensionConfig<ExtType>} config - the `DriverConfig` or `PluginConfig` instance used for this command * @property {boolean} json - whether the output of this command should be JSON or text */ /** * Extra stuff about extensions; used indirectly by {@linkcode ExtensionCliCommand.list}. * * @typedef ExtensionListMetadata * @property {boolean} installed - If `true`, the extension is installed * @property {boolean} upToDate - If the extension is installed and the latest * @property {string|null} updateVersion - If the extension is installed, the version it can be updated to * @property {string|null} unsafeUpdateVersion - Same as above, but a major version bump * @property {string} [updateError] - Update check error message (if present) * @property {boolean} [devMode] - If Appium is run from an extension's working copy */ /** * @typedef {import('@appium/types').ExtensionType} ExtensionType * @typedef {import('@appium/types').DriverType} DriverType * @typedef {import('@appium/types').PluginType} PluginType */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtRecord<ExtType>} ExtRecord */ /** * @template {ExtensionType} ExtType * @typedef {import('../extension/extension-config').ExtensionConfig<ExtType>} ExtensionConfig */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtMetadata<ExtType>} ExtMetadata */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtManifest<ExtType>} ExtManifest */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtPackageJson<ExtType>} ExtPackageJson */ /** * @template {ExtensionType} ExtType * @typedef {import('appium/types').ExtInstallReceipt<ExtType>} ExtInstallReceipt */ /** * Possible return value for {@linkcode ExtensionCliCommand.list} * @template {ExtensionType} ExtType * @typedef {Partial<ExtManifest<ExtType>> & Partial<ExtensionListMetadata>} ExtensionListData */ /** * @template {ExtensionType} ExtType * @typedef {ExtManifest<ExtType> & ExtensionListMetadata} InstalledExtensionListData */ /** * Return value of {@linkcode ExtensionCliCommand.list}. * @template {ExtensionType} ExtType * @typedef {Record<string,ExtensionListData<ExtType>>} ExtensionList */ /** * Options for {@linkcode ExtensionCliCommand._run}. * @typedef RunOptions * @property {string} installSpec - name of the extension to run a script from * @property {string} [scriptName] - name of the script to run. If not provided * then all available script names will be printed * @property {string[]} [extraArgs] - arguments to pass to the script * @property {boolean} [bufferOutput] - if true, will buffer the output of the script and return it */ /** * Options for {@linkcode ExtensionCliCommand.doctor}. * @typedef DoctorOptions * @property {string} installSpec - name of the extension to run doctor checks for */ /** * Return value of {@linkcode ExtensionCliCommand._run} * * @typedef RunOutput * @property {string} [error] - error message if script ran unsuccessfully, otherwise undefined * @property {string[]} [output] - script output if `bufferOutput` was `true` in {@linkcode RunOptions} */ /** * Options for {@linkcode ExtensionCliCommand._update}. * @typedef ExtensionUpdateOpts * @property {string} installSpec - the name of the extension to update * @property {boolean} unsafe - if true, will perform unsafe updates past major revision boundaries */ /** * Return value of {@linkcode ExtensionCliCommand._update}. * @typedef ExtensionUpdateResult * @property {Record<string,Error>} errors - map of ext names to error objects * @property {Record<string,UpdateReport>} updates - map of ext names to {@linkcode UpdateReport}s */ /** * Part of result of {@linkcode ExtensionCliCommand._update}. * @typedef UpdateReport * @property {string} from - version the extension was updated from * @property {string} to - version the extension was updated to */ /** * Options for {@linkcode ExtensionCliCommand._uninstall}. * @typedef UninstallOpts * @property {string} installSpec - the name or spec of an extension to uninstall */ /** * Used by {@linkcode ExtensionCliCommand.getPostInstallText} * @typedef ExtensionArgs * @property {string} extName - the name of an extension * @property {object} extData - the data for an installed extension */ /** * Options for {@linkcode ExtensionCliCommand.installViaNpm} * @typedef InstallViaNpmArgs * @property {string} installSpec - the name or spec of an extension to install * @property {string} pkgName - the NPM package name of the extension * @property {import('appium/types').InstallType} installType - type of install * @property {string} [pkgVer] - the specific version of the NPM package */ /** * Object returned by {@linkcode ExtensionCliCommand.checkForExtensionUpdate} * @typedef PossibleUpdates * @property {string} current - current version * @property {string?} safeUpdate - version we can safely update to if it exists, or null * @property {string?} unsafeUpdate - version we can unsafely update to if it exists, or null */ /** * Options for {@linkcode ExtensionCliCommand._install} * @typedef InstallOpts * @property {string} installSpec - the name or spec of an extension to install * @property {InstallType} installType - how to install this extension. One of the INSTALL_TYPES * @property {string} [packageName] - for git/github installs, the extension node package name */ /** * @template {ExtensionType} ExtType * @typedef {ExtType extends DriverType ? typeof import('../constants').KNOWN_DRIVERS : ExtType extends PluginType ? typeof import('../constants').KNOWN_PLUGINS : never} KnownExtensions */ /** * @typedef ListOptions * @property {boolean} showInstalled - whether should show only installed extensions * @property {boolean} showUpdates - whether should show available updates * @property {boolean} [verbose] - whether to show additional data from the extension */ /** * Opts for {@linkcode ExtensionCliCommand.getInstallationReceipt} * @template {ExtensionType} ExtType * @typedef GetInstallationReceiptOpts * @property {string} installPath * @property {string} installSpec * @property {ExtPackageJson<ExtType>} pkg * @property {InstallType} installType */ /** * @typedef {import('appium/types').InstallType} InstallType */ //# sourceMappingURL=extension-command.js.map