diff --git a/app/common/public/locales/en/translation.json b/app/common/public/locales/en/translation.json index 6ad2110bfb..a053978b93 100644 --- a/app/common/public/locales/en/translation.json +++ b/app/common/public/locales/en/translation.json @@ -295,5 +295,8 @@ "gestureNameCannotBeEmptyError": "Gesture name cannot be empty", "gestureInvalidJsonError": "Invalid JSON file. Unable to parse the file content", "gestureImportedFrom": "Gesture imported from '{{fileName}}'", - "invalidSessionFile": "Invalid session file" + "invalidSessionFile": "Invalid session file", + "Environment Variables": "Environment Variables", + "Variable Name": "Variable Name", + "Delete Variable": "Delete Variable" } diff --git a/app/common/renderer/actions/Inspector.js b/app/common/renderer/actions/Inspector.js index 64c7cff204..8c83275069 100644 --- a/app/common/renderer/actions/Inspector.js +++ b/app/common/renderer/actions/Inspector.js @@ -1,6 +1,10 @@ import _ from 'lodash'; -import {SAVED_FRAMEWORK, SET_SAVED_GESTURES} from '../../shared/setting-defs'; +import { + ENVIRONMENT_VARIABLES, + SAVED_FRAMEWORK, + SET_SAVED_GESTURES, +} from '../../shared/setting-defs'; import {POINTER_TYPES} from '../constants/gestures'; import {APP_MODE, NATIVE_APP} from '../constants/session-inspector'; import i18n from '../i18next'; @@ -116,6 +120,7 @@ export const TOGGLE_SHOW_ATTRIBUTES = 'TOGGLE_SHOW_ATTRIBUTES'; export const TOGGLE_REFRESHING_STATE = 'TOGGLE_REFRESHING_STATE'; export const SET_GESTURE_UPLOAD_ERROR = 'SET_GESTURE_UPLOAD_ERROR'; +export const SET_ENVIRONMENT_VARIABLES = 'SET_ENVIRONMENT_VARIABLES'; const KEEP_ALIVE_PING_INTERVAL = 20 * 1000; const NO_NEW_COMMAND_LIMIT = 24 * 60 * 60 * 1000; // Set timeout to 24 hours @@ -357,6 +362,9 @@ export function quitSession(reason, killedByUser = true) { const applyAction = applyClientMethod({methodName: 'quit'}); await applyAction(dispatch, getState); dispatch({type: QUIT_SESSION_DONE}); + // Reload environment variables from persistent settings after session ends + const loadEnvAction = loadEnvironmentVariables(); + await loadEnvAction(dispatch); if (!killedByUser) { showError(new Error(reason || i18n.t('Session has been terminated')), {secs: 0}); } @@ -919,6 +927,13 @@ export function setGestureUploadErrors(errors) { }; } +export function loadEnvironmentVariables() { + return async (dispatch) => { + const envVars = (await getSetting(ENVIRONMENT_VARIABLES)) || []; + dispatch({type: SET_ENVIRONMENT_VARIABLES, envVars}); + }; +} + export function uploadGesturesFromFile(fileList) { return async (dispatch) => { const gestures = await readTextFromUploadedFiles(fileList); diff --git a/app/common/renderer/actions/Session.js b/app/common/renderer/actions/Session.js index fa7594b264..129590e7d2 100644 --- a/app/common/renderer/actions/Session.js +++ b/app/common/renderer/actions/Session.js @@ -15,11 +15,14 @@ import {APP_MODE} from '../constants/session-inspector'; import i18n from '../i18next'; import {getSetting, ipcRenderer, setSetting} from '../polyfills'; import {fetchSessionInformation, formatSeleniumGridSessions} from '../utils/attaching-to-session'; +import {interpolateEnvironmentVariables} from '../utils/env-utils'; import {downloadFile, parseSessionFileContents} from '../utils/file-handling'; import {log} from '../utils/logger'; import {addVendorPrefixes} from '../utils/other'; import {quitSession, setSessionDetails} from './Inspector'; +export const ENVIRONMENT_VARIABLES = 'ENVIRONMENT_VARIABLES'; + export const NEW_SESSION_REQUESTED = 'NEW_SESSION_REQUESTED'; export const NEW_SESSION_LOADING = 'NEW_SESSION_LOADING'; export const NEW_SESSION_DONE = 'NEW_SESSION_DONE'; @@ -68,6 +71,10 @@ export const SET_CAPABILITY_NAME_ERROR = 'SET_CAPABILITY_NAME_ERROR'; export const SET_STATE_FROM_URL = 'SET_STATE_FROM_URL'; export const SET_STATE_FROM_FILE = 'SET_STATE_FROM_FILE'; +export const SET_ENVIRONMENT_VARIABLES = 'SET_ENVIRONMENT_VARIABLES'; +export const ADD_ENVIRONMENT_VARIABLE = 'ADD_ENVIRONMENT_VARIABLE'; +export const DELETE_ENVIRONMENT_VARIABLE = 'DELETE_ENVIRONMENT_VARIABLE'; + const APPIUM_SESSION_FILE_VERSION = '1.0'; const CAPS_NEW_COMMAND = 'appium:newCommandTimeout'; @@ -229,7 +236,25 @@ export function newSession(caps, attachSessId = null) { dispatch({type: NEW_SESSION_REQUESTED, caps}); + // Get environment variables from state + const environmentVariables = session.environmentVariables || []; + + // Get capabilities and interpolate environment variables let desiredCapabilities = caps ? getCapsObject(caps) : {}; + + // Modify this section to handle W3C capabilities format + if (desiredCapabilities.alwaysMatch) { + desiredCapabilities.alwaysMatch = interpolateEnvironmentVariables( + desiredCapabilities.alwaysMatch, + environmentVariables, + ); + } else { + desiredCapabilities = interpolateEnvironmentVariables( + desiredCapabilities, + environmentVariables, + ); + } + let host, port, username, accessKey, https, path, token; desiredCapabilities = addCustomCaps(desiredCapabilities); @@ -1190,3 +1215,35 @@ export function initFromQueryString(loadNewSession) { } }; } + +export function setEnvironmentVariables(envVars) { + return async (dispatch) => { + await setSetting(ENVIRONMENT_VARIABLES, envVars); + dispatch({type: SET_ENVIRONMENT_VARIABLES, envVars}); + }; +} + +export function loadEnvironmentVariables() { + return async (dispatch) => { + const envVars = (await getSetting(ENVIRONMENT_VARIABLES)) || []; + dispatch({type: SET_ENVIRONMENT_VARIABLES, envVars}); + }; +} + +export function addEnvironmentVariable(key, value) { + return async (dispatch, getState) => { + const currentEnvVars = getState().inspector.environmentVariables || []; + const newEnvVars = [...currentEnvVars, {key, value}]; + await setSetting(ENVIRONMENT_VARIABLES, newEnvVars); + dispatch({type: ADD_ENVIRONMENT_VARIABLE, key, value}); + }; +} + +export function deleteEnvironmentVariable(key) { + return async (dispatch, getState) => { + const currentEnvVars = getState().inspector.environmentVariables || []; + const newEnvVars = currentEnvVars.filter((v) => v.key !== key); + await setSetting(ENVIRONMENT_VARIABLES, newEnvVars); + dispatch({type: DELETE_ENVIRONMENT_VARIABLE, key}); + }; +} diff --git a/app/common/renderer/components/Inspector/Inspector.jsx b/app/common/renderer/components/Inspector/Inspector.jsx index cea311001c..b803147ed3 100644 --- a/app/common/renderer/components/Inspector/Inspector.jsx +++ b/app/common/renderer/components/Inspector/Inspector.jsx @@ -30,7 +30,7 @@ import {downloadFile} from '../../utils/file-handling'; import Commands from './Commands.jsx'; import GestureEditor from './GestureEditor.jsx'; import HeaderButtons from './HeaderButtons.jsx'; -import InspectorStyles from './Inspector.module.css'; +import styles from './Inspector.module.css'; import Recorder from './Recorder.jsx'; import SavedGestures from './SavedGestures.jsx'; import Screenshot from './Screenshot.jsx'; @@ -221,7 +221,7 @@ const Inspector = (props) => { }, [showKeepAlivePrompt]); const screenShotControls = ( -
+
{ ); const main = ( -
+
{screenShotControls} @@ -274,11 +274,11 @@ const Inspector = (props) => { {screenshotError && t('couldNotObtainScreenshot', {screenshotError})} {!showScreenshot && ( -
+
)}
-
+
{
{ {t('selectedElement')} } - className={InspectorStyles['selected-element-card']} + className={styles['selected-element-card']} > {selectedElement.path && } {!selectedElement.path && {t('selectElementInSource')}} @@ -359,7 +359,7 @@ const Inspector = (props) => { {t('Execute Commands')} } - className={InspectorStyles['interaction-tab-card']} + className={styles['interaction-tab-card']} > @@ -376,7 +376,7 @@ const Inspector = (props) => { {t('Gesture Builder')} } - className={InspectorStyles['interaction-tab-card']} + className={styles['interaction-tab-card']} > @@ -387,7 +387,7 @@ const Inspector = (props) => { {t('Saved Gestures')} } - className={InspectorStyles['interaction-tab-card']} + className={styles['interaction-tab-card']} > @@ -410,7 +410,7 @@ const Inspector = (props) => { {t('Session Information')} } - className={InspectorStyles['interaction-tab-card']} + className={styles['interaction-tab-card']} > @@ -423,7 +423,7 @@ const Inspector = (props) => { ); return ( -
+
{main} { + const [newVar, setNewVar] = useState({key: '', value: ''}); + + const tableData = Array.from(envVars, ([key, value]) => ({key, value})); + + const columns = [ + { + title: t('Variable Name'), + dataIndex: 'key', + key: 'key', + width: '40%', + }, + { + title: t('Value'), + dataIndex: 'value', + key: 'value', + width: '40%', + render: (text) => , + }, + { + title: t('Actions'), + key: 'action', + width: '20%', + render: (_, record) => ( + + deleteVariable(record.key)} + > + + +
+ + + + ); +}; + +export default EnvironmentVariables; diff --git a/app/common/renderer/components/Session/Session.jsx b/app/common/renderer/components/Session/Session.jsx index 4005cb12b7..9915812b97 100644 --- a/app/common/renderer/components/Session/Session.jsx +++ b/app/common/renderer/components/Session/Session.jsx @@ -18,6 +18,7 @@ import AttachToSession from './AttachToSession.jsx'; import CapabilityEditor from './CapabilityEditor.jsx'; import CloudProviders from './CloudProviders.jsx'; import CloudProviderSelector from './CloudProviderSelector.jsx'; +import EnvironmentVariables from './EnvironmentVariables.jsx'; import SavedSessions from './SavedSessions.jsx'; import ServerTabCustom from './ServerTabCustom.jsx'; import SessionStyles from './Session.module.css'; @@ -41,6 +42,9 @@ const Session = (props) => { newSessionLoading, attachSessId, t, + environmentVariables, + addEnvironmentVariable, + deleteEnvironmentVariable, } = props; const navigate = useNavigate(); @@ -73,6 +77,7 @@ const Session = (props) => { bindWindowClose, initFromQueryString, saveSessionAsFile, + loadEnvironmentVariables, } = props; (async () => { try { @@ -82,6 +87,7 @@ const Session = (props) => { await setVisibleProviders(); await setSavedServerParams(); await setLocalServerParams(); + await loadEnvironmentVariables(); initFromQueryString(loadNewSession); await initFromSessionFile(); ipcRenderer.on('sessionfile:apply', (_, sessionFileString) => @@ -157,6 +163,19 @@ const Session = (props) => { className: SessionStyles.scrollingTab, children: , }, + { + label: t('Environment Variables'), + key: SESSION_BUILDER_TABS.ENV_VARS, + className: SessionStyles.scrollingTab, + children: ( + + ), + }, ]} /> diff --git a/app/common/renderer/components/Session/Session.module.css b/app/common/renderer/components/Session/Session.module.css index 814f34d097..aaa9317f61 100644 --- a/app/common/renderer/components/Session/Session.module.css +++ b/app/common/renderer/components/Session/Session.module.css @@ -316,3 +316,19 @@ color: #ff4d4f; font-size: 12px; } + +.interaction-tab-card :global(.ant-card-body) { + overflow: scroll; + flex: 2; + height: 100%; +} + +.container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.addForm { + padding: 8px 0; +} diff --git a/app/common/renderer/constants/session-builder.js b/app/common/renderer/constants/session-builder.js index 315d354bea..ee77f64bf7 100644 --- a/app/common/renderer/constants/session-builder.js +++ b/app/common/renderer/constants/session-builder.js @@ -2,6 +2,7 @@ export const SESSION_BUILDER_TABS = { CAPS_BUILDER: 'new', SAVED_CAPS: 'saved', ATTACH_TO_SESSION: 'attach', + ENV_VARS: 'envVars', }; export const SERVER_TYPES = { diff --git a/app/common/renderer/reducers/Inspector.js b/app/common/renderer/reducers/Inspector.js index cf2b7ec3d7..e360bb5192 100644 --- a/app/common/renderer/reducers/Inspector.js +++ b/app/common/renderer/reducers/Inspector.js @@ -85,6 +85,11 @@ import { UNSELECT_HOVERED_ELEMENT, UNSELECT_TICK_ELEMENT, } from '../actions/Inspector'; +import { + ADD_ENVIRONMENT_VARIABLE, + DELETE_ENVIRONMENT_VARIABLE, + SET_ENVIRONMENT_VARIABLES, +} from '../actions/Session'; import {SCREENSHOT_INTERACTION_MODE} from '../constants/screenshot'; import {APP_MODE, INSPECTOR_TABS, NATIVE_APP} from '../constants/session-inspector'; @@ -94,6 +99,7 @@ const INITIAL_STATE = { savedGestures: [], driver: null, automationName: null, + environmentVariables: [], keepAliveInterval: null, showKeepAlivePrompt: false, userWaitTimeout: null, @@ -659,6 +665,29 @@ export default function inspector(state = INITIAL_STATE, action) { case SET_GESTURE_UPLOAD_ERROR: return {...state, gestureUploadErrors: action.errors}; + case SET_ENVIRONMENT_VARIABLES: + return { + ...state, + environmentVariables: action.envVars, + }; + + case ADD_ENVIRONMENT_VARIABLE: + return { + ...state, + environmentVariables: [ + ...(state.environmentVariables || []), + {key: action.key, value: action.value}, + ], + }; + + case DELETE_ENVIRONMENT_VARIABLE: + return { + ...state, + environmentVariables: (state.environmentVariables || []).filter( + (envVar) => envVar.key !== action.key, + ), + }; + default: return {...state}; } diff --git a/app/common/renderer/reducers/Session.js b/app/common/renderer/reducers/Session.js index eb174c3f87..3c37a60d58 100644 --- a/app/common/renderer/reducers/Session.js +++ b/app/common/renderer/reducers/Session.js @@ -4,7 +4,9 @@ import { ABORT_DESIRED_CAPS_EDITOR, ABORT_DESIRED_CAPS_NAME_EDITOR, ADD_CAPABILITY, + ADD_ENVIRONMENT_VARIABLE, CHANGE_SERVER_TYPE, + DELETE_ENVIRONMENT_VARIABLE, DELETE_SAVED_SESSION_DONE, DELETE_SAVED_SESSION_REQUESTED, ENABLE_DESIRED_CAPS_EDITOR, @@ -30,6 +32,7 @@ import { SET_CAPABILITY_PARAM, SET_CAPS_AND_SERVER, SET_DESIRED_CAPS_NAME, + SET_ENVIRONMENT_VARIABLES, SET_PROVIDERS, SET_RAW_DESIRED_CAPS, SET_SAVE_AS_TEXT, @@ -75,12 +78,34 @@ const INITIAL_STATE = { isValidatingCapsJson: false, isAddingCloudProvider: false, addVendorPrefixes: true, + environmentVariables: [], }; let nextState; export default function session(state = INITIAL_STATE, action) { switch (action.type) { + case SET_ENVIRONMENT_VARIABLES: + return { + ...state, + environmentVariables: new Map(action.envVars.map(({key, value}) => [key, value])), + }; + + case ADD_ENVIRONMENT_VARIABLE: + return { + ...state, + environmentVariables: new Map(state.environmentVariables).set(action.key, action.value), + }; + + case DELETE_ENVIRONMENT_VARIABLE: { + const newEnvVars = new Map(state.environmentVariables); + newEnvVars.delete(action.key); + return { + ...state, + environmentVariables: newEnvVars, + }; + } + case NEW_SESSION_REQUESTED: return { ...state, diff --git a/app/common/renderer/utils/env-utils.js b/app/common/renderer/utils/env-utils.js new file mode 100644 index 0000000000..c2c7555edb --- /dev/null +++ b/app/common/renderer/utils/env-utils.js @@ -0,0 +1,41 @@ +/** + * Recursively interpolate environment variables in an object + * @param {*} obj - The object to interpolate + * @param {Map} envVars - Map of environment variables + * @returns {*} - The interpolated object + */ +export function interpolateEnvironmentVariables(obj, envVars) { + // Handle primitive types + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === 'number' || typeof obj === 'boolean') { + return obj; + } + + // Handle strings + if (typeof obj === 'string') { + let result = obj; + for (const [key, value] of envVars) { + result = result.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), value); + } + return result; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map((item) => interpolateEnvironmentVariables(item, envVars)); + } + + // Handle objects + if (typeof obj === 'object') { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = interpolateEnvironmentVariables(value, envVars); + } + return result; + } + + return obj; +} diff --git a/app/common/shared/setting-defs.js b/app/common/shared/setting-defs.js index e9ee56879b..925ca8ad7c 100644 --- a/app/common/shared/setting-defs.js +++ b/app/common/shared/setting-defs.js @@ -10,6 +10,7 @@ export const SESSION_SERVER_PARAMS = 'SESSION_SERVER_PARAMS'; export const SESSION_SERVER_TYPE = 'SESSION_SERVER_TYPE'; export const SAVED_FRAMEWORK = 'SAVED_FRAMEWORK'; export const VISIBLE_PROVIDERS = 'VISIBLE_PROVIDERS'; +export const ENVIRONMENT_VARIABLES = 'ENVIRONMENT_VARIABLES'; export const DEFAULT_SETTINGS = { [PREFERRED_LANGUAGE]: fallbackLng, @@ -20,4 +21,5 @@ export const DEFAULT_SETTINGS = { [SESSION_SERVER_TYPE]: null, [SAVED_FRAMEWORK]: 'java', [VISIBLE_PROVIDERS]: null, + [ENVIRONMENT_VARIABLES]: [], }; diff --git a/test/unit/utils-env.spec.js b/test/unit/utils-env.spec.js new file mode 100644 index 0000000000..d38839e953 --- /dev/null +++ b/test/unit/utils-env.spec.js @@ -0,0 +1,79 @@ +import {describe, expect, it} from 'vitest'; + +import {interpolateEnvironmentVariables} from '../../app/common/renderer/utils/env-utils'; + +describe('interpolateEnvironmentVariables', function () { + const envVars = new Map([ + ['HOME', '/home/user'], + ['PATH', '/usr/bin'], + ['APP_ENV', 'test'], + ]); + + it('should interpolate environment variables in strings', function () { + const input = 'My home is ${HOME} and path is ${PATH}'; + const expected = 'My home is /home/user and path is /usr/bin'; + expect(interpolateEnvironmentVariables(input, envVars)).to.equal(expected); + }); + + it('should leave unmatched variables unchanged', function () { + const input = 'Value: ${UNKNOWN_VAR}'; + expect(interpolateEnvironmentVariables(input, envVars)).to.equal('Value: ${UNKNOWN_VAR}'); + }); + + it('should interpolate variables in arrays', function () { + const input = ['${HOME}/files', '${PATH}/python']; + const expected = ['/home/user/files', '/usr/bin/python']; + expect(interpolateEnvironmentVariables(input, envVars)).to.deep.equal(expected); + }); + + it('should interpolate variables in nested objects', function () { + const input = { + home: '${HOME}', + paths: { + bin: '${PATH}', + config: '${HOME}/.config', + }, + env: '${APP_ENV}', + }; + const expected = { + home: '/home/user', + paths: { + bin: '/usr/bin', + config: '/home/user/.config', + }, + env: 'test', + }; + expect(interpolateEnvironmentVariables(input, envVars)).to.deep.equal(expected); + }); + + it('should handle null values', function () { + expect(interpolateEnvironmentVariables(null, envVars)).to.equal(null); + }); + + it('should handle undefined values', function () { + expect(interpolateEnvironmentVariables(undefined, envVars)).to.equal(undefined); + }); + + it('should handle number values', function () { + expect(interpolateEnvironmentVariables(42, envVars)).to.equal(42); + }); + + it('should handle boolean values', function () { + expect(interpolateEnvironmentVariables(true, envVars)).to.equal(true); + expect(interpolateEnvironmentVariables(false, envVars)).to.equal(false); + }); + + it('should handle empty arrays', function () { + expect(interpolateEnvironmentVariables([], envVars)).to.deep.equal([]); + }); + + it('should handle empty objects', function () { + expect(interpolateEnvironmentVariables({}, envVars)).to.deep.equal({}); + }); + + it('should handle multiple variables in single string', function () { + const input = '${HOME}:${PATH}:${APP_ENV}'; + const expected = '/home/user:/usr/bin:test'; + expect(interpolateEnvironmentVariables(input, envVars)).to.equal(expected); + }); +});