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)}
+ >
+ } />
+
+
+ ),
+ },
+ ];
+
+ const handleAddVariable = () => {
+ if (!newVar.key || !newVar.value) {
+ return;
+ }
+
+ // Check for duplicate keys is no longer needed since Map handles this automatically
+ addVariable(newVar.key, newVar.value);
+ setNewVar({key: '', value: ''});
+ };
+
+ return (
+
+ {t('Environment Variables')}
+
+ }
+ className={SessionStyles['interaction-tab-card']}
+ >
+
+
+
+ setNewVar({...newVar, key: e.target.value})}
+ />
+ setNewVar({...newVar, value: e.target.value})}
+ />
+ }
+ onClick={handleAddVariable}
+ disabled={!newVar.key || !newVar.value}
+ >
+ {t('Add')}
+
+
+
+
+
+
+ );
+};
+
+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);
+ });
+});