diff --git a/app/common/public/locales/en/translation.json b/app/common/public/locales/en/translation.json index 76458f6263..c59f4e050b 100644 --- a/app/common/public/locales/en/translation.json +++ b/app/common/public/locales/en/translation.json @@ -320,5 +320,9 @@ "toggleTableFormatting": "Toggle Table Formatting", "copyResultToClipboard": "Copy Result to Clipboard", "Property": "Property", - "noCapsFound": "No session capabilities found. Please use the Capability Builder to edit, add, and save your capability set. Refer to the tutorial at {{url}}" + "noCapsFound": "No session capabilities found. Please use the Capability Builder to edit, add, and save your capability set. Refer to the tutorial at {{url}}", + "executeMethods": "Execute Methods", + "dynamicCommandsDescription": "Run commands supported by the currently active driver and plugin(s). Note that some commands may only work in specific contexts. Any commands not supported by WebdriverIO are not included in this list.", + "dynamicExecuteMethodsDescription": "Run any execute method supported by the currently active driver and plugin(s).", + "methodDeprecated": "This method is deprecated" } diff --git a/app/common/renderer/actions/SessionInspector.js b/app/common/renderer/actions/SessionInspector.js index c71a32fbc6..34eec112a1 100644 --- a/app/common/renderer/actions/SessionInspector.js +++ b/app/common/renderer/actions/SessionInspector.js @@ -81,10 +81,6 @@ export const HIDE_PROMPT_KEEP_ALIVE = 'HIDE_PROMPT_KEEP_ALIVE'; export const SELECT_INSPECTOR_TAB = 'SELECT_INSPECTOR_TAB'; -export const ENTERING_COMMAND_ARGS = 'ENTERING_COMMAND_ARGS'; -export const CANCEL_PENDING_COMMAND = 'CANCEL_PENDING_COMMAND'; -export const SET_COMMAND_ARG = 'SET_COMMAND_ARG'; - export const SET_CONTEXT = 'SET_CONTEXT'; export const SET_APP_ID = 'SET_APP_ID'; @@ -95,8 +91,6 @@ export const SET_KEEP_ALIVE_INTERVAL = 'SET_KEEP_ALIVE_INTERVAL'; export const SET_USER_WAIT_TIMEOUT = 'SET_USER_WAIT_TIMEOUT'; export const SET_LAST_ACTIVE_MOMENT = 'SET_LAST_ACTIVE_MOMENT'; -export const SET_VISIBLE_COMMAND_RESULT = 'SET_VISIBLE_COMMAND_RESULT'; - export const SET_AWAITING_MJPEG_STREAM = 'SET_AWAITING_MJPEG_STREAM'; export const SHOW_GESTURE_EDITOR = 'SHOW_GESTURE_EDITOR'; @@ -343,7 +337,7 @@ export function restartSession(error, params) { }); const quitSes = quitSession('Window closed'); const newSes = newSession(getState().builder.caps); - const getPageSrc = applyClientMethod({methodName: 'getPageSource', ignoreResult: true}); + const getPageSrc = applyClientMethod({methodName: 'getPageSource'}); const storeSessionSet = storeSessionSettings(); const getSavedClientFrame = getSavedClientFramework(); const runKeepAliveLp = runKeepAliveLoop(); @@ -458,7 +452,6 @@ export function storeSessionSettings(updatedSessionSettings = null) { const action = applyClientMethod({ methodName: 'getSettings', skipRefresh: true, - ignoreResult: true, }); sessionSettings = await action(dispatch, getState); } @@ -587,7 +580,6 @@ export function setLocatorTestElement(elementId) { methodName: 'getElementRect', skipRefresh: true, skipRecord: true, - ignoreResult: true, }); const {commandRes} = await action(dispatch, getState); dispatch({ @@ -824,21 +816,23 @@ export function selectInspectorTab(interaction) { }; } -export function startEnteringCommandArgs(commandName, command) { - return (dispatch) => { - dispatch({type: ENTERING_COMMAND_ARGS, commandName, command}); - }; -} - -export function cancelPendingCommand() { - return (dispatch) => { - dispatch({type: CANCEL_PENDING_COMMAND}); - }; -} +export function getSupportedSessionMethods() { + return async (_dispatch, getState) => { + async function safelyCallCommand(methodName) { + try { + const action = executeDriverCommand({methodName}); + const {commandRes} = await action(getState); + return commandRes; + } catch { + return []; + } + } -export function setCommandArg(index, value) { - return (dispatch) => { - dispatch({type: SET_COMMAND_ARG, index, value}); + const [commands, executeMethods] = await Promise.all([ + safelyCallCommand('getAppiumCommands'), + safelyCallCommand('getAppiumExtensions'), + ]); + return {commands, executeMethods}; }; } @@ -907,7 +901,6 @@ export function callClientMethod(params) { return async (dispatch, getState) => { const {driver, appMode, isUsingMjpegMode, isSourceRefreshOn, autoSessionRestart} = getState().inspector; - const {methodName, ignoreResult = true} = params; params.appMode = appMode; params.autoSessionRestart = autoSessionRestart; @@ -927,22 +920,6 @@ export function callClientMethod(params) { action(dispatch, getState); const inspectorDriver = InspectorDriver.instance(driver); const res = await inspectorDriver.run(params); - let {commandRes} = res; - - // Ignore empty objects - if (_.isObject(res) && _.isEmpty(res)) { - commandRes = null; - } - - if (!ignoreResult) { - // if the user is running actions manually, we want to show the full response with the - // ability to scroll etc... - const result = JSON.stringify(commandRes, null, ' '); - const truncatedResult = _.truncate(result, {length: 2000}); - log.info(`Result of client command was:`); - log.info(truncatedResult); - setVisibleCommandResult(result, methodName)(dispatch); - } res.elementId = res.id; return res; } catch (error) { @@ -957,9 +934,14 @@ export function callClientMethod(params) { }; } -export function setVisibleCommandResult(result, methodName) { - return (dispatch) => { - dispatch({type: SET_VISIBLE_COMMAND_RESULT, result, methodName}); +// Simple alternative to callClientMethod, for when we only want to +// run the command without any side-effects +export function executeDriverCommand(params) { + return async (getState) => { + const {driver} = getState().inspector; + params.skipRefresh = true; + const inspectorDriver = InspectorDriver.instance(driver); + return await inspectorDriver.run(params); }; } diff --git a/app/common/renderer/components/SessionInspector/CommandsTab/CommandResultModal.jsx b/app/common/renderer/components/SessionInspector/CommandsTab/CommandResultModal.jsx index ee5e6a4339..38ad02fb87 100644 --- a/app/common/renderer/components/SessionInspector/CommandsTab/CommandResultModal.jsx +++ b/app/common/renderer/components/SessionInspector/CommandsTab/CommandResultModal.jsx @@ -173,7 +173,7 @@ const CommandResultRawTable = ({result}) => { }; const CommandResultModalFooter = ({ - visibleCommandResult, + commandResult, closeCommandModal, setFormatResult, formatResult, @@ -195,7 +195,7 @@ const CommandResultModalFooter = ({ - - - ))} - - ({ - key: commandGroup, - label: t(commandGroup), - children: ( - - {_.toPairs(commands).map( - ([commandName, command], index) => - (!command.drivers || command.drivers.includes(automationName)) && ( - -
- - - -
- - ), - )} -
- ), - }))} + {hasMethodsMap === false && } + {hasMethodsMap && ( + - - {!!pendingCommand && ( + )} + {!!curCommandDetails && ( executeCommand()} - onCancel={() => cancelPendingCommand()} + open={!_.isEmpty(curCommandDetails.details.params)} + onOk={() => prepareAndRunCommand(curCommandDetails)} + onCancel={() => clearCurrentCommand()} + footer={(_, {OkBtn}) => } > - {pendingCommand.command.notes && ( - - )} - {!_.isEmpty(pendingCommand.command.args) && - _.map(pendingCommand.command.args, ([argName, argType], index) => ( - - - {argType === COMMAND_ARG_TYPES.NUMBER && ( - setCommandArg(index, _.toNumber(e.target.value))} - /> - )} - {argType === COMMAND_ARG_TYPES.BOOLEAN && ( -
- {t(argName)}{' '} - setCommandArg(index, v)} - /> -
- )} - {argType === COMMAND_ARG_TYPES.STRING && ( - setCommandArg(index, e.target.value)} - /> - )} - -
- ))} + {_.map(curCommandDetails.details.params, (param, index) => ( + + + (curCommandParamVals.current[index] = e.target.value)} + /> + + + ))}
)} + {commandResult && ( + + )} ); diff --git a/app/common/renderer/components/SessionInspector/CommandsTab/Commands.module.css b/app/common/renderer/components/SessionInspector/CommandsTab/Commands.module.css index 8ab1f589ba..eac76667f6 100644 --- a/app/common/renderer/components/SessionInspector/CommandsTab/Commands.module.css +++ b/app/common/renderer/components/SessionInspector/CommandsTab/Commands.module.css @@ -1,3 +1,7 @@ +.commandsContainer { + height: 100%; +} + .commandsContainer :global(.ant-select) { width: 100%; } @@ -8,6 +12,17 @@ .btnContainer { padding: 8px; + height: 100%; +} + +.methodMapTab { + height: 100%; + display: flex; + flex-direction: column; +} + +.methodMapGrid { + overflow-y: auto; } .argContainer { @@ -36,3 +51,18 @@ white-space: pre-wrap; cursor: pointer; } + +.methodBtn { + white-space: normal; + word-break: break-all; + height: 100%; + min-height: 30px; +} + +.deprecatedMethod { + background-color: #f8f8e3; +} + +:global(.dark) .deprecatedMethod { + background-color: #292919; +} diff --git a/app/common/renderer/components/SessionInspector/CommandsTab/MethodMapCommandsList.jsx b/app/common/renderer/components/SessionInspector/CommandsTab/MethodMapCommandsList.jsx new file mode 100644 index 0000000000..bdc7243efe --- /dev/null +++ b/app/common/renderer/components/SessionInspector/CommandsTab/MethodMapCommandsList.jsx @@ -0,0 +1,104 @@ +import {SearchOutlined} from '@ant-design/icons'; +import {Button, Col, Divider, Input, Row, Tabs, Tooltip} from 'antd'; +import _ from 'lodash'; +import {useState} from 'react'; + +import {filterMethodPairs} from '../../../utils/commands-tab.js'; +import styles from './Commands.module.css'; + +// Dynamic list of driver commands, generated from the driver's method map responses. +// Unlike StaticCommandsList, we cannot predict the contents of the method map response, +// and we also want to be able to filter it, so just render all methods in a single grid. +const MethodMapCommandsList = (props) => { + const {driverCommands, driverExecuteMethods, startCommand, t} = props; + + const [searchQuery, setSearchQuery] = useState(''); + + const hasNoCommands = _.isEmpty(driverCommands.current); + const hasNoExecuteMethods = _.isEmpty(driverExecuteMethods.current); + + const filteredDriverCommands = filterMethodPairs(driverCommands.current, searchQuery); + const filteredDriverExecuteMethods = filterMethodPairs(driverExecuteMethods.current, searchQuery); + + const methodButton = (methodName, methodDetails, isExecute) => ( +
+ {!methodDetails.deprecated && !methodDetails.info && ( + + )} + {(methodDetails.deprecated || methodDetails.info) && ( + + {methodDetails.deprecated &&
{t('methodDeprecated')}
} + {methodDetails.info &&
{methodDetails.info}
} + + } + destroyOnHidden={true} + > + +
+ )} +
+ ); + + const methodMapContent = (driverMethods, isExecute) => ( + <> + {isExecute ? t('dynamicExecuteMethodsDescription') : t('dynamicCommandsDescription')} + +
+ + {driverMethods.map(([methodName, methodDetails]) => ( + + {methodButton(methodName, methodDetails, isExecute)} + + ))} + +
+ + ); + + return ( + setSearchQuery(e.target.value)} + value={searchQuery} + allowClear + prefix={} + /> + } + /> + ); +}; + +export default MethodMapCommandsList; diff --git a/app/common/renderer/components/SessionInspector/CommandsTab/StaticCommandsList.jsx b/app/common/renderer/components/SessionInspector/CommandsTab/StaticCommandsList.jsx new file mode 100644 index 0000000000..169ee95c3a --- /dev/null +++ b/app/common/renderer/components/SessionInspector/CommandsTab/StaticCommandsList.jsx @@ -0,0 +1,50 @@ +import {Button, Col, Collapse, Row, Space} from 'antd'; +import _ from 'lodash'; + +import {COMMAND_DEFINITIONS, TOP_LEVEL_COMMANDS} from '../../../constants/commands.js'; +import inspectorStyles from '../SessionInspector.module.css'; +import styles from './Commands.module.css'; + +// Static list of driver commands, shown only for drivers that do not support +// the listCommands/listExtensions endpoints +const StaticCommandsList = (props) => { + const {startCommand, t} = props; + + return ( + + {t('commandsDescription')} + + {_.toPairs(TOP_LEVEL_COMMANDS).map(([cmdName, cmdDetails]) => ( + +
+ +
+ + ))} +
+ ({ + key: commandGroup, + label: t(commandGroup), + children: ( + + {_.toPairs(commands).map(([cmdName, cmdDetails]) => ( + +
+ +
+ + ))} +
+ ), + }))} + /> +
+ ); +}; + +export default StaticCommandsList; diff --git a/app/common/renderer/components/SessionInspector/SessionInspector.jsx b/app/common/renderer/components/SessionInspector/SessionInspector.jsx index 3ffe26e81d..b8fabd9971 100644 --- a/app/common/renderer/components/SessionInspector/SessionInspector.jsx +++ b/app/common/renderer/components/SessionInspector/SessionInspector.jsx @@ -19,7 +19,6 @@ import { SESSION_EXPIRY_PROMPT_TIMEOUT, } from '../../constants/session-inspector.js'; import {downloadFile} from '../../utils/file-handling.js'; -import CommandResultModal from './CommandsTab/CommandResultModal.jsx'; import Commands from './CommandsTab/Commands.jsx'; import GestureEditor from './GesturesTab/GestureEditor.jsx'; import SavedGestures from './GesturesTab/SavedGestures.jsx'; @@ -187,7 +186,7 @@ const Inspector = (props) => { useEffect(() => { resizeWindowOnLaunch(); - applyClientMethod({methodName: 'getPageSource', ignoreResult: true}); + applyClientMethod({methodName: 'getPageSource'}); storeSessionSettings(); getSavedClientFramework(); runKeepAliveLoop(); @@ -363,7 +362,6 @@ const Inspector = (props) => { >

{t('Your session is about to expire')}

- ); }; diff --git a/app/common/renderer/constants/commands.js b/app/common/renderer/constants/commands.js index 7223ab2710..81092d9671 100644 --- a/app/common/renderer/constants/commands.js +++ b/app/common/renderer/constants/commands.js @@ -1,26 +1,281 @@ -export const COMMAND_ARG_TYPES = { - STRING: 'string', - NUMBER: 'number', - BOOLEAN: 'boolean', +/** + * Only used for the dynamic commands map. + * Not all Appium commands exist in WDIO, and for those that do, they may have different names. + * Since commands are retrieved with their Appium names, but executed using their WDIO names, + * they must be filtered and renamed first, thereby requiring this mapping. + * Any commands not in this map are considered to be unsupported. + * + * NOTE: This map should be updated whenever: + * * WDIO or Appium adds a new command that already exists in the other tool + * * WDIO or Appium changes the name of any command in this list + */ +export const APPIUM_TO_WD_COMMANDS = { + // WDIO WebDriver standard protocol commands + // https://webdriver.io/docs/api/webdriver + // createSession: 'newSession', // not applicable for Commands tab + // deleteSession: 'deleteSession', // not applicable for Commands tab + getStatus: 'status', + getTimeouts: 'getTimeouts', + timeouts: 'setTimeouts', + getUrl: 'getUrl', + setUrl: 'navigateTo', + back: 'back', + forward: 'forward', + refresh: 'refresh', + title: 'getTitle', + getWindowHandle: 'getWindowHandle', + closeWindow: 'closeWindow', + setWindow: 'switchToWindow', + createNewWindow: 'createWindow', + getWindowHandles: 'getWindowHandles', + printPage: 'printPage', + setFrame: 'switchToFrame', + switchToParentFrame: 'switchToParentFrame', + getWindowRect: 'getWindowRect', + setWindowRect: 'setWindowRect', + maximizeWindow: 'maximizeWindow', + minimizeWindow: 'minimizeWindow', + fullScreenWindow: 'fullscreenWindow', + findElement: 'findElement', + findElementFromShadowRoot: 'findElementFromShadowRoot', + findElements: 'findElements', + findElementsFromShadowRoot: 'findElementsFromShadowRoot', + findElementFromElement: 'findElementFromElement', + findElementsFromElement: 'findElementsFromElement', + elementShadowRoot: 'getElementShadowRoot', + active: 'getActiveElement', + elementSelected: 'isElementSelected', + elementDisplayed: 'isElementDisplayed', + getAttribute: 'getElementAttribute', + getProperty: 'getElementProperty', + getCssProperty: 'getElementCSSValue', + getText: 'getElementText', + getName: 'getElementTagName', + getElementRect: 'getElementRect', + elementEnabled: 'isElementEnabled', + click: 'elementClick', // also WDIO touchClick (removed in Appium 3) + clear: 'elementClear', + setValue: 'elementSendKeys', + getPageSource: 'getPageSource', + execute: 'executeScript', + executeAsync: 'executeAsyncScript', + getCookies: 'getAllCookies', + setCookie: 'addCookie', + deleteCookies: 'deleteAllCookies', + getCookie: 'getNamedCookie', + deleteCookie: 'deleteCookie', + performActions: 'performActions', + releaseActions: 'releaseActions', + postDismissAlert: 'dismissAlert', + postAcceptAlert: 'acceptAlert', + getAlertText: 'getAlertText', + setAlertText: 'sendAlertText', + getScreenshot: 'takeScreenshot', + getElementScreenshot: 'takeElementScreenshot', + getComputedRole: 'getElementComputedRole', + getComputedLabel: 'getElementComputedLabel', + // WDIO WebDriver extended protocol commands + // https://webdriver.io/docs/api/webdriver + setPermissions: 'setPermissions', + generateTestReport: 'generateTestReport', + createVirtualSensor: 'createMockSensor', + getVirtualSensorInfo: 'getMockSensor', + updateVirtualSensorReading: 'updateMockSensor', + deleteVirtualSensor: 'deleteMockSensor', + addVirtualAuthenticator: 'addVirtualAuthenticator', + removeVirtualAuthenticator: 'removeVirtualAuthenticator', + addAuthCredential: 'addCredential', + getAuthCredential: 'getCredentials', + removeAllAuthCredentials: 'removeAllCredentials', + removeAuthCredential: 'removeCredential', + setUserAuthVerified: 'setUserVerified', + // WDIO MJSONWP commands + // https://webdriver.io/docs/api/mjsonwp + getNetworkConnection: 'getNetworkConnection', + setNetworkConnection: 'setNetworkConnection', + // WDIO Appium protocol commands (includes some JSONWP/MJSONWP) + // https://webdriver.io/docs/api/appium + getLog: 'getLogs', + getLogTypes: 'getLogTypes', + getSession: 'getSession', + getCurrentContext: 'getAppiumContext', + getContexts: 'getAppiumContexts', + setContext: 'switchAppiumContext', + listCommands: 'getAppiumCommands', + listExtensions: 'getAppiumExtensions', + getAppiumSessionCapabilities: 'getAppiumSessionCapabilities', + setRotation: 'rotateDevice', + installApp: 'installApp', + activateApp: 'activateApp', + removeApp: 'removeApp', + terminateApp: 'terminateApp', + isAppInstalled: 'isAppInstalled', + queryAppState: 'queryAppState', + hideKeyboard: 'hideKeyboard', + isKeyboardShown: 'isKeyboardShown', + pushFile: 'pushFile', + pullFile: 'pullFile', + pullFolder: 'pullFolder', + getDeviceTime: 'getDeviceTime', + getSettings: 'getSettings', + updateSettings: 'updateSettings', + executeDriverScript: 'executeDriverScript', // execute-driver-plugin + getLogEvents: 'getEvents', + logCustomEvent: 'logEvent', + compareImages: 'compareImages', // images-plugin + availableIMEEngines: 'availableIMEEngines', + getActiveIMEEngine: 'getActiveIMEEngine', + isIMEActivated: 'isIMEActivated', + deactivateIMEEngine: 'deactivateIMEEngine', + activateIMEEngine: 'activateIMEEngine', + getOrientation: 'getOrientation', + setOrientation: 'setOrientation', + setGeoLocation: 'setGeoLocation', + getGeoLocation: 'getGeoLocation', + // WDIO Appium protocol commands removed in Appium 3 + mobileShake: 'shake', + lock: 'lock', + unlock: 'unlock', + isLocked: 'isLocked', + startRecordingScreen: 'startRecordingScreen', // moved to drivers + stopRecordingScreen: 'stopRecordingScreen', // moved to drivers + getPerformanceDataTypes: 'getPerformanceDataTypes', + getPerformanceData: 'getPerformanceData', + pressKeyCode: 'pressKeyCode', + longPressKeyCode: 'longPressKeyCode', + keyevent: 'sendKeyEvent', + getCurrentActivity: 'getCurrentActivity', + getCurrentPackage: 'getCurrentPackage', + toggleFlightMode: 'toggleAirplaneMode', + toggleData: 'toggleData', + toggleWiFi: 'toggleWiFi', + toggleLocationServices: 'toggleLocationServices', + networkSpeed: 'toggleNetworkSpeed', + openNotifications: 'openNotifications', + startActivity: 'startActivity', + getSystemBars: 'getSystemBars', + getDisplayDensity: 'getDisplayDensity', + touchId: 'touchId', + toggleEnrollTouchId: 'toggleEnrollTouchId', + launchApp: 'launchApp', + closeApp: 'closeApp', + background: 'background', + endCoverage: 'endCoverage', + getStrings: 'getStrings', + setValueImmediate: 'setValueImmediate', + replaceValue: 'replaceValue', + receiveAsyncResponse: 'receiveAsyncResponse', + gsmCall: 'gsmCall', + gsmSignal: 'gsmSignal', + powerCapacity: 'powerCapacity', + powerAC: 'powerAC', + gsmVoice: 'gsmVoice', + sendSMS: 'sendSms', + fingerprint: 'fingerPrint', + setClipboard: 'setClipboard', + getClipboard: 'getClipboard', + performTouch: 'touchPerform', + performMultiAction: 'multiTouchPerform', + implicitWait: 'implicitWait', + getLocationInView: 'getLocationInView', + keys: 'sendKeys', + asyncScriptTimeout: 'asyncScriptTimeout', + submit: 'submit', + getSize: 'getElementSize', + getLocation: 'getElementLocation', + touchDown: 'touchDown', + touchUp: 'touchUp', + touchMove: 'touchMove', + touchLongClick: 'touchLongClick', + flick: 'touchFlick', }; -const {STRING, NUMBER} = COMMAND_ARG_TYPES; +/** + * Only used for the dynamic commands map. + * Certain commands differ in their signature between Appium and WDIO, + * so the WDIO formats must be explicitly defined. + * Any URL parameters are excluded, as they are handled separately. + */ +export const COMMANDS_WITH_MISMATCHED_PARAMS = { + execute: [ + // The WebDriver spec requires 'args' to be an array + // (https://w3c.github.io/webdriver/#dfn-extract-the-script-arguments-from-a-request), + // but if the script doesn't use any arguments, we allow the user to omit it. + // This is handled in Commands.jsx:prepareCommand. + {name: 'script', required: true}, + {name: 'args', required: false}, + ], + timeouts: [ + // different order, no non-W3C params + {name: 'implicit', required: false}, + {name: 'pageLoad', required: false}, + {name: 'script', required: false}, + ], + printPage: [ + // page & margin separated into components + {name: 'orientation', required: false}, + {name: 'scale', required: false}, + {name: 'background', required: false}, + {name: 'width', required: false}, + {name: 'height', required: false}, + {name: 'top', required: false}, + {name: 'bottom', required: false}, + {name: 'left', required: false}, + {name: 'right', required: false}, + {name: 'shrinkToFit', required: false}, + {name: 'pageRanges', required: false}, + ], + addAuthCredential: [ + // userHandle & signCount are required + {name: 'credentialId', required: true}, + {name: 'isResidentCredential', required: true}, + {name: 'rpId', required: true}, + {name: 'privateKey', required: true}, + {name: 'userHandle', required: true}, + {name: 'signCount', required: true}, + {name: 'largeBlob', required: false}, + ], + setUserAuthVerified: [], // no isUserVerified property + getDeviceTime: [], // only GET endpoint is supported + installApp: [{name: 'appPath', required: true}], // no options + activateApp: [{name: 'appId', required: true}], // no bundleId & options + removeApp: [{name: 'appId', required: true}], // no bundleId & options + terminateApp: [{name: 'appId', required: true}], // no bundleId + isAppInstalled: [{name: 'appId', required: true}], // no bundleId + queryAppState: [{name: 'appId', required: true}], // no bundleId + getLogEvents: [{name: 'type', required: true}], // type is required + stopRecordingScreen: [ + // options separated into components + {name: 'remotePath', required: false}, + {name: 'username', required: false}, + {name: 'password', required: false}, + {name: 'method', required: false}, + ], +}; -// Commonly used commands not hidden under a collapse +/** + * Only used for the static commands map. + * Commonly used commands not hidden under a collapse. + */ export const TOP_LEVEL_COMMANDS = { executeScript: { - args: [ - ['executeScriptCommand', STRING], - ['jsonArgument', STRING], + params: [ + {name: 'script', required: true}, + {name: 'args', required: false}, ], }, updateSettings: { - args: [['settingsJson', STRING]], + params: [{name: 'settings', required: true}], }, getSettings: {}, }; -// Note: When changing COMMAND_DEFINITIONS categories, or 'notes' for any command, update `en/translation.json` +/** + * Only used for the static commands map. + * Defines the full mapping of categories and commands. + * + * NOTE: When changing top-level categories, update `en/translation.json` + */ export const COMMAND_DEFINITIONS = { Session: { status: {}, @@ -30,22 +285,22 @@ export const COMMAND_DEFINITIONS = { getAppiumSessionCapabilities: {}, getTimeouts: {}, setTimeouts: { - args: [ - ['implicitTimeout', NUMBER], - ['pageLoadTimeout', NUMBER], - ['scriptTimeout', NUMBER], + params: [ + {name: 'implicit', required: false}, + {name: 'pageLoad', required: false}, + {name: 'script', required: false}, ], }, getLogTypes: {}, getLogs: { - args: [['logType', STRING]], + params: [{name: 'type', required: true}], }, }, Context: { getAppiumContext: {}, getAppiumContexts: {}, switchAppiumContext: { - args: [['name', STRING]], + params: [{name: 'name', required: true}], refresh: true, }, }, @@ -59,68 +314,61 @@ export const COMMAND_DEFINITIONS = { isKeyboardShown: {}, getOrientation: {}, setOrientation: { - args: [['orientation', STRING]], + params: [{name: 'orientation', required: true}], refresh: true, }, getGeoLocation: {}, setGeoLocation: { - args: [ - ['latitude', NUMBER], - ['longitude', NUMBER], - ['altitude', NUMBER], - ], + params: [{name: 'location', required: true}], }, rotateDevice: { - args: [ - ['x', NUMBER], - ['y', NUMBER], - ['duration', NUMBER], - ['radius', NUMBER], - ['rotation', NUMBER], - ['touchCount', NUMBER], + params: [ + {name: 'x', required: true}, + {name: 'y', required: true}, + {name: 'z', required: true}, ], refresh: true, }, }, 'App Management': { installApp: { - args: [['appPathOrUrl', STRING]], + params: [{name: 'appPath', required: true}], }, isAppInstalled: { - args: [['appId', STRING]], + params: [{name: 'appId', required: true}], }, activateApp: { - args: [['appId', STRING]], + params: [{name: 'appId', required: true}], refresh: true, }, terminateApp: { - args: [['appId', STRING]], + params: [{name: 'appId', required: true}], refresh: true, }, removeApp: { - args: [['appId', STRING]], + params: [{name: 'appId', required: true}], }, queryAppState: { - args: [['appId', STRING]], + params: [{name: 'appId', required: true}], }, }, 'File Transfer': { pushFile: { - args: [ - ['pathToInstallTo', STRING], - ['fileContentString', STRING], + params: [ + {name: 'path', required: true}, + {name: 'data', required: true}, ], }, pullFile: { - args: [['pathToPullFrom', STRING]], + params: [{name: 'path', required: true}], }, pullFolder: { - args: [['folderToPullFrom', STRING]], + params: [{name: 'path', required: true}], }, }, Web: { navigateTo: { - args: [['url', STRING]], + params: [{name: 'url', required: true}], refresh: true, }, getUrl: {}, @@ -139,12 +387,12 @@ export const COMMAND_DEFINITIONS = { refresh: true, }, switchToWindow: { - args: [['handle', STRING]], + params: [{name: 'handle', required: true}], refresh: true, }, getWindowHandles: {}, createWindow: { - args: [['type', STRING]], + params: [{name: 'type', required: true}], refresh: true, }, }, diff --git a/app/common/renderer/constants/webdriver.js b/app/common/renderer/constants/webdriver.js index 65d25c4a44..c7918e0de0 100644 --- a/app/common/renderer/constants/webdriver.js +++ b/app/common/renderer/constants/webdriver.js @@ -33,8 +33,6 @@ export const AVOID_CMDS = [ 'newSession', 'findElement', 'findElements', - 'findElementFromElement', - 'findElementsFromElement', 'executeScript', 'executeAsyncScript', ]; diff --git a/app/common/renderer/lib/appium/session-driver.js b/app/common/renderer/lib/appium/session-driver.js index 00e83e0e88..6952c5e11e 100644 --- a/app/common/renderer/lib/appium/session-driver.js +++ b/app/common/renderer/lib/appium/session-driver.js @@ -19,7 +19,7 @@ import {AppiumProtocol, MJsonWProtocol, WebDriverProtocol} from '@wdio/protocols'; import _ from 'lodash'; -import {AVOID_CMDS, BROWSER_PROPERTIES, ELEMENT_CMDS} from '../../constants/webdriver.js'; +import {AVOID_CMDS, BROWSER_PROPERTIES} from '../../constants/webdriver.js'; import {getElementFromResponse} from './session-element.js'; /** @@ -90,10 +90,6 @@ for (const proto of [WebDriverProtocol, MJsonWProtocol, AppiumProtocol]) { if (AVOID_CMDS.includes(cmdName)) { continue; } - // likewise skip element commands - if (ELEMENT_CMDS.includes(cmdName)) { - continue; - } WDSessionDriver.prototype[cmdName] = async function (...args) { return await this.cmd(cmdName, ...args); diff --git a/app/common/renderer/reducers/SessionInspector.js b/app/common/renderer/reducers/SessionInspector.js index 73e6032f77..541bfbfeea 100644 --- a/app/common/renderer/reducers/SessionInspector.js +++ b/app/common/renderer/reducers/SessionInspector.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import { ADD_ASSIGNED_VAR_CACHE, - CANCEL_PENDING_COMMAND, CLEAR_ASSIGNED_VAR_CACHE, CLEAR_COORD_ACTION, CLEAR_RECORDING, @@ -11,7 +10,6 @@ import { CLEAR_TAP_COORDINATES, DELETE_SAVED_GESTURES_DONE, DELETE_SAVED_GESTURES_REQUESTED, - ENTERING_COMMAND_ARGS, FINDING_ELEMENT_IN_SOURCE, FINDING_ELEMENT_IN_SOURCE_COMPLETED, GET_FIND_ELEMENTS_TIMES, @@ -45,7 +43,6 @@ import { SET_AUTO_SESSION_RESTART, SET_AWAITING_MJPEG_STREAM, SET_CLIENT_FRAMEWORK, - SET_COMMAND_ARG, SET_CONTEXT, SET_COORD_END, SET_COORD_START, @@ -72,7 +69,6 @@ import { SET_SIRI_COMMAND_VALUE, SET_SOURCE_AND_SCREENSHOT, SET_USER_WAIT_TIMEOUT, - SET_VISIBLE_COMMAND_RESULT, SHOW_GESTURE_ACTION, SHOW_GESTURE_EDITOR, SHOW_LOCATOR_TEST_MODAL, @@ -125,12 +121,9 @@ const INITIAL_STATE = { searchedForElementBounds: null, selectedInspectorTab: INSPECTOR_TABS.SOURCE, appMode: APP_MODE.NATIVE, - pendingCommand: null, findElementsExecutionTimes: [], isFindingElementsTimes: false, isFindingLocatedElementInSource: false, - visibleCommandResult: null, - visibleCommandMethod: null, isAwaitingMjpegStream: true, showSourceAttrs: false, gestureUploadErrors: null, @@ -507,31 +500,6 @@ export default function inspector(state = INITIAL_STATE, action) { showCentroids: action.show, }; - case ENTERING_COMMAND_ARGS: - return { - ...state, - pendingCommand: { - commandName: action.commandName, - command: action.command, - args: [], - }, - }; - - case SET_COMMAND_ARG: - return { - ...state, - pendingCommand: { - ...state.pendingCommand, - args: Object.assign([], state.pendingCommand.args, {[action.index]: action.value}), // Replace 'value' at 'index' - }, - }; - - case CANCEL_PENDING_COMMAND: - return { - ...state, - pendingCommand: null, - }; - case SET_CONTEXT: return { ...state, @@ -556,13 +524,6 @@ export default function inspector(state = INITIAL_STATE, action) { lastActiveMoment: action.lastActiveMoment, }; - case SET_VISIBLE_COMMAND_RESULT: - return { - ...state, - visibleCommandResult: action.result, - visibleCommandMethod: action.methodName, - }; - case SET_SESSION_TIME: return { ...state, diff --git a/app/common/renderer/utils/commands-tab.js b/app/common/renderer/utils/commands-tab.js new file mode 100644 index 0000000000..68dc2e4afb --- /dev/null +++ b/app/common/renderer/utils/commands-tab.js @@ -0,0 +1,230 @@ +import _ from 'lodash'; + +import {APPIUM_TO_WD_COMMANDS, COMMANDS_WITH_MISMATCHED_PARAMS} from '../constants/commands.js'; + +/** + * Try to detect if the input value should be a boolean/number/array/object, + * and if so, convert it to that + * + * @param {string} value the value of a command parameter + * @returns the value converted to the type it matches best + */ +export function adjustParamValueType(value) { + if (value === '') { + return null; + } else if (Number(value).toString() === value) { + return Number(value); + } else if (['true', 'false'].includes(value)) { + return value === 'true'; + } else { + try { + return JSON.parse(value); + } catch { + return value; + } + } +} + +/** + * Filter the array of method key-value pairs to only include methods matching the search query. + * + * @param {[string, Object][]} methodPairs array of [methodName, methodDetails] pairs + * @param {string} searchQuery user-provided search query + * @returns filtered array of [methodName, methodDetails] pairs + */ +export function filterMethodPairs(methodPairs, searchQuery) { + if (!searchQuery) { + return methodPairs; + } + return _.filter(methodPairs, ([methodName]) => + methodName.toLowerCase().includes(searchQuery.toLowerCase()), + ); +} + +/** + * Check if a value is an empty object or array ({} or []) + * + * @param {*} value any value (object, array, primitive) + * @returns whether the item is an empty object/array or not + */ +const isEmptyObject = (value) => _.isObjectLike(value) && _.isEmpty(value); + +/** + * Recursively remove key/value pairs (or array entries) whose values are empty objects or arrays. + * If this causes the parent object/array to become empty, + * this parent will be removed from its own parent as well. + * + * @param {*} value any value (object, array, primitive) + * @returns the value with empty entries removed + */ +export function deepFilterEmpty(value) { + if (_.isArray(value)) { + // Recurse into each array element, then remove empty entries + const recurse = (arr) => arr.map(deepFilterEmpty); + const clean = (arr) => arr.filter(_.negate(isEmptyObject)); + const recurseAndClean = _.flow([recurse, clean]); + return recurseAndClean(value); + } + + if (_.isPlainObject(value)) { + // Recurse into object properties, then pick only non-empty values + const recurse = (obj) => _.mapValues(obj, deepFilterEmpty); + const clean = (obj) => _.pickBy(obj, _.negate(isEmptyObject)); + const recurseAndClean = _.flow([recurse, clean]); + return recurseAndClean(value); + } + + // Return primitives as-is + return value; +} + +/** + * Extract any parameter names (except sessionId) from a command path. + * + * @param {string} path command endpoint URL + * @returns array of parameter names + */ +export function extractParamsFromCommandPath(path) { + return path + .split('/') + .flatMap((segment) => + segment.startsWith(':') && segment !== ':sessionId' ? [segment.slice(1)] : [], + ); +} + +/** + * Filter and transform a given map of command paths/methods/details. + * + * @param {Object} pathsToCmdsMap map of command paths to their HTTP methods and details + * @returns flat map of command names to their details + */ +function transformInnerCommandsMap(pathsToCmdsMap) { + const transformedMap = {}; + for (const path in pathsToCmdsMap) { + const pathsCmdsMap = pathsToCmdsMap[path]; + // pathsCmdsMap: HTTP methods to commands map + for (const method in pathsCmdsMap) { + // Skip commands that don't have the command name + if (_.isEmpty(pathsCmdsMap[method]) || !('command' in pathsCmdsMap[method])) { + continue; + } + const cmdName = pathsCmdsMap[method].command; + // Skip commands not supported by WDIO + if (!(cmdName in APPIUM_TO_WD_COMMANDS)) { + continue; + } + // Filter out any entries with empty values + const commandDetails = deepFilterEmpty(pathsCmdsMap[method]); + // If we have multiple entries for the same method name in the same source (e.g. /execute + // and /execute/sync in Appium 2 are both named 'execute'), skip any deprecated entries + if (APPIUM_TO_WD_COMMANDS[cmdName] in transformedMap && 'deprecated' in commandDetails) { + continue; + } + // Some commands require parameter adjustments due to WDIO method signature differences + if (cmdName in COMMANDS_WITH_MISMATCHED_PARAMS) { + commandDetails.params = COMMANDS_WITH_MISMATCHED_PARAMS[cmdName]; + } + // For commands that include additional parameters in the path (e.g. /session/:sessionId/element/:elementId), + // WDIO includes them in the method itself, so we need to extract their names from the path + // and add them to the start of the parameters array + const commandPathParamNames = extractParamsFromCommandPath(path); + if (commandPathParamNames.length > 0) { + const commandPathParamEntries = commandPathParamNames.map((paramName) => ({ + name: paramName, + required: true, + })); + commandDetails.params = [...commandPathParamEntries, ...(commandDetails.params || [])]; + } + // Add the adjusted command details to the result map, using the WDIO command name. + transformedMap[APPIUM_TO_WD_COMMANDS[cmdName]] = commandDetails; + } + } + return transformedMap; +} + +/** + * Filter and transform the map of commands supported by the current driver using certain criteria: + * * Remove entries with empty values (similarly to {@link deepFilterEmpty}) + * * Remove commands not supported by WDIO + * + * In addition to filtering, the map is modified to remove the base/driver/plugin scopes, + * the path and the HTTP method. + * + * @param {Object} cmdsResponse {@link https://github.com/appium/appium/blob/master/packages/types/lib/command-maps.ts `ListCommandsResponse`} + * @returns array of key-value pairs with command names and their details + */ +export function transformCommandsMap(cmdsResponse) { + let adjBaseCmdsMap = {}, + adjDriverCmdsMap = {}, + adjPluginCmdsMap = {}; + // cmdsResponse: REST/BiDi to base/driver/plugins source map + // only use the REST commands for now + if (_.isEmpty(cmdsResponse) || !('rest' in cmdsResponse) || _.isEmpty(cmdsResponse.rest)) { + return []; + } + const restCmdsMap = cmdsResponse.rest; + // restCmdsMap: base/driver/plugins source to command paths/plugin names map + for (const source in restCmdsMap) { + if (source === 'plugins') { + const pluginNamesMap = restCmdsMap[source]; + // pluginNamesMap: plugin names to plugin command paths map + for (const pluginName in pluginNamesMap) { + const pluginCmdsMap = pluginNamesMap[pluginName]; + // pluginCmdsMap: plugin command paths to HTTP methods map + // Use spread operator to handle multiple plugins + adjPluginCmdsMap = {...adjPluginCmdsMap, ...transformInnerCommandsMap(pluginCmdsMap)}; + } + } else { + const sourceCmdsMap = restCmdsMap[source]; + // sourceCmdsMap: base/driver command paths to HTTP methods map + if (source === 'base') { + adjBaseCmdsMap = transformInnerCommandsMap(sourceCmdsMap); + } else if (source === 'driver') { + adjDriverCmdsMap = transformInnerCommandsMap(sourceCmdsMap); + } + } + } + // Merge all maps in a logical priority order + return _.toPairs({...adjBaseCmdsMap, ...adjDriverCmdsMap, ...adjPluginCmdsMap}); +} + +/** + * Filter and transform the map of execute methods supported by the current driver, + * by removing entries with empty values (similarly to {@link deepFilterEmpty}) + * + * In addition to filtering, the map is modified to remove the driver/plugin scopes. + * + * @param {Object} execMethodsResponse {@link https://github.com/appium/appium/blob/master/packages/types/lib/command-maps.ts `ListExtensionsResponse`} + * @returns array of key-value pairs with execute method names and their details + */ +export function transformExecMethodsMap(execMethodsResponse) { + let adjExecMethodsMap = {}; + // execMethodsResponse: REST to driver/plugins source map + if ( + _.isEmpty(execMethodsResponse) || + !('rest' in execMethodsResponse) || + _.isEmpty(execMethodsResponse.rest) + ) { + return []; + } + const restExecMethodsMap = execMethodsResponse.rest; + // restExecMethodsMap: driver/plugins source to method names/execute methods map + for (const source in restExecMethodsMap) { + if (source === 'plugins') { + const pluginNamesMap = restExecMethodsMap[source]; + // pluginNamesMap: plugin names to execute methods map + for (const pluginName in pluginNamesMap) { + const pluginExecMethodsMap = pluginNamesMap[pluginName]; + // pluginExecMethodsMap: plugin execute method names to method details map + // Any plugin execute methods should override driver ones if there are name conflicts + adjExecMethodsMap = {...adjExecMethodsMap, ...deepFilterEmpty(pluginExecMethodsMap)}; + } + } else if (source === 'driver') { + const driverExecMethodsMap = restExecMethodsMap[source]; + // driverExecMethodsMap: driver execute method names to method details map + // Any plugin execute methods should override driver ones if there are name conflicts + adjExecMethodsMap = {...deepFilterEmpty(driverExecMethodsMap), ...adjExecMethodsMap}; + } + } + return _.toPairs(adjExecMethodsMap); +} diff --git a/docs/quickstart/requirements.md b/docs/quickstart/requirements.md index c726457bb6..a9b6ab4342 100644 --- a/docs/quickstart/requirements.md +++ b/docs/quickstart/requirements.md @@ -30,11 +30,13 @@ If setting up your own server, make sure to also install the **Appium driver(s)* platform(s). You can find links to all known drivers in the [Appium documentation's Ecosystem page](https://appium.io/docs/en/latest/ecosystem/drivers/). Refer to each driver's documentation for its specific requirements and setup instructions. -The following driver versions are recommended for best compatibility: +For official drivers, the following versions are recommended for best compatibility: -- [Espresso](https://github.com/appium/appium-espresso-driver): `2.23.0` or later -- [UiAutomator2](https://github.com/appium/appium-uiautomator2-driver): `2.21.0` or later -- [XCUITest](https://appium.github.io/appium-xcuitest-driver/latest/): `3.38.0` or later +- [Espresso](https://github.com/appium/appium-espresso-driver): `4.0.0` or later +- [Mac2](https://github.com/appium/appium-mac2-driver): `2.0.0` or later +- [UiAutomator2](https://github.com/appium/appium-uiautomator2-driver): `4.0.0` or later +- [Windows](https://github.com/appium/appium-windows-driver/): `4.0.0` or later +- [XCUITest](https://appium.github.io/appium-xcuitest-driver/latest/): `4.21.27` or later Continue with the [Installation](./installation.md) steps, or jump directly to [Starting a Session](./starting-a-session.md)! diff --git a/docs/session-inspector/assets/images/commands/command-info.png b/docs/session-inspector/assets/images/commands/command-info.png new file mode 100644 index 0000000000..f4f8e04197 Binary files /dev/null and b/docs/session-inspector/assets/images/commands/command-info.png differ diff --git a/docs/session-inspector/assets/images/commands/command-params.png b/docs/session-inspector/assets/images/commands/command-params.png index efa1699845..449443fd8c 100644 Binary files a/docs/session-inspector/assets/images/commands/command-params.png and b/docs/session-inspector/assets/images/commands/command-params.png differ diff --git a/docs/session-inspector/assets/images/commands/commands-nav.png b/docs/session-inspector/assets/images/commands/commands-nav.png new file mode 100644 index 0000000000..dc60424cd7 Binary files /dev/null and b/docs/session-inspector/assets/images/commands/commands-nav.png differ diff --git a/docs/session-inspector/assets/images/commands/commands-tab.png b/docs/session-inspector/assets/images/commands/commands-tab.png index 3f4b951f40..b3e7ab4d5d 100644 Binary files a/docs/session-inspector/assets/images/commands/commands-tab.png and b/docs/session-inspector/assets/images/commands/commands-tab.png differ diff --git a/docs/session-inspector/assets/images/commands/deprecated-command.png b/docs/session-inspector/assets/images/commands/deprecated-command.png new file mode 100644 index 0000000000..43d3b248c8 Binary files /dev/null and b/docs/session-inspector/assets/images/commands/deprecated-command.png differ diff --git a/docs/session-inspector/assets/images/commands/opened-category.png b/docs/session-inspector/assets/images/commands/opened-category.png deleted file mode 100644 index f5221567e3..0000000000 Binary files a/docs/session-inspector/assets/images/commands/opened-category.png and /dev/null differ diff --git a/docs/session-inspector/commands.md b/docs/session-inspector/commands.md index 2afe9e86ec..882d5582e6 100644 --- a/docs/session-inspector/commands.md +++ b/docs/session-inspector/commands.md @@ -2,44 +2,66 @@ title: Commands Tab --- -The Commands tab provides a way to execute various Appium driver commands through the Inspector GUI. +The Commands tab provides a way to run various Appium driver commands and +[execute methods](https://appium.io/docs/en/latest/guides/execute-methods/) through the +Inspector GUI. ![Commands Tab](./assets/images/commands/commands-tab.png) -Most commands are grouped into various categories. Opening any category shows several buttons, each -of which corresponds to an Appium driver command. +The list of available commands and execute methods is tied to the active Appium driver: all drivers +support a set of [common protocol commands](https://appium.io/docs/en/latest/reference/api/), +but they can also define their own commands and execute methods. The Inspector retrieves all of +these commands/methods from the driver itself, and adjusts the list accordingly. -![Opened Commands Category](./assets/images/commands/opened-category.png) +Appium _plugins_ may also define their own commands and execute methods. If such plugins are +active during an Inspector session, their commands/methods are also included in the Commands tab. -!!! note +Note that there are two important limitations to this approach: - Commands may be driver-specific, in which case their buttons may not be visible when using - other drivers. +- The Commands sub-tab ^^**only lists the commands supported by [the WebdriverIO client](https://webdriver.io/).**^^ + Under the hood, the Inspector uses WebdriverIO to run all commands, therefore any command that is + not defined in WebdriverIO (for example, any custom third-party driver command) will not work. Due + to this, the Inspector simply filters out all such commands from the Commands list. This limitation + does not apply to execute methods. -## Parameters and Conditions +- ^^**Appium `2.16.0` or later is required.**^^ For older Appium versions, a predefined list of + commands is shown instead. Please be aware that not all commands in the predefined list are + compatible with all drivers. -A command may support additional parameters, which affects the behavior of its button: +## Navigation -- For a command without parameters, clicking its button will execute the command -- For a command with parameters, clicking its button will open the parameters popup: +The top of the Commands tab includes two sub-tabs, which can be used to switch between the +supported commands and execute methods. Since these lists can be lengthy, a search bar is also +available, which can be used to filter both sub-tabs simultaneously. - ![Command Parameters](./assets/images/commands/command-params.png) +![Commands Tab Navigation](./assets/images/commands/commands-nav.png) + +## Command Properties + +Commands and execute methods may have additional properties specified by their driver/plugin: + +- They may be marked as deprecated, in which case they are shown with a yellow-tinted background. + Check the driver/plugin documentation for more details on these commands. -A command may also have special conditions (e.g. its functionality is only supported in simulators). -This additional information, if present, is shown as follows: + ![Deprecated Command](./assets/images/commands/deprecated-command.png) -- For a command without parameters - in a tooltip visible by hovering over the button -- For a command with parameters - inside the parameters popup +- They may include additional information, which is shown upon mouseover: + + ![Additional Command Information](./assets/images/commands/command-info.png) + +- They may support additional parameters, and clicking on them will open the parameters popup. + Parameters can be either required or optional. + + ![Command Parameters](./assets/images/commands/command-params.png) ## Command Result -Upon execution, certain commands may trigger a refresh for the application screenshot and source. -However, any command will always trigger a new popup upon finishing execution, which shows the -command result. +Upon finishing execution, any command or execute method will always trigger a new popup with the +command result: ![Command Result](./assets/images/commands/command-result.png) -The popup also has several buttons for interacting with the result. +The popup also has several buttons for interacting with the result: ### Toggle Table Formatting @@ -49,8 +71,8 @@ Formats the result as a table, which provides sorting and filtering capabilities shown for array or object values. Clicking on the contents of any table cell allows copying them to the clipboard. -This button is enabled only if the command result is an array or object. While the button is toggled -on, the copy result button is disabled. +This button is enabled only if the command/method result is an array or object. While this button +is toggled on, the Copy Result button is disabled. ### Copy Result diff --git a/test/unit/utils-commands-tab.spec.js b/test/unit/utils-commands-tab.spec.js new file mode 100644 index 0000000000..5e45819be9 --- /dev/null +++ b/test/unit/utils-commands-tab.spec.js @@ -0,0 +1,429 @@ +import {describe, expect, it} from 'vitest'; + +import { + adjustParamValueType, + deepFilterEmpty, + extractParamsFromCommandPath, + filterMethodPairs, + transformCommandsMap, + transformExecMethodsMap, +} from '../../app/common/renderer/utils/commands-tab.js'; + +describe('utils/commands-tab.js', function () { + describe('#adjustParamValueType', function () { + const commonCases = [ + ['test', 'test'], + ['109', 109], + ['true', true], + ['false', false], + ['null', null], + ['[1, 2, 3]', [1, 2, 3]], + ['{"a":1}', {a: 1}], + ]; + commonCases.forEach(([input, expected]) => { + it(`should detect the correct type for common input "${input}"`, function () { + expect(adjustParamValueType(input)).toEqual(expected); + }); + }); + const edgeCases = [ + // Leading zero numeric string should be left as string + ['01', '01'], + // Empty string -> null + ['', null], + // Invalid arrays/JSON stay as string + ['[invalid,]', '[invalid,]'], + ['{invalid:}', '{invalid:}'], + ]; + edgeCases.forEach(([input, expected]) => { + it(`should detect the correct type for edge case input "${input}"`, function () { + expect(adjustParamValueType(input)).toEqual(expected); + }); + }); + }); + + describe('#filterMethodPairs', function () { + it('should return the same array if the search query is empty', function () { + const methodPairs = [['method', {command: 'test'}]]; + expect(filterMethodPairs(methodPairs, '')).toEqual([['method', {command: 'test'}]]); + }); + it('should filter the array to methods that match the search query', function () { + const methodPairs = [ + ['method1', {command: 'test'}], + ['method2', {command: 'rest'}], + ]; + const queriesToResults = [ + ['hod2', [['method2', {command: 'rest'}]]], + ['method1', [['method1', {command: 'test'}]]], + ['somethingelse', []], + ]; + queriesToResults.forEach(([input, expected]) => { + expect(filterMethodPairs(methodPairs, input)).toEqual(expected); + }); + }); + }); + + describe('#deepFilterEmpty', function () { + it('should not affect primitive values', function () { + const commonPrimitives = [false, 10, 'test']; + commonPrimitives.forEach((input) => expect(deepFilterEmpty(input)).toEqual(input)); + }); + it('should return empty object or array if all its leaf values are empty', function () { + const emptyLeafCases = [ + [[[], {}], []], + [{rest: {}, best: []}, {}], + [[[[[[]]]]], []], + [{first: {second: {third: {fourth: {fifth: {}}}}}}, {}], + ]; + emptyLeafCases.forEach(([input, expected]) => { + expect(deepFilterEmpty(input)).toEqual(expected); + }); + }); + it('should apply correct filtering for arrays', function () { + const testArray = [false, [], 10, {}, 'test', [20, true, []]]; + expect(deepFilterEmpty(testArray)).toEqual([false, 10, 'test', [20, true]]); + }); + it('should apply correct filtering for objects like ListExtensionsResponse', function () { + const getExtsResponse = { + rest: { + driver: { + 'mobile: first': {}, + 'mobile: second': { + command: 'second', + deprecated: false, + info: 'For testing only', + params: [], + }, + 'mobile: third': {command: '', params: [{}, {name: 'thirdParam', required: true}]}, + }, + plugins: {}, + }, + }; + expect(deepFilterEmpty(getExtsResponse)).toEqual({ + rest: { + driver: { + 'mobile: second': {command: 'second', deprecated: false, info: 'For testing only'}, + 'mobile: third': {command: '', params: [{name: 'thirdParam', required: true}]}, + }, + }, + }); + }); + }); + + describe('#extractParamsFromCommandPath', function () { + it('should return an empty array for paths without parameters', function () { + expect(extractParamsFromCommandPath('/session')).toEqual([]); + }); + it('should return an empty array for paths with only sessionId', function () { + expect(extractParamsFromCommandPath('/session/:sessionId')).toEqual([]); + }); + it('should extract all other parameters', function () { + expect(extractParamsFromCommandPath('/session/:sessionId/element/:elementId')).toEqual([ + 'elementId', + ]); + expect( + extractParamsFromCommandPath( + '/session/:sessionId/webauthn/authenticator/:authenticatorId/credentials/:credentialId', + ), + ).toEqual(['authenticatorId', 'credentialId']); + }); + }); + + describe('#transformCommandsMap', function () { + it('should return empty array if no REST command details are found', function () { + const structsWithoutRestCmdDetails = [ + {}, + {notrest: {}}, + {rest: {}}, + {rest: {base: {}}}, + {rest: {base: {'/status': {}}}}, + {rest: {base: {'/status': {GET: {}}}}}, + ]; + structsWithoutRestCmdDetails.forEach((input) => + expect(transformCommandsMap(input)).toEqual([]), + ); + }); + it('should transform a basic response whose command names match those in WDIO', function () { + const getCmdsResponse = { + rest: { + base: { + '/session/:sessionId/forward': {GET: {command: 'forward'}}, + '/session/:sessionId/appium/device/pull_file': { + POST: {command: 'pullFile', params: [{name: 'path', required: true}]}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['forward', {command: 'forward'}], + ['pullFile', {command: 'pullFile', params: [{name: 'path', required: true}]}], + ]); + }); + it('should translate supported command names if they differ between Appium and WDIO', function () { + const getCmdsResponse = { + rest: { + base: { + '/status': {GET: {command: 'getStatus'}}, + '/session/:sessionId/frame': { + POST: {command: 'setFrame', params: [{name: 'id', required: true}]}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['status', {command: 'getStatus'}], + ['switchToFrame', {command: 'setFrame', params: [{name: 'id', required: true}]}], + ]); + }); + it('should filter out commands with missing or unsupported command names', function () { + const getCmdsResponse = { + rest: { + base: { + '/status': {GET: {notcommand: 'getStatus'}}, + '/session/:sessionId/appium/commands': {GET: {command: 'notListCommands'}}, + '/session': {POST: {command: 'createSession'}}, + '/session/:sessionId/appium/extensions': {GET: {command: 'listExtensions'}}, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['getAppiumExtensions', {command: 'listExtensions'}], + ]); + }); + it('should filter out empty command parameters', function () { + const getCmdsResponse = { + rest: { + base: { + '/status': {GET: {command: 'getStatus', params: []}}, + '/session/:sessionId/appium/device/pull_file': { + POST: {command: 'pullFile', params: [{}, {name: 'path', required: true}]}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['status', {command: 'getStatus'}], + ['pullFile', {command: 'pullFile', params: [{name: 'path', required: true}]}], + ]); + }); + it('should extract parameters from the command path', function () { + const getCmdsResponse = { + rest: { + base: { + '/session/:sessionId/element/:elementId/value': { + POST: {command: 'setValue', params: [{name: 'text', required: true}]}, + }, + '/session/:sessionId/element/:elementId/property/:name': { + GET: {command: 'getCssProperty'}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + [ + 'elementSendKeys', + { + command: 'setValue', + params: [ + {name: 'elementId', required: true}, + {name: 'text', required: true}, + ], + }, + ], + [ + 'getElementCSSValue', + { + command: 'getCssProperty', + params: [ + {name: 'elementId', required: true}, + {name: 'name', required: true}, + ], + }, + ], + ]); + }); + it('should merge commands from all scopes into one', function () { + const getCmdsResponse = { + rest: { + base: { + '/status': {GET: {command: 'getStatus'}}, + }, + driver: { + '/session/:sessionId/appium/commands': {GET: {command: 'listCommands'}}, + }, + plugins: { + plugin1: { + '/session/:sessionId/appium/extensions': {GET: {command: 'listExtensions'}}, + }, + plugin2: { + '/session/:sessionId/forward': {POST: {command: 'forward'}}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['status', {command: 'getStatus'}], + ['getAppiumCommands', {command: 'listCommands'}], + ['getAppiumExtensions', {command: 'listExtensions'}], + ['forward', {command: 'forward'}], + ]); + }); + it('should prefer driver commands over base commands in case of overrides', function () { + const getCmdsResponse = { + rest: { + base: { + '/session/:sessionId/forward': {POST: {command: 'forward', info: 'from-base'}}, + }, + driver: { + '/session/:sessionId/forward': {POST: {command: 'forward', info: 'from-driver'}}, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['forward', {command: 'forward', info: 'from-driver'}], + ]); + }); + it('should prefer plugin commands over driver commands in case of overrides', function () { + const getCmdsResponse = { + rest: { + driver: { + '/session/:sessionId/forward': {POST: {command: 'forward', info: 'from-driver'}}, + }, + plugins: { + pluginA: { + '/session/:sessionId/forward': {POST: {command: 'forward', info: 'from-plugin'}}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['forward', {command: 'forward', info: 'from-plugin'}], + ]); + }); + it('should not apply intra-source overrides using deprecated methods', function () { + const getCmdsResponse = { + rest: { + base: { + '/session/:sessionId/forward_1': { + POST: {command: 'forward', info: 'forward-supported'}, + }, + '/session/:sessionId/forward_2': { + POST: {command: 'forward', deprecated: true, info: 'forward-deprecated'}, + }, + }, + }, + }; + expect(transformCommandsMap(getCmdsResponse)).toEqual([ + ['forward', {command: 'forward', info: 'forward-supported'}], + ]); + }); + }); + + describe('#transformExecMethodsMap', function () { + it('should return empty response if no REST method details are found', function () { + const structsWithoutRestCmdDetails = [ + {}, + {notrest: {}}, + {rest: {}}, + {rest: {driver: {}}}, + {rest: {driver: {'mobile: shell': {}}}}, + ]; + structsWithoutRestCmdDetails.forEach((input) => + expect(transformCommandsMap(input)).toEqual([]), + ); + }); + it('should transform a basic response', function () { + const getExecMethodsResponse = { + rest: { + driver: { + 'mobile: startLogsBroadcast': {command: 'mobileStartLogsBroadcast'}, + 'mobile: performEditorAction': { + command: 'mobilePerformEditorAction', + params: [{name: 'action', required: true}], + }, + }, + }, + }; + expect(transformExecMethodsMap(getExecMethodsResponse)).toEqual([ + ['mobile: startLogsBroadcast', {command: 'mobileStartLogsBroadcast'}], + [ + 'mobile: performEditorAction', + { + command: 'mobilePerformEditorAction', + params: [{name: 'action', required: true}], + }, + ], + ]); + }); + it('should filter out empty method parameters', function () { + const getExecMethodsResponse = { + rest: { + driver: { + 'mobile: startLogsBroadcast': {command: 'mobileStartLogsBroadcast', params: []}, + 'mobile: performEditorAction': { + command: 'mobilePerformEditorAction', + params: [{}, {name: 'action', required: true}], + }, + }, + }, + }; + expect(transformExecMethodsMap(getExecMethodsResponse)).toEqual([ + ['mobile: startLogsBroadcast', {command: 'mobileStartLogsBroadcast'}], + [ + 'mobile: performEditorAction', + { + command: 'mobilePerformEditorAction', + params: [{name: 'action', required: true}], + }, + ], + ]); + }); + it('should merge methods from all scopes into one', function () { + const getExecMethodsResponse = { + rest: { + driver: { + 'mobile: startLogsBroadcast': {command: 'mobileStartLogsBroadcast'}, + }, + plugins: { + plugin1: { + 'mobile: getNotifications': {command: 'mobileGetNotifications'}, + }, + plugin2: { + 'mobile: performEditorAction': { + command: 'mobilePerformEditorAction', + params: [{name: 'action', required: true}], + }, + }, + }, + }, + }; + expect(transformExecMethodsMap(getExecMethodsResponse)).toEqual([ + ['mobile: startLogsBroadcast', {command: 'mobileStartLogsBroadcast'}], + ['mobile: getNotifications', {command: 'mobileGetNotifications'}], + [ + 'mobile: performEditorAction', + { + command: 'mobilePerformEditorAction', + params: [{name: 'action', required: true}], + }, + ], + ]); + }); + it('should prefer plugin commands over driver commands in case of overrides', function () { + const getExecMethodsResponse = { + rest: { + driver: { + 'mobile: doThing': {command: 'doThing', info: 'from-driver'}, + }, + plugins: { + somePlugin: { + 'mobile: doThing': {command: 'doThing', info: 'from-plugin'}, + }, + }, + }, + }; + expect(transformExecMethodsMap(getExecMethodsResponse)).toEqual([ + ['mobile: doThing', {command: 'doThing', info: 'from-plugin'}], + ]); + }); + }); +});