From a6b6077ecd0749598f52d9f29b3220f47d7ad636 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 25 Jan 2025 17:02:41 +0100 Subject: [PATCH] feat: Add /appium/extensions API to list available extensions (#20931) --- packages/appium/lib/appium.js | 44 +--- packages/appium/lib/inspector-commands.ts | 190 ++++++++++++++++++ packages/appium/lib/utils.js | 168 ---------------- packages/appium/test/e2e/driver.e2e.spec.js | 23 ++- .../lib/basedriver/commands/inspector.ts | 22 +- packages/base-driver/lib/protocol/index.js | 2 + packages/base-driver/lib/protocol/protocol.js | 1 + packages/base-driver/lib/protocol/routes.js | 5 +- .../test/unit/protocol/routes.spec.js | 2 +- packages/types/lib/command.ts | 20 +- packages/types/lib/driver.ts | 4 +- 11 files changed, 270 insertions(+), 211 deletions(-) create mode 100644 packages/appium/lib/inspector-commands.ts diff --git a/packages/appium/lib/appium.js b/packages/appium/lib/appium.js index 02f4570f8..f16187c0d 100644 --- a/packages/appium/lib/appium.js +++ b/packages/appium/lib/appium.js @@ -9,11 +9,10 @@ import { DELETE_SESSION_COMMAND, GET_STATUS_COMMAND, LIST_DRIVER_COMMANDS_COMMAND, + LIST_DRIVER_EXTENSIONS_COMMAND, promoteAppiumOptions, promoteAppiumOptionsForObject, generateDriverLogPrefix, - METHOD_MAP as BASE_METHOD_MAP, - BIDI_COMMANDS as BASE_BIDI_COMMANDS, } from '@appium/base-driver'; import AsyncLock from 'async-lock'; import { @@ -21,13 +20,12 @@ import { pullSettings, makeNonW3cCapsError, validateFeatures, - toRestCommandsMap, - toBiDiCommandsMap, } from './utils'; import {util} from '@appium/support'; import {getDefaultsForExtension} from './schema'; import {DRIVER_TYPE, BIDI_BASE_PATH} from './constants'; import * as bidiHelpers from './bidi'; +import * as inspectorCommands from './inspector-commands'; const desiredCapabilityConstraints = /** @type {const} */ ({ automationName: { @@ -570,36 +568,6 @@ class AppiumDriver extends DriverCore { } } - /** - * @param {string} sessionId - * @returns {import('@appium/types').ListCommandsResponse} - */ - listCommands(sessionId) { - /** @type {import('@appium/types').MethodMap} */ - let driverRestMethodMap = {}; - /** @type {import('@appium/types').BidiModuleMap} */ - let driverBiDiCommands = {}; - /** @type {Record>} */ - let pluginRestMethodMaps = {}; - /** @type {Record} */ - let pluginBiDiCommands = {}; - if (sessionId) { - const driverClass = /** @type {import('@appium/types').DriverClass | undefined} */ ( - this.driverForSession(sessionId)?.constructor - ); - driverRestMethodMap = driverClass?.newMethodMap ?? {}; - driverBiDiCommands = driverClass?.newBidiCommands ?? {}; - const pluginClasses = this.pluginsForSession(sessionId) - .map((p) => /** @type {import('@appium/types').PluginClass} */ (p.constructor)); - pluginRestMethodMaps = _.fromPairs(pluginClasses.map((c) => [c.name, c.newMethodMap ?? {}])); - pluginBiDiCommands = _.fromPairs(pluginClasses.map((c) => [c.name, c.newBidiCommands ?? {}])); - } - return { - rest: toRestCommandsMap(BASE_METHOD_MAP, driverRestMethodMap, pluginRestMethodMaps), - bidi: toBiDiCommandsMap(BASE_BIDI_COMMANDS, driverBiDiCommands, pluginBiDiCommands), - }; - } - /** * @param {string} sessionId */ @@ -973,6 +941,8 @@ class AppiumDriver extends DriverCore { onBidiConnection = bidiHelpers.onBidiConnection; onBidiMessage = bidiHelpers.onBidiMessage; onBidiServerError = bidiHelpers.onBidiServerError; + listCommands = inspectorCommands.listCommands; + listExtensions = inspectorCommands.listExtensions; } /** @@ -983,7 +953,11 @@ class AppiumDriver extends DriverCore { */ function isAppiumDriverCommand(cmd) { return !isSessionCommand(cmd) - || _.includes([DELETE_SESSION_COMMAND, LIST_DRIVER_COMMANDS_COMMAND], cmd); + || _.includes([ + DELETE_SESSION_COMMAND, + LIST_DRIVER_COMMANDS_COMMAND, + LIST_DRIVER_EXTENSIONS_COMMAND, + ], cmd); } /** diff --git a/packages/appium/lib/inspector-commands.ts b/packages/appium/lib/inspector-commands.ts new file mode 100644 index 000000000..548c2aece --- /dev/null +++ b/packages/appium/lib/inspector-commands.ts @@ -0,0 +1,190 @@ +import _ from 'lodash'; +import { + METHOD_MAP as BASE_METHOD_MAP, + BIDI_COMMANDS as BASE_BIDI_COMMANDS, +} from '@appium/base-driver'; +import type { + ListCommandsResponse, + MethodMap, + BidiModuleMap, + DriverClass, + PluginClass, + ListExtensionsResponse, + PayloadParams, + RestCommandItemParam, + RestMethodsToCommandsMap, + BiDiCommandsMap, + BidiMethodParams, + BiDiCommandItemParam, + BiDiCommandNamesToInfosMap, + ExecuteMethodMap, +} from '@appium/types'; +import type { AppiumDriver } from './appium'; + + +export async function listCommands(this: AppiumDriver, sessionId?: string): Promise { + let driverRestMethodMap: MethodMap = {}; + let driverBiDiCommands: BidiModuleMap = {}; + let pluginRestMethodMaps: Record> = {}; + let pluginBiDiCommands: Record = {}; + if (sessionId) { + const driverClass = this.driverForSession(sessionId)?.constructor as (DriverClass | undefined); + driverRestMethodMap = driverClass?.newMethodMap ?? {}; + driverBiDiCommands = driverClass?.newBidiCommands ?? {}; + const pluginClasses = this.pluginsForSession(sessionId) + .map((p) => p.constructor as PluginClass); + pluginRestMethodMaps = _.fromPairs(pluginClasses.map((c) => [c.name, c.newMethodMap ?? {}])); + pluginBiDiCommands = _.fromPairs(pluginClasses.map((c) => [c.name, c.newBidiCommands ?? {}])); + } + return { + rest: { + base: methodMapToRestCommandsInfo(BASE_METHOD_MAP), + driver: methodMapToRestCommandsInfo(driverRestMethodMap), + plugins: pluginRestMethodMaps ? _.mapValues(pluginRestMethodMaps, methodMapToRestCommandsInfo) : undefined, + }, + bidi: toBiDiCommandsMap(BASE_BIDI_COMMANDS, driverBiDiCommands, pluginBiDiCommands), + }; +} + +export async function listExtensions(this: AppiumDriver, sessionId?: string): Promise { + let driverExecuteMethodMap: ExecuteMethodMap = {}; + let pluginExecuteMethodMaps: Record> = {}; + if (sessionId) { + const driverClass = this.driverForSession(sessionId)?.constructor as (DriverClass | undefined); + driverExecuteMethodMap = driverClass?.executeMethodMap ?? {}; + const pluginClasses = this.pluginsForSession(sessionId) + .map((p) => p.constructor as PluginClass); + pluginExecuteMethodMaps = _.fromPairs(pluginClasses.map((c) => [c.name, c.executeMethodMap ?? {}])); + } + return { + rest: { + driver: executeMethodMapToCommandsInfo(driverExecuteMethodMap), + plugins: pluginExecuteMethodMaps ? _.mapValues(pluginExecuteMethodMaps, executeMethodMapToCommandsInfo) : undefined, + }, + }; +} + +function toRestCommandParams(params: PayloadParams | undefined): RestCommandItemParam[] | undefined { + if (!params) { + return; + } + + const toRestCommandItemParam = (x: any, isRequired: boolean): RestCommandItemParam | undefined => { + const isNameAnArray = _.isArray(x); + const name = isNameAnArray ? x[0] : x; + if (!_.isString(name)) { + return; + } + + // If parameter names are arrays then this means + // either of them is required. + // Not sure we could reflect that in here. + const required = isRequired && !isNameAnArray; + return { + name, + required, + }; + }; + + const requiredParams: RestCommandItemParam[] = (params.required ?? []) + .map((name: any) => toRestCommandItemParam(name, true)) + .filter((x) => !_.isUndefined(x)); + const optionalParams: RestCommandItemParam[] = (params.optional ?? []) + .map((name: any) => toRestCommandItemParam(name, false)) + .filter((x) => !_.isUndefined(x)); + return requiredParams.length || optionalParams.length + ? [...requiredParams, ...optionalParams] + : undefined; +} + +function methodMapToRestCommandsInfo (mm: MethodMap): Record { + const res: Record = {}; + for (const [uriPath, methods] of _.toPairs(mm)) { + const methodsMap = {}; + for (const [method, spec] of _.toPairs(methods)) { + methodsMap[method] = { + command: spec.command, + deprecated: spec.deprecated, + info: spec.info, + params: toRestCommandParams(spec.payloadParams), + }; + } + res[uriPath] = methodsMap; + } + return res; +} + +function executeMethodMapToCommandsInfo(emm: ExecuteMethodMap): RestMethodsToCommandsMap { + const result: RestMethodsToCommandsMap = {}; + for (const [name, info] of _.toPairs(emm)) { + result[name] = { + command: info.command, + deprecated: info.deprecated, + info: info.info, + params: toRestCommandParams(info.params), + }; + } + return result; +} + +function toBiDiCommandsMap( + baseModuleMap: BidiModuleMap, + driverModuleMap: BidiModuleMap, + pluginModuleMaps: Record +): BiDiCommandsMap { + const toBiDiCommandParams = (params: BidiMethodParams | undefined): BiDiCommandItemParam[] | undefined => { + if (!params) { + return; + } + + const toBiDiCommandItemParam = (x: any, isRequired: boolean): BiDiCommandItemParam | undefined => { + const isNameAnArray = _.isArray(x); + const name = isNameAnArray ? x[0] : x; + if (!_.isString(name)) { + return; + } + + // If parameter names are arrays then this means + // either of them is required. + // Not sure we could reflect that in here. + const required = isRequired && !isNameAnArray; + return { + name, + required, + }; + }; + + const requiredParams: BiDiCommandItemParam[] = (params.required ?? []) + .map((name) => toBiDiCommandItemParam(name, true)) + .filter((x) => !_.isUndefined(x)); + const optionalParams: BiDiCommandItemParam[] = (params.optional ?? []) + .map((name) => toBiDiCommandItemParam(name, false)) + .filter((x) => !_.isUndefined(x)); + return requiredParams.length || optionalParams.length + ? [...requiredParams, ...optionalParams] + : undefined; + }; + + const moduleMapToBiDiCommandsInfo = (mm: BidiModuleMap): Record => { + const res: Record = {}; + for (const [domain, commands] of _.toPairs(mm)) { + const commandsMap = {}; + for (const [name, spec] of _.toPairs(commands)) { + commandsMap[name] = { + command: spec.command, + deprecated: spec.deprecated, + info: spec.info, + params: toBiDiCommandParams(spec.params), + }; + } + res[domain] = commandsMap; + } + return res; + }; + + return { + base: moduleMapToBiDiCommandsInfo(baseModuleMap), + driver: moduleMapToBiDiCommandsInfo(driverModuleMap), + plugins: pluginModuleMaps ? _.mapValues(pluginModuleMaps, moduleMapToBiDiCommandsInfo) : undefined, + }; +} \ No newline at end of file diff --git a/packages/appium/lib/utils.js b/packages/appium/lib/utils.js index 4a339ffc0..914a9e1c7 100644 --- a/packages/appium/lib/utils.js +++ b/packages/appium/lib/utils.js @@ -494,174 +494,6 @@ export function validateFeatures(features) { return features.map(validator); } -/** - * - * @param {import('@appium/types').MethodMap} baseMethodMap - * @param {import('@appium/types').MethodMap} driverMethodMap - * @param {Record>} [pluginMethodMaps] - * @returns {import('@appium/types').RestCommandsMap} - */ -export function toRestCommandsMap(baseMethodMap, driverMethodMap, pluginMethodMaps) { - /** - * @param {import("@appium/types").PayloadParams | undefined} params - * @returns {import("@appium/types").RestCommandItemParam[] | undefined} - */ - const toRestCommandParams = (params) => { - if (!params) { - return; - } - - /** - * - * @param {any} x - * @param {boolean} isRequired - * @returns {import("@appium/types").RestCommandItemParam | undefined} - */ - const toRestCommandItemParam = (x, isRequired) => { - const isNameAnArray = _.isArray(x); - const name = isNameAnArray ? x[0] : x; - if (!_.isString(name)) { - return; - } - - // If parameter names are arrays then this means - // either of them is required. - // Not sure we could reflect that in here. - const required = isRequired && !isNameAnArray; - return { - name, - required, - }; - }; - - /** @type {import("@appium/types").RestCommandItemParam[]} */ - const requiredParams = (params.required ?? []) - .map((name) => toRestCommandItemParam(name, true)) - .filter((x) => !_.isUndefined(x)); - /** @type {import("@appium/types").RestCommandItemParam[]} */ - const optionalParams = (params.optional ?? []) - .map((name) => toRestCommandItemParam(name, false)) - .filter((x) => !_.isUndefined(x)); - return requiredParams.length || optionalParams.length - ? [...requiredParams, ...optionalParams] - : undefined; - }; - - /** - * - * @param {import('@appium/types').MethodMap} mm - * @returns {Record} - */ - const methodMapToRestCommandsInfo = (mm) => { - /** @type {Record} */ - const res = {}; - for (const [uriPath, methods] of _.toPairs(mm)) { - const methodsMap = {}; - for (const [method, spec] of _.toPairs(methods)) { - methodsMap[method] = { - command: spec.command, - deprecated: spec.deprecated, - info: spec.info, - params: toRestCommandParams(spec.payloadParams), - }; - } - // @ts-ignore this is OK - res[uriPath] = methodsMap; - } - return res; - }; - - return { - base: methodMapToRestCommandsInfo(baseMethodMap), - driver: methodMapToRestCommandsInfo(driverMethodMap), - plugins: pluginMethodMaps ? _.mapValues(pluginMethodMaps, methodMapToRestCommandsInfo) : undefined, - }; -} - -/** - * - * @param {import('@appium/types').BidiModuleMap} baseModuleMap - * @param {import('@appium/types').BidiModuleMap} driverModuleMap - * @param {Record} [pluginModuleMaps] - * @returns {import('@appium/types').BiDiCommandsMap} - */ -export function toBiDiCommandsMap(baseModuleMap, driverModuleMap, pluginModuleMaps) { - /** - * @param {import("@appium/types").BidiMethodParams | undefined} params - * @returns {import("@appium/types").BiDiCommandItemParam[] | undefined} - */ - const toBiDiCommandParams = (params) => { - if (!params) { - return; - } - - /** - * - * @param {any} x - * @param {boolean} isRequired - * @returns {import("@appium/types").BiDiCommandItemParam | undefined} - */ - const toBiDiCommandItemParam = (x, isRequired) => { - const isNameAnArray = _.isArray(x); - const name = isNameAnArray ? x[0] : x; - if (!_.isString(name)) { - return; - } - - // If parameter names are arrays then this means - // either of them is required. - // Not sure we could reflect that in here. - const required = isRequired && !isNameAnArray; - return { - name, - required, - }; - }; - - /** @type {import("@appium/types").BiDiCommandItemParam[]} */ - const requiredParams = (params.required ?? []) - .map((name) => toBiDiCommandItemParam(name, true)) - .filter((x) => !_.isUndefined(x)); - /** @type {import("@appium/types").BiDiCommandItemParam[]} */ - const optionalParams = (params.optional ?? []) - .map((name) => toBiDiCommandItemParam(name, false)) - .filter((x) => !_.isUndefined(x)); - return requiredParams.length || optionalParams.length - ? [...requiredParams, ...optionalParams] - : undefined; - }; - - /** - * - * @param {import('@appium/types').BidiModuleMap} mm - * @returns {Record} - */ - const moduleMapToBiDiCommandsInfo = (mm) => { - /** @type {Record} */ - const res = {}; - for (const [domain, commands] of _.toPairs(mm)) { - const commandsMap = {}; - for (const [name, spec] of _.toPairs(commands)) { - commandsMap[name] = { - command: spec.command, - deprecated: spec.deprecated, - info: spec.info, - params: toBiDiCommandParams(spec.params), - }; - } - // @ts-ignore this is OK - res[domain] = commandsMap; - } - return res; - }; - - return { - base: moduleMapToBiDiCommandsInfo(baseModuleMap), - driver: moduleMapToBiDiCommandsInfo(driverModuleMap), - plugins: pluginModuleMaps ? _.mapValues(pluginModuleMaps, moduleMapToBiDiCommandsInfo) : undefined, - }; -} - /** * @typedef {import('@appium/types').StringRecord} StringRecord * @typedef {import('@appium/types').BaseDriverCapConstraints} BaseDriverCapConstraints diff --git a/packages/appium/test/e2e/driver.e2e.spec.js b/packages/appium/test/e2e/driver.e2e.spec.js index d3b9786e1..80665f651 100644 --- a/packages/appium/test/e2e/driver.e2e.spec.js +++ b/packages/appium/test/e2e/driver.e2e.spec.js @@ -225,7 +225,7 @@ describe('FakeDriver via HTTP', function () { it('should list available driver commands', async function () { driver.addCommand( 'listCommands', - async () => (await axios.post( + async () => (await axios.get( `${testServerBaseSessionUrl}/${driver.sessionId}/appium/commands` )).data.value ); @@ -256,6 +256,27 @@ describe('FakeDriver via HTTP', function () { _.size(commands.bidi.base).should.be.greaterThan(1); _.size(commands.bidi.driver).should.be.greaterThan(0); }); + + it('should list available driver extensions', async function () { + driver.addCommand( + 'listExtensions', + async () => (await axios.get( + `${testServerBaseSessionUrl}/${driver.sessionId}/appium/extensions` + )).data.value + ); + + const extensions = await driver.listExtensions(); + JSON.stringify(extensions.rest.driver['fake: setThing']).should.eql( + JSON.stringify({ + command: 'setFakeThing', + params: [{ + name: 'thing', + required: true + }] + }) + ); + _.size(extensions.rest.driver).should.be.greaterThan(1); + }); }); describe('session handling', function () { diff --git a/packages/base-driver/lib/basedriver/commands/inspector.ts b/packages/base-driver/lib/basedriver/commands/inspector.ts index 177756bf7..b3a9388fe 100644 --- a/packages/base-driver/lib/basedriver/commands/inspector.ts +++ b/packages/base-driver/lib/basedriver/commands/inspector.ts @@ -1,4 +1,9 @@ -import type {Constraints, IInspectorCommands, ListCommandsResponse} from '@appium/types'; +import type { + Constraints, + IInspectorCommands, + ListCommandsResponse, + ListExtensionsResponse, +} from '@appium/types'; import {mixin} from './mixin'; declare module '../driver' { @@ -15,9 +20,20 @@ const InspectorCommands: IInspectorCommands = { * @returns */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - listCommands(sessionId?: string | null): ListCommandsResponse { + async listCommands(sessionId?: string | null): Promise { return {}; - } + }, + + /** + * This command is supposed to be handled by the umbrella driver. + * + * @param sessionId + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async listExtensions(sessionId?: string | null): Promise { + return {}; + }, }; mixin(InspectorCommands); diff --git a/packages/base-driver/lib/protocol/index.js b/packages/base-driver/lib/protocol/index.js index eafaffa44..dfd959f32 100644 --- a/packages/base-driver/lib/protocol/index.js +++ b/packages/base-driver/lib/protocol/index.js @@ -6,6 +6,7 @@ import { DELETE_SESSION_COMMAND, GET_STATUS_COMMAND, LIST_DRIVER_COMMANDS_COMMAND, + LIST_DRIVER_EXTENSIONS_COMMAND, makeArgs, checkParams, validateExecuteMethodParams, @@ -32,4 +33,5 @@ export { DELETE_SESSION_COMMAND, GET_STATUS_COMMAND, LIST_DRIVER_COMMANDS_COMMAND, + LIST_DRIVER_EXTENSIONS_COMMAND, }; diff --git a/packages/base-driver/lib/protocol/protocol.js b/packages/base-driver/lib/protocol/protocol.js index c562d1f47..7ef8df34b 100644 --- a/packages/base-driver/lib/protocol/protocol.js +++ b/packages/base-driver/lib/protocol/protocol.js @@ -20,6 +20,7 @@ export const CREATE_SESSION_COMMAND = 'createSession'; export const DELETE_SESSION_COMMAND = 'deleteSession'; export const GET_STATUS_COMMAND = 'getStatus'; export const LIST_DRIVER_COMMANDS_COMMAND = 'listCommands'; +export const LIST_DRIVER_EXTENSIONS_COMMAND = 'listExtensions'; /** @type {Set} */ const deprecatedCommandsLogged = new Set(); diff --git a/packages/base-driver/lib/protocol/routes.js b/packages/base-driver/lib/protocol/routes.js index c8e56c0dd..418b8b28c 100644 --- a/packages/base-driver/lib/protocol/routes.js +++ b/packages/base-driver/lib/protocol/routes.js @@ -871,7 +871,10 @@ export const METHOD_MAP = /** @type {const} */ ({ // #region Inspector '/session/:sessionId/appium/commands': { - POST: {command: 'listCommands'}, + GET: {command: 'listCommands'}, + }, + '/session/:sessionId/appium/extensions': { + GET: {command: 'listExtensions'}, }, // #endregion diff --git a/packages/base-driver/test/unit/protocol/routes.spec.js b/packages/base-driver/test/unit/protocol/routes.spec.js index 9f7d457ef..1d4e9f65a 100644 --- a/packages/base-driver/test/unit/protocol/routes.spec.js +++ b/packages/base-driver/test/unit/protocol/routes.spec.js @@ -41,7 +41,7 @@ describe('Protocol', function () { } let hash = shasum.digest('hex').substring(0, 8); // Modify the hash whenever the protocol has intentionally been modified. - hash.should.equal('3590ffcf'); + hash.should.equal('392df577'); }); }); diff --git a/packages/types/lib/command.ts b/packages/types/lib/command.ts index e71fd55d6..d7645b737 100644 --- a/packages/types/lib/command.ts +++ b/packages/types/lib/command.ts @@ -274,7 +274,7 @@ export interface BiDiCommandItem { } export interface BiDiCommandNamesToInfosMap { - [name: string]: Record; + [name: string]: BiDiCommandItem; } export interface BiDiCommandsMap { @@ -302,3 +302,21 @@ export interface ListCommandsResponse { */ bidi?: BiDiCommandsMap; } + +export interface RestExtensionsMap { + /** + * Driver execute methods mapping + */ + driver: RestMethodsToCommandsMap; + /** + * Plugins execute methods mapping + */ + plugins?: Record; +} + +export interface ListExtensionsResponse { + /** + * Rest extensions mapping + */ + rest?: RestExtensionsMap; +} diff --git a/packages/types/lib/driver.ts b/packages/types/lib/driver.ts index fe12ecf48..740931d8b 100644 --- a/packages/types/lib/driver.ts +++ b/packages/types/lib/driver.ts @@ -8,6 +8,7 @@ import type { ExecuteMethodMap, MethodMap, ListCommandsResponse, + ListExtensionsResponse, } from './command'; import type {ServerArgs} from './config'; import type {HTTPHeaders, HTTPMethod} from './http'; @@ -360,7 +361,8 @@ export interface IBidiCommands { } export interface IInspectorCommands { - listCommands(): ListCommandsResponse; + listCommands(): Promise; + listExtensions(): Promise; } /**