feat: Add /appium/extensions API to list available extensions (#20931)

This commit is contained in:
Mykola Mokhnach
2025-01-25 17:02:41 +01:00
committed by GitHub
parent caff2c98a9
commit a6b6077ecd
11 changed files with 270 additions and 211 deletions
+9 -35
View File
@@ -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<any>} */
let driverRestMethodMap = {};
/** @type {import('@appium/types').BidiModuleMap} */
let driverBiDiCommands = {};
/** @type {Record<string, import('@appium/types').MethodMap<any>>} */
let pluginRestMethodMaps = {};
/** @type {Record<string, import('@appium/types').BidiModuleMap>} */
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);
}
/**
+190
View File
@@ -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<ListCommandsResponse> {
let driverRestMethodMap: MethodMap<any> = {};
let driverBiDiCommands: BidiModuleMap = {};
let pluginRestMethodMaps: Record<string, MethodMap<any>> = {};
let pluginBiDiCommands: Record<string, BidiModuleMap> = {};
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<ListExtensionsResponse> {
let driverExecuteMethodMap: ExecuteMethodMap<any> = {};
let pluginExecuteMethodMaps: Record<string, ExecuteMethodMap<any>> = {};
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<any>): Record<string, RestMethodsToCommandsMap> {
const res: Record<string, RestMethodsToCommandsMap> = {};
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<any>): 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<string, BidiModuleMap>
): 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<string, BiDiCommandNamesToInfosMap> => {
const res: Record<string, BiDiCommandNamesToInfosMap> = {};
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,
};
}
-168
View File
@@ -494,174 +494,6 @@ export function validateFeatures(features) {
return features.map(validator);
}
/**
*
* @param {import('@appium/types').MethodMap<any>} baseMethodMap
* @param {import('@appium/types').MethodMap<any>} driverMethodMap
* @param {Record<string, import('@appium/types').MethodMap<any>>} [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<any>} mm
* @returns {Record<string, import('@appium/types').RestMethodsToCommandsMap>}
*/
const methodMapToRestCommandsInfo = (mm) => {
/** @type {Record<string, import('@appium/types').RestMethodsToCommandsMap>} */
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<string, import('@appium/types').BidiModuleMap>} [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<string, import('@appium/types').BiDiCommandNamesToInfosMap>}
*/
const moduleMapToBiDiCommandsInfo = (mm) => {
/** @type {Record<string, import('@appium/types').BiDiCommandNamesToInfosMap>} */
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
+22 -1
View File
@@ -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 () {
@@ -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<ListCommandsResponse> {
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<ListExtensionsResponse> {
return {};
},
};
mixin(InspectorCommands);
@@ -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,
};
@@ -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<string>} */
const deprecatedCommandsLogged = new Set();
+4 -1
View File
@@ -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
@@ -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');
});
});
+19 -1
View File
@@ -274,7 +274,7 @@ export interface BiDiCommandItem {
}
export interface BiDiCommandNamesToInfosMap {
[name: string]: Record<string, BiDiCommandItem>;
[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<string, RestMethodsToCommandsMap>;
}
export interface ListExtensionsResponse {
/**
* Rest extensions mapping
*/
rest?: RestExtensionsMap;
}
+3 -1
View File
@@ -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<ListCommandsResponse>;
listExtensions(): Promise<ListExtensionsResponse>;
}
/**