From ce91686852b0baab5f46990f5d40db4747922924 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Wed, 17 Dec 2025 16:01:38 +0100 Subject: [PATCH] feat(appium): Add repository links to verbose extensions list (#21813) --- packages/appium/lib/cli/extension-command.js | 334 +++++++++++++----- .../appium/test/e2e/cli-driver.e2e.spec.js | 22 +- 2 files changed, 267 insertions(+), 89 deletions(-) diff --git a/packages/appium/lib/cli/extension-command.js b/packages/appium/lib/cli/extension-command.js index 4e10a47ff..bb85fd014 100644 --- a/packages/appium/lib/cli/extension-command.js +++ b/packages/appium/lib/cli/extension-command.js @@ -20,6 +20,7 @@ import {getAppiumModuleRoot, npmPackage} from '../utils'; import * as semver from 'semver'; const UPDATE_ALL = 'installed'; +const MAX_CONCURRENT_REPO_FETCHES = 5; class NotUpdatableError extends Error {} class NoUpdatesAvailableError extends Error {} @@ -96,6 +97,7 @@ class ExtensionCliCommand { * 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} @@ -121,18 +123,45 @@ class ExtensionCliCommand { /** * List extensions + * * @template {ExtensionType} ExtType * @param {ListOptions} opts * @return {Promise>} 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 listData = this._buildListData(showInstalled); + + const lsMsg = + `Listing ${showInstalled ? 'installed' : 'available'} ${this.type}s` + + (verbose ? ' (verbose mode)' : ' (rerun with --verbose for more info)'); + await this._checkForUpdates(listData, showUpdates, lsMsg); + + if (this.isJsonOutput) { + await this._addRepositoryUrlsToListData(listData); + return listData; } + + if (verbose) { + await this._addRepositoryUrlsToListData(listData); + this.log.log(inspect(listData, {colors: true, depth: null})); + return listData; + } + + return await this._displayNormalListOutput(listData, showUpdates); + } + + /** + * Build the initial list data structure from installed and known extensions + * + * @template {ExtensionType} ExtType + * @param {boolean} showInstalled + * @returns {ExtensionList} + * @private + */ + _buildListData(showInstalled) { const installedNames = Object.keys(this.config.installedExtensions); const knownNames = Object.keys(this.knownExtensions); - const listData = [...installedNames, ...knownNames].reduce((acc, name) => { + return [...installedNames, ...knownNames].reduce((acc, name) => { if (!acc[name]) { if (installedNames.includes(name)) { acc[name] = { @@ -148,101 +177,235 @@ class ExtensionCliCommand { } return acc; }, /** @type {ExtensionList} */ ({})); + } - // if we want to show whether updates are available, put that behind a spinner + /** + * Check for available updates for installed extensions + * + * @template {ExtensionType} ExtType + * @param {ExtensionList} listData + * @param {boolean} showUpdates + * @param {string} lsMsg + * @returns {Promise} + * @private + */ + async _checkForUpdates(listData, showUpdates, lsMsg) { await spinWith(this.isJsonOutput, lsMsg, async () => { + // We'd like to still show lsMsg even if showUpdates is false if (!showUpdates) { return; } - for (const [ext, data] of _.toPairs(listData)) { - if (!data.installed || data.installType !== 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} - */ - const extIsInstalled = (data) => Boolean(data.installed); + // Filter to only extensions that need update checks (installed npm packages) + const extensionsToCheck = _.toPairs(listData).filter( + ([, data]) => data.installed && data.installType === INSTALL_TYPE_NPM + ); - // 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(inspect(listData, {colors: true, depth: null})); - return listData; - } - for (const [name, data] of _.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 INSTALL_TYPE_GIT: - case INSTALL_TYPE_GITHUB: - typeTxt = `(cloned from ${installSpec})`.yellow; - break; - case INSTALL_TYPE_LOCAL: - typeTxt = `(linked from ${installSpec})`.magenta; - break; - case 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; - } + await B.map( + extensionsToCheck, + async ([ext, data]) => { + 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; } - } - } + }, + {concurrency: MAX_CONCURRENT_REPO_FETCHES} + ); + }); + } - this.log.log(`- ${name.yellow}${installTxt}${updateTxt}${upToDateTxt}${unsafeUpdateTxt}`); + /** + * Add repository URLs to list data for all extensions + * + * @template {ExtensionType} ExtType + * @param {ExtensionList} listData + * @returns {Promise} + * @private + */ + async _addRepositoryUrlsToListData(listData) { + await spinWith(this.isJsonOutput, 'Fetching repository information', async () => { + await B.map( + _.values(listData), + async (data) => { + const repoUrl = await this._getRepositoryUrl(data); + if (repoUrl) { + data.repositoryUrl = repoUrl; + } + }, + {concurrency: MAX_CONCURRENT_REPO_FETCHES} + ); + }); + } + + /** + * Display normal formatted output + * + * @template {ExtensionType} ExtType + * @param {ExtensionList} listData + * @param {boolean} showUpdates + * @returns {Promise>} + * @private + */ + async _displayNormalListOutput(listData, showUpdates) { + for (const [name, data] of _.toPairs(listData)) { + const line = await this._formatExtensionLine(name, data, showUpdates); + this.log.log(line); } return listData; } + /** + * Format a single extension line for display + * + * @template {ExtensionType} ExtType + * @param {string} name + * @param {ExtensionListData} data + * @param {boolean} showUpdates + * @returns {Promise} + * @private + */ + async _formatExtensionLine(name, data, showUpdates) { + if (data.installed) { + const installTxt = this._formatInstallText(/** @type {InstalledExtensionListData} */ (data)); + const updateTxt = showUpdates ? this._formatUpdateText(/** @type {InstalledExtensionListData} */ (data)) : ''; + return `- ${name.yellow}${installTxt}${updateTxt}`; + } + const installTxt = ' [not installed]'.grey; + return `- ${name.yellow}${installTxt}`; + } + + /** + * Format installation status text + * + * @template {ExtensionType} ExtType + * @param {InstalledExtensionListData} data + * @returns {string} + * @private + */ + _formatInstallText(data) { + const {installType, installSpec, version} = data; + let typeTxt; + switch (installType) { + case INSTALL_TYPE_GIT: + case INSTALL_TYPE_GITHUB: + typeTxt = `(cloned from ${installSpec})`.yellow; + break; + case INSTALL_TYPE_LOCAL: + typeTxt = `(linked from ${installSpec})`.magenta; + break; + case INSTALL_TYPE_DEV: + typeTxt = '(dev mode)'; + break; + default: + typeTxt = '(npm)'; + } + return `@${version.yellow} ${('[installed ' + typeTxt + ']').green}`; + } + + /** + * Format update information text + * + * @template {ExtensionType} ExtType + * @param {InstalledExtensionListData} data + * @returns {string} + * @private + */ + _formatUpdateText(data) { + const {updateVersion, unsafeUpdateVersion, upToDate, updateError} = data; + if (updateError) { + return ` [Cannot check for updates: ${updateError}]`.red; + } + let txt = ''; + if (updateVersion) { + txt += ` [${updateVersion} available]`.magenta; + } + if (upToDate) { + txt += ` [Up to date]`.green; + } + if (unsafeUpdateVersion) { + txt += ` [${unsafeUpdateVersion} available (potentially unsafe)]`.cyan; + } + return txt; + } + + /** + * Get repository URL from package data + * + * @template {ExtensionType} ExtType + * @param {ExtensionListData} data + * @returns {Promise} + * @private + */ + async _getRepositoryUrl(data) { + if (data.installed && data.installPath) { + return await this._getRepositoryUrlFromInstalled( + /** @type {InstalledExtensionListData} */ (data) + ); + } + if (data.pkgName && !data.installed) { + return await this._getRepositoryUrlFromNpm(data.pkgName); + } + return null; + } + + /** + * Get repository URL from installed extension's package.json + * + * @template {ExtensionType} ExtType + * @param {InstalledExtensionListData} data + * @returns {Promise} + * @private + */ + async _getRepositoryUrlFromInstalled(data) { + try { + const pkgJsonPath = path.join(data.installPath, 'package.json'); + if (await fs.exists(pkgJsonPath)) { + const pkg = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')); + if (pkg.repository) { + if (typeof pkg.repository === 'string') { + return pkg.repository; + } + if (pkg.repository.url) { + return pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, ''); + } + } + } + } catch { + // Ignore errors reading package.json + } + return null; + } + + /** + * Get repository URL from npm for a package name + * + * @param {string} pkgName + * @returns {Promise} + * @private + */ + async _getRepositoryUrlFromNpm(pkgName) { + try { + const repoInfo = await npm.getPackageInfo(pkgName, ['repository']); + // When requesting only 'repository', npm.getPackageInfo returns the repository object directly + if (repoInfo) { + if (typeof repoInfo === 'string') { + return repoInfo; + } + if (repoInfo.url) { + return repoInfo.url.replace(/^git\+/, '').replace(/\.git$/, ''); + } + } + } catch { + // Ignore errors fetching from npm + } + return null; + } + /** * Checks whether the given extension is compatible with the currently installed server * @@ -1013,6 +1176,7 @@ export {ExtensionCliCommand as ExtensionCommand}; * @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 + * @property {string} [repositoryUrl] - Repository URL for the extension (if available) */ /** diff --git a/packages/appium/test/e2e/cli-driver.e2e.spec.js b/packages/appium/test/e2e/cli-driver.e2e.spec.js index a54ca7695..be83c95d6 100644 --- a/packages/appium/test/e2e/cli-driver.e2e.spec.js +++ b/packages/appium/test/e2e/cli-driver.e2e.spec.js @@ -98,10 +98,12 @@ describe('Driver CLI', function () { it('should list available drivers in json format', async function () { const driverData = await runList(); for (const d of Object.keys(KNOWN_DRIVERS)) { - driverData[d].should.eql({ - installed: false, - pkgName: KNOWN_DRIVERS[d], - }); + driverData[d].should.have.property('installed', false); + driverData[d].should.have.property('pkgName', KNOWN_DRIVERS[d]); + // repositoryUrl may be present if available + if (driverData[d].repositoryUrl) { + driverData[d].repositoryUrl.should.be.a('string'); + } } }); @@ -179,6 +181,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed']); // @ts-ignore delete list.uiautomator2.installed; + // @ts-ignore + delete list.uiautomator2.repositoryUrl; list.should.eql(ret); }); @@ -190,6 +194,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed']); // @ts-ignore delete list.fake.installed; + // @ts-ignore + delete list.fake.repositoryUrl; list.should.eql(ret); }); @@ -223,6 +229,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed']); // @ts-ignore delete list.fake.installed; + // @ts-ignore + delete list.fake.repositoryUrl; list.should.eql(ret); }); @@ -245,6 +253,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed']); // @ts-ignore delete list.fake.installed; + // @ts-ignore + delete list.fake.repositoryUrl; list.should.eql(ret); }); @@ -262,6 +272,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed', '--json']); // @ts-ignore delete list.fake.installed; + // @ts-ignore + delete list.fake.repositoryUrl; list.should.eql(ret); }); @@ -284,6 +296,8 @@ describe('Driver CLI', function () { const list = await runList(['--installed']); // @ts-ignore delete list.fake.installed; + // @ts-ignore + delete list.fake.repositoryUrl; list.should.eql(ret); });