diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
index c07dbf671..7882d48a8 100644
--- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
+++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
@@ -445,18 +445,7 @@ const EnvironmentVariablesTable = ({
const otherCurrent = namedValues.filter((variable) => !belongsToActiveTab(variable));
const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable));
- // Compare against what's on disk: for an ephemeral overlay, that's
- // `persistedValue`, not the scripted value Redux is holding.
- const baselineForCompare = (v) => {
- const stripped = stripEnvVarUid(v);
- if (v?.ephemeral && v?.persistedValue !== undefined) {
- stripped.value = v.persistedValue;
- }
- return stripped;
- };
- // Compare without UIDs; only the active tab's subset decides if there's anything to save.
- const hasChanges
- = JSON.stringify(activeCurrent.map(stripEnvVarUid)) !== JSON.stringify(activeSaved.map(baselineForCompare));
+ const hasChanges = JSON.stringify(activeCurrent.map(stripEnvVarUid)) !== JSON.stringify(activeSaved.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -759,11 +748,6 @@ const EnvironmentVariablesTable = ({
isSecret={variable.secret}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
- // Clear ephemeral metadata when user manually edits the value
- if (variable.ephemeral) {
- formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
- formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
- }
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
diff --git a/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js
index 94d036d6b..9a498f547 100644
--- a/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/ConfirmCloseEnvironment/index.js
@@ -33,15 +33,15 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
-
-
+
Cancel
-
+
Save
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index f22c6acaa..f9636b6f1 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -219,7 +219,7 @@ class MultiLineEditor extends Component {
*/
secretEye = (isSecret) => {
return isSecret === true ? (
-
this.toggleVisibleSecret()}>
+ this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
) : (
diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js
index ccd4b0d3d..52640659f 100644
--- a/packages/bruno-app/src/components/SingleLineEditor/index.js
+++ b/packages/bruno-app/src/components/SingleLineEditor/index.js
@@ -302,7 +302,7 @@ class SingleLineEditor extends Component {
*/
secretEye = (isSecret) => {
return isSecret === true ? (
- this.toggleVisibleSecret()}>
+ this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
) : (
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index b7d58871a..e14fc2caf 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -22,10 +22,11 @@ import {
runFolderEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent,
+ runtimeVariablesUpdateEvent,
streamDataReceived,
setDotEnvVariables
} from 'providers/ReduxStore/slices/collections';
-import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, persistActiveEnvironment, collectionVariablesUpdateEvent } from 'providers/ReduxStore/slices/collections/actions';
import {
workspaceOpenedEvent,
workspaceConfigUpdatedEvent,
@@ -35,7 +36,7 @@ import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'provide
import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
import { isElectron } from 'utils/common/platform';
-import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
+import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments, _clearScriptGlobalEnvBaseline } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState, collectionLoadedFromTree } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { loadNotifications } from 'providers/ReduxStore/slices/notifications';
@@ -201,23 +202,36 @@ const useIpcEvents = () => {
}
});
- const removeScriptEnvUpdateListener = ipcRenderer.on('main:script-environment-update', (val) => {
- dispatch(scriptEnvironmentUpdateEvent(val));
+ const removeRuntimeVariablesUpdateListener = ipcRenderer.on('main:runtime-variables-update', (val) => {
+ dispatch(runtimeVariablesUpdateEvent(val));
});
- const removePersistentEnvVariablesUpdateListener = ipcRenderer.on('main:persistent-env-variables-update', (val) => {
- dispatch(mergeAndPersistEnvironment(val));
+ const removeScriptEnvUpdateListener = ipcRenderer.on('main:script-environment-update', (val) => {
+ dispatch(scriptEnvironmentUpdateEvent(val));
+ if (val.collectionUid) {
+ dispatch(persistActiveEnvironment(val.collectionUid, val.requestUid));
+ }
});
const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
dispatch(globalEnvironmentsUpdateEvent(val));
});
+ const removeCollectionVariablesUpdateListener = ipcRenderer.on('main:collection-variables-update', (val) => {
+ dispatch(collectionVariablesUpdateEvent(val));
+ });
+
const removeCollectionRenamedListener = ipcRenderer.on('main:collection-renamed', (val) => {
dispatch(collectionRenamedEvent(val));
});
const removeRunFolderEventListener = ipcRenderer.on('main:run-folder-event', (val) => {
+ // Folder runs reuse the workspace baseline across N requests; clear it
+ // per request so request N's global-env update doesn't diff against
+ // request N-1's pre-flush snapshot.
+ if (val.type === 'testrun-started' || val.type === 'request-queued') {
+ dispatch(_clearScriptGlobalEnvBaseline());
+ }
dispatch(runFolderEvent(val));
});
@@ -393,7 +407,8 @@ const useIpcEvents = () => {
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();
- removePersistentEnvVariablesUpdateListener();
+ removeCollectionVariablesUpdateListener();
+ removeRuntimeVariablesUpdateListener();
removeSystemResourcesListener();
gitVersionListener();
removeLoadNotificationsListener();
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index 843ffa5ef..16b7f2c0a 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -60,6 +60,9 @@ import {
updateFolderVar,
addCollectionVar,
updateCollectionVar,
+ scriptUpdateCollectionVars,
+ setScriptCollVarBaseline,
+ _clearScriptCollectionBaselines,
addTransientDirectory,
addSaveTransientRequestModal,
updatePathParam,
@@ -85,12 +88,12 @@ import {
mergeHeaders
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
-import { buildPersistedEnvVariables } from 'utils/environments';
+import { applyScriptEnvVars, getScriptModifiedKeys } from 'utils/environments';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
import { resolveInheritedAuth } from 'utils/auth';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab } from './index';
-import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { saveGlobalEnvironment, _clearScriptGlobalEnvBaseline } from 'providers/ReduxStore/slices/global-environments';
import { getTabToFocusForCurrentWorkspace } from 'providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace';
import { clearPersistedScope } from 'hooks/usePersistedState/PersistedScopeProvider';
import {
@@ -501,6 +504,10 @@ export const wsConnectOnly = (item, collectionUid) => (dispatch, getState) => {
const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
+ // WS connect does not run user scripts — no baseline to clear.
+ // Wiping baselines here would also wipe collection._scriptRequestUid, opening
+ // a window where a late HTTP post-response could pass the stale-update gate.
+
connectWS(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables, { connectOnly: true })
.then(resolve)
.catch((err) => {
@@ -619,6 +626,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
return reject(error);
}
+ dispatch(clearScriptVariableBaselines(collectionUid));
+
await dispatch(
initRunRequestEvent({
requestUid,
@@ -2011,12 +2020,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const { ipcRenderer } = window;
- // strip "ephemeral" metadata
- const variablesToCopy = (baseEnv.variables || [])
- .filter((v) => !v.ephemeral)
- .map(({ ephemeral, ...rest }) => {
- return rest;
- });
+ const variablesToCopy = baseEnv.variables || [];
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variablesToCopy)
@@ -2100,8 +2104,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
- const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
- environment.variables = persisted;
+ environment.variables = variables;
const { ipcRenderer } = window;
const envForValidation = cloneDeep(environment);
@@ -2110,7 +2113,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envForValidation))
.then(() => {
- dispatch(_saveEnvironment({ variables: persisted, environmentUid, collectionUid }));
+ dispatch(_saveEnvironment({ variables, environmentUid, collectionUid }));
})
.then(resolve)
.catch(reject);
@@ -2247,9 +2250,7 @@ export const updateVariableInScope = (variableName, newValue, scopeInfo, collect
const updatedVariables = environment.variables.map((v) => {
if (v.uid === variable.uid) {
- // Clear ephemeral metadata when user manually edits the value
- const { ephemeral, persistedValue, ...rest } = v;
- return { ...rest, value: newValue };
+ return { ...v, value: newValue };
}
return v;
});
@@ -2363,9 +2364,7 @@ export const updateVariableInScope = (variableName, newValue, scopeInfo, collect
const updatedVariables = environment.variables.map((v) => {
if (v.uid === variable.uid) {
- // Clear ephemeral metadata when user manually edits the value
- const { ephemeral, persistedValue, ...rest } = v;
- return { ...rest, value: newValue };
+ return { ...v, value: newValue };
}
return v;
});
@@ -2404,81 +2403,100 @@ export const updateVariableInScope = (variableName, newValue, scopeInfo, collect
});
};
-export const mergeAndPersistEnvironment
- = ({ persistentEnvVariables, collectionUid }) =>
- (_dispatch, getState) => {
- return new Promise((resolve, reject) => {
- const state = getState();
- const collection = findCollectionByUid(state.collections.collections, collectionUid);
+// Clears all three script-driven baselines for a given collection:
+// collection-scope env baseline, collection-scope coll-vars baseline, and the
+// workspace-scope global-env baseline. Call at the start of any request kickoff
+// path so a stale baseline from a previous send can't leak into draft merging.
+export const clearScriptVariableBaselines = (collectionUid) => (dispatch) => {
+ dispatch(_clearScriptCollectionBaselines({ collectionUid }));
+ dispatch(_clearScriptGlobalEnvBaseline());
+};
- if (!collection) {
- return reject(new Error('Collection not found'));
- }
+export const persistActiveEnvironment = (collectionUid, requestUid) => (dispatch, getState) => {
+ const state = getState();
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ if (!collection) return;
- const environmentUid = collection.activeEnvironmentUid;
- if (!environmentUid) {
- return reject(new Error('No active environment found'));
- }
+ // Ignore stale updates from superseded requests so an in-flight pre/post
+ // from request N-1 can't trigger a disk write for request N.
+ if (requestUid && collection._scriptRequestUid && requestUid !== collection._scriptRequestUid) return;
- const collectionCopy = cloneDeep(collection);
- const environment = findEnvironmentInCollection(collectionCopy, environmentUid);
- if (!environment) {
- return reject(new Error('Environment not found'));
- }
+ const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
+ if (!environment) return;
- // Only proceed if there are persistent variables to save
- if (!persistentEnvVariables || Object.keys(persistentEnvVariables).length === 0) {
- return resolve();
- }
+ if (collection._scriptEnvBaseline) {
+ // Baseline exists — a draft was flushed earlier in this request cycle.
+ // Write to disk silently (without dispatching _saveEnvironment) to avoid
+ // racing with file-watcher callbacks.
+ const envCopy = { ...environment };
+ const { ipcRenderer } = window;
+ environmentSchema
+ .validate(envCopy)
+ .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, envCopy))
+ .catch((err) => console.error('Failed to persist environment during script execution:', err));
+ return;
+ }
- let existingVars = environment.variables || [];
+ dispatch(saveEnvironment(environment.variables, environment.uid, collectionUid))
+ .catch((err) => console.error('Failed to persist environment during script execution:', err));
+};
- let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => {
- const inferred = getDataTypeFromValue(value);
- return {
- uid: uuid(),
- name,
- value,
- type: 'text',
- enabled: true,
- secret: false,
- ...(inferred !== 'string' ? { dataType: inferred } : {})
- };
- });
+export const collectionVariablesUpdateEvent = ({ collectionVariables, collectionUid, requestUid }) => (dispatch, getState) => {
+ if (!collectionVariables || !collectionUid) return;
- const merged = existingVars.map((v) => {
- const found = normalizedNewVars.find((nv) => nv.name === v.name);
- if (!found) return v;
- const { dataType: _oldDataType, ...rest } = v;
- return { ...rest, value: found.value, ...(found.dataType ? { dataType: found.dataType } : {}) };
- });
- normalizedNewVars.forEach((nv) => {
- if (!merged.some((v) => v.name === nv.name)) {
- merged.push(nv);
- }
- });
+ const state = getState();
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ if (!collection) return;
- // Save all non-ephemeral vars and all variables that were previously persisted
- const persistedNames = new Set(Object.keys(persistentEnvVariables));
+ // Ignore stale updates from superseded requests.
+ if (requestUid && collection._scriptRequestUid && requestUid !== collection._scriptRequestUid) {
+ return;
+ }
- // Add all existing non-ephemeral variables to persistedNames so they are preserved
- existingVars.forEach((v) => {
- if (!v.ephemeral) {
- persistedNames.add(v.name);
- }
- });
+ const savedVars = get(collection, 'root.request.vars.req', []);
+ const draftVars = collection.draft?.root
+ ? get(collection, 'draft.root.request.vars.req', null)
+ : null;
- const environmentToSave = cloneDeep(environment);
- environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames });
+ let baseline = collection._scriptCollVarBaseline || null;
- const { ipcRenderer } = window;
- environmentSchema
- .validate(environmentToSave)
- .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave))
- .then(resolve)
- .catch(reject);
- });
- };
+ if (!baseline && draftVars) {
+ baseline = {};
+ savedVars.forEach((v) => {
+ if (v.enabled) baseline[v.name] = v.value;
+ });
+ dispatch(setScriptCollVarBaseline({ collectionUid, baseline }));
+ }
+
+ let vars = cloneDeep(draftVars || savedVars);
+
+ vars = applyScriptEnvVars(vars, collectionVariables, baseline);
+
+ // Re-infer dataType only for vars the script actually modified; baseline-mode no-op writes
+ // must NOT overwrite a user's in-progress draft type change.
+ const modifiedKeys = getScriptModifiedKeys(collectionVariables, baseline);
+ modifiedKeys.forEach((name) => {
+ const existing = vars.find((v) => v.name === name);
+ if (!existing) return;
+ const inferred = getDataTypeFromValue(collectionVariables[name]);
+ if (inferred === 'string') {
+ delete existing.dataType;
+ } else {
+ existing.dataType = inferred;
+ }
+ });
+
+ dispatch(scriptUpdateCollectionVars({ collectionUid, vars }));
+
+ // Save from root (not draft) so draft headers/auth/scripts are not persisted to disk.
+ const fresh = findCollectionByUid(getState().collections.collections, collectionUid);
+ if (fresh) {
+ const collectionRootToSave = transformCollectionRootToSave({ root: fresh.root });
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:save-collection-root', fresh.pathname, collectionRootToSave, fresh.brunoConfig)
+ .catch((err) => console.error('Failed to persist collection variables:', err));
+ }
+};
export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
@@ -2506,6 +2524,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
}
});
+ dispatch(clearScriptVariableBaselines(collectionUid));
+
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
resolve();
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-isolation.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-isolation.spec.js
new file mode 100644
index 000000000..815f94a61
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-isolation.spec.js
@@ -0,0 +1,465 @@
+jest.mock('nanoid', () => ({
+ customAlphabet: () => () => 'mock-uid'
+}));
+
+jest.mock('@usebruno/schema', () => ({
+ collectionSchema: { validate: () => Promise.resolve() },
+ environmentSchema: { validate: () => Promise.resolve() },
+ itemSchema: { validate: () => Promise.resolve() }
+}));
+
+import { configureStore } from '@reduxjs/toolkit';
+import collectionsReducer from 'providers/ReduxStore/slices/collections';
+import { collectionVariablesUpdateEvent } from 'providers/ReduxStore/slices/collections/actions';
+
+const COLLECTION_UID = 'col-1';
+
+const makeVar = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ enabled
+});
+
+const makeHeader = (name, value, enabled = true) => ({
+ uid: `hdr-${name}`,
+ name,
+ value,
+ description: '',
+ enabled
+});
+
+let invokedSaveArgs = [];
+
+beforeEach(() => {
+ invokedSaveArgs = [];
+ window.ipcRenderer = {
+ invoke: jest.fn((...args) => {
+ invokedSaveArgs.push(args);
+ return Promise.resolve(true);
+ })
+ };
+});
+
+const createStore = ({ rootVars = [], rootHeaders = [], rootAuth, draft } = {}) => {
+ const root = {
+ request: {
+ headers: rootHeaders,
+ auth: rootAuth || { mode: 'none' },
+ script: { req: '', res: '' },
+ vars: {
+ req: rootVars,
+ res: []
+ },
+ tests: ''
+ }
+ };
+
+ const preloadedState = {
+ collections: {
+ collections: [
+ {
+ uid: COLLECTION_UID,
+ pathname: '/coll',
+ root,
+ draft: draft !== undefined ? draft : null,
+ brunoConfig: { version: '1', name: 'test', type: 'collection' },
+ environments: [],
+ items: []
+ }
+ ],
+ collectionSortOrder: 'default',
+ activeWorkspaceUid: null
+ }
+ };
+
+ return configureStore({
+ reducer: {
+ collections: collectionsReducer
+ },
+ preloadedState
+ });
+};
+
+const getCollection = (store) => store.getState().collections.collections[0];
+const getRootVars = (store) => getCollection(store).root?.request?.vars?.req || [];
+const getRootHeaders = (store) => getCollection(store).root?.request?.headers || [];
+const getRootAuth = (store) => getCollection(store).root?.request?.auth;
+const getDraftHeaders = (store) => getCollection(store).draft?.root?.request?.headers;
+const getDraft = (store) => getCollection(store).draft;
+
+const getSavedRootPayload = () => {
+ const call = invokedSaveArgs.find(([channel]) => channel === 'renderer:save-collection-root');
+ // [channel, pathname, collectionRootToSave, brunoConfig]
+ return call ? call[2] : null;
+};
+
+describe('collectionVariablesUpdateEvent — draft isolation', () => {
+ describe('does not persist draft headers to disk', () => {
+ test('draft headers are NOT written to disk when script updates collection vars', () => {
+ const rootVars = [makeVar('HOST', 'https://saved.com')];
+ const rootHeaders = [makeHeader('X-Saved', 'original')];
+
+ const draftRoot = {
+ request: {
+ headers: [makeHeader('X-Saved', 'original'), makeHeader('X-Draft-Only', 'draft-value')],
+ auth: { mode: 'bearer', bearer: { token: 'draft-token' } },
+ script: { req: 'console.log("draft")', res: '' },
+ vars: {
+ req: [makeVar('HOST', 'https://saved.com')],
+ res: []
+ },
+ tests: ''
+ }
+ };
+
+ const store = createStore({
+ rootVars,
+ rootHeaders,
+ draft: { root: draftRoot }
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ expect(savedRoot).not.toBeNull();
+
+ // Saved headers should be from root, not draft
+ expect(savedRoot.request.headers).toHaveLength(1);
+ expect(savedRoot.request.headers[0].name).toBe('X-Saved');
+ expect(savedRoot.request.headers[0].value).toBe('original');
+
+ // Draft header should NOT be persisted
+ expect(savedRoot.request.headers.find((h) => h.name === 'X-Draft-Only')).toBeUndefined();
+ });
+
+ test('draft auth is NOT written to disk when script updates collection vars', () => {
+ const rootAuth = { mode: 'none' };
+ const draftRoot = {
+ request: {
+ headers: [],
+ auth: { mode: 'bearer', bearer: { token: 'draft-secret-token' } },
+ script: { req: '', res: '' },
+ vars: { req: [makeVar('HOST', 'https://saved.com')], res: [] },
+ tests: ''
+ }
+ };
+
+ const store = createStore({
+ rootVars: [makeVar('HOST', 'https://saved.com')],
+ rootAuth,
+ draft: { root: draftRoot }
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ expect(savedRoot).not.toBeNull();
+ // Auth on disk should be from root (none), not draft (bearer)
+ expect(savedRoot.request.auth).toEqual({ mode: 'none' });
+ // Root auth in Redux should also remain unchanged
+ expect(getRootAuth(store)).toEqual({ mode: 'none' });
+ });
+
+ test('draft scripts are NOT written to disk when script updates collection vars', () => {
+ const draftRoot = {
+ request: {
+ headers: [],
+ auth: { mode: 'none' },
+ script: { req: 'console.log("draft script")', res: 'console.log("draft post")' },
+ vars: { req: [], res: [] },
+ tests: 'test("draft test", () => {})'
+ }
+ };
+
+ const store = createStore({
+ draft: { root: draftRoot }
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { NEW_VAR: 'value' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ expect(savedRoot).not.toBeNull();
+ // Scripts and tests on disk should be from root (empty), not draft
+ expect(savedRoot.request.script).toEqual({ req: '', res: '' });
+ expect(savedRoot.request.tests).toBe('');
+ });
+ });
+
+ describe('vars are saved correctly from root', () => {
+ test('updated vars are persisted to disk from saved state', () => {
+ const store = createStore({
+ rootVars: [makeVar('HOST', 'https://saved.com')]
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ expect(savedRoot).not.toBeNull();
+ expect(savedRoot.request.vars.req).toHaveLength(1);
+ expect(savedRoot.request.vars.req[0].value).toBe('https://updated.com');
+ });
+
+ test('new vars from script are persisted to disk', () => {
+ const store = createStore({ rootVars: [] });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { TOKEN: 'abc123' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ const vars = savedRoot.request.vars.req;
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('TOKEN');
+ expect(vars[0].value).toBe('abc123');
+ });
+ });
+
+ describe('draft is preserved after script-driven save', () => {
+ test('draft with header edits is NOT cleared by collectionVariablesUpdateEvent', () => {
+ const draftRoot = {
+ request: {
+ headers: [makeHeader('X-Draft', 'draft-val')],
+ auth: { mode: 'none' },
+ script: { req: '', res: '' },
+ vars: { req: [makeVar('HOST', 'https://saved.com')], res: [] },
+ tests: ''
+ }
+ };
+
+ const store = createStore({
+ rootVars: [makeVar('HOST', 'https://saved.com')],
+ rootHeaders: [],
+ draft: { root: draftRoot }
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ // Draft should still exist (not cleared)
+ const draft = getDraft(store);
+ expect(draft).not.toBeNull();
+
+ // Draft headers should be untouched
+ const draftHeaders = getDraftHeaders(store);
+ expect(draftHeaders).toHaveLength(1);
+ expect(draftHeaders[0].name).toBe('X-Draft');
+ });
+
+ test('draft vars are synced with script changes while draft remains', () => {
+ const draftRoot = {
+ request: {
+ headers: [makeHeader('X-Draft', 'keep-me')],
+ auth: { mode: 'none' },
+ script: { req: '', res: '' },
+ vars: { req: [makeVar('HOST', 'https://draft.com')], res: [] },
+ tests: ''
+ }
+ };
+
+ const store = createStore({
+ rootVars: [makeVar('HOST', 'https://saved.com')],
+ draft: { root: draftRoot }
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://script.com', NEW: 'val' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ // Root should have the updated vars
+ const rootVars = getRootVars(store);
+ expect(rootVars.find((v) => v.name === 'HOST').value).toBe('https://script.com');
+ expect(rootVars.find((v) => v.name === 'NEW').value).toBe('val');
+
+ // Draft vars should also be synced
+ const col = getCollection(store);
+ const draftVars = col.draft.root.request.vars.req;
+ expect(draftVars.find((v) => v.name === 'HOST').value).toBe('https://script.com');
+ expect(draftVars.find((v) => v.name === 'NEW').value).toBe('val');
+
+ // Draft headers should be untouched
+ expect(col.draft.root.request.headers[0].name).toBe('X-Draft');
+ });
+ });
+
+ describe('disabled vars are preserved', () => {
+ test('disabled collection vars survive when script returns only enabled vars', () => {
+ const store = createStore({
+ rootVars: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('DEBUG', 'true', false) // disabled
+ ]
+ });
+
+ // Script only returns enabled vars (disabled are not passed to script runtime)
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getRootVars(store);
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'DEBUG')).toBeDefined();
+ expect(vars.find((v) => v.name === 'DEBUG').enabled).toBe(false);
+ });
+
+ test('disabled vars survive when script adds a new var', () => {
+ const store = createStore({
+ rootVars: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_SECRET', 'hidden', false) // disabled
+ ]
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com', TOKEN: 'new-token' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getRootVars(store);
+ expect(vars).toHaveLength(3);
+ expect(vars.find((v) => v.name === 'OLD_SECRET')).toBeDefined();
+ expect(vars.find((v) => v.name === 'OLD_SECRET').enabled).toBe(false);
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('new-token');
+ });
+
+ test('disabled vars survive when script deletes an enabled var', () => {
+ const store = createStore({
+ rootVars: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('REMOVE_ME', 'bye'),
+ makeVar('KEEP_DISABLED', 'secret', false) // disabled
+ ]
+ });
+
+ // Script deleted REMOVE_ME (not in output)
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getRootVars(store);
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'HOST')).toBeDefined();
+ expect(vars.find((v) => v.name === 'KEEP_DISABLED')).toBeDefined();
+ expect(vars.find((v) => v.name === 'KEEP_DISABLED').enabled).toBe(false);
+ expect(vars.find((v) => v.name === 'REMOVE_ME')).toBeUndefined();
+ });
+
+ test('disabled vars are persisted to disk', () => {
+ const store = createStore({
+ rootVars: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('DISABLED_VAR', 'keep-me', false) // disabled
+ ]
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const savedRoot = getSavedRootPayload();
+ expect(savedRoot).not.toBeNull();
+ const savedVars = savedRoot.request.vars.req;
+ expect(savedVars).toHaveLength(2);
+ expect(savedVars.find((v) => v.name === 'DISABLED_VAR')).toBeDefined();
+ expect(savedVars.find((v) => v.name === 'DISABLED_VAR').enabled).toBe(false);
+ });
+
+ test('preserves var that user disabled in draft even when script does not return it', () => {
+ const draftRoot = {
+ request: {
+ headers: [],
+ auth: { mode: 'none' },
+ script: { req: '', res: '' },
+ vars: {
+ req: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret', false) // disabled in draft only
+ ],
+ res: []
+ },
+ tests: ''
+ }
+ };
+
+ const store = createStore({
+ rootVars: [
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret') // still enabled in root
+ ],
+ draft: { root: draftRoot }
+ });
+
+ // Script only returns enabled vars — TOKEN is disabled in draft, so not included
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getRootVars(store);
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeDefined();
+ // The var should be preserved with its draft disabled state
+ expect(vars.find((v) => v.name === 'TOKEN').enabled).toBe(false);
+ });
+
+ test('disabled vars survive when collectionVariables is empty (no collection vars defined)', () => {
+ const store = createStore({
+ rootVars: [
+ makeVar('ONLY_DISABLED', 'value', false) // disabled
+ ]
+ });
+
+ // Empty collectionVariables — happens when no enabled vars exist
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: {},
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getRootVars(store);
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('ONLY_DISABLED');
+ expect(vars[0].enabled).toBe(false);
+ });
+ });
+
+ describe('no draft — root headers remain unchanged', () => {
+ test('existing root headers are preserved when vars are updated', () => {
+ const store = createStore({
+ rootVars: [makeVar('HOST', 'https://saved.com')],
+ rootHeaders: [makeHeader('Authorization', 'Bearer saved-token')]
+ });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://updated.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ // Root headers should not change
+ const headers = getRootHeaders(store);
+ expect(headers).toHaveLength(1);
+ expect(headers[0].value).toBe('Bearer saved-token');
+
+ // No draft should be created
+ expect(getDraft(store)).toBeNull();
+ });
+ });
+});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-merge.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-merge.spec.js
new file mode 100644
index 000000000..0c005f5a9
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-collection-vars-merge.spec.js
@@ -0,0 +1,638 @@
+jest.mock('nanoid', () => ({
+ customAlphabet: () => () => 'mock-uid'
+}));
+
+jest.mock('@usebruno/schema', () => ({
+ collectionSchema: { validate: () => Promise.resolve() },
+ environmentSchema: { validate: () => Promise.resolve() },
+ itemSchema: { validate: () => Promise.resolve() }
+}));
+
+import { configureStore } from '@reduxjs/toolkit';
+import collectionsReducer, { initRunRequestEvent } from 'providers/ReduxStore/slices/collections';
+import { collectionVariablesUpdateEvent } from 'providers/ReduxStore/slices/collections/actions';
+
+const COLLECTION_UID = 'col-1';
+const ITEM_UID = 'req-1';
+const REQUEST_UID = 'run-1';
+
+const makeVar = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ enabled
+});
+
+beforeAll(() => {
+ window.ipcRenderer = { invoke: jest.fn().mockResolvedValue(true) };
+});
+
+const createStore = (rootVars = [], opts = {}) => {
+ const root = {
+ request: {
+ vars: {
+ req: rootVars
+ }
+ }
+ };
+
+ const preloadedState = {
+ collections: {
+ collections: [
+ {
+ uid: COLLECTION_UID,
+ pathname: '/coll',
+ root,
+ draft: opts.draft !== undefined ? opts.draft : null,
+ brunoConfig: { version: '1', name: 'test', type: 'collection' },
+ environments: [],
+ items: [
+ {
+ uid: ITEM_UID,
+ name: 'ping',
+ type: 'http-request',
+ request: { url: 'https://example.com/ping', method: 'GET' }
+ }
+ ]
+ }
+ ],
+ collectionSortOrder: 'default',
+ activeWorkspaceUid: null
+ }
+ };
+
+ return configureStore({
+ reducer: {
+ collections: collectionsReducer
+ },
+ preloadedState
+ });
+};
+
+const getReqVars = (store) => {
+ const col = store.getState().collections.collections[0];
+ // scriptUpdateCollectionVars writes to root and syncs to draft if present
+ const root = col.root || {};
+ return root.request?.vars?.req || [];
+};
+
+const getCollection = (store) => store.getState().collections.collections[0];
+
+describe('collectionVariablesUpdateEvent — draft-aware merge', () => {
+ describe('no draft pending', () => {
+ test('updates existing variable values', () => {
+ const store = createStore([makeVar('HOST', 'https://old.com')]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://new.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars).toHaveLength(1);
+ expect(vars[0].value).toBe('https://new.com');
+ });
+
+ test('adds new variables from script', () => {
+ const store = createStore([makeVar('HOST', 'https://example.com')]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com', TOKEN: 'abc' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ });
+
+ test('removes enabled variables deleted by script (deleteCollectionVar)', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_VAR', 'remove-me')
+ ]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('HOST');
+ });
+
+ test('preserves disabled variables even if not in script output', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('DISABLED_VAR', 'keep', false)
+ ]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'DISABLED_VAR')).toBeDefined();
+ });
+
+ test('deleteAllCollectionVars removes all enabled variables', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret'),
+ makeVar('DISABLED', 'keep', false)
+ ]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: {},
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('DISABLED');
+ });
+
+ test('preserves typed values without stringifying', () => {
+ const store = createStore([]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { count: 42, flag: true },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ const countVar = vars.find((v) => v.name === 'count');
+ const flagVar = vars.find((v) => v.name === 'flag');
+ expect(countVar.value).toBe(42);
+ expect(countVar.dataType).toBe('number');
+ expect(flagVar.value).toBe(true);
+ expect(flagVar.dataType).toBe('boolean');
+ });
+ });
+
+ describe('draft pending — baseline-diff mode', () => {
+ test('preserves draft-only variables that script does not know about', () => {
+ const rootVars = [makeVar('HOST', 'https://saved.com')];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://saved.com'), makeVar('DRAFT_NEW', 'from-user')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // DRAFT_NEW was not in baseline — script can't delete it, so it survives
+ expect(vars.find((v) => v.name === 'DRAFT_NEW')).toBeDefined();
+ expect(vars.find((v) => v.name === 'DRAFT_NEW').value).toBe('from-user');
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://saved.com');
+ });
+
+ test('applies script modification over draft edit of same variable', () => {
+ const rootVars = [makeVar('TOKEN', 'saved-token')];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('TOKEN', 'draft-token')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script changed TOKEN (different from saved)
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { TOKEN: 'script-token' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ // Script's change wins because it explicitly modified the value
+ expect(getReqVars(store)[0].value).toBe('script-token');
+ });
+
+ test('preserves draft value when script returns unchanged value', () => {
+ const rootVars = [makeVar('HOST', 'https://saved.com')];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://draft-edit.com')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script didn't change HOST, but added TOKEN
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com', TOKEN: 'new-token' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // HOST: script didn't change it → draft value preserved
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft-edit.com');
+ // TOKEN: new from script → added
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('new-token');
+ // Baseline should be set
+ expect(getCollection(store)._scriptCollVarBaseline).toEqual({ HOST: 'https://saved.com' });
+ });
+
+ test('user deletes variable in draft, script returns same value — stays deleted', () => {
+ const rootVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('API_KEY', 'saved-key')
+ ];
+ // User deleted API_KEY in the draft
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://saved.com')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script returns API_KEY unchanged
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com', API_KEY: 'saved-key' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // API_KEY was not modified by script (same as baseline), so user's delete wins
+ expect(vars.find((v) => v.name === 'API_KEY')).toBeUndefined();
+ expect(vars).toHaveLength(1);
+ });
+
+ test('user deletes variable in draft, script sets new value — variable re-added', () => {
+ const rootVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('API_KEY', 'saved-key')
+ ];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://saved.com')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script explicitly changed API_KEY
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com', API_KEY: 'new-key-from-script' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars.find((v) => v.name === 'API_KEY').value).toBe('new-key-from-script');
+ });
+
+ test('deleteCollectionVar removes variable that existed in saved state', () => {
+ const rootVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://draft.com'), makeVar('TOKEN', 'draft-token')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script deleted TOKEN (absent from output)
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // TOKEN was in baseline and removed by script — should be gone
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ // HOST draft value preserved (script didn't change it)
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ });
+
+ test('deleteAllCollectionVars with draft preserves draft-only vars', () => {
+ const rootVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('DRAFT_ONLY', 'user-added')
+ ]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script called bru.deleteAllCollectionVars() — empty output
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: {},
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // HOST and TOKEN were in baseline and deleted by script — gone
+ expect(vars.find((v) => v.name === 'HOST')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ // DRAFT_ONLY was not in baseline — survives
+ expect(vars.find((v) => v.name === 'DRAFT_ONLY').value).toBe('user-added');
+ });
+ });
+
+ describe('baseline exists — subsequent script events', () => {
+ test('subsequent script events reuse baseline and preserve draft edits', () => {
+ const rootVars = [makeVar('HOST', 'https://saved.com')];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://draft.com')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // First event: creates baseline
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com', TOKEN: 'abc' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ expect(getReqVars(store).find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(getReqVars(store).find((v) => v.name === 'TOKEN').value).toBe('abc');
+
+ // Second event: baseline still active, draft edits still preserved
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com', TOKEN: 'abc', RESULT: 'ok' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ expect(vars.find((v) => v.name === 'RESULT').value).toBe('ok');
+ });
+ });
+
+ describe('mixed operations — set + delete in same script', () => {
+ test('script adds new var and deletes existing var', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_TOKEN', 'remove-me')
+ ]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://example.com', NEW_TOKEN: 'fresh' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ expect(vars.find((v) => v.name === 'OLD_TOKEN')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'NEW_TOKEN').value).toBe('fresh');
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://example.com');
+ });
+
+ test('with draft: script adds, modifies, and deletes vars — draft edits preserved', () => {
+ const rootVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token'),
+ makeVar('TO_DELETE', 'remove-me')
+ ];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('TO_DELETE', 'draft-value'),
+ makeVar('DRAFT_NEW', 'from-user')
+ ]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Script: modified TOKEN, deleted TO_DELETE, added SCRIPT_NEW, left HOST unchanged
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: {
+ HOST: 'https://saved.com',
+ TOKEN: 'script-token',
+ SCRIPT_NEW: 'from-script'
+ },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const vars = getReqVars(store);
+ // HOST: unchanged by script → draft value preserved
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ // TOKEN: modified by script → script wins
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('script-token');
+ // TO_DELETE: was in baseline, absent from script → deleted
+ expect(vars.find((v) => v.name === 'TO_DELETE')).toBeUndefined();
+ // DRAFT_NEW: not in baseline → preserved
+ expect(vars.find((v) => v.name === 'DRAFT_NEW').value).toBe('from-user');
+ // SCRIPT_NEW: new from script → added
+ expect(vars.find((v) => v.name === 'SCRIPT_NEW').value).toBe('from-script');
+ });
+ });
+
+ describe('typed values — dataType inference', () => {
+ test('infers number dataType when script sets a numeric value', () => {
+ const store = createStore([makeVar('COUNT', '0')]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { COUNT: 42 },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBe('number');
+ });
+
+ test('infers boolean dataType when script sets a boolean value', () => {
+ const store = createStore([makeVar('FLAG', 'false')]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { FLAG: true },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'FLAG');
+ expect(v.dataType).toBe('boolean');
+ });
+
+ test('infers object dataType when script sets an object value', () => {
+ const store = createStore([makeVar('CONFIG', '')]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { CONFIG: { port: 3000 } },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'CONFIG');
+ expect(v.dataType).toBe('object');
+ });
+
+ test('keeps existing dataType on a typed var the script did not touch', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ const store = createStore([typedVar, makeVar('HOST', 'https://example.com')]);
+
+ // Script touched HOST only; the runtime payload still carries COUNT (unchanged).
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { COUNT: 42, HOST: 'https://new.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBe('number');
+ });
+
+ test('drops dataType when script replaces a typed value with a string', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ const store = createStore([typedVar]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { COUNT: 'not-a-number' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBeUndefined();
+ });
+
+ test('updates dataType when script changes the value type', () => {
+ const typedVar = { ...makeVar('VAL', 42), dataType: 'number' };
+ const store = createStore([typedVar]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { VAL: true },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const v = getReqVars(store).find((v) => v.name === 'VAL');
+ expect(v.dataType).toBe('boolean');
+ });
+ });
+
+ describe('baseline cleanup', () => {
+ test('initRunRequestEvent clears collection var baseline', () => {
+ const rootVars = [makeVar('HOST', 'https://saved.com')];
+ const draftRoot = {
+ request: {
+ vars: {
+ req: [makeVar('HOST', 'https://draft.com')]
+ }
+ }
+ };
+ const store = createStore(rootVars, { draft: { root: draftRoot } });
+
+ // Create baseline via script event
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { HOST: 'https://saved.com' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ expect(getCollection(store)._scriptCollVarBaseline).toBeDefined();
+
+ // Start a new request — baseline should be cleared
+ store.dispatch(initRunRequestEvent({
+ requestUid: REQUEST_UID,
+ itemUid: ITEM_UID,
+ collectionUid: COLLECTION_UID
+ }));
+
+ expect(getCollection(store)._scriptCollVarBaseline).toBeUndefined();
+ });
+ });
+
+ describe('description / annotations preservation across script-driven updates', () => {
+ const annotations = [
+ { name: 'number' },
+ { name: 'description', value: 'server port' }
+ ];
+
+ test('script touching a sibling var must NOT erase annotations on untouched vars', () => {
+ const annotated = { ...makeVar('PORT', 8080), dataType: 'number', annotations: annotations };
+ const sibling = makeVar('TOKEN', 'abc');
+
+ const store = createStore([annotated, sibling]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { PORT: 8080, TOKEN: 'new-token' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const port = getReqVars(store).find((v) => v.name === 'PORT');
+ expect(port.annotations).toEqual(annotations);
+ expect(port.dataType).toBe('number');
+ });
+
+ test('script overwriting a var\'s value preserves its annotations', () => {
+ const annotated = { ...makeVar('PORT', 8080), dataType: 'number', annotations: annotations };
+
+ const store = createStore([annotated]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { PORT: 9090 },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const port = getReqVars(store).find((v) => v.name === 'PORT');
+ expect(port.value).toBe(9090);
+ expect(port.annotations).toEqual(annotations);
+ expect(port.dataType).toBe('number');
+ });
+ });
+
+ describe('object/array typed-var no-op writes preserve draft (deep-equal compare)', () => {
+ test('script re-writing a structurally-equal object value does NOT clobber draft edit', () => {
+ const savedVar = { ...makeVar('CFG', { port: 3000 }), dataType: 'object' };
+ const draftVar = { ...makeVar('CFG', { port: 4000 }), dataType: 'object' };
+
+ const draftRoot = { request: { vars: { req: [draftVar] } } };
+ const store = createStore([savedVar], { draft: { root: draftRoot } });
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { CFG: { port: 3000 } },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const cfg = getReqVars(store).find((v) => v.name === 'CFG');
+ expect(cfg.value).toEqual({ port: 4000 });
+ });
+ });
+
+ describe('disabled-var name collision — script targets enabled slot only', () => {
+ test('script setting X writes to the enabled X, leaves the disabled X untouched', () => {
+ const disabledX = makeVar('X', 'archived', false);
+ const enabledX = makeVar('X', 'current');
+
+ const store = createStore([disabledX, enabledX]);
+
+ store.dispatch(collectionVariablesUpdateEvent({
+ collectionVariables: { X: 'updated' },
+ collectionUid: COLLECTION_UID
+ }));
+
+ const xVars = getReqVars(store).filter((v) => v.name === 'X');
+ expect(xVars).toHaveLength(2);
+ expect(xVars.find((v) => v.enabled === false).value).toBe('archived');
+ expect(xVars.find((v) => v.enabled === true).value).toBe('updated');
+ });
+ });
+});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-env-merge.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-env-merge.spec.js
new file mode 100644
index 000000000..9c5e76011
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/draft-env-merge.spec.js
@@ -0,0 +1,627 @@
+jest.mock('nanoid', () => ({
+ customAlphabet: () => () => 'mock-uid'
+}));
+
+import reducer, {
+ scriptEnvironmentUpdateEvent,
+ initRunRequestEvent
+} from 'providers/ReduxStore/slices/collections';
+
+const COLLECTION_UID = 'col-1';
+const ENV_UID = 'env-1';
+const ITEM_UID = 'req-1';
+const REQUEST_UID = 'run-1';
+
+const makeVar = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ type: 'text',
+ secret: false,
+ enabled
+});
+
+const makeInitialState = (envVars = [], opts = {}) => ({
+ collections: [
+ {
+ uid: COLLECTION_UID,
+ pathname: '/coll',
+ activeEnvironmentUid: ENV_UID,
+ environments: [
+ {
+ uid: ENV_UID,
+ name: 'Test',
+ variables: envVars
+ }
+ ],
+ environmentsDraft: opts.draft || null,
+ _scriptEnvBaseline: opts.baseline || undefined,
+ runtimeVariables: {},
+ items: [
+ {
+ uid: ITEM_UID,
+ name: 'ping',
+ type: 'http-request',
+ request: { url: 'https://example.com/ping', method: 'GET' }
+ }
+ ]
+ }
+ ],
+ collectionSortOrder: 'default',
+ activeWorkspaceUid: null
+});
+
+const scriptEvent = (envVariables) =>
+ scriptEnvironmentUpdateEvent({
+ collectionUid: COLLECTION_UID,
+ envVariables
+ });
+
+const getEnv = (state) => state.collections[0].environments[0];
+const getCollection = (state) => state.collections[0];
+
+describe('scriptEnvironmentUpdateEvent — draft-aware merge', () => {
+ describe('no draft pending (original behavior)', () => {
+ test('updates existing variable values', () => {
+ let state = makeInitialState([makeVar('HOST', 'https://old.com')]);
+
+ state = reducer(state, scriptEvent({ HOST: 'https://new.com', __name__: 'Test' }));
+
+ expect(getEnv(state).variables).toHaveLength(1);
+ expect(getEnv(state).variables[0].value).toBe('https://new.com');
+ });
+
+ test('adds new variables from script', () => {
+ let state = makeInitialState([makeVar('HOST', 'https://example.com')]);
+
+ state = reducer(state, scriptEvent({ HOST: 'https://example.com', TOKEN: 'abc', __name__: 'Test' }));
+
+ expect(getEnv(state).variables).toHaveLength(2);
+ expect(getEnv(state).variables[1].name).toBe('TOKEN');
+ expect(getEnv(state).variables[1].value).toBe('abc');
+ });
+
+ test('removes enabled variables deleted by script', () => {
+ let state = makeInitialState([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_VAR', 'remove-me')
+ ]);
+
+ state = reducer(state, scriptEvent({ HOST: 'https://example.com', __name__: 'Test' }));
+
+ expect(getEnv(state).variables).toHaveLength(1);
+ expect(getEnv(state).variables[0].name).toBe('HOST');
+ });
+
+ test('preserves disabled variables even if not in script output', () => {
+ let state = makeInitialState([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('DISABLED_VAR', 'keep-me', false)
+ ]);
+
+ state = reducer(state, scriptEvent({ HOST: 'https://example.com', __name__: 'Test' }));
+
+ expect(getEnv(state).variables).toHaveLength(2);
+ expect(getEnv(state).variables[1].name).toBe('DISABLED_VAR');
+ });
+ });
+
+ describe('draft pending — first script event', () => {
+ test('flushes draft, creates baseline, and applies only script changes', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft-edit.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script didn't change HOST, but added TOKEN
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'new-token',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ // HOST should keep the draft value (script didn't change it)
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft-edit.com');
+ // TOKEN should be added (new in script)
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('new-token');
+ // Draft should be cleared
+ expect(getCollection(state).environmentsDraft).toBeNull();
+ // Baseline should be set
+ expect(getCollection(state)._scriptEnvBaseline).toEqual({ HOST: 'https://saved.com' });
+ });
+
+ test('applies script modification over draft edit of same variable', () => {
+ const savedVars = [makeVar('TOKEN', 'saved-token')];
+ const draftVars = [makeVar('TOKEN', 'draft-token')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script changed TOKEN (different from saved)
+ state = reducer(state, scriptEvent({ TOKEN: 'script-token', __name__: 'Test' }));
+
+ // Script's change wins because it explicitly modified the value
+ expect(getEnv(state).variables[0].value).toBe('script-token');
+ });
+
+ test('preserves draft-only variables that script does not know about', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('NEW_DRAFT_VAR', 'draft-only-value')
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ state = reducer(state, scriptEvent({ HOST: 'https://saved.com', __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'NEW_DRAFT_VAR').value).toBe('draft-only-value');
+ });
+
+ test('user deletes variable in draft, script returns same value — stays deleted', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('API_KEY', 'saved-key')
+ ];
+ // User deleted API_KEY in the draft
+ const draftVars = [makeVar('HOST', 'https://saved.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script returns API_KEY unchanged
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ API_KEY: 'saved-key',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ // API_KEY was not modified by script (same as baseline), so user's delete wins
+ expect(vars.find((v) => v.name === 'API_KEY')).toBeUndefined();
+ expect(vars).toHaveLength(1);
+ });
+
+ test('user deletes variable in draft, script sets new value — variable re-added', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('API_KEY', 'saved-key')
+ ];
+ const draftVars = [makeVar('HOST', 'https://saved.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script explicitly changed API_KEY
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ API_KEY: 'new-key-from-script',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ expect(vars.find((v) => v.name === 'API_KEY').value).toBe('new-key-from-script');
+ });
+
+ test('ignores draft for non-active environment', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: 'other-env-uid', variables: [makeVar('HOST', 'https://draft.com')] }
+ });
+
+ state = reducer(state, scriptEvent({ HOST: 'https://new.com', __name__: 'Test' }));
+
+ // Draft for a different env should NOT be flushed
+ expect(getCollection(state).environmentsDraft).not.toBeNull();
+ // Should apply script output directly (no baseline)
+ expect(getEnv(state).variables[0].value).toBe('https://new.com');
+ expect(getCollection(state)._scriptEnvBaseline).toBeUndefined();
+ });
+ });
+
+ describe('baseline exists — subsequent script events', () => {
+ test('preserves draft edits across multiple script events', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // First event: flushes draft, creates baseline
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'abc',
+ __name__: 'Test'
+ }));
+
+ expect(getEnv(state).variables.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(getEnv(state).variables.find((v) => v.name === 'TOKEN').value).toBe('abc');
+
+ // Second event: baseline exists, no draft — should still preserve draft edits
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'abc',
+ __name__: 'Test'
+ }));
+
+ // HOST should still be the draft value
+ expect(getEnv(state).variables.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(getEnv(state).variables.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ });
+
+ test('applies new changes from later script events', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft.com')];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // First event: pre-request sets TOKEN
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'abc',
+ __name__: 'Test'
+ }));
+
+ // Second event: post-response sets RESULT (new var)
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'abc',
+ RESULT: 'ok',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ expect(vars.find((v) => v.name === 'RESULT').value).toBe('ok');
+ });
+ });
+
+ describe('deleteEnvVar — script deletes a variable', () => {
+ test('no draft: deleteEnvVar removes the variable', () => {
+ let state = makeInitialState([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret')
+ ]);
+
+ // Script called bru.deleteEnvVar("TOKEN") — TOKEN absent from output
+ state = reducer(state, scriptEvent({ HOST: 'https://example.com', __name__: 'Test' }));
+
+ expect(getEnv(state).variables).toHaveLength(1);
+ expect(getEnv(state).variables[0].name).toBe('HOST');
+ });
+
+ test('with draft: deleteEnvVar removes variable that existed in saved state', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token')
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script deleted TOKEN (absent from output)
+ state = reducer(state, scriptEvent({ HOST: 'https://saved.com', __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ // TOKEN was in baseline and removed by script — should be gone
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ // HOST draft value preserved (script didn't change it)
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ });
+
+ test('with draft: deleteEnvVar does not remove draft-only variables', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('DRAFT_ONLY', 'user-added')
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script output only has HOST (DRAFT_ONLY was never in saved state)
+ state = reducer(state, scriptEvent({ HOST: 'https://saved.com', __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ // DRAFT_ONLY should survive — it wasn't in baseline, so script can't "delete" it
+ expect(vars.find((v) => v.name === 'DRAFT_ONLY').value).toBe('user-added');
+ });
+ });
+
+ describe('deleteAllEnvVars — script clears all variables', () => {
+ test('no draft: deleteAllEnvVars removes all enabled variables', () => {
+ let state = makeInitialState([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret'),
+ makeVar('DISABLED', 'keep', false)
+ ]);
+
+ // Script called bru.deleteAllEnvVars() — only __name__ remains
+ state = reducer(state, scriptEvent({ __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ // Only disabled var survives
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('DISABLED');
+ });
+
+ test('with draft: deleteAllEnvVars removes saved vars but keeps draft-only vars', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('DRAFT_ONLY', 'user-added')
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script called bru.deleteAllEnvVars() — only __name__ in output
+ state = reducer(state, scriptEvent({ __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ // HOST and TOKEN were in baseline and deleted by script — gone
+ expect(vars.find((v) => v.name === 'HOST')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ // DRAFT_ONLY was not in baseline — survives
+ expect(vars.find((v) => v.name === 'DRAFT_ONLY').value).toBe('user-added');
+ });
+
+ test('with draft: deleteAllEnvVars preserves disabled variables', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('DISABLED', 'keep', false)
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('DISABLED', 'keep', false)
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ state = reducer(state, scriptEvent({ __name__: 'Test' }));
+
+ const vars = getEnv(state).variables;
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('DISABLED');
+ expect(vars[0].enabled).toBe(false);
+ });
+ });
+
+ describe('mixed operations — set + delete in same script', () => {
+ test('no draft: script adds new var and deletes existing var', () => {
+ let state = makeInitialState([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_TOKEN', 'remove-me')
+ ]);
+
+ // Script deleted OLD_TOKEN and added NEW_TOKEN
+ state = reducer(state, scriptEvent({
+ HOST: 'https://example.com',
+ NEW_TOKEN: 'fresh',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ expect(vars.find((v) => v.name === 'OLD_TOKEN')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'NEW_TOKEN').value).toBe('fresh');
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://example.com');
+ });
+
+ test('with draft: script adds, modifies, and deletes vars — draft edits preserved', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token'),
+ makeVar('TO_DELETE', 'remove-me')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('TO_DELETE', 'draft-value'),
+ makeVar('DRAFT_NEW', 'from-user')
+ ];
+ let state = makeInitialState(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script: modified TOKEN, deleted TO_DELETE, added SCRIPT_NEW, left HOST unchanged
+ state = reducer(state, scriptEvent({
+ HOST: 'https://saved.com',
+ TOKEN: 'script-token',
+ SCRIPT_NEW: 'from-script',
+ __name__: 'Test'
+ }));
+
+ const vars = getEnv(state).variables;
+ // HOST: unchanged by script → draft value preserved
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ // TOKEN: modified by script → script wins
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('script-token');
+ // TO_DELETE: was in baseline, absent from script → deleted
+ expect(vars.find((v) => v.name === 'TO_DELETE')).toBeUndefined();
+ // DRAFT_NEW: not in baseline → preserved
+ expect(vars.find((v) => v.name === 'DRAFT_NEW').value).toBe('from-user');
+ // SCRIPT_NEW: new from script → added
+ expect(vars.find((v) => v.name === 'SCRIPT_NEW').value).toBe('from-script');
+ });
+ });
+
+ describe('baseline cleanup', () => {
+ test('initRunRequestEvent clears baseline from previous request', () => {
+ let state = makeInitialState([makeVar('HOST', 'https://saved.com')], {
+ baseline: { HOST: 'https://saved.com' }
+ });
+
+ state = reducer(state, initRunRequestEvent({
+ requestUid: REQUEST_UID,
+ itemUid: ITEM_UID,
+ collectionUid: COLLECTION_UID
+ }));
+
+ expect(getCollection(state)._scriptEnvBaseline).toBeUndefined();
+ });
+ });
+
+ describe('typed values — dataType inference', () => {
+ test('infers number dataType when script sets a numeric value', () => {
+ let state = makeInitialState([makeVar('COUNT', '0')]);
+
+ state = reducer(state, scriptEvent({ COUNT: 42, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'COUNT');
+ expect(v.value).toBe(42);
+ expect(v.dataType).toBe('number');
+ });
+
+ test('infers boolean dataType when script sets a boolean value', () => {
+ let state = makeInitialState([makeVar('FLAG', 'false')]);
+
+ state = reducer(state, scriptEvent({ FLAG: true, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'FLAG');
+ expect(v.value).toBe(true);
+ expect(v.dataType).toBe('boolean');
+ });
+
+ test('infers object dataType when script sets an object value', () => {
+ let state = makeInitialState([makeVar('CONFIG', '')]);
+
+ state = reducer(state, scriptEvent({ CONFIG: { port: 3000 }, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'CONFIG');
+ expect(v.value).toEqual({ port: 3000 });
+ expect(v.dataType).toBe('object');
+ });
+
+ test('keeps existing dataType on a typed var the script did not touch', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ let state = makeInitialState([
+ typedVar,
+ makeVar('HOST', 'https://example.com')
+ ]);
+
+ // Script touched HOST only; the runtime payload still carries COUNT (unchanged).
+ state = reducer(state, scriptEvent({ COUNT: 42, HOST: 'https://new.com', __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBe('number');
+ });
+
+ test('drops dataType when script replaces a typed value with a string', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ let state = makeInitialState([typedVar]);
+
+ state = reducer(state, scriptEvent({ COUNT: 'not-a-number', __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBeUndefined();
+ });
+
+ test('updates dataType when script changes the value type', () => {
+ const typedVar = { ...makeVar('VAL', 42), dataType: 'number' };
+ let state = makeInitialState([typedVar]);
+
+ state = reducer(state, scriptEvent({ VAL: true, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'VAL');
+ expect(v.value).toBe(true);
+ expect(v.dataType).toBe('boolean');
+ });
+ });
+
+ describe('object/array typed-var no-op writes preserve draft (deep-equal compare)', () => {
+ test('script re-writing a structurally-equal object value does NOT clobber draft edit', () => {
+ const savedVar = { ...makeVar('CFG', { port: 3000 }), dataType: 'object' };
+ const draftVar = { ...makeVar('CFG', { port: 4000 }), dataType: 'object' };
+
+ let state = makeInitialState([savedVar], {
+ draft: { environmentUid: ENV_UID, variables: [draftVar] }
+ });
+
+ // Script reads CFG (={port:3000}), passes it back unchanged. Pre-fix the strict-!==
+ // compare in applyScriptEnvVars saw two different references and clobbered the draft.
+ state = reducer(state, scriptEvent({ CFG: { port: 3000 }, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'CFG');
+ expect(v.value).toEqual({ port: 4000 });
+ });
+
+ test('script re-writing a structurally-equal array value does NOT clobber draft edit', () => {
+ const savedVar = { ...makeVar('TAGS', [1, 2]), dataType: 'array' };
+ const draftVar = { ...makeVar('TAGS', [1, 2, 3]), dataType: 'array' };
+
+ let state = makeInitialState([savedVar], {
+ draft: { environmentUid: ENV_UID, variables: [draftVar] }
+ });
+
+ state = reducer(state, scriptEvent({ TAGS: [1, 2], __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'TAGS');
+ expect(v.value).toEqual([1, 2, 3]);
+ });
+
+ test('dataType inference is NOT applied when the merge skipped the var (no-op write)', () => {
+ // User is mid-edit converting `42` (number) → `'forty-two'` (string) in their draft.
+ const savedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ const draftVar = { ...makeVar('COUNT', 'forty-two') };
+
+ let state = makeInitialState([savedVar], {
+ draft: { environmentUid: ENV_UID, variables: [draftVar] }
+ });
+
+ // Script does a no-op write of the saved value. With the modifiedKeys-scoped inference,
+ // we must NOT reset the dataType back to 'number' — the draft's string-typed edit is preserved.
+ state = reducer(state, scriptEvent({ COUNT: 42, __name__: 'Test' }));
+
+ const v = getEnv(state).variables.find((v) => v.name === 'COUNT');
+ expect(v.value).toBe('forty-two');
+ expect(v.dataType).toBeUndefined();
+ });
+ });
+
+ describe('disabled-var name collision — script targets enabled slot only', () => {
+ test('script setting X writes to the enabled X, leaves the disabled X untouched', () => {
+ const disabledX = makeVar('X', 'archived', false);
+ const enabledX = makeVar('X', 'current');
+
+ let state = makeInitialState([disabledX, enabledX]);
+
+ state = reducer(state, scriptEvent({ X: 'updated', __name__: 'Test' }));
+
+ const xVars = getEnv(state).variables.filter((v) => v.name === 'X');
+ expect(xVars).toHaveLength(2);
+ const stillDisabled = xVars.find((v) => v.enabled === false);
+ const nowEnabled = xVars.find((v) => v.enabled === true);
+ expect(stillDisabled.value).toBe('archived');
+ expect(nowEnabled.value).toBe('updated');
+ });
+
+ test('when only a disabled X exists, the script write creates a NEW enabled slot', () => {
+ const disabledOnly = makeVar('X', 'archived', false);
+
+ let state = makeInitialState([disabledOnly]);
+
+ state = reducer(state, scriptEvent({ X: 'created', __name__: 'Test' }));
+
+ const xVars = getEnv(state).variables.filter((v) => v.name === 'X');
+ expect(xVars).toHaveLength(2);
+ expect(xVars.find((v) => v.enabled === false).value).toBe('archived');
+ expect(xVars.find((v) => v.enabled === true).value).toBe('created');
+ });
+ });
+});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 05b97effa..2dc35d5cc 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -18,6 +18,7 @@ import {
isItemARequest
} from 'utils/collections';
import { parsePathParams, splitOnFirst } from 'utils/url';
+import { applyScriptEnvVars, getScriptModifiedKeys } from 'utils/environments';
import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
@@ -217,6 +218,8 @@ const initiatedWsResponse = {
trailers: []
};
+// Properties prefixed with `_` (e.g. `_scriptEnvBaseline`) are transient runtime state —
+// never persisted to disk or included in exports.
export const collectionsSlice = createSlice({
name: 'collections',
initialState,
@@ -452,84 +455,60 @@ export const collectionsSlice = createSlice({
}
},
scriptEnvironmentUpdateEvent: (state, action) => {
- const { collectionUid, envVariables, runtimeVariables, persistentEnvVariables } = action.payload;
+ const { collectionUid, envVariables, requestUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
- const activeEnvironmentUid = collection.activeEnvironmentUid;
- const activeEnvironment = findEnvironmentInCollection(collection, activeEnvironmentUid);
-
- if (activeEnvironment) {
- const existingEnvVarNames = new Set(Object.keys(envVariables));
-
- // Update or add variables that exist in envVariables
- forOwn(envVariables, (value, key) => {
- const variable = find(activeEnvironment.variables, (v) => v.name === key);
- const isPersistent = persistentEnvVariables && persistentEnvVariables[key] !== undefined;
-
- if (variable) {
- // For updates coming from scripts, treat them as ephemeral overlays unless they are persistent.
- if (variable.value !== value) {
- /*
- Overlay (persist: false): keep new value in Redux for UI and mark ephemeral
- so it isn't written to disk. persistedValue stores the previous on-disk value;
- save/persist uses that base unless the key is explicitly persisted.
- */
- const previousValue = variable.value;
- const wasEphemeral = !!variable.ephemeral;
- variable.value = value;
- variable.ephemeral = !isPersistent;
- // Capture the on-disk base only when shadowing a real (non-ephemeral) var for
- // the first time. A script-created ephemeral has no on-disk value to restore,
- // so giving it a persistedValue would leak its overlay value into the file.
- if (variable.persistedValue === undefined && !wasEphemeral) {
- variable.persistedValue = previousValue;
- }
-
- // Secrets carry a dataType too; infer it from the value like any other var.
- const inferred = getDataTypeFromValue(value);
- variable.dataType = inferred === 'string' ? undefined : inferred;
- }
- } else {
- // __name__ is a private variable used to store the name of the environment
- // this is not a user defined variable and hence should not be updated
- if (key !== '__name__') {
- const inferred = getDataTypeFromValue(value);
- activeEnvironment.variables.push({
- name: key,
- value,
- secret: false,
- enabled: true,
- type: 'text',
- uid: uuid(),
- ephemeral: !isPersistent,
- ...(inferred !== 'string' ? { dataType: inferred } : {})
- });
- }
- }
- });
-
- // Handle variables that were deleted via bru.deleteEnvVar()
- activeEnvironment.variables = activeEnvironment.variables.filter((variable) => {
- // Variable still exists in envVariables after script execution - keep it
- if (existingEnvVarNames.has(variable.name)) {
- return true;
- }
-
- // Variable was deleted via bru.deleteEnvVar() - handle based on its state
- // If variable was modified by script (has persistedValue), restore original value
- if (variable.persistedValue !== undefined) {
- variable.value = variable.persistedValue;
- variable.ephemeral = false;
- delete variable.persistedValue;
- return true;
- }
-
- // Remove variable: either ephemeral (created by scripts) or non-ephemeral deleted via API
- return false;
- });
+ // Ignore stale updates from superseded requests so an in-flight pre/post
+ // from request N-1 can't clobber state for request N.
+ if (requestUid && collection._scriptRequestUid && requestUid !== collection._scriptRequestUid) {
+ return;
}
+ const activeEnvironment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
+
+ if (activeEnvironment) {
+ const draft = collection.environmentsDraft;
+ if (draft && draft.environmentUid === activeEnvironment.uid && draft.variables) {
+ const baseline = {};
+ activeEnvironment.variables.forEach((v) => {
+ if (v.enabled) baseline[v.name] = v.value;
+ });
+ collection._scriptEnvBaseline = baseline;
+
+ activeEnvironment.variables = cloneDeep(draft.variables);
+ collection.environmentsDraft = null;
+ }
+
+ activeEnvironment.variables = applyScriptEnvVars(
+ activeEnvironment.variables,
+ envVariables,
+ collection._scriptEnvBaseline,
+ { skipKeys: ['__name__'] }
+ );
+
+ // Re-infer dataType only for vars the script actually modified — otherwise a no-op
+ // script re-write would clobber a user's in-progress draft type change.
+ const modifiedKeys = getScriptModifiedKeys(envVariables, collection._scriptEnvBaseline, { skipKeys: ['__name__'] });
+ activeEnvironment.variables.forEach((v) => {
+ if (!modifiedKeys.has(v.name)) return;
+ const inferred = getDataTypeFromValue(envVariables[v.name]);
+ if (inferred === 'string') {
+ delete v.dataType;
+ } else {
+ v.dataType = inferred;
+ }
+ });
+ }
+ }
+ },
+ runtimeVariablesUpdateEvent: (state, action) => {
+ const { collectionUid, runtimeVariables, requestUid } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (collection) {
+ if (requestUid && collection._scriptRequestUid && requestUid !== collection._scriptRequestUid) {
+ return;
+ }
collection.runtimeVariables = runtimeVariables;
}
},
@@ -2761,6 +2740,42 @@ export const collectionsSlice = createSlice({
set(collection, 'draft.root.request.vars.res', mappedVars);
}
},
+ scriptUpdateCollectionVars: (state, action) => {
+ const { collectionUid, vars } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+
+ // Preserve description/annotations/secret/type and any other per-var metadata via `...rest`
+ // — earlier this reducer cherry-picked only {uid, name, value, enabled, dataType} which
+ // wiped those fields whenever a script touched any collection var.
+ const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, dataType, ...rest }) => {
+ const out = { ...rest, uid: uid || uuid(), name, value, enabled };
+ if (dataType && dataType !== 'string') out.dataType = dataType;
+ return out;
+ });
+
+ set(collection, 'root.request.vars.req', mappedVars);
+
+ if (collection.draft?.root) {
+ set(collection, 'draft.root.request.vars.req', mappedVars);
+ }
+ },
+ setScriptCollVarBaseline: (state, action) => {
+ const { collectionUid, baseline } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+ collection._scriptCollVarBaseline = baseline;
+ },
+ _clearScriptCollectionBaselines: (state, action) => {
+ const { collectionUid } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+ delete collection._scriptEnvBaseline;
+ delete collection._scriptCollVarBaseline;
+ // Also drop the inflight request UID so updates from WS/OAuth2 paths (which
+ // don't dispatch initRunRequestEvent) aren't gated out by a stale HTTP UID.
+ delete collection._scriptRequestUid;
+ },
collectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
@@ -3014,32 +3029,11 @@ export const collectionsSlice = createSlice({
const existingEnv = collection.environments.find((e) => e.uid === environment.uid);
if (existingEnv) {
- const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
existingEnv.name = environment.name;
existingEnv.pathname = environment.pathname;
existingEnv.variables = environment.variables;
existingEnv.color = environment.color;
existingEnv.externalSecrets = environment.externalSecrets;
- /*
- Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
- */
- prevEphemerals.forEach((ev) => {
- const target = existingEnv.variables?.find((v) => v.name === ev.name);
- if (target) {
- if (target.value !== ev.value) {
- if (target.persistedValue === undefined) target.persistedValue = target.value;
- target.value = ev.value;
- }
- target.ephemeral = true;
- } else if (ev.persistedValue === undefined) {
- /*
- No counterpart in the file. A script-created overlay (persistedValue undefined) never
- existed on disk, so a sibling persist:true save must not erase it — keep it visible.
- An ephemeral with persistedValue shadowed a now-absent disk var (deleted), so it drops.
- */
- existingEnv.variables.push(ev);
- }
- });
} else {
collection.environments.push(environment);
collection.environments.sort((a, b) => a.name.localeCompare(b.name));
@@ -3093,6 +3087,10 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
+ delete collection._scriptEnvBaseline;
+ delete collection._scriptCollVarBaseline;
+ collection._scriptRequestUid = requestUid;
+
const item = findItemInCollection(collection, itemUid);
if (!item) return;
@@ -3222,6 +3220,9 @@ export const collectionsSlice = createSlice({
// todo
// get startedAt and endedAt from the runner and display it in the UI
if (type === 'testrun-started') {
+ delete collection._scriptEnvBaseline;
+ delete collection._scriptCollVarBaseline;
+
const info = collection.runnerResult.info;
info.collectionUid = collectionUid;
info.folderUid = folderUid;
@@ -3242,6 +3243,13 @@ export const collectionsSlice = createSlice({
}
if (type === 'request-queued') {
+ // Folder runs reuse the same collection across N requests; clear baselines
+ // per request so request N's script-update doesn't diff against request N-1's
+ // pre-flush snapshot.
+ delete collection._scriptEnvBaseline;
+ delete collection._scriptCollVarBaseline;
+ collection._scriptRequestUid = action.payload.requestUid || null;
+
collection.runnerResult.items.push({
uid: request.uid,
status: 'queued'
@@ -3897,6 +3905,7 @@ export const {
renameItem,
cloneItem,
scriptEnvironmentUpdateEvent,
+ runtimeVariablesUpdateEvent,
processEnvUpdateEvent,
workspaceEnvUpdateEvent,
setDotEnvVariables,
@@ -3986,6 +3995,9 @@ export const {
updateCollectionVar,
deleteCollectionVar,
setCollectionVars,
+ scriptUpdateCollectionVars,
+ setScriptCollVarBaseline,
+ _clearScriptCollectionBaselines,
updateCollectionAuthMode,
updateCollectionAuth,
updateCollectionRequestScript,
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.spec.js
index 5689be81a..5f72940b6 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.spec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.spec.js
@@ -3,9 +3,7 @@ import { collectionsSlice } from './index';
const {
setRequestVars,
setFolderVars,
- setCollectionVars,
- collectionAddEnvFileEvent,
- scriptEnvironmentUpdateEvent
+ setCollectionVars
} = collectionsSlice.actions;
const reducer = collectionsSlice.reducer;
@@ -33,102 +31,6 @@ const assertGuardedVars = (vars) => {
expect(vars[2].dataType).toBeUndefined();
};
-describe('collectionAddEnvFileEvent — ephemeral env vars on disk reload', () => {
- const stateWithEnv = (variables) => ({
- collections: [
- {
- uid: 'col1',
- items: [],
- environments: [{ uid: 'env1', name: 'dev', pathname: '/dev.bru', variables }]
- }
- ]
- });
-
- // A persist:true setEnvVar writes the file, which reloads only the persisted var.
- const fileReload = reducer(
- stateWithEnv([
- { uid: 'v1', name: 'test_env_var', value: 'test', ephemeral: true },
- { uid: 'v2', name: 'test_env_var_test', value: 'test', ephemeral: false }
- ]),
- collectionAddEnvFileEvent({
- collectionUid: 'col1',
- environment: {
- uid: 'env1',
- name: 'dev',
- pathname: '/dev.bru',
- variables: [{ uid: 'v2', name: 'test_env_var_test', value: 'test' }]
- }
- })
- );
- const reloadedVars = fileReload.collections[0].environments[0].variables;
-
- it('keeps a script-created ephemeral var absent from the reloaded file', () => {
- const kept = reloadedVars.find((v) => v.name === 'test_env_var');
- expect(kept).toMatchObject({ name: 'test_env_var', value: 'test', ephemeral: true });
- });
-
- it('drops an overlay ephemeral (persistedValue set) absent from the reloaded file', () => {
- const next = reducer(
- stateWithEnv([{ uid: 'v1', name: 'deleted_overlay', value: 'temp', ephemeral: true, persistedValue: 'orig' }]),
- collectionAddEnvFileEvent({
- collectionUid: 'col1',
- environment: { uid: 'env1', name: 'dev', pathname: '/dev.bru', variables: [] }
- })
- );
-
- expect(next.collections[0].environments[0].variables).toHaveLength(0);
- });
-});
-
-describe('scriptEnvironmentUpdateEvent — re-updating a script-created ephemeral var', () => {
- const stateWithEnvVar = (variable) => ({
- collections: [
- {
- uid: 'col1',
- items: [],
- activeEnvironmentUid: 'env1',
- environments: [{ uid: 'env1', name: 'dev', variables: [variable] }]
- }
- ]
- });
-
- it('does not give a script-created ephemeral var a persistedValue when its value changes again', () => {
- // First run left this in Redux: created by setEnvVar (persist:false), never on disk.
- const existing = { uid: 'v1', name: 'test_env_var', value: 'test', enabled: true, ephemeral: true };
-
- const next = reducer(
- stateWithEnvVar(existing),
- scriptEnvironmentUpdateEvent({
- collectionUid: 'col1',
- envVariables: { test_env_var: 'updated' },
- runtimeVariables: {},
- persistentEnvVariables: {}
- })
- );
-
- const variable = next.collections[0].environments[0].variables[0];
- expect(variable).toMatchObject({ name: 'test_env_var', value: 'updated', ephemeral: true });
- expect(variable.persistedValue).toBeUndefined();
- });
-
- it('captures the on-disk base as persistedValue when first shadowing a real var', () => {
- const onDisk = { uid: 'v1', name: 'api_url', value: 'https://disk', enabled: true };
-
- const next = reducer(
- stateWithEnvVar(onDisk),
- scriptEnvironmentUpdateEvent({
- collectionUid: 'col1',
- envVariables: { api_url: 'https://overlay' },
- runtimeVariables: {},
- persistentEnvVariables: {}
- })
- );
-
- const variable = next.collections[0].environments[0].variables[0];
- expect(variable).toMatchObject({ value: 'https://overlay', ephemeral: true, persistedValue: 'https://disk' });
- });
-});
-
describe('setRequestVars — strips dataType: \'string\' (implicit default)', () => {
it('drops a stray string-dataType on request vars and preserves typed datatypes', () => {
const item = {
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js
index dbce8a915..a024c414e 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js
@@ -1,27 +1,33 @@
import { createSlice } from '@reduxjs/toolkit';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
-import { getDataTypeFromValue, valueToString } from '@usebruno/common/utils';
-import { cloneDeep, has } from 'lodash';
-
-const typedFieldsFor = (value) => {
- const inferred = getDataTypeFromValue(value);
- return inferred === 'string' ? { dataType: undefined } : { dataType: inferred };
-};
+import { getDataTypeFromValue } from '@usebruno/common/utils';
+import { cloneDeep } from 'lodash';
+import { applyScriptEnvVars, getScriptModifiedKeys } from 'utils/environments';
const initialState = {
globalEnvironments: [],
activeGlobalEnvironmentUid: null,
- globalEnvironmentDraft: null
+ globalEnvironmentDraft: null,
+ _scriptGlobalEnvBaseline: null
};
+// Properties prefixed with `_` (e.g. `_scriptGlobalEnvBaseline`) are transient runtime state —
+// never persisted to disk or included in exports.
export const globalEnvironmentsSlice = createSlice({
name: 'global-environments',
initialState,
reducers: {
updateGlobalEnvironments: (state, action) => {
- state.globalEnvironments = action.payload?.globalEnvironments;
- state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
+ const newEnvs = action.payload?.globalEnvironments || [];
+ const incomingActiveUid = action.payload?.activeGlobalEnvironmentUid ?? null;
+
+ const resolvedActiveUid = incomingActiveUid && newEnvs.some((e) => e?.uid === incomingActiveUid)
+ ? incomingActiveUid
+ : null;
+
+ state.globalEnvironments = newEnvs;
+ state.activeGlobalEnvironmentUid = resolvedActiveUid;
},
_addGlobalEnvironment: (state, action) => {
const { name, uid, variables = [], color } = action.payload;
@@ -89,6 +95,12 @@ export const globalEnvironmentsSlice = createSlice({
clearGlobalEnvironmentDraft: (state) => {
state.globalEnvironmentDraft = null;
},
+ _setScriptGlobalEnvBaseline: (state, action) => {
+ state._scriptGlobalEnvBaseline = action.payload;
+ },
+ _clearScriptGlobalEnvBaseline: (state) => {
+ state._scriptGlobalEnvBaseline = null;
+ },
_updateGlobalEnvironmentColor: (state, action) => {
const { environmentUid, color } = action.payload;
if (environmentUid) {
@@ -108,7 +120,9 @@ export const {
_deleteGlobalEnvironment,
_updateGlobalEnvironmentColor,
setGlobalEnvironmentDraft,
- clearGlobalEnvironmentDraft
+ clearGlobalEnvironmentDraft,
+ _setScriptGlobalEnvBaseline,
+ _clearScriptGlobalEnvBaseline
} = globalEnvironmentsSlice.actions;
const getWorkspaceContext = (state) => {
@@ -270,64 +284,71 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
});
};
-export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
- return new Promise((resolve, reject) => {
- const { ipcRenderer } = window;
- if (!globalEnvironmentVariables) resolve();
+export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables, collectionUid, requestUid }) => (dispatch, getState) => {
+ if (!globalEnvironmentVariables) return;
- const state = getState();
- const { workspaceUid, workspacePath } = getWorkspaceContext(state);
- const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
- const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
- const environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
+ const state = getState();
- if (!environment || !environmentUid) {
- return resolve();
+ // Ignore stale updates from superseded requests on the originating collection.
+ if (collectionUid && requestUid) {
+ const sourceCollection = state?.collections?.collections?.find((c) => c.uid === collectionUid);
+ if (sourceCollection?._scriptRequestUid && requestUid !== sourceCollection._scriptRequestUid) {
+ return;
}
+ }
- let variables = cloneDeep(environment?.variables);
+ const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
+ const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
+ const environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
- variables = variables?.map?.((variable) => {
- if (!has(globalEnvironmentVariables, variable?.name)) return variable;
- const newValue = globalEnvironmentVariables[variable?.name];
+ if (!environment || !environmentUid) return;
- return {
- ...variable,
- value: newValue,
- ...typedFieldsFor(newValue)
- };
+ const draft = state?.globalEnvironments?.globalEnvironmentDraft;
+ if (draft && draft.environmentUid === environmentUid && draft.variables) {
+ const baseline = {};
+ environment.variables?.forEach((v) => {
+ if (v.enabled) baseline[v.name] = v.value;
});
+ dispatch(_setScriptGlobalEnvBaseline(baseline));
- Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
- const isAnExistingVariable = variables?.find((v) => v?.name == key);
- if (!isAnExistingVariable) {
- variables.push({
- uid: uuid(),
- name: key,
- value,
- type: 'text',
- secret: false,
- enabled: true,
- ...typedFieldsFor(value)
- });
- }
- });
+ dispatch(_saveGlobalEnvironment({ environmentUid, variables: draft.variables }));
+ dispatch(clearGlobalEnvironmentDraft());
+ }
- const environmentToSave = { ...environment, variables };
+ const updatedState = getState();
+ const updatedEnv = updatedState?.globalEnvironments?.globalEnvironments?.find((env) => env?.uid == environmentUid);
+ const baseline = updatedState?.globalEnvironments?._scriptGlobalEnvBaseline;
+ let variables = cloneDeep(updatedEnv?.variables || []);
- environmentSchema
- .validate(environmentToSave)
- .then(() => ipcRenderer.invoke('renderer:save-global-environment', {
- environmentUid,
- variables,
- color: environment.color,
- workspaceUid,
- workspacePath
- }))
- .then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
- .then(resolve)
- .catch(reject);
+ variables = applyScriptEnvVars(variables, globalEnvironmentVariables, baseline, { skipKeys: ['__name__'] });
+
+ // Re-infer dataType only for vars the script actually modified — preserves draft-only type edits
+ // when a script does a structurally-equal no-op write.
+ const modifiedKeys = getScriptModifiedKeys(globalEnvironmentVariables, baseline, { skipKeys: ['__name__'] });
+ variables.forEach((v) => {
+ if (!modifiedKeys.has(v.name)) return;
+ const inferred = getDataTypeFromValue(globalEnvironmentVariables[v.name]);
+ if (inferred === 'string') {
+ delete v.dataType;
+ } else {
+ v.dataType = inferred;
+ }
});
+
+ dispatch(_saveGlobalEnvironment({ environmentUid, variables }));
+
+ const { ipcRenderer } = window;
+ const { workspaceUid, workspacePath } = getWorkspaceContext(state);
+ environmentSchema
+ .validate({ ...environment, variables })
+ .then(() => ipcRenderer.invoke('renderer:save-global-environment', {
+ environmentUid,
+ variables,
+ color: environment.color,
+ workspaceUid,
+ workspacePath
+ }))
+ .catch((err) => console.error('Failed to persist global environment:', err));
};
export const updateGlobalEnvironmentColor = (environmentUid, color) => (dispatch, getState) => {
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.spec.js
new file mode 100644
index 000000000..2508a1eb2
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.spec.js
@@ -0,0 +1,531 @@
+jest.mock('nanoid', () => ({
+ customAlphabet: () => () => 'mock-uid'
+}));
+
+jest.mock('@usebruno/schema', () => ({
+ environmentSchema: { validate: () => Promise.resolve() }
+}));
+
+import { configureStore } from '@reduxjs/toolkit';
+import globalEnvironmentsReducer, {
+ globalEnvironmentsUpdateEvent,
+ _clearScriptGlobalEnvBaseline,
+ updateGlobalEnvironments
+} from 'providers/ReduxStore/slices/global-environments';
+
+const ENV_UID = 'genv-1';
+
+const makeVar = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ type: 'text',
+ secret: false,
+ enabled
+});
+
+// Minimal mock for window.ipcRenderer used by the thunk's disk persistence
+beforeAll(() => {
+ window.ipcRenderer = { invoke: jest.fn().mockResolvedValue(true) };
+});
+
+const createStore = (envVars = [], opts = {}) => {
+ const preloadedState = {
+ globalEnvironments: {
+ globalEnvironments: [
+ { uid: ENV_UID, name: 'GlobalTest', variables: envVars, color: null }
+ ],
+ activeGlobalEnvironmentUid: ENV_UID,
+ globalEnvironmentDraft: opts.draft || null,
+ _scriptGlobalEnvBaseline: opts.baseline || null
+ },
+ workspaces: {
+ activeWorkspaceUid: 'ws-1',
+ workspaces: [{ uid: 'ws-1', pathname: '/workspace' }]
+ }
+ };
+
+ return configureStore({
+ reducer: {
+ globalEnvironments: globalEnvironmentsReducer,
+ workspaces: (state = preloadedState.workspaces) => state
+ },
+ preloadedState
+ });
+};
+
+const getEnv = (store) => {
+ const state = store.getState();
+ return state.globalEnvironments.globalEnvironments.find((e) => e.uid === ENV_UID);
+};
+
+const getGlobalState = (store) => store.getState().globalEnvironments;
+
+describe('globalEnvironmentsUpdateEvent — draft-aware merge', () => {
+ describe('no draft pending (original behavior)', () => {
+ test('updates existing variable values', () => {
+ const store = createStore([makeVar('HOST', 'https://old.com')]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { HOST: 'https://new.com' } }));
+
+ expect(getEnv(store).variables).toHaveLength(1);
+ expect(getEnv(store).variables[0].value).toBe('https://new.com');
+ });
+
+ test('adds new variables from script', () => {
+ const store = createStore([makeVar('HOST', 'https://example.com')]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://example.com', TOKEN: 'abc' }
+ }));
+
+ expect(getEnv(store).variables).toHaveLength(2);
+ expect(getEnv(store).variables.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ });
+
+ test('removes enabled variables deleted by script', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('OLD_VAR', 'remove-me')
+ ]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://example.com' }
+ }));
+
+ expect(getEnv(store).variables).toHaveLength(1);
+ expect(getEnv(store).variables[0].name).toBe('HOST');
+ });
+
+ test('preserves disabled variables even if not in script output', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('DISABLED_VAR', 'keep', false)
+ ]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://example.com' }
+ }));
+
+ expect(getEnv(store).variables).toHaveLength(2);
+ expect(getEnv(store).variables.find((v) => v.name === 'DISABLED_VAR')).toBeDefined();
+ });
+ });
+
+ describe('draft pending — first script event', () => {
+ test('flushes draft, creates baseline, and applies only script changes', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft-edit.com')];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com', TOKEN: 'new-token' }
+ }));
+
+ const vars = getEnv(store).variables;
+ // HOST: script didn't change it → draft value preserved
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft-edit.com');
+ // TOKEN: new from script → added
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('new-token');
+ // Draft cleared
+ expect(getGlobalState(store).globalEnvironmentDraft).toBeNull();
+ // Baseline set
+ expect(getGlobalState(store)._scriptGlobalEnvBaseline).toEqual({ HOST: 'https://saved.com' });
+ });
+
+ test('applies script modification over draft edit of same variable', () => {
+ const savedVars = [makeVar('TOKEN', 'saved-token')];
+ const draftVars = [makeVar('TOKEN', 'draft-token')];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { TOKEN: 'script-token' }
+ }));
+
+ expect(getEnv(store).variables[0].value).toBe('script-token');
+ });
+
+ test('preserves draft-only variables that script does not know about', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('NEW_DRAFT_VAR', 'draft-only-value')
+ ];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com' }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars).toHaveLength(2);
+ expect(vars.find((v) => v.name === 'NEW_DRAFT_VAR').value).toBe('draft-only-value');
+ });
+
+ test('ignores draft for non-active environment', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: 'other-env-uid', variables: [makeVar('HOST', 'https://draft.com')] }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://new.com' }
+ }));
+
+ // Draft should NOT be flushed (different env)
+ expect(getGlobalState(store).globalEnvironmentDraft).not.toBeNull();
+ // Should apply directly
+ expect(getEnv(store).variables[0].value).toBe('https://new.com');
+ expect(getGlobalState(store)._scriptGlobalEnvBaseline).toBeNull();
+ });
+ });
+
+ describe('baseline exists — subsequent script events', () => {
+ test('preserves draft edits across multiple script events', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft.com')];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // First event
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com', TOKEN: 'abc' }
+ }));
+
+ // Second event — same data
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com', TOKEN: 'abc' }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ });
+
+ test('applies new changes from later script events', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [makeVar('HOST', 'https://draft.com')];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // First event
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com', TOKEN: 'abc' }
+ }));
+
+ // Second event: post-response adds RESULT
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com', TOKEN: 'abc', RESULT: 'ok' }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('abc');
+ expect(vars.find((v) => v.name === 'RESULT').value).toBe('ok');
+ });
+ });
+
+ describe('deleteGlobalEnvVar — script deletes a variable', () => {
+ test('no draft: removes the variable', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret')
+ ]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://example.com' }
+ }));
+
+ expect(getEnv(store).variables).toHaveLength(1);
+ expect(getEnv(store).variables[0].name).toBe('HOST');
+ });
+
+ test('with draft: removes variable that existed in saved state', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token')
+ ];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ // Script deleted TOKEN
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com' }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ });
+
+ test('with draft: does not remove draft-only variables', () => {
+ const savedVars = [makeVar('HOST', 'https://saved.com')];
+ const draftVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('DRAFT_ONLY', 'user-added')
+ ];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: { HOST: 'https://saved.com' }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'DRAFT_ONLY').value).toBe('user-added');
+ });
+ });
+
+ describe('deleteAllGlobalEnvVars — script clears all variables', () => {
+ test('no draft: removes all enabled variables', () => {
+ const store = createStore([
+ makeVar('HOST', 'https://example.com'),
+ makeVar('TOKEN', 'secret'),
+ makeVar('DISABLED', 'keep', false)
+ ]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: {}
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars).toHaveLength(1);
+ expect(vars[0].name).toBe('DISABLED');
+ });
+
+ test('with draft: removes saved vars but keeps draft-only vars', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('DRAFT_ONLY', 'user-added')
+ ];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: {}
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'HOST')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'TOKEN')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'DRAFT_ONLY').value).toBe('user-added');
+ });
+ });
+
+ describe('mixed operations — set + delete in same script', () => {
+ test('with draft: script adds, modifies, and deletes — draft edits preserved', () => {
+ const savedVars = [
+ makeVar('HOST', 'https://saved.com'),
+ makeVar('TOKEN', 'saved-token'),
+ makeVar('TO_DELETE', 'remove-me')
+ ];
+ const draftVars = [
+ makeVar('HOST', 'https://draft.com'),
+ makeVar('TOKEN', 'draft-token'),
+ makeVar('TO_DELETE', 'draft-value'),
+ makeVar('DRAFT_NEW', 'from-user')
+ ];
+ const store = createStore(savedVars, {
+ draft: { environmentUid: ENV_UID, variables: draftVars }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({
+ globalEnvironmentVariables: {
+ HOST: 'https://saved.com',
+ TOKEN: 'script-token',
+ SCRIPT_NEW: 'from-script'
+ }
+ }));
+
+ const vars = getEnv(store).variables;
+ expect(vars.find((v) => v.name === 'HOST').value).toBe('https://draft.com');
+ expect(vars.find((v) => v.name === 'TOKEN').value).toBe('script-token');
+ expect(vars.find((v) => v.name === 'TO_DELETE')).toBeUndefined();
+ expect(vars.find((v) => v.name === 'DRAFT_NEW').value).toBe('from-user');
+ expect(vars.find((v) => v.name === 'SCRIPT_NEW').value).toBe('from-script');
+ });
+ });
+
+ describe('typed values — dataType inference', () => {
+ test('infers number dataType when script sets a numeric value', () => {
+ const store = createStore([makeVar('COUNT', '0')]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { COUNT: 42 } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'COUNT');
+ expect(v.value).toBe(42);
+ expect(v.dataType).toBe('number');
+ });
+
+ test('infers boolean dataType when script sets a boolean value', () => {
+ const store = createStore([makeVar('FLAG', 'false')]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { FLAG: true } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'FLAG');
+ expect(v.value).toBe(true);
+ expect(v.dataType).toBe('boolean');
+ });
+
+ test('infers object dataType when script sets an object value', () => {
+ const store = createStore([makeVar('CONFIG', '')]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { CONFIG: { port: 3000 } } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'CONFIG');
+ expect(v.value).toEqual({ port: 3000 });
+ expect(v.dataType).toBe('object');
+ });
+
+ test('keeps existing dataType on a typed var the script did not touch', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ const store = createStore([typedVar, makeVar('HOST', 'https://example.com')]);
+
+ // Script touched HOST only; the runtime payload still carries COUNT (unchanged).
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { COUNT: 42, HOST: 'https://new.com' } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBe('number');
+ });
+
+ test('drops dataType when script replaces a typed value with a string', () => {
+ const typedVar = { ...makeVar('COUNT', 42), dataType: 'number' };
+ const store = createStore([typedVar]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { COUNT: 'not-a-number' } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'COUNT');
+ expect(v.dataType).toBeUndefined();
+ });
+
+ test('updates dataType when script changes the value type', () => {
+ const typedVar = { ...makeVar('VAL', 42), dataType: 'number' };
+ const store = createStore([typedVar]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { VAL: true } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'VAL');
+ expect(v.value).toBe(true);
+ expect(v.dataType).toBe('boolean');
+ });
+ });
+
+ describe('baseline cleanup', () => {
+ test('_clearScriptGlobalEnvBaseline clears the baseline', () => {
+ const store = createStore([makeVar('HOST', 'https://saved.com')], {
+ baseline: { HOST: 'https://saved.com' }
+ });
+
+ expect(getGlobalState(store)._scriptGlobalEnvBaseline).not.toBeNull();
+
+ store.dispatch(_clearScriptGlobalEnvBaseline());
+
+ expect(getGlobalState(store)._scriptGlobalEnvBaseline).toBeNull();
+ });
+ });
+
+ describe('object/array typed-var no-op writes preserve draft (deep-equal compare)', () => {
+ test('script re-writing a structurally-equal object value does NOT clobber draft edit', () => {
+ const savedVar = { ...makeVar('CFG', { port: 3000 }), dataType: 'object' };
+ const draftVar = { ...makeVar('CFG', { port: 4000 }), dataType: 'object' };
+ const store = createStore([savedVar], {
+ draft: { environmentUid: ENV_UID, variables: [draftVar] }
+ });
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { CFG: { port: 3000 } } }));
+
+ const v = getEnv(store).variables.find((v) => v.name === 'CFG');
+ expect(v.value).toEqual({ port: 4000 });
+ });
+ });
+
+ describe('disabled-var name collision — script targets enabled slot only', () => {
+ test('script setting X writes to the enabled X, leaves the disabled X untouched', () => {
+ const disabledX = makeVar('X', 'archived', false);
+ const enabledX = makeVar('X', 'current');
+ const store = createStore([disabledX, enabledX]);
+
+ store.dispatch(globalEnvironmentsUpdateEvent({ globalEnvironmentVariables: { X: 'updated' } }));
+
+ const xVars = getEnv(store).variables.filter((v) => v.name === 'X');
+ expect(xVars).toHaveLength(2);
+ expect(xVars.find((v) => v.enabled === false).value).toBe('archived');
+ expect(xVars.find((v) => v.enabled === true).value).toBe('updated');
+ });
+ });
+});
+
+describe('updateGlobalEnvironments — activeGlobalEnvironmentUid resolution', () => {
+ // The reducer trusts the caller-supplied active uid (every consumer derives it from the
+ // per-workspace electron store) and only validates it against the new env list, dropping
+ // to null if the uid doesn't match any env.
+ const setup = (preEnvs, preActiveUid, payloadEnvs, payloadActiveUid) => {
+ const preloadedState = {
+ globalEnvironments: {
+ globalEnvironments: preEnvs,
+ activeGlobalEnvironmentUid: preActiveUid,
+ globalEnvironmentDraft: null,
+ _scriptGlobalEnvBaseline: null
+ }
+ };
+ const store = configureStore({
+ reducer: { globalEnvironments: globalEnvironmentsReducer },
+ preloadedState
+ });
+ store.dispatch(updateGlobalEnvironments({
+ globalEnvironments: payloadEnvs,
+ activeGlobalEnvironmentUid: payloadActiveUid
+ }));
+ return store.getState().globalEnvironments.activeGlobalEnvironmentUid;
+ };
+
+ test('incoming uid present in new envs → resolved to incoming uid', () => {
+ const result = setup(
+ [{ uid: 'old-1', name: 'Stage' }],
+ 'old-1',
+ [{ uid: 'new-1', name: 'Stage' }, { uid: 'new-2', name: 'Prod' }],
+ 'new-2'
+ );
+ expect(result).toBe('new-2');
+ });
+
+ test('incoming uid not in new envs → null (stale uid is dropped)', () => {
+ const result = setup(
+ [{ uid: 'gone', name: 'DeletedEnv' }],
+ 'gone',
+ [{ uid: 'other', name: 'Different' }],
+ 'gone'
+ );
+ expect(result).toBeNull();
+ });
+
+ test('no incoming uid + no prior state → null', () => {
+ const result = setup(
+ [],
+ null,
+ [{ uid: 'env-A', name: 'Stage' }],
+ null
+ );
+ expect(result).toBeNull();
+ });
+});
diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js
index 972e502d7..97c7bd703 100644
--- a/packages/bruno-app/src/utils/codemirror/autocomplete.js
+++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js
@@ -3,9 +3,6 @@ import { mockDataFunctions } from '@usebruno/common';
const CodeMirror = require('codemirror');
// Static API hints - Bruno JavaScript API (subgrouped by category)
-// TODO: Restore the commented-out APIs once the UI update fixes are live.
-// Currently these APIs only work within the request lifecycle but fail to update the UI tables.
-// e.g., setCollectionVar only sets the variable in the request lifecycle, fails to update the table in the UI.
const STATIC_API_HINTS = {
req: [
'req',
@@ -109,13 +106,12 @@ const STATIC_API_HINTS = {
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
- // 'bru.setCollectionVar(key, value)',
+ 'bru.setCollectionVar(key, value)',
'bru.hasCollectionVar(key)',
- // 'bru.deleteCollectionVar(key)',
- // 'bru.deleteAllCollectionVars()',
- // 'bru.getAllCollectionVars()',
+ 'bru.deleteCollectionVar(key)',
+ 'bru.deleteAllCollectionVars()',
+ 'bru.getAllCollectionVars()',
'bru.setEnvVar(key, value)',
- 'bru.setEnvVar(key, value, options)',
'bru.deleteEnvVar(key)',
'bru.getAllEnvVars()',
'bru.deleteAllEnvVars()',
@@ -139,9 +135,9 @@ const STATIC_API_HINTS = {
'bru.hasGlobalEnvVar(key)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
- // 'bru.deleteGlobalEnvVar(key)',
+ 'bru.deleteGlobalEnvVar(key)',
'bru.getAllGlobalEnvVars()',
- // 'bru.deleteAllGlobalEnvVars()',
+ 'bru.deleteAllGlobalEnvVars()',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 20c8ea776..0c10eb31b 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1,6 +1,5 @@
import { cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
-import { buildPersistedEnvVariables } from 'utils/environments';
import { sortByNameThenSequence } from 'utils/common/index';
import path from 'utils/common/path';
import { isRequestTagsIncluded } from '@usebruno/common';
@@ -603,11 +602,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
collectionToSave.version = '1';
collectionToSave.items = [];
collectionToSave.activeEnvironmentUid = collection.activeEnvironmentUid;
- // Save environments without runtime metadata (ephemeral/persistedValue)
- collectionToSave.environments = (collection.environments || []).map((env) => ({
- ...env,
- variables: buildPersistedEnvVariables(env?.variables, { mode: 'save' })
- }));
+ collectionToSave.environments = collection.environments || [];
collectionToSave.root = {
request: {}
diff --git a/packages/bruno-app/src/utils/environments.js b/packages/bruno-app/src/utils/environments.js
index 856a54bc6..2ee5276a4 100644
--- a/packages/bruno-app/src/utils/environments.js
+++ b/packages/bruno-app/src/utils/environments.js
@@ -1,34 +1,6 @@
+import { isEqual } from 'lodash';
import { uuid } from './common/index';
-const isPersistableEnvVarForMerge = (persistedNames) => (v) => {
- return !v?.ephemeral || v?.persistedValue !== undefined || (v?.name && persistedNames.has(v.name));
-};
-
-const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
- const { ephemeral, persistedValue, ...rest } = v || {};
- if (v?.ephemeral && persistedValue !== undefined && !(v?.name && persistedNames.has(v.name))) {
- return { ...rest, value: persistedValue };
- }
- return rest;
-};
-
-const toPersistedEnvVarForSave = (v) => {
- const { ephemeral, persistedValue, ...rest } = v || {};
- return rest;
-};
-
-// mode 'save': commit the visible value (Save button).
-// mode 'merge': commit only allowed vars — non-ephemeral, ephemerals with
-// persistedValue, or names explicitly persisted this run.
-export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
- const src = Array.isArray(variables) ? variables : [];
- if (mode === 'merge') {
- const names = persistedNames instanceof Set ? persistedNames : new Set();
- return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
- }
- return src.map(toPersistedEnvVarForSave);
-};
-
export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
const isSecret = !!obj.secret;
let envVariable = {
@@ -53,6 +25,84 @@ export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {
};
};
+/**
+ * Apply script-produced environment variables onto a variables array.
+ *
+ * With baseline: only applies values the script changed relative to the snapshot (preserves draft edits).
+ * Without baseline: direct apply — overwrites all values from script output.
+ * Disabled variables are always preserved; script writes target the enabled slot only — if no
+ * enabled var with `key` exists, a new enabled one is inserted (any same-named disabled var is left intact).
+ *
+ * Pure: does not mutate the input array or its entries. Returns a new array of new objects.
+ */
+export const applyScriptEnvVars = (variables, scriptVars, baseline, { skipKeys = [] } = {}) => {
+ const scriptVarNames = new Set(Object.keys(scriptVars));
+ const skip = new Set(skipKeys);
+ const next = (variables || []).map((v) => ({ ...v }));
+
+ if (baseline) {
+ Object.entries(scriptVars).forEach(([key, value]) => {
+ if (skip.has(key)) return;
+ const isNew = !(key in baseline);
+ // Deep-equal so object/array typed vars whose structurally-equal value is re-written by the
+ // script aren't treated as modifications (and thus don't clobber draft edits).
+ const isModified = !isNew && !isEqual(baseline[key], value);
+
+ if (isNew || isModified) {
+ // Target only the enabled slot — a draft-disabled var with the same name must be preserved.
+ const existing = next.find((v) => v.name === key && v.enabled);
+ if (existing) {
+ existing.value = value;
+ } else {
+ next.push({ uid: uuid(), name: key, value, type: 'text', secret: false, enabled: true });
+ }
+ }
+ });
+
+ return next.filter((v) => {
+ if (!v.enabled) return true;
+ if (v.name in baseline && !scriptVarNames.has(v.name)) return false;
+ return true;
+ });
+ }
+
+ Object.entries(scriptVars).forEach(([key, value]) => {
+ if (skip.has(key)) return;
+ const existing = next.find((v) => v.name === key && v.enabled);
+ if (existing) {
+ existing.value = value;
+ } else {
+ next.push({ uid: uuid(), name: key, value, type: 'text', secret: false, enabled: true });
+ }
+ });
+
+ return next.filter((v) => !v.enabled || scriptVarNames.has(v.name));
+};
+
+/**
+ * Returns the set of keys the script actually modified relative to a baseline (or all script keys
+ * when no baseline is supplied — direct-apply mode). Used by the slice reducers to scope dataType
+ * re-inference to vars that actually changed; without this the dataType loop would clobber a user's
+ * draft-only typed value edit on every no-op script re-run.
+ */
+export const getScriptModifiedKeys = (scriptVars, baseline, { skipKeys = [] } = {}) => {
+ const skip = new Set(skipKeys);
+ const out = new Set();
+ Object.entries(scriptVars || {}).forEach(([key, value]) => {
+ if (skip.has(key)) return;
+ if (baseline) {
+ const isNew = !(key in baseline);
+ if (!isNew && isEqual(baseline[key], value)) return;
+ }
+ out.add(key);
+ });
+ return out;
+};
+
+/**
+ * Strips the UID from an environment variable for comparison purposes.
+ * This is useful when comparing variables where UIDs may differ but the actual data is the same.
+ */
export const stripEnvVarUid = (variable) => {
const { name, value, type, enabled, secret, dataType } = variable;
const result = { name, value, type, enabled, secret };
diff --git a/packages/bruno-app/src/utils/environments.spec.js b/packages/bruno-app/src/utils/environments.spec.js
index e30d49ddd..6118fa4e4 100644
--- a/packages/bruno-app/src/utils/environments.spec.js
+++ b/packages/bruno-app/src/utils/environments.spec.js
@@ -3,7 +3,7 @@ jest.mock('nanoid', () => ({
customAlphabet: () => () => 'aaaaaaaaaaaaaaaaaaaa1'
}));
-import { buildEnvVariable, stripEnvVarUid, buildPersistedEnvVariables } from './environments';
+import { applyScriptEnvVars, buildEnvVariable, stripEnvVarUid } from './environments';
describe('buildEnvVariable — dataType preservation for env export/import', () => {
it('preserves non-string datatypes on non-secret variables', () => {
@@ -55,6 +55,238 @@ describe('stripEnvVarUid — datatype-aware comparison key', () => {
});
});
+describe('applyScriptEnvVars', () => {
+ const v = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ type: 'text',
+ secret: false,
+ enabled
+ });
+
+ describe('direct-apply mode (no baseline)', () => {
+ it('updates the value of an existing variable', () => {
+ const result = applyScriptEnvVars([v('host', 'old')], { host: 'new' }, null);
+ expect(result.find((x) => x.name === 'host').value).toBe('new');
+ });
+
+ it('appends variables present in scriptVars but not in the array', () => {
+ const result = applyScriptEnvVars([v('host', 'h')], { host: 'h', token: 'abc' }, null);
+ expect(result).toHaveLength(2);
+ expect(result.find((x) => x.name === 'token')).toMatchObject({
+ name: 'token',
+ value: 'abc',
+ type: 'text',
+ secret: false,
+ enabled: true
+ });
+ });
+
+ it('removes enabled variables missing from scriptVars (script deleted them)', () => {
+ const result = applyScriptEnvVars([v('host', 'h'), v('stale', 'remove-me')], { host: 'h' }, null);
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('host');
+ });
+
+ it('preserves disabled variables even if missing from scriptVars', () => {
+ const result = applyScriptEnvVars([v('host', 'h'), v('keep', 'k', false)], { host: 'h' }, null);
+ expect(result.map((x) => x.name).sort()).toEqual(['host', 'keep']);
+ });
+
+ it('honors skipKeys — entries are neither applied nor used for the removal filter', () => {
+ const result = applyScriptEnvVars(
+ [v('host', 'h')],
+ { host: 'h', __name__: 'Test' },
+ null,
+ { skipKeys: ['__name__'] }
+ );
+ // __name__ is not pushed as a new var even though it appeared in scriptVars
+ expect(result.find((x) => x.name === '__name__')).toBeUndefined();
+ // host is still present (it IS in scriptVarNames, which is built before skipKeys is applied)
+ expect(result.find((x) => x.name === 'host')).toBeDefined();
+ });
+
+ it('preserves typed (non-string) values without coercion', () => {
+ const result = applyScriptEnvVars([], { count: 42, flag: true, cfg: { k: 1 } }, null);
+ expect(result.find((x) => x.name === 'count').value).toBe(42);
+ expect(result.find((x) => x.name === 'flag').value).toBe(true);
+ expect(result.find((x) => x.name === 'cfg').value).toEqual({ k: 1 });
+ });
+
+ it('returns an empty array when both inputs are empty', () => {
+ expect(applyScriptEnvVars([], {}, null)).toEqual([]);
+ });
+
+ it('preserves dataType on an existing var when only its value is updated', () => {
+ const existing = [{ ...v('count', 41), dataType: 'number' }];
+ const result = applyScriptEnvVars(existing, { count: 42 }, null);
+ const out = result.find((x) => x.name === 'count');
+ expect(out.value).toBe(42);
+ expect(out.dataType).toBe('number');
+ });
+
+ it('does NOT attach a dataType to newly-pushed vars — that is the caller\'s responsibility', () => {
+ // applyScriptEnvVars never calls getDataTypeFromValue; the slice that owns the
+ // dispatch (scriptEnvironmentUpdateEvent / globalEnvironmentsUpdateEvent /
+ // collectionVariablesUpdateEvent) infers and attaches dataType after this merge.
+ const result = applyScriptEnvVars([], { count: 42, flag: true, cfg: { k: 1 } }, null);
+ expect(result.find((x) => x.name === 'count').dataType).toBeUndefined();
+ expect(result.find((x) => x.name === 'flag').dataType).toBeUndefined();
+ expect(result.find((x) => x.name === 'cfg').dataType).toBeUndefined();
+ });
+ });
+
+ describe('baseline-diff mode (preserves draft edits)', () => {
+ it('does NOT overwrite a draft edit when the script value matches the baseline (unchanged)', () => {
+ const draftVars = [v('host', 'draft-edit')];
+ const baseline = { host: 'saved-value' };
+ const scriptVars = { host: 'saved-value' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ expect(result.find((x) => x.name === 'host').value).toBe('draft-edit');
+ });
+
+ it('overwrites the draft value when the script value differs from baseline (modified)', () => {
+ const draftVars = [v('host', 'draft-edit')];
+ const baseline = { host: 'saved-value' };
+ const scriptVars = { host: 'script-new-value' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ expect(result.find((x) => x.name === 'host').value).toBe('script-new-value');
+ });
+
+ it('adds variables that appear in scriptVars but not in baseline (new)', () => {
+ const draftVars = [v('host', 'h')];
+ const baseline = { host: 'h' };
+ const scriptVars = { host: 'h', fresh: 'from-script' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ expect(result.find((x) => x.name === 'fresh')).toMatchObject({
+ name: 'fresh',
+ value: 'from-script',
+ enabled: true
+ });
+ });
+
+ it('removes variables that were in baseline but missing from scriptVars (script deleted)', () => {
+ const draftVars = [v('host', 'h'), v('wasSaved', 'value')];
+ const baseline = { host: 'h', wasSaved: 'value' };
+ const scriptVars = { host: 'h' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ expect(result.find((x) => x.name === 'wasSaved')).toBeUndefined();
+ });
+
+ it('preserves draft-only variables (not in baseline, not in scriptVars)', () => {
+ const draftVars = [v('host', 'h'), v('draft-only', 'user-added')];
+ const baseline = { host: 'h' };
+ const scriptVars = { host: 'h' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ expect(result.find((x) => x.name === 'draft-only')).toMatchObject({
+ name: 'draft-only',
+ value: 'user-added'
+ });
+ });
+
+ it('preserves disabled variables even when they would otherwise be removed', () => {
+ const draftVars = [v('host', 'h'), v('disabled', 'keep', false)];
+ const baseline = { host: 'h', disabled: 'keep' };
+ const scriptVars = { host: 'h' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ // 'disabled' is in baseline and missing from scriptVars, but it's disabled so it stays
+ expect(result.find((x) => x.name === 'disabled')).toMatchObject({ name: 'disabled', enabled: false });
+ });
+
+ it('honors skipKeys — does not modify or add skipped entries', () => {
+ const draftVars = [v('host', 'h')];
+ const baseline = { host: 'h' };
+ const scriptVars = { host: 'h', __name__: 'Test' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline, { skipKeys: ['__name__'] });
+ expect(result.find((x) => x.name === '__name__')).toBeUndefined();
+ });
+
+ it('preserves dataType on an existing var when only its value is updated', () => {
+ const draftVars = [{ ...v('count', 41), dataType: 'number' }];
+ const baseline = { count: 40 };
+ const scriptVars = { count: 42 };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ const out = result.find((x) => x.name === 'count');
+ expect(out.value).toBe(42);
+ expect(out.dataType).toBe('number');
+ });
+
+ it('preserves dataType on a disabled typed var that the script does not touch', () => {
+ const draftVars = [v('host', 'h'), { ...v('flag', false, false), dataType: 'boolean' }];
+ const baseline = { host: 'h', flag: false };
+ const scriptVars = { host: 'h' };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ const out = result.find((x) => x.name === 'flag');
+ expect(out).toMatchObject({ name: 'flag', enabled: false, dataType: 'boolean' });
+ });
+
+ it('combined: script adds, modifies, deletes; draft edits to unchanged vars are preserved', () => {
+ const draftVars = [
+ v('host', 'draft-host'), // user-edited, script will leave value matching baseline
+ v('token', 'draft-token'), // user-edited, script will override
+ v('stale', 'draft-stale'), // script will delete (was in baseline)
+ v('draft-only', 'user-added') // user-only, not in baseline
+ ];
+ const baseline = {
+ host: 'saved-host',
+ token: 'saved-token',
+ stale: 'saved-stale'
+ };
+ const scriptVars = {
+ host: 'saved-host', // unchanged from baseline → draft 'draft-host' wins
+ token: 'script-new-token', // modified → script wins
+ added: 'from-script' // new → added
+ };
+
+ const result = applyScriptEnvVars(draftVars, scriptVars, baseline);
+ const byName = Object.fromEntries(result.map((x) => [x.name, x.value]));
+ expect(byName).toEqual({
+ 'host': 'draft-host',
+ 'token': 'script-new-token',
+ 'added': 'from-script',
+ 'draft-only': 'user-added'
+ });
+ expect(result.find((x) => x.name === 'stale')).toBeUndefined();
+ });
+ });
+
+ describe('secret flag preservation', () => {
+ const secretVar = (name, value, enabled = true) => ({
+ uid: `uid-${name}`,
+ name,
+ value,
+ type: 'text',
+ secret: true,
+ enabled
+ });
+
+ it('preserves secret: true when script updates an enabled secret var (baseline mode)', () => {
+ const result = applyScriptEnvVars(
+ [secretVar('apiToken', 'old')],
+ { apiToken: 'new' },
+ { apiToken: 'old' }
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ name: 'apiToken', value: 'new', secret: true, enabled: true });
+ });
+
+ it('preserves secret: true in direct-apply (no baseline) mode', () => {
+ const result = applyScriptEnvVars([secretVar('apiToken', 'old')], { apiToken: 'new' }, null);
+ expect(result[0]).toMatchObject({ name: 'apiToken', value: 'new', secret: true });
+ });
+ });
+});
+
describe('Env export → import round-trip via JSON', () => {
it('preserves dataType across export → JSON.stringify → JSON.parse → import for every supported type', () => {
const reduxEnvVars = [
@@ -89,61 +321,3 @@ describe('Env export → import round-trip via JSON', () => {
expect(reimported[5]).toMatchObject({ name: 'token', value: '', secret: true, dataType: 'number' });
});
});
-
-describe('buildPersistedEnvVariables — save mode', () => {
- // Regression guard: save mode must commit the visible value of an ephemeral
- // var, not roll it back to persistedValue.
- it('keeps the visible value when an ephemeral var has a persistedValue', () => {
- const variables = [
- {
- name: 'apiKey',
- value: 'testvalue',
- type: 'text',
- enabled: true,
- secret: true,
- ephemeral: true,
- persistedValue: 'test'
- }
- ];
-
- expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
- { name: 'apiKey', value: 'testvalue', type: 'text', enabled: true, secret: true }
- ]);
- });
-
- it('keeps the visible value for a non-secret ephemeral var too', () => {
- const variables = [
- {
- name: 'host',
- value: 'localhost-from-script',
- type: 'text',
- enabled: true,
- secret: false,
- ephemeral: true,
- persistedValue: 'localhost'
- }
- ];
-
- expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
- { name: 'host', value: 'localhost-from-script', type: 'text', enabled: true, secret: false }
- ]);
- });
-
- it('strips ephemeral/persistedValue from non-ephemeral vars', () => {
- const variables = [
- {
- name: 'plain',
- value: 'v',
- type: 'text',
- enabled: true,
- secret: false,
- ephemeral: false,
- persistedValue: 'leftover'
- }
- ];
-
- expect(buildPersistedEnvVariables(variables, { mode: 'save' })).toEqual([
- { name: 'plain', value: 'v', type: 'text', enabled: true, secret: false }
- ]);
- });
-});
diff --git a/packages/bruno-electron/src/ipc/ai/script-prompts.js b/packages/bruno-electron/src/ipc/ai/script-prompts.js
index df6a92a4f..7b9a5cf9f 100644
--- a/packages/bruno-electron/src/ipc/ai/script-prompts.js
+++ b/packages/bruno-electron/src/ipc/ai/script-prompts.js
@@ -4,7 +4,6 @@ const BRUNO_API_REFERENCE = `## Bruno API Reference
\`\`\`js
bru.getEnvVar(key)
bru.setEnvVar(key, value)
-bru.setEnvVar(key, value, { persist: true })
bru.hasEnvVar(key)
bru.deleteEnvVar(key)
bru.getEnvName()
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index ba9a458d7..bc2b759c6 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -506,7 +506,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
const filename = format === 'yml' ? 'opencollection.yml' : 'collection.bru';
const content = await stringifyCollection(collectionRoot, brunoConfig, { format });
- await writeFile(path.join(collectionPathname, filename), content);
+ const filePath = path.join(collectionPathname, filename);
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
+ if (content === existing) return; // skip write if content unchanged
+ await writeFile(filePath, content);
} catch (error) {
console.error('Error in save-collection-root:', error);
return Promise.reject(error);
@@ -766,6 +769,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
const content = await stringifyEnvironment(environment, { format });
+ const existing = fs.readFileSync(envFilePath, 'utf8');
+ if (content === existing) return; // skip write if content unchanged
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 79cc0b714..ffdee4066 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -508,6 +508,41 @@ const registerNetworkIpc = (mainWindow) => {
};
};
+ const sendVariableUpdates = (result, { collectionUid, requestUid, collection }) => {
+ if (result.runtimeVariables) {
+ mainWindow.webContents.send('main:runtime-variables-update', {
+ runtimeVariables: result.runtimeVariables,
+ requestUid,
+ collectionUid
+ });
+ }
+
+ if (result.envVariables) {
+ mainWindow.webContents.send('main:script-environment-update', {
+ envVariables: result.envVariables,
+ requestUid,
+ collectionUid
+ });
+ }
+
+ if (result.globalEnvironmentVariables) {
+ mainWindow.webContents.send('main:global-environment-variables-update', {
+ globalEnvironmentVariables: result.globalEnvironmentVariables,
+ requestUid,
+ collectionUid
+ });
+ collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
+ }
+
+ if (result.collectionVariables) {
+ mainWindow.webContents.send('main:collection-variables-update', {
+ collectionVariables: result.collectionVariables,
+ requestUid,
+ collectionUid
+ });
+ }
+ };
+
const resetOauth2Credentials = ({ oauth2CredentialsToReset, request, collectionUid }) => {
if (!oauth2CredentialsToReset?.length) return;
for (const credentialId of oauth2CredentialsToReset) {
@@ -559,25 +594,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionName
);
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: scriptResult.envVariables,
- runtimeVariables: scriptResult.runtimeVariables,
- persistentEnvVariables: scriptResult.persistentEnvVariables,
- requestUid,
- collectionUid
- });
-
- mainWindow.webContents.send('main:persistent-env-variables-update', {
- persistentEnvVariables: scriptResult.persistentEnvVariables,
- collectionUid
- });
-
- mainWindow.webContents.send('main:global-environment-variables-update', {
- globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
- });
-
- collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
-
+ sendVariableUpdates(scriptResult, { collectionUid, requestUid, collection });
resetOauth2Credentials({ oauth2CredentialsToReset: scriptResult.oauth2CredentialsToReset, request, collectionUid });
const domainsWithCookies = await getDomainsWithCookies();
@@ -669,24 +686,7 @@ const registerNetworkIpc = (mainWindow) => {
);
if (result) {
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: result.envVariables,
- runtimeVariables: result.runtimeVariables,
- persistentEnvVariables: result.persistentEnvVariables,
- requestUid,
- collectionUid
- });
-
- mainWindow.webContents.send('main:persistent-env-variables-update', {
- persistentEnvVariables: result.persistentEnvVariables,
- collectionUid
- });
-
- mainWindow.webContents.send('main:global-environment-variables-update', {
- globalEnvironmentVariables: result.globalEnvironmentVariables
- });
-
- collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
+ sendVariableUpdates(result, { collectionUid, requestUid, collection });
}
if (result?.error) {
@@ -714,25 +714,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionName
);
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: scriptResult.envVariables,
- runtimeVariables: scriptResult.runtimeVariables,
- persistentEnvVariables: scriptResult.persistentEnvVariables,
- requestUid,
- collectionUid
- });
-
- mainWindow.webContents.send('main:persistent-env-variables-update', {
- persistentEnvVariables: scriptResult.persistentEnvVariables,
- collectionUid
- });
-
- mainWindow.webContents.send('main:global-environment-variables-update', {
- globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
- });
-
- collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
-
+ sendVariableUpdates(scriptResult, { collectionUid, requestUid, collection });
resetOauth2Credentials({ oauth2CredentialsToReset: scriptResult.oauth2CredentialsToReset, request, collectionUid });
const domainsWithCookiesPost = await getDomainsWithCookies();
@@ -741,12 +723,13 @@ const registerNetworkIpc = (mainWindow) => {
return scriptResult;
};
- const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false, callerBru = null, parentExecutionMode = null, parentRunnerEventData = null }) => {
+ const runRequest = async ({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground = false, callerBru = null, parentExecutionMode = null, parentRunnerEventData = null, parentRequestUid = null }) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
- // Nested bru.runRequest() invocations have no item.requestUid; mint one.
- const requestUid = item.requestUid || uuid();
+ // Nested bru.runRequest() invocations have no item.requestUid; inherit the parent's
+ // so script-driven variable updates aren't dropped by the renderer's requestUid gate.
+ const requestUid = item.requestUid || parentRequestUid || uuid();
const runRequestByItemPathname = async (relativeItemPathname, callerBru) => {
return new Promise(async (resolve, reject) => {
@@ -797,7 +780,7 @@ const registerNetworkIpc = (mainWindow) => {
const startedAt = Date.now();
let res, err;
try {
- res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true, callerBru, parentExecutionMode, parentRunnerEventData });
+ res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true, callerBru, parentExecutionMode, parentRunnerEventData, parentRequestUid: requestUid });
} catch (e) {
err = e;
}
@@ -929,6 +912,9 @@ const registerNetworkIpc = (mainWindow) => {
if (preRequestError?.partialResults) {
preRequestScriptResult = preRequestError.partialResults;
+ // Forward any variable mutations the script made before throwing so the UI
+ // and disk stay in sync with the partial test results we're about to render.
+ sendVariableUpdates(preRequestScriptResult, { collectionUid, requestUid, collection });
}
emitScriptedRequestEvents('pre-request', preRequestScriptResult);
@@ -1127,6 +1113,8 @@ const registerNetworkIpc = (mainWindow) => {
// (e.g., if 2 tests pass then script throws, we still want to show those 2 passing tests)
if (postResponseError?.partialResults) {
postResponseScriptResult = postResponseError.partialResults;
+ // Forward any variable mutations the script made before throwing.
+ sendVariableUpdates(postResponseScriptResult, { collectionUid, requestUid, collection });
}
emitScriptedRequestEvents('post-response', postResponseScriptResult);
@@ -1221,24 +1209,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: testResults.envVariables,
- runtimeVariables: testResults.runtimeVariables,
- requestUid,
- collectionUid
- });
-
- mainWindow.webContents.send('main:persistent-env-variables-update', {
- persistentEnvVariables: testResults.persistentEnvVariables,
- collectionUid
- });
-
- mainWindow.webContents.send('main:global-environment-variables-update', {
- globalEnvironmentVariables: testResults.globalEnvironmentVariables
- });
-
- collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
-
+ sendVariableUpdates(testResults, { collectionUid, requestUid, collection });
resetOauth2Credentials({ oauth2CredentialsToReset: testResults.oauth2CredentialsToReset, request, collectionUid });
!runInBackground && notifyScriptExecution({
@@ -1643,8 +1614,11 @@ const registerNetworkIpc = (mainWindow) => {
let timeStart;
let timeEnd;
+ const requestUid = uuid();
+
mainWindow.webContents.send('main:run-folder-event', {
type: 'request-queued',
+ requestUid,
...eventData
});
@@ -1669,8 +1643,6 @@ const registerNetworkIpc = (mainWindow) => {
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
- const requestUid = uuid();
-
const promptVars = await extractPromptVariablesForRequest({ request, collection, envVars, runtimeVariables, processEnvVars });
if (promptVars.length > 0) {
@@ -1729,6 +1701,7 @@ const registerNetworkIpc = (mainWindow) => {
if (preRequestError?.partialResults) {
preRequestScriptResult = preRequestError.partialResults;
+ sendVariableUpdates(preRequestScriptResult, { collectionUid, requestUid, collection });
}
preRequestScriptResult = appendScriptErrorResult('pre-request', preRequestScriptResult, preRequestError);
@@ -1987,6 +1960,7 @@ const registerNetworkIpc = (mainWindow) => {
// (e.g., if 2 tests pass then script throws, we still want to show those 2 passing tests)
if (postResponseError?.partialResults) {
postResponseScriptResult = postResponseError.partialResults;
+ sendVariableUpdates(postResponseScriptResult, { collectionUid, requestUid, collection });
}
postResponseScriptResult = appendScriptErrorResult('post-response', postResponseScriptResult, postResponseError);
@@ -2094,18 +2068,7 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: testResults.envVariables,
- runtimeVariables: testResults.runtimeVariables,
- collectionUid
- });
-
- mainWindow.webContents.send('main:global-environment-variables-update', {
- globalEnvironmentVariables: testResults.globalEnvironmentVariables
- });
-
- collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
-
+ sendVariableUpdates(testResults, { collectionUid, requestUid, collection });
resetOauth2Credentials({ oauth2CredentialsToReset: testResults.oauth2CredentialsToReset, request, collectionUid });
notifyScriptExecution({
diff --git a/packages/bruno-electron/tests/store/env-secrets.spec.js b/packages/bruno-electron/tests/store/env-secrets.spec.js
new file mode 100644
index 000000000..dc339e179
--- /dev/null
+++ b/packages/bruno-electron/tests/store/env-secrets.spec.js
@@ -0,0 +1,62 @@
+const EnvironmentSecretsStore = require('../../src/store/env-secrets');
+const { decryptStringSafe } = require('../../src/utils/encryption');
+
+describe('EnvironmentSecretsStore', () => {
+ let secretsStore;
+ const collectionPath = '/tmp/test-collection';
+
+ beforeEach(() => {
+ secretsStore = new EnvironmentSecretsStore();
+ secretsStore.store.clear();
+ });
+
+ it('encrypts secret values before persisting them', () => {
+ const environment = {
+ name: 'Local',
+ variables: [
+ { name: 'apiToken', value: 'new', enabled: true, secret: true },
+ { name: 'host', value: 'http://localhost', enabled: true, secret: false }
+ ]
+ };
+
+ secretsStore.storeEnvSecrets(collectionPath, environment);
+
+ const stored = secretsStore.getEnvSecrets(collectionPath, environment);
+ expect(stored).toHaveLength(1);
+ expect(stored[0].name).toBe('apiToken');
+ expect(stored[0].value).not.toBe('new');
+ expect(typeof stored[0].value).toBe('string');
+ expect(stored[0].value.length).toBeGreaterThan(0);
+ });
+
+ it('round-trips the secret value through decryptStringSafe', () => {
+ const environment = {
+ name: 'Local',
+ variables: [{ name: 'apiToken', value: 'new', enabled: true, secret: true }]
+ };
+
+ secretsStore.storeEnvSecrets(collectionPath, environment);
+
+ const [stored] = secretsStore.getEnvSecrets(collectionPath, environment);
+ const decrypted = decryptStringSafe(stored.value);
+ expect(decrypted.success).toBe(true);
+ expect(decrypted.value).toBe('new');
+ });
+
+ it('overwrites the prior encrypted value when the same secret is re-stored', () => {
+ const environment = {
+ name: 'Local',
+ variables: [{ name: 'apiToken', value: 'first', enabled: true, secret: true }]
+ };
+ secretsStore.storeEnvSecrets(collectionPath, environment);
+ const [first] = secretsStore.getEnvSecrets(collectionPath, environment);
+
+ environment.variables[0].value = 'second';
+ secretsStore.storeEnvSecrets(collectionPath, environment);
+ const after = secretsStore.getEnvSecrets(collectionPath, environment);
+
+ expect(after).toHaveLength(1);
+ expect(after[0].value).not.toBe(first.value);
+ expect(decryptStringSafe(after[0].value).value).toBe('second');
+ });
+});
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 34637cc3e..badad7abc 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -1,4 +1,4 @@
-const { cloneDeep } = require('lodash');
+const { cloneDeep, isEqual } = require('lodash');
const xmlFormat = require('xml-formatter');
const { interpolate: _interpolate } = require('@usebruno/common');
const { createSendRequest } = require('@usebruno/requests').scripting;
@@ -76,8 +76,11 @@ class Bru {
createCookieJar,
getCookiesForUrl
});
- // Holds variables that are marked as persistent by scripts
- this.persistentEnvVariables = {};
+ // Dirty flags — set by mutators so runtimes can skip IPC/disk writes for unchanged scopes
+ this._envDirty = false;
+ this._globalEnvDirty = false;
+ this._collVarsDirty = false;
+ this._runtimeVarsDirty = false;
// Holds credential IDs to be reset after script execution
this.oauth2CredentialsToReset = [];
this.runner = {
@@ -192,7 +195,7 @@ class Bru {
return this.interpolate(this.envVariables[key]);
}
- setEnvVar(key, value, options = {}) {
+ setEnvVar(key, value) {
if (!key) {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
@@ -203,19 +206,21 @@ class Bru {
);
}
- this.envVariables[key] = value;
-
- if (options?.persist) {
- this.persistentEnvVariables[key] = value;
- } else {
- if (this.persistentEnvVariables[key]) {
- delete this.persistentEnvVariables[key];
- }
+ // Deep-equal compare so object/array writes that mutate in place
+ // (e.g. `const c = bru.getEnvVar('cfg'); c.port = 4000; bru.setEnvVar('cfg', c);`)
+ // still flip the dirty flag — strict `!==` returned false for same-reference writes.
+ if (!Object.hasOwn(this.envVariables, key) || !isEqual(this.envVariables[key], value)) {
+ this.envVariables[key] = value;
+ this._envDirty = true;
}
}
deleteEnvVar(key) {
- delete this.envVariables[key];
+ if (key === '__name__') return;
+ if (Object.hasOwn(this.envVariables, key)) {
+ delete this.envVariables[key];
+ this._envDirty = true;
+ }
}
getAllEnvVars() {
@@ -225,15 +230,15 @@ class Bru {
}
deleteAllEnvVars() {
- const envName = this.envVariables.__name__;
- for (let key in this.envVariables) {
- if (this.envVariables.hasOwnProperty(key)) {
- delete this.envVariables[key];
- }
- }
- if (envName !== undefined) {
- this.envVariables.__name__ = envName;
+ // Iterate via Object.keys (own enumerable) so a user-set `hasOwnProperty` var
+ // can't shadow Object.prototype.hasOwnProperty and crash the loop.
+ let removed = false;
+ for (const key of Object.keys(this.envVariables)) {
+ if (key === '__name__') continue;
+ delete this.envVariables[key];
+ removed = true;
}
+ if (removed) this._envDirty = true;
}
hasGlobalEnvVar(key) {
@@ -249,28 +254,31 @@ class Bru {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
- this.globalEnvironmentVariables[key] = value;
+ if (!Object.hasOwn(this.globalEnvironmentVariables, key) || !isEqual(this.globalEnvironmentVariables[key], value)) {
+ this.globalEnvironmentVariables[key] = value;
+ this._globalEnvDirty = true;
+ }
}
- // TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // deleteGlobalEnvVar(key) {
- // delete this.globalEnvironmentVariables[key];
- // }
+ deleteGlobalEnvVar(key) {
+ if (Object.hasOwn(this.globalEnvironmentVariables, key)) {
+ delete this.globalEnvironmentVariables[key];
+ this._globalEnvDirty = true;
+ }
+ }
getAllGlobalEnvVars() {
return Object.assign({}, this.globalEnvironmentVariables);
}
- // TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // deleteAllGlobalEnvVars() {
- // for (let key in this.globalEnvironmentVariables) {
- // if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
- // delete this.globalEnvironmentVariables[key];
- // }
- // }
- // }
+ deleteAllGlobalEnvVars() {
+ const keys = Object.keys(this.globalEnvironmentVariables);
+ if (!keys.length) return;
+ for (const key of keys) {
+ delete this.globalEnvironmentVariables[key];
+ }
+ this._globalEnvDirty = true;
+ }
getOauth2CredentialVar(key) {
return this.interpolate(this.oauth2CredentialVariables[key]);
@@ -310,7 +318,10 @@ class Bru {
);
}
- this.runtimeVariables[key] = value;
+ if (!Object.hasOwn(this.runtimeVariables, key) || !isEqual(this.runtimeVariables[key], value)) {
+ this.runtimeVariables[key] = value;
+ this._runtimeVarsDirty = true;
+ }
}
getVar(key) {
@@ -325,15 +336,19 @@ class Bru {
}
deleteVar(key) {
- delete this.runtimeVariables[key];
+ if (Object.hasOwn(this.runtimeVariables, key)) {
+ delete this.runtimeVariables[key];
+ this._runtimeVarsDirty = true;
+ }
}
deleteAllVars() {
- for (let key in this.runtimeVariables) {
- if (this.runtimeVariables.hasOwnProperty(key)) {
- delete this.runtimeVariables[key];
- }
+ const keys = Object.keys(this.runtimeVariables);
+ if (!keys.length) return;
+ for (const key of keys) {
+ delete this.runtimeVariables[key];
}
+ this._runtimeVarsDirty = true;
}
getAllVars() {
@@ -344,48 +359,47 @@ class Bru {
return this.interpolate(this.collectionVariables[key]);
}
- // TODO: setCollectionVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // setCollectionVar(key, value) {
- // if (!key) {
- // throw new Error('Creating a variable without specifying a name is not allowed.');
- // }
- //
- // if (variableNameRegex.test(key) === false) {
- // throw new Error(
- // `Variable name: "${key}" contains invalid characters!`
- // + ' Names must only contain alpha-numeric characters, "-", "_", "."'
- // );
- // }
- //
- // this.collectionVariables[key] = value;
- // }
+ setCollectionVar(key, value) {
+ if (!key) {
+ throw new Error('Creating a variable without specifying a name is not allowed.');
+ }
+
+ if (variableNameRegex.test(key) === false) {
+ throw new Error(
+ `Variable name: "${key}" contains invalid characters!`
+ + ' Names must only contain alpha-numeric characters, "-", "_", "."'
+ );
+ }
+
+ if (!Object.hasOwn(this.collectionVariables, key) || !isEqual(this.collectionVariables[key], value)) {
+ this.collectionVariables[key] = value;
+ this._collVarsDirty = true;
+ }
+ }
hasCollectionVar(key) {
return Object.hasOwn(this.collectionVariables, key);
}
- // TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // deleteCollectionVar(key) {
- // delete this.collectionVariables[key];
- // }
+ deleteCollectionVar(key) {
+ if (Object.hasOwn(this.collectionVariables, key)) {
+ delete this.collectionVariables[key];
+ this._collVarsDirty = true;
+ }
+ }
- // TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // deleteAllCollectionVars() {
- // for (let key in this.collectionVariables) {
- // if (this.collectionVariables.hasOwnProperty(key)) {
- // delete this.collectionVariables[key];
- // }
- // }
- // }
+ deleteAllCollectionVars() {
+ const keys = Object.keys(this.collectionVariables);
+ if (!keys.length) return;
+ for (const key of keys) {
+ delete this.collectionVariables[key];
+ }
+ this._collVarsDirty = true;
+ }
- // TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // getAllCollectionVars() {
- // return Object.assign({}, this.collectionVariables);
- // }
+ getAllCollectionVars() {
+ return Object.assign({}, this.collectionVariables);
+ }
getFolderVar(key) {
return this.interpolate(this.folderVariables[key]);
diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js
index 44bc6ce7d..1dfa96f96 100644
--- a/packages/bruno-js/src/runtime/script-runtime.js
+++ b/packages/bruno-js/src/runtime/script-runtime.js
@@ -89,10 +89,10 @@ class ScriptRuntime {
// Extracted to avoid duplication across runtime branches
const buildRequestScriptResult = () => ({
request,
- envVariables: cleanJson(envVariables),
- runtimeVariables: cleanJson(runtimeVariables),
- persistentEnvVariables: bru.persistentEnvVariables,
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
@@ -225,10 +225,10 @@ class ScriptRuntime {
// Extracted to avoid duplication across runtime branches
const buildResponseScriptResult = () => ({
response,
- envVariables: cleanJson(envVariables),
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
- runtimeVariables: cleanJson(runtimeVariables),
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js
index 3742aee59..61e6e425a 100644
--- a/packages/bruno-js/src/runtime/test-runtime.js
+++ b/packages/bruno-js/src/runtime/test-runtime.js
@@ -62,9 +62,10 @@ class TestRuntime {
if (!testsFile || !testsFile.length) {
return {
request,
- envVariables,
- runtimeVariables,
- globalEnvironmentVariables,
+ envVariables: null,
+ runtimeVariables: null,
+ collectionVariables: null,
+ globalEnvironmentVariables: null,
results: __brunoTestResults.getResults(),
nextRequestName: bru.nextRequest
};
@@ -125,10 +126,10 @@ class TestRuntime {
const result = {
request,
- envVariables: cleanJson(envVariables),
- runtimeVariables: cleanJson(runtimeVariables),
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js
index 483501db0..7c80d7eac 100644
--- a/packages/bruno-js/src/runtime/vars-runtime.js
+++ b/packages/bruno-js/src/runtime/vars-runtime.js
@@ -86,10 +86,10 @@ class VarsRuntime {
}
return {
- envVariables,
- runtimeVariables,
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
error
};
}
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
index 6b60ea1bc..11faae4f0 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
@@ -54,8 +54,8 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getEnvVar', getEnvVar);
getEnvVar.dispose();
- let setEnvVar = vm.newFunction('setEnvVar', function (key, value, options = {}) {
- bru.setEnvVar(vm.dump(key), vm.dump(value), vm.dump(options));
+ let setEnvVar = vm.newFunction('setEnvVar', function (key, value) {
+ bru.setEnvVar(vm.dump(key), vm.dump(value));
});
vm.setProp(bruObject, 'setEnvVar', setEnvVar);
setEnvVar.dispose();
@@ -102,13 +102,11 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
setGlobalEnvVar.dispose();
- // TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
- // bru.deleteGlobalEnvVar(vm.dump(key));
- // });
- // vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
- // deleteGlobalEnvVar.dispose();
+ let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
+ bru.deleteGlobalEnvVar(vm.dump(key));
+ });
+ vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
+ deleteGlobalEnvVar.dispose();
let getAllGlobalEnvVars = vm.newFunction('getAllGlobalEnvVars', function () {
return marshallToVm(bru.getAllGlobalEnvVars(), vm);
@@ -122,13 +120,11 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'hasGlobalEnvVar', hasGlobalEnvVar);
hasGlobalEnvVar.dispose();
- // TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
- // bru.deleteAllGlobalEnvVars();
- // });
- // vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
- // deleteAllGlobalEnvVars.dispose();
+ let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
+ bru.deleteAllGlobalEnvVars();
+ });
+ vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
+ deleteAllGlobalEnvVars.dispose();
let hasVar = vm.newFunction('hasVar', function (key) {
return marshallToVm(bru.hasVar(vm.dump(key)), vm);
@@ -220,13 +216,11 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getCollectionVar', getCollectionVar);
getCollectionVar.dispose();
- // TODO: setCollectionVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
- // bru.setCollectionVar(vm.dump(key), vm.dump(value));
- // });
- // vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
- // setCollectionVar.dispose();
+ let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
+ bru.setCollectionVar(vm.dump(key), vm.dump(value));
+ });
+ vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
+ setCollectionVar.dispose();
let hasCollectionVar = vm.newFunction('hasCollectionVar', function (key) {
return marshallToVm(bru.hasCollectionVar(vm.dump(key)), vm);
@@ -234,29 +228,23 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'hasCollectionVar', hasCollectionVar);
hasCollectionVar.dispose();
- // TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
- // bru.deleteCollectionVar(vm.dump(key));
- // });
- // vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
- // deleteCollectionVar.dispose();
+ let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
+ bru.deleteCollectionVar(vm.dump(key));
+ });
+ vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
+ deleteCollectionVar.dispose();
- // TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
- // bru.deleteAllCollectionVars();
- // });
- // vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
- // deleteAllCollectionVars.dispose();
+ let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
+ bru.deleteAllCollectionVars();
+ });
+ vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
+ deleteAllCollectionVars.dispose();
- // TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
- // Re-enable once the UI sync issue is resolved.
- // let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
- // return marshallToVm(bru.getAllCollectionVars(), vm);
- // });
- // vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
- // getAllCollectionVars.dispose();
+ let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
+ return marshallToVm(bru.getAllCollectionVars(), vm);
+ });
+ vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
+ getAllCollectionVars.dispose();
let getTestResults = vm.newFunction('getTestResults', () => {
const promise = vm.newPromise();
diff --git a/packages/bruno-js/tests/collectionVar.spec.js b/packages/bruno-js/tests/collectionVar.spec.js
new file mode 100644
index 000000000..450fcccdb
--- /dev/null
+++ b/packages/bruno-js/tests/collectionVar.spec.js
@@ -0,0 +1,217 @@
+const Bru = require('../src/bru');
+
+describe('Collection Variable APIs', () => {
+ const makeBru = (collectionVariables = {}) =>
+ new Bru({
+ runtime: 'quickjs',
+ envVariables: {},
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test',
+ collectionVariables
+ });
+
+ describe('bru.setCollectionVar', () => {
+ test('sets a new collection variable', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('baseUrl', 'https://api.example.com');
+ expect(bru.collectionVariables.baseUrl).toBe('https://api.example.com');
+ });
+
+ test('overwrites an existing collection variable', () => {
+ const bru = makeBru({ baseUrl: 'https://old.com' });
+ bru.setCollectionVar('baseUrl', 'https://new.com');
+ expect(bru.collectionVariables.baseUrl).toBe('https://new.com');
+ });
+
+ test('allows non-string values', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('count', 42);
+ expect(bru.collectionVariables.count).toBe(42);
+
+ bru.setCollectionVar('active', true);
+ expect(bru.collectionVariables.active).toBe(true);
+
+ bru.setCollectionVar('config', { port: 3000 });
+ expect(bru.collectionVariables.config).toEqual({ port: 3000 });
+ });
+
+ test('throws when key is empty', () => {
+ const bru = makeBru();
+ expect(() => bru.setCollectionVar('', 'v')).toThrow(/without specifying a name/);
+ });
+
+ test('throws when key has invalid characters', () => {
+ const bru = makeBru();
+ expect(() => bru.setCollectionVar('invalid key', 'v')).toThrow(/contains invalid characters/);
+ });
+
+ test('re-run: setting same key twice does not throw', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('key', 'first');
+ bru.setCollectionVar('key', 'second');
+ expect(bru.collectionVariables.key).toBe('second');
+ });
+ });
+
+ describe('bru.setCollectionVar — dirty flag for typed values', () => {
+ test('setting a number trips the collection-vars dirty flag', () => {
+ const bru = makeBru();
+ expect(bru._collVarsDirty).toBe(false);
+ bru.setCollectionVar('count', 42);
+ expect(bru._collVarsDirty).toBe(true);
+ });
+
+ test('setting a boolean trips the collection-vars dirty flag', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('active', true);
+ expect(bru._collVarsDirty).toBe(true);
+ });
+
+ test('setting an object trips the collection-vars dirty flag', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('config', { port: 3000 });
+ expect(bru._collVarsDirty).toBe(true);
+ });
+ });
+
+ describe('bru.setCollectionVar — dirty flag for reference-mutation idiom', () => {
+ test('the getCollectionVar → mutate → setCollectionVar idiom trips the dirty flag', () => {
+ const bru = makeBru({ config: { port: 3000 } });
+ expect(bru._collVarsDirty).toBe(false);
+ // Real-script idiom: getCollectionVar deep-copies through interpolate's JSON roundtrip,
+ // so mutating the result leaves the store untouched and the deep-equal guard fires.
+ const config = bru.getCollectionVar('config');
+ config.port = 4000;
+ bru.setCollectionVar('config', config);
+ expect(bru._collVarsDirty).toBe(true);
+ expect(bru.collectionVariables.config).toEqual({ port: 4000 });
+ });
+
+ test('re-setting a structurally-equal object value does NOT trip the dirty flag', () => {
+ const bru = makeBru({ config: { port: 3000 } });
+ bru.setCollectionVar('config', { port: 3000 });
+ expect(bru._collVarsDirty).toBe(false);
+ });
+
+ test('re-setting a structurally-equal primitive value does NOT trip the dirty flag', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.setCollectionVar('token', 'abc');
+ expect(bru._collVarsDirty).toBe(false);
+ });
+ });
+
+ describe('bru.deleteCollectionVar — dirty flag contract', () => {
+ test('deleting an existing key trips the collection-vars dirty flag', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteCollectionVar('token');
+ expect(bru._collVarsDirty).toBe(true);
+ });
+
+ test('deleting a non-existent key leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteCollectionVar('missing');
+ expect(bru._collVarsDirty).toBe(false);
+ });
+ });
+
+ describe('bru.deleteAllCollectionVars — dirty flag contract', () => {
+ test('deleting populated scope trips the dirty flag', () => {
+ const bru = makeBru({ a: '1', b: '2' });
+ bru.deleteAllCollectionVars();
+ expect(bru._collVarsDirty).toBe(true);
+ });
+
+ test('calling on empty scope leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteAllCollectionVars();
+ expect(bru._collVarsDirty).toBe(false);
+ });
+ });
+
+ describe('bru.getCollectionVar', () => {
+ test('returns the value of an existing variable', () => {
+ const bru = makeBru({ token: 'abc123' });
+ expect(bru.getCollectionVar('token')).toBe('abc123');
+ });
+
+ test('returns undefined for a non-existent variable', () => {
+ const bru = makeBru();
+ expect(bru.getCollectionVar('missing')).toBeUndefined();
+ });
+ });
+
+ describe('bru.hasCollectionVar', () => {
+ test('returns true for an existing variable', () => {
+ const bru = makeBru({ token: 'abc' });
+ expect(bru.hasCollectionVar('token')).toBe(true);
+ });
+
+ test('returns false for a non-existent variable', () => {
+ const bru = makeBru();
+ expect(bru.hasCollectionVar('missing')).toBe(false);
+ });
+ });
+
+ describe('bru.deleteCollectionVar', () => {
+ test('removes an existing variable', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteCollectionVar('token');
+ expect(bru.collectionVariables.token).toBeUndefined();
+ expect(bru.hasCollectionVar('token')).toBe(false);
+ });
+
+ test('re-run: deleting a non-existent key is a silent no-op', () => {
+ const bru = makeBru();
+ expect(() => bru.deleteCollectionVar('missing')).not.toThrow();
+ });
+
+ test('re-run: deleting an already-deleted key is a silent no-op', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteCollectionVar('token');
+ expect(() => bru.deleteCollectionVar('token')).not.toThrow();
+ });
+ });
+
+ describe('bru.deleteAllCollectionVars', () => {
+ test('removes all collection variables', () => {
+ const bru = makeBru({ a: '1', b: '2', c: '3' });
+ bru.deleteAllCollectionVars();
+ expect(bru.collectionVariables).toEqual({});
+ });
+
+ test('re-run: calling on empty scope is a silent no-op', () => {
+ const bru = makeBru();
+ expect(() => bru.deleteAllCollectionVars()).not.toThrow();
+ expect(bru.collectionVariables).toEqual({});
+ });
+
+ test('re-run: calling twice is a silent no-op', () => {
+ const bru = makeBru({ a: '1' });
+ bru.deleteAllCollectionVars();
+ expect(() => bru.deleteAllCollectionVars()).not.toThrow();
+ expect(bru.collectionVariables).toEqual({});
+ });
+ });
+
+ describe('bru.getAllCollectionVars', () => {
+ test('returns a copy of all collection variables', () => {
+ const bru = makeBru({ a: '1', b: '2' });
+ const all = bru.getAllCollectionVars();
+ expect(all).toEqual({ a: '1', b: '2' });
+ });
+
+ test('returned object is a copy, not a reference', () => {
+ const bru = makeBru({ a: '1' });
+ const all = bru.getAllCollectionVars();
+ all.a = 'mutated';
+ expect(bru.collectionVariables.a).toBe('1');
+ });
+
+ test('returns empty object when no variables exist', () => {
+ const bru = makeBru();
+ expect(bru.getAllCollectionVars()).toEqual({});
+ });
+ });
+});
diff --git a/packages/bruno-js/tests/globalEnvVar.spec.js b/packages/bruno-js/tests/globalEnvVar.spec.js
new file mode 100644
index 000000000..4bf397f14
--- /dev/null
+++ b/packages/bruno-js/tests/globalEnvVar.spec.js
@@ -0,0 +1,207 @@
+const Bru = require('../src/bru');
+
+describe('Global Environment Variable APIs', () => {
+ const makeBru = (globalEnvironmentVariables = {}) =>
+ new Bru({
+ runtime: 'quickjs',
+ envVariables: {},
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test',
+ globalEnvironmentVariables
+ });
+
+ describe('bru.setGlobalEnvVar', () => {
+ test('sets a new global env variable', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('apiKey', 'abc123');
+ expect(bru.globalEnvironmentVariables.apiKey).toBe('abc123');
+ });
+
+ test('overwrites an existing variable', () => {
+ const bru = makeBru({ apiKey: 'old' });
+ bru.setGlobalEnvVar('apiKey', 'new');
+ expect(bru.globalEnvironmentVariables.apiKey).toBe('new');
+ });
+
+ test('allows non-string values', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('count', 42);
+ expect(bru.globalEnvironmentVariables.count).toBe(42);
+
+ bru.setGlobalEnvVar('config', { port: 3000 });
+ expect(bru.globalEnvironmentVariables.config).toEqual({ port: 3000 });
+ });
+
+ test('throws when key is empty', () => {
+ const bru = makeBru();
+ expect(() => bru.setGlobalEnvVar('', 'v')).toThrow(/without specifying a name/);
+ });
+
+ test('re-run: setting same key twice does not throw', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('key', 'first');
+ bru.setGlobalEnvVar('key', 'second');
+ expect(bru.globalEnvironmentVariables.key).toBe('second');
+ });
+ });
+
+ describe('bru.setGlobalEnvVar — dirty flag for typed values', () => {
+ test('setting a number trips the global-env dirty flag', () => {
+ const bru = makeBru();
+ expect(bru._globalEnvDirty).toBe(false);
+ bru.setGlobalEnvVar('count', 42);
+ expect(bru._globalEnvDirty).toBe(true);
+ });
+
+ test('setting a boolean trips the global-env dirty flag', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('active', true);
+ expect(bru._globalEnvDirty).toBe(true);
+ });
+
+ test('setting an object trips the global-env dirty flag', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('config', { port: 3000 });
+ expect(bru._globalEnvDirty).toBe(true);
+ });
+ });
+
+ describe('bru.setGlobalEnvVar — dirty flag for reference-mutation idiom', () => {
+ test('the getGlobalEnvVar → mutate → setGlobalEnvVar idiom trips the dirty flag', () => {
+ const bru = makeBru({ config: { port: 3000 } });
+ expect(bru._globalEnvDirty).toBe(false);
+ // Real-script idiom: getGlobalEnvVar deep-copies through interpolate's JSON roundtrip,
+ // so mutating the result leaves the store untouched and the deep-equal guard fires.
+ const config = bru.getGlobalEnvVar('config');
+ config.port = 4000;
+ bru.setGlobalEnvVar('config', config);
+ expect(bru._globalEnvDirty).toBe(true);
+ expect(bru.globalEnvironmentVariables.config).toEqual({ port: 4000 });
+ });
+
+ test('re-setting a structurally-equal object value does NOT trip the dirty flag', () => {
+ const bru = makeBru({ config: { port: 3000 } });
+ bru.setGlobalEnvVar('config', { port: 3000 });
+ expect(bru._globalEnvDirty).toBe(false);
+ });
+
+ test('re-setting a structurally-equal primitive value does NOT trip the dirty flag', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.setGlobalEnvVar('token', 'abc');
+ expect(bru._globalEnvDirty).toBe(false);
+ });
+ });
+
+ describe('bru.deleteGlobalEnvVar — dirty flag contract', () => {
+ test('deleting an existing key trips the global-env dirty flag', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteGlobalEnvVar('token');
+ expect(bru._globalEnvDirty).toBe(true);
+ });
+
+ test('deleting a non-existent key leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteGlobalEnvVar('missing');
+ expect(bru._globalEnvDirty).toBe(false);
+ });
+ });
+
+ describe('bru.deleteAllGlobalEnvVars — dirty flag contract', () => {
+ test('deleting populated scope trips the dirty flag', () => {
+ const bru = makeBru({ a: '1', b: '2' });
+ bru.deleteAllGlobalEnvVars();
+ expect(bru._globalEnvDirty).toBe(true);
+ });
+
+ test('calling on empty scope leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteAllGlobalEnvVars();
+ expect(bru._globalEnvDirty).toBe(false);
+ });
+ });
+
+ describe('bru.getGlobalEnvVar', () => {
+ test('returns value of an existing variable', () => {
+ const bru = makeBru({ token: 'abc' });
+ expect(bru.getGlobalEnvVar('token')).toBe('abc');
+ });
+
+ test('returns undefined for non-existent variable', () => {
+ const bru = makeBru();
+ expect(bru.getGlobalEnvVar('missing')).toBeUndefined();
+ });
+ });
+
+ describe('bru.hasGlobalEnvVar', () => {
+ test('returns true for existing variable', () => {
+ const bru = makeBru({ token: 'abc' });
+ expect(bru.hasGlobalEnvVar('token')).toBe(true);
+ });
+
+ test('returns false for non-existent variable', () => {
+ const bru = makeBru();
+ expect(bru.hasGlobalEnvVar('missing')).toBe(false);
+ });
+ });
+
+ describe('bru.deleteGlobalEnvVar', () => {
+ test('removes an existing variable', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteGlobalEnvVar('token');
+ expect(bru.globalEnvironmentVariables.token).toBeUndefined();
+ expect(bru.hasGlobalEnvVar('token')).toBe(false);
+ });
+
+ test('re-run: deleting a non-existent key is a silent no-op', () => {
+ const bru = makeBru();
+ expect(() => bru.deleteGlobalEnvVar('missing')).not.toThrow();
+ });
+
+ test('re-run: deleting an already-deleted key is a silent no-op', () => {
+ const bru = makeBru({ token: 'abc' });
+ bru.deleteGlobalEnvVar('token');
+ expect(() => bru.deleteGlobalEnvVar('token')).not.toThrow();
+ });
+ });
+
+ describe('bru.deleteAllGlobalEnvVars', () => {
+ test('removes all global env variables', () => {
+ const bru = makeBru({ a: '1', b: '2', c: '3' });
+ bru.deleteAllGlobalEnvVars();
+ expect(bru.globalEnvironmentVariables).toEqual({});
+ });
+
+ test('re-run: calling on empty scope is a silent no-op', () => {
+ const bru = makeBru();
+ expect(() => bru.deleteAllGlobalEnvVars()).not.toThrow();
+ expect(bru.globalEnvironmentVariables).toEqual({});
+ });
+
+ test('re-run: calling twice is a silent no-op', () => {
+ const bru = makeBru({ a: '1' });
+ bru.deleteAllGlobalEnvVars();
+ expect(() => bru.deleteAllGlobalEnvVars()).not.toThrow();
+ });
+ });
+
+ describe('bru.getAllGlobalEnvVars', () => {
+ test('returns a copy of all global env variables', () => {
+ const bru = makeBru({ a: '1', b: '2' });
+ expect(bru.getAllGlobalEnvVars()).toEqual({ a: '1', b: '2' });
+ });
+
+ test('returned object is a copy, not a reference', () => {
+ const bru = makeBru({ a: '1' });
+ const all = bru.getAllGlobalEnvVars();
+ all.a = 'mutated';
+ expect(bru.globalEnvironmentVariables.a).toBe('1');
+ });
+
+ test('returns empty object when no variables exist', () => {
+ const bru = makeBru();
+ expect(bru.getAllGlobalEnvVars()).toEqual({});
+ });
+ });
+});
diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js
index 603d2d57a..2a6068836 100644
--- a/packages/bruno-js/tests/runtime.spec.js
+++ b/packages/bruno-js/tests/runtime.spec.js
@@ -180,61 +180,121 @@ describe('runtime', () => {
});
});
- describe('persistent environment variables validation', () => {
- it('should persist non-string values without throwing', async () => {
+ describe('environment variables from scripts', () => {
+ it('should allow any value type', async () => {
const script = `
- bru.setEnvVar('number', 42, {persist: true});
- bru.setEnvVar('isActive', true, {persist: true});
- bru.setEnvVar('config', {port: 3000}, {persist: true});
- bru.setEnvVar('items', ['item1', 'item2'], {persist: true});
- `;
- const runtime = new ScriptRuntime({ runtime: 'nodevm' });
-
- const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
-
- expect(result.envVariables.number).toBe(42);
- expect(result.persistentEnvVariables.number).toBe(42);
- expect(result.envVariables.isActive).toBe(true);
- expect(result.persistentEnvVariables.isActive).toBe(true);
- expect(result.envVariables.config).toEqual({ port: 3000 });
- expect(result.persistentEnvVariables.config).toEqual({ port: 3000 });
- expect(result.envVariables.items).toEqual(['item1', 'item2']);
- expect(result.persistentEnvVariables.items).toEqual(['item1', 'item2']);
- });
-
- it('should allow string values when persist is true', async () => {
- const script = `bru.setEnvVar('api_key', 'abc123', {persist: true});`;
- const runtime = new ScriptRuntime({ runtime: 'nodevm' });
-
- const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
-
- expect(result.envVariables.api_key).toBe('abc123');
- });
-
- it('should allow non-string values when persist is false', async () => {
- const script = `
- bru.setEnvVar('number', 42, {persist: false});
- bru.setEnvVar('boolean', true, {persist: false});
- bru.setEnvVar('object', {key: 'value'}, {persist: false});
- bru.setEnvVar('array', [1, 2, 3], {persist: false});
+ bru.setEnvVar('str', 'hello');
+ bru.setEnvVar('number', 42);
+ bru.setEnvVar('boolean', true);
+ bru.setEnvVar('object', {key: 'value'});
+ bru.setEnvVar('array', [1, 2, 3]);
`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
+ expect(result.envVariables.str).toBe('hello');
expect(result.envVariables.number).toBe(42);
expect(result.envVariables.boolean).toBe(true);
expect(result.envVariables.object).toEqual({ key: 'value' });
expect(result.envVariables.array).toEqual([1, 2, 3]);
});
- it('should allow non-string values when persist is not specified', async () => {
- const script = `bru.setEnvVar('number', 42);`;
+ it('should preserve typed values through the QuickJS shim', async () => {
+ await quickJsLoader();
+ const script = `
+ bru.setEnvVar('num', 42);
+ bru.setEnvVar('bool', true);
+ bru.setEnvVar('obj', { key: 'value' });
+ bru.setCollectionVar('collNum', 7);
+ bru.setGlobalEnvVar('globalBool', false);
+ `;
+ const runtime = new ScriptRuntime({ runtime: 'quickjs' });
+ const onConsoleLog = () => {};
+
+ const result = await runtime.runRequestScript(script, {}, {}, {}, '.', onConsoleLog, process.env);
+
+ expect(typeof result.envVariables.num).toBe('number');
+ expect(result.envVariables.num).toBe(42);
+ expect(typeof result.envVariables.bool).toBe('boolean');
+ expect(result.envVariables.bool).toBe(true);
+ expect(typeof result.envVariables.obj).toBe('object');
+ expect(result.envVariables.obj).toEqual({ key: 'value' });
+ expect(typeof result.collectionVariables.collNum).toBe('number');
+ expect(result.collectionVariables.collNum).toBe(7);
+ expect(typeof result.globalEnvironmentVariables.globalBool).toBe('boolean');
+ expect(result.globalEnvironmentVariables.globalBool).toBe(false);
+ });
+
+ it('should return null for scopes the script did not touch (dirty-flag gating)', async () => {
+ const script = `bru.setEnvVar('only_env', 'val');`;
const runtime = new ScriptRuntime({ runtime: 'nodevm' });
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
- expect(result.envVariables.number).toBe(42);
+ expect(result.envVariables).not.toBeNull();
+ expect(result.envVariables.only_env).toBe('val');
+ expect(result.collectionVariables).toBeNull();
+ expect(result.globalEnvironmentVariables).toBeNull();
+ });
+
+ it('should return null for scopes the script did not touch — QuickJS parity', async () => {
+ await quickJsLoader();
+ const script = `bru.setEnvVar('only_env', 'val');`;
+ const runtime = new ScriptRuntime({ runtime: 'quickjs' });
+ const onConsoleLog = () => {};
+
+ const result = await runtime.runRequestScript(script, {}, {}, {}, '.', onConsoleLog, process.env);
+
+ expect(result.envVariables).not.toBeNull();
+ expect(result.envVariables.only_env).toBe('val');
+ expect(result.collectionVariables).toBeNull();
+ expect(result.globalEnvironmentVariables).toBeNull();
+ });
+
+ it('should not include persistentEnvVariables in result', async () => {
+ const script = `bru.setEnvVar('key', 'val');`;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+
+ const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
+
+ expect(result).not.toHaveProperty('persistentEnvVariables');
+ });
+
+ it('should include collectionVariables in result', async () => {
+ const script = `bru.setCollectionVar('myVar', 'myValue');`;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+
+ const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
+
+ expect(result.collectionVariables).toBeDefined();
+ expect(result.collectionVariables.myVar).toBe('myValue');
+ });
+
+ it('should silently ignore old persist flag as extra argument', async () => {
+ const scriptTrue = `bru.setEnvVar('key1', 'val1', { persist: true });`;
+ const scriptFalse = `bru.setEnvVar('key2', 'val2', { persist: false });`;
+ const runtime = new ScriptRuntime({ runtime: 'nodevm' });
+
+ const result1 = await runtime.runRequestScript(scriptTrue, {}, {}, {}, '.', null, process.env);
+ expect(result1.envVariables.key1).toBe('val1');
+
+ const result2 = await runtime.runRequestScript(scriptFalse, {}, {}, {}, '.', null, process.env);
+ expect(result2.envVariables.key2).toBe('val2');
+ });
+
+ it('should silently ignore old persist flag as extra argument — QuickJS parity', async () => {
+ await quickJsLoader();
+ const scriptTrue = `bru.setEnvVar('key1', 'val1', { persist: true });`;
+ const scriptFalse = `bru.setEnvVar('key2', 'val2', { persist: false });`;
+ const runtime = new ScriptRuntime({ runtime: 'quickjs' });
+ const onConsoleLog = () => {};
+
+ const result1 = await runtime.runRequestScript(scriptTrue, {}, {}, {}, '.', onConsoleLog, process.env);
+ expect(result1.envVariables.key1).toBe('val1');
+
+ const result2 = await runtime.runRequestScript(scriptFalse, {}, {}, {}, '.', onConsoleLog, process.env);
+ expect(result2.envVariables.key2).toBe('val2');
});
});
diff --git a/packages/bruno-js/tests/setEnvVar.spec.js b/packages/bruno-js/tests/setEnvVar.spec.js
index d1669a0c6..678d77dcc 100644
--- a/packages/bruno-js/tests/setEnvVar.spec.js
+++ b/packages/bruno-js/tests/setEnvVar.spec.js
@@ -1,112 +1,220 @@
const Bru = require('../src/bru');
-const { valueToString } = require('@usebruno/common/utils');
-describe('Bru.setEnvVar', () => {
- const makeBru = () =>
- new Bru({
+const makeBru = () =>
+ new Bru({
+ runtime: 'quickjs',
+ envVariables: {},
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test'
+ });
+
+describe('bru.setEnvVar', () => {
+ test('sets envVariables[key] to value', () => {
+ const bru = makeBru();
+ bru.setEnvVar('token', 'abc123');
+ expect(bru.envVariables.token).toBe('abc123');
+ });
+
+ test('allows non-string values', () => {
+ const bru = makeBru();
+ bru.setEnvVar('count', 42);
+ expect(bru.envVariables.count).toBe(42);
+
+ bru.setEnvVar('active', true);
+ expect(bru.envVariables.active).toBe(true);
+
+ bru.setEnvVar('config', { port: 3000 });
+ expect(bru.envVariables.config).toEqual({ port: 3000 });
+ });
+
+ test('overwrites existing value', () => {
+ const bru = makeBru();
+ bru.setEnvVar('key', 'old');
+ bru.setEnvVar('key', 'new');
+ expect(bru.envVariables.key).toBe('new');
+ });
+
+ test('throws when key is empty', () => {
+ const bru = makeBru();
+ expect(() => bru.setEnvVar('', 'v')).toThrow(/without specifying a name/);
+ });
+
+ test('rejects key with invalid characters', () => {
+ const bru = makeBru();
+ expect(() => bru.setEnvVar('invalid key', 'v')).toThrow(/contains invalid characters/);
+ });
+});
+
+describe('bru.setEnvVar — dirty flag for typed values', () => {
+ test('setting a number trips the env dirty flag', () => {
+ const bru = makeBru();
+ expect(bru._envDirty).toBe(false);
+ bru.setEnvVar('count', 42);
+ expect(bru._envDirty).toBe(true);
+ });
+
+ test('setting a boolean trips the env dirty flag', () => {
+ const bru = makeBru();
+ bru.setEnvVar('active', true);
+ expect(bru._envDirty).toBe(true);
+ });
+
+ test('setting an object trips the env dirty flag', () => {
+ const bru = makeBru();
+ bru.setEnvVar('config', { port: 3000 });
+ expect(bru._envDirty).toBe(true);
+ });
+});
+
+describe('bru.deleteEnvVar', () => {
+ test('removes an existing variable', () => {
+ const bru = makeBru();
+ bru.setEnvVar('token', 'abc');
+ bru.deleteEnvVar('token');
+ expect(bru.envVariables.token).toBeUndefined();
+ });
+
+ test('deleting a non-existent key is a silent no-op', () => {
+ const bru = makeBru();
+ expect(() => bru.deleteEnvVar('missing')).not.toThrow();
+ });
+
+ test('does not delete the internal __name__ marker', () => {
+ const bru = new Bru({
runtime: 'quickjs',
- envVariables: {},
+ envVariables: { __name__: 'dev', token: 'abc' },
runtimeVariables: {},
processEnvVars: {},
collectionPath: '/',
collectionName: 'Test'
});
-
- test('updates envVariables and does not mark persistent when persist=false', () => {
- const bru = makeBru();
- bru.setEnvVar('non_persist', 'value', { persist: false });
- expect(bru.envVariables.non_persist).toBe('value');
- expect(bru.persistentEnvVariables.non_persist).toBeUndefined();
- });
-
- test('updates envVariables and tracks persistent when persist=true (string only)', () => {
- const bru = makeBru();
- bru.setEnvVar('persist_me', 'value', { persist: true });
- expect(bru.envVariables.persist_me).toBe('value');
- expect(bru.persistentEnvVariables.persist_me).toBe('value');
- });
-
- test('updates envVariables when options are omitted (defaults to non-persistent)', () => {
- const bru = makeBru();
- bru.setEnvVar('no_options', 'value');
- expect(bru.envVariables.no_options).toBe('value');
- expect(bru.persistentEnvVariables.no_options).toBeUndefined();
- });
-
- describe('persist=true with non-string values', () => {
- test('stores numbers as-is without throwing', () => {
- const bru = makeBru();
- expect(() => bru.setEnvVar('n', 123, { persist: true })).not.toThrow();
- expect(bru.envVariables.n).toBe(123);
- expect(bru.persistentEnvVariables.n).toBe(123);
- });
-
- test('stores booleans as-is without throwing', () => {
- const bru = makeBru();
- expect(() => bru.setEnvVar('b', true, { persist: true })).not.toThrow();
- expect(bru.persistentEnvVariables.b).toBe(true);
- });
-
- test('stores plain objects and arrays by reference without throwing', () => {
- const bru = makeBru();
- const obj = { a: 1 };
- const arr = [1, 2, 3];
- bru.setEnvVar('o', obj, { persist: true });
- bru.setEnvVar('a', arr, { persist: true });
- expect(bru.persistentEnvVariables.o).toBe(obj);
- expect(bru.persistentEnvVariables.a).toBe(arr);
- });
-
- test('stores functions and symbols without throwing — but they round-trip to "" via valueToString', () => {
- const bru = makeBru();
- const fn = () => 42;
- const sym = Symbol('s');
- bru.setEnvVar('fn', fn, { persist: true });
- bru.setEnvVar('sym', sym, { persist: true });
-
- // Raw values land in persistentEnvVariables...
- expect(bru.persistentEnvVariables.fn).toBe(fn);
- expect(bru.persistentEnvVariables.sym).toBe(sym);
- // ...but the serializer used by mergeAndPersistEnvironment produces ''
- // for both, so the value is silently lost on the next save round-trip.
- expect(valueToString(fn)).toBe('');
- expect(valueToString(sym)).toBe('');
- });
-
- test('stores circular objects without throwing — but they round-trip to "" via valueToString', () => {
- const bru = makeBru();
- const circular = { a: 1 };
- circular.self = circular;
- bru.setEnvVar('c', circular, { persist: true });
-
- expect(bru.persistentEnvVariables.c).toBe(circular);
- // JSON.stringify throws on circulars; valueToString swallows that and returns ''.
- expect(valueToString(circular)).toBe('');
- });
- });
-
- test('changing existing key to non-persistent removes prior persisted entry', () => {
- const bru = makeBru();
- bru.setEnvVar('same_key', 'old', { persist: true });
- expect(bru.persistentEnvVariables.same_key).toBe('old');
-
- bru.setEnvVar('same_key', 'new');
- expect(bru.envVariables.same_key).toBe('new');
- expect(bru.persistentEnvVariables.same_key).toBeUndefined();
- });
-
- test('changing existing key to persistent updates persisted value', () => {
- const bru = makeBru();
- bru.setEnvVar('same_key', 'old');
- expect(bru.persistentEnvVariables.same_key).toBeUndefined();
-
- bru.setEnvVar('same_key', 'new', { persist: true });
- expect(bru.envVariables.same_key).toBe('new');
- expect(bru.persistentEnvVariables.same_key).toBe('new');
- });
-
- test('validates key name - invalid characters are rejected', () => {
- const bru = makeBru();
- expect(() => bru.setEnvVar('invalid key', 'v')).toThrow(/contains invalid characters/);
+ bru.deleteEnvVar('__name__');
+ expect(bru.envVariables.__name__).toBe('dev');
+ expect(bru._envDirty).toBe(false);
+ });
+});
+
+describe('bru.setEnvVar — dirty flag for reference-mutation idiom', () => {
+ test('the getEnvVar → mutate → setEnvVar idiom trips the dirty flag', () => {
+ const bru = new Bru({
+ runtime: 'quickjs',
+ envVariables: { config: { port: 3000 } },
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test'
+ });
+ expect(bru._envDirty).toBe(false);
+ // Real-script idiom: `const c = bru.getEnvVar('config'); c.port = 4000; bru.setEnvVar('config', c);`
+ // `getEnvVar` deep-copies through interpolate's JSON roundtrip, so mutating the result
+ // leaves envVariables.config untouched; the deep-equal guard then sees a real change.
+ // Pre-fix the strict-`!==` guard missed cases where the script structurally rebuilt the value.
+ const config = bru.getEnvVar('config');
+ config.port = 4000;
+ bru.setEnvVar('config', config);
+ expect(bru._envDirty).toBe(true);
+ expect(bru.envVariables.config).toEqual({ port: 4000 });
+ });
+
+ test('re-setting a structurally-equal object value does NOT trip the dirty flag', () => {
+ const bru = new Bru({
+ runtime: 'quickjs',
+ envVariables: { config: { port: 3000 } },
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test'
+ });
+ bru.setEnvVar('config', { port: 3000 });
+ expect(bru._envDirty).toBe(false);
+ });
+
+ test('re-setting a structurally-equal primitive value does NOT trip the dirty flag', () => {
+ const bru = new Bru({
+ runtime: 'quickjs',
+ envVariables: { token: 'abc' },
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test'
+ });
+ bru.setEnvVar('token', 'abc');
+ expect(bru._envDirty).toBe(false);
+ });
+});
+
+describe('bru.deleteEnvVar — dirty flag contract', () => {
+ test('deleting an existing key trips the env dirty flag', () => {
+ const bru = makeBru();
+ bru.setEnvVar('token', 'abc');
+ bru._envDirty = false; // reset post-set
+ bru.deleteEnvVar('token');
+ expect(bru._envDirty).toBe(true);
+ });
+
+ test('deleting a non-existent key leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteEnvVar('missing');
+ expect(bru._envDirty).toBe(false);
+ });
+});
+
+describe('bru.deleteAllEnvVars — dirty flag contract', () => {
+ test('deleting populated env trips the dirty flag', () => {
+ const bru = new Bru({
+ runtime: 'quickjs',
+ envVariables: { a: '1', b: '2' },
+ runtimeVariables: {},
+ processEnvVars: {},
+ collectionPath: '/',
+ collectionName: 'Test'
+ });
+ bru.deleteAllEnvVars();
+ expect(bru._envDirty).toBe(true);
+ });
+
+ test('calling on empty env leaves the dirty flag clean', () => {
+ const bru = makeBru();
+ bru.deleteAllEnvVars();
+ expect(bru._envDirty).toBe(false);
+ });
+});
+
+describe('bru.deleteAll* methods — resilient to user-shadowed Object.prototype methods', () => {
+ // Use Object.hasOwn (not the property accessor) to check deletion, since after
+ // delete the prototype's hasOwnProperty becomes visible again on a plain object.
+ test('deleteAllEnvVars works when a var named "hasOwnProperty" was set', () => {
+ const bru = makeBru();
+ bru.setEnvVar('hasOwnProperty', 'shadow');
+ bru.setEnvVar('other', 'value');
+ expect(() => bru.deleteAllEnvVars()).not.toThrow();
+ expect(Object.hasOwn(bru.envVariables, 'hasOwnProperty')).toBe(false);
+ expect(Object.hasOwn(bru.envVariables, 'other')).toBe(false);
+ });
+
+ test('deleteAllGlobalEnvVars works when a var named "hasOwnProperty" was set', () => {
+ const bru = makeBru();
+ bru.setGlobalEnvVar('hasOwnProperty', 'shadow');
+ bru.setGlobalEnvVar('other', 'value');
+ expect(() => bru.deleteAllGlobalEnvVars()).not.toThrow();
+ expect(Object.hasOwn(bru.globalEnvironmentVariables, 'hasOwnProperty')).toBe(false);
+ });
+
+ test('deleteAllCollectionVars works when a var named "hasOwnProperty" was set', () => {
+ const bru = makeBru();
+ bru.setCollectionVar('hasOwnProperty', 'shadow');
+ bru.setCollectionVar('other', 'value');
+ expect(() => bru.deleteAllCollectionVars()).not.toThrow();
+ expect(Object.hasOwn(bru.collectionVariables, 'hasOwnProperty')).toBe(false);
+ });
+
+ test('deleteAllVars works when a runtime var named "hasOwnProperty" was set', () => {
+ const bru = makeBru();
+ bru.setVar('hasOwnProperty', 'shadow');
+ bru.setVar('other', 'value');
+ expect(() => bru.deleteAllVars()).not.toThrow();
+ expect(Object.hasOwn(bru.runtimeVariables, 'hasOwnProperty')).toBe(false);
});
});
diff --git a/packages/bruno-tests/collection/ping.bru b/packages/bruno-tests/collection/ping.bru
index 04248eafc..8f4f3c6f7 100644
--- a/packages/bruno-tests/collection/ping.bru
+++ b/packages/bruno-tests/collection/ping.bru
@@ -12,4 +12,4 @@ get {
script:pre-request {
bru.runner.stopExecution();
-}
\ No newline at end of file
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteAllCollectionVars.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteAllCollectionVars.bru
index 9ef73a779..3063d2d73 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/deleteAllCollectionVars.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteAllCollectionVars.bru
@@ -11,9 +11,6 @@ get {
}
script:pre-request {
- // TODO: skipped because deleteAllCollectionVars does not update the UI
- bru.runner.skipRequest();
- return;
bru.setCollectionVar("testDelAllCollectionA", "a");
bru.setCollectionVar("testDelAllCollectionB", "b");
}
@@ -29,6 +26,12 @@ tests {
expect(valB).to.be.undefined;
});
+ test("should not throw when called on empty scope", function() {
+ expect(function() {
+ bru.deleteAllCollectionVars();
+ }).to.not.throw();
+ });
+
// Restore collection vars for subsequent requests
for (const [key, value] of Object.entries(savedCollectionVars)) {
bru.setCollectionVar(key, value);
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteAllEnvVars.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteAllEnvVars.bru
index 28f5f2210..1e01509d2 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/deleteAllEnvVars.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteAllEnvVars.bru
@@ -28,6 +28,12 @@ tests {
expect(envName).to.equal("Prod");
});
+ test("should not throw when called on empty scope", function() {
+ expect(function() {
+ bru.deleteAllEnvVars();
+ }).to.not.throw();
+ });
+
// Restore env vars for subsequent requests
for (const [key, value] of Object.entries(savedEnvVars)) {
bru.setEnvVar(key, value);
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteAllGlobalEnvVars.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteAllGlobalEnvVars.bru
index 1c899bee4..2cdaafa1a 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/deleteAllGlobalEnvVars.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteAllGlobalEnvVars.bru
@@ -11,9 +11,6 @@ get {
}
script:pre-request {
- // TODO: skipped because deleteAllGlobalEnvVars does not update the UI
- bru.runner.skipRequest();
- return;
bru.setGlobalEnvVar("testDelAllGlobalA", "a");
bru.setGlobalEnvVar("testDelAllGlobalB", "b");
}
@@ -29,6 +26,12 @@ tests {
expect(valB).to.be.undefined;
});
+ test("should not throw when called on empty scope", function() {
+ expect(function() {
+ bru.deleteAllGlobalEnvVars();
+ }).to.not.throw();
+ });
+
// Restore global env vars for subsequent requests
for (const [key, value] of Object.entries(savedGlobalEnvVars)) {
bru.setGlobalEnvVar(key, value);
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteCollectionVar.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteCollectionVar.bru
index 3f1b5a5e9..d8d27bc74 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/deleteCollectionVar.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteCollectionVar.bru
@@ -11,10 +11,10 @@ get {
}
script:pre-request {
- // TODO: skipped because deleteCollectionVar does not update the UI
- bru.runner.skipRequest();
- return;
bru.setCollectionVar("testDeleteCollectionVar", "to-be-deleted");
+}
+
+script:post-response {
bru.deleteCollectionVar("testDeleteCollectionVar");
}
@@ -23,4 +23,10 @@ tests {
const val = bru.getCollectionVar("testDeleteCollectionVar");
expect(val).to.be.undefined;
});
+
+ test("should not throw when deleting non-existent key", function() {
+ expect(function() {
+ bru.deleteCollectionVar("non-existent-key");
+ }).to.not.throw();
+ });
}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteEnvVar.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteEnvVar.bru
new file mode 100644
index 000000000..f2865c984
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteEnvVar.bru
@@ -0,0 +1,32 @@
+meta {
+ name: deleteEnvVar
+ type: http
+ seq: 30
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ bru.setEnvVar("testDeleteEnvVar", "to-be-deleted");
+}
+
+script:post-response {
+ bru.deleteEnvVar("testDeleteEnvVar");
+}
+
+tests {
+ test("should delete env var", function() {
+ const val = bru.getEnvVar("testDeleteEnvVar");
+ expect(val).to.be.undefined;
+ });
+
+ test("should not throw when deleting non-existent key", function() {
+ expect(function() {
+ bru.deleteEnvVar("non-existent-key");
+ }).to.not.throw();
+ });
+}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/deleteGlobalEnvVar.bru b/packages/bruno-tests/collection/scripting/api/bru/deleteGlobalEnvVar.bru
index baffdff91..c1bec177a 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/deleteGlobalEnvVar.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/deleteGlobalEnvVar.bru
@@ -11,10 +11,10 @@ get {
}
script:pre-request {
- // TODO: skipped because deleteGlobalEnvVar does not update the UI
- bru.runner.skipRequest();
- return;
bru.setGlobalEnvVar("testDeleteGlobalEnvVar", "to-be-deleted");
+}
+
+script:post-response {
bru.deleteGlobalEnvVar("testDeleteGlobalEnvVar");
}
@@ -23,4 +23,10 @@ tests {
const val = bru.getGlobalEnvVar("testDeleteGlobalEnvVar");
expect(val).to.be.undefined;
});
+
+ test("should not throw when deleting non-existent key", function() {
+ expect(function() {
+ bru.deleteGlobalEnvVar("non-existent-key");
+ }).to.not.throw();
+ });
}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/getAllCollectionVars.bru b/packages/bruno-tests/collection/scripting/api/bru/getAllCollectionVars.bru
index 484c93651..91b25b1de 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/getAllCollectionVars.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/getAllCollectionVars.bru
@@ -11,9 +11,6 @@ get {
}
script:pre-request {
- // TODO: skipped because getAllCollectionVars does not update the UI
- bru.runner.skipRequest();
- return;
bru.setCollectionVar("testCollectionA", "valueA");
bru.setCollectionVar("testCollectionB", "valueB");
}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/setCollectionVar.bru b/packages/bruno-tests/collection/scripting/api/bru/setCollectionVar.bru
index e89ec8d9c..4927f18aa 100644
--- a/packages/bruno-tests/collection/scripting/api/bru/setCollectionVar.bru
+++ b/packages/bruno-tests/collection/scripting/api/bru/setCollectionVar.bru
@@ -10,11 +10,6 @@ get {
auth: none
}
-script:pre-request {
- // TODO: skipped because setCollectionVar does not update the UI
- bru.runner.skipRequest();
-}
-
script:post-response {
bru.setCollectionVar("testSetCollectionVar", "collection-test-value")
}
diff --git a/packages/bruno-tests/collection/scripting/api/bru/setGlobalEnvVar.bru b/packages/bruno-tests/collection/scripting/api/bru/setGlobalEnvVar.bru
new file mode 100644
index 000000000..8d2f4902c
--- /dev/null
+++ b/packages/bruno-tests/collection/scripting/api/bru/setGlobalEnvVar.bru
@@ -0,0 +1,28 @@
+meta {
+ name: setGlobalEnvVar
+ type: http
+ seq: 31
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("testSetGlobalEnvVar", "global-test-value");
+}
+
+tests {
+ test("should set global env var in scripts", function() {
+ const val = bru.getGlobalEnvVar("testSetGlobalEnvVar");
+ expect(val).to.equal("global-test-value");
+ });
+
+ test("should overwrite existing global env var", function() {
+ bru.setGlobalEnvVar("testSetGlobalEnvVar", "overwritten");
+ const val = bru.getGlobalEnvVar("testSetGlobalEnvVar");
+ expect(val).to.equal("overwritten");
+ });
+}
diff --git a/tests/environments/api-setCollectionVar/api-deleteCollectionVar.spec.ts b/tests/environments/api-setCollectionVar/api-deleteCollectionVar.spec.ts
new file mode 100644
index 000000000..1a0292071
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/api-deleteCollectionVar.spec.ts
@@ -0,0 +1,66 @@
+import fs from 'fs';
+import path from 'path';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import { sendRequest, waitForReadyPage } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const COLLECTION_FILE_ORIGINAL = `meta {
+ name: collection
+}
+
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+ existingCollVar: original-coll-value
+}
+`;
+
+const restoreCollectionFixture = (collectionFixturePath: string) => {
+ const collectionBru = path.join(collectionFixturePath, 'collection.bru');
+ fs.writeFileSync(collectionBru, COLLECTION_FILE_ORIGINAL, 'utf8');
+};
+
+test.describe.serial('bru.deleteCollectionVar(name) - removes var from collection.bru', () => {
+ test.afterEach(async ({ collectionFixturePath }) => {
+ if (collectionFixturePath) {
+ restoreCollectionFixture(collectionFixturePath);
+ }
+ });
+
+ test('collection var deletion via script persists across restart', async ({
+ pageWithUserData: page,
+ collectionFixturePath,
+ restartApp
+ }) => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru');
+ const locators = buildCommonLocators(page);
+
+ // Sanity: fixture starts with existingCollVar present.
+ expect(fs.readFileSync(collectionBruPath, 'utf8')).toMatch(/existingCollVar:\s*original-coll-value/);
+
+ await locators.sidebar.collection('collection').click();
+ await page.getByText('api-deleteCollectionVar', { exact: true }).click();
+ await sendRequest(page, 200);
+
+ // Pre-restart: collection.bru no longer contains existingCollVar.
+ await expect.poll(() => fs.readFileSync(collectionBruPath, 'utf8'), { timeout: 5000 })
+ .not.toMatch(/existingCollVar/);
+
+ // Pre-restart UI: the vars tab no longer lists existingCollVar.
+ await locators.sidebar.collection('collection').click();
+ await locators.paneTabs.collectionSettingsTab('vars').click();
+ await expect(locators.environment.variableRowByName('existingCollVar')).toHaveCount(0);
+
+ // Restart: the deletion survives.
+ const newApp = await restartApp();
+ const newPage = await waitForReadyPage(newApp);
+ const newLocators = buildCommonLocators(newPage);
+
+ await newLocators.sidebar.collection('collection').click();
+ await newLocators.paneTabs.collectionSettingsTab('vars').click();
+ await expect(newLocators.environment.variableRowByName('existingCollVar')).toHaveCount(0);
+
+ expect(fs.readFileSync(collectionBruPath, 'utf8')).not.toMatch(/existingCollVar/);
+
+ await closeElectronApp(newApp);
+ });
+});
diff --git a/tests/environments/api-setCollectionVar/api-setCollectionVar-default-persistence.spec.ts b/tests/environments/api-setCollectionVar/api-setCollectionVar-default-persistence.spec.ts
new file mode 100644
index 000000000..522ad7391
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/api-setCollectionVar-default-persistence.spec.ts
@@ -0,0 +1,49 @@
+import fs from 'fs';
+import path from 'path';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import { sendRequest, waitForReadyPage } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+test.describe.serial('bru.setCollectionVar(name, value) - default persistence', () => {
+ test('collection var set via script persists across restart', async ({
+ pageWithUserData: page,
+ restartApp,
+ collectionFixturePath
+ }) => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru');
+ const locators = buildCommonLocators(page);
+
+ const openCollectionVarsTab = async (p = page) => {
+ const l = buildCommonLocators(p);
+ await l.sidebar.collection('collection').click();
+ await l.paneTabs.collectionSettingsTab('vars').click();
+ };
+
+ // Open collection, run the request — script writes `token: secret` to collection vars.
+ await locators.sidebar.collection('collection').click();
+ await page.getByText('api-setCollectionVar-default-persistence', { exact: true }).click();
+ await sendRequest(page, 200);
+
+ // Pre-restart UI: vars tab shows the script-set var.
+ await openCollectionVarsTab();
+ await expect(locators.environment.variableRowByName('token')).toBeVisible();
+ await expect(locators.environment.variableValue('token')).toContainText('secret');
+
+ // Pre-restart disk: collection.bru contains `token: secret`.
+ await expect.poll(() => fs.readFileSync(collectionBruPath, 'utf8'), { timeout: 5000 })
+ .toMatch(/token:\s*secret/);
+
+ // Restart and re-verify both UI and disk.
+ const newApp = await restartApp();
+ const newPage = await waitForReadyPage(newApp);
+ const newLocators = buildCommonLocators(newPage);
+
+ await openCollectionVarsTab(newPage);
+ await expect(newLocators.environment.variableRowByName('token')).toBeVisible();
+ await expect(newLocators.environment.variableValue('token')).toContainText('secret');
+
+ expect(fs.readFileSync(collectionBruPath, 'utf8')).toMatch(/token:\s*secret/);
+
+ await closeElectronApp(newApp);
+ });
+});
diff --git a/tests/environments/api-setCollectionVar/api-setCollectionVar-typed.spec.ts b/tests/environments/api-setCollectionVar/api-setCollectionVar-typed.spec.ts
new file mode 100644
index 000000000..480fc9801
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/api-setCollectionVar-typed.spec.ts
@@ -0,0 +1,78 @@
+import fs from 'fs';
+import path from 'path';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import { sendRequest, waitForReadyPage } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const COLLECTION_FILE_ORIGINAL = `meta {
+ name: collection
+}
+
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+ existingCollVar: original-coll-value
+}
+`;
+
+const restoreCollectionFixture = (collectionFixturePath: string) => {
+ const collectionBru = path.join(collectionFixturePath, 'collection.bru');
+ fs.writeFileSync(collectionBru, COLLECTION_FILE_ORIGINAL, 'utf8');
+};
+
+test.describe.serial('bru.setCollectionVar(name, value) — typed value persistence', () => {
+ test.afterEach(async ({ collectionFixturePath }) => {
+ if (collectionFixturePath) {
+ restoreCollectionFixture(collectionFixturePath);
+ }
+ });
+
+ test('persists number/boolean/object/string values with the correct dataType annotation', async ({
+ pageWithUserData: page,
+ collectionFixturePath,
+ restartApp
+ }) => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru');
+ const locators = buildCommonLocators(page);
+
+ await test.step('Run the typed-persist request', async () => {
+ await locators.sidebar.collection('collection').click();
+ await page.getByText('api-setCollectionVar-typed', { exact: true }).click();
+ await sendRequest(page, 200);
+ });
+
+ await test.step('collection.bru on disk carries the right dataType annotations', async () => {
+ await expect.poll(() => fs.readFileSync(collectionBruPath, 'utf8'), { timeout: 5000 })
+ .toMatch(/@number\s+coll_num:\s*42/);
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+
+ expect(content).toMatch(/@boolean\s+coll_bool:\s*true/);
+ // @object values are pretty-printed in a '''…''' block.
+ expect(content).toMatch(/@object\s+coll_obj:\s*'''[\s\S]*"k":\s*1[\s\S]*'''/);
+ // 'string' is the implicit default — not materialized.
+ expect(content).not.toMatch(/@string\s+coll_str:/);
+ expect(content).toMatch(/coll_str:\s*hello/);
+ });
+
+ await test.step('Restart and verify the collection vars tab reflects the persisted datatypes', async () => {
+ const newApp = await restartApp();
+ const newPage = await waitForReadyPage(newApp);
+ const newLocators = buildCommonLocators(newPage);
+
+ await newLocators.sidebar.collection('collection').click();
+ await newLocators.paneTabs.collectionSettingsTab('vars').click();
+
+ const numRow = newLocators.environment.variableRowByName('coll_num');
+ const boolRow = newLocators.environment.variableRowByName('coll_bool');
+ const objRow = newLocators.environment.variableRowByName('coll_obj');
+ const strRow = newLocators.environment.variableRowByName('coll_str');
+
+ await expect(numRow).toBeVisible();
+ await expect(newLocators.dataTypeSelector.typeLabel(numRow)).toHaveText('number', { timeout: 5000 });
+ await expect(newLocators.dataTypeSelector.typeLabel(boolRow)).toHaveText('boolean');
+ await expect(newLocators.dataTypeSelector.typeLabel(objRow)).toHaveText('object');
+ await expect(newLocators.dataTypeSelector.typeLabel(strRow)).toHaveText('string');
+
+ await closeElectronApp(newApp);
+ });
+ });
+});
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/api-deleteCollectionVar.bru b/tests/environments/api-setCollectionVar/fixtures/collection/api-deleteCollectionVar.bru
new file mode 100644
index 000000000..96c7cc0d9
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/api-deleteCollectionVar.bru
@@ -0,0 +1,16 @@
+meta {
+ name: api-deleteCollectionVar
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ // Removes a pre-existing collection var declared in collection.bru.
+ bru.deleteCollectionVar("existingCollVar");
+}
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/api-multi-persist-coll-vars.bru b/tests/environments/api-setCollectionVar/fixtures/collection/api-multi-persist-coll-vars.bru
new file mode 100644
index 000000000..12a673f5f
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/api-multi-persist-coll-vars.bru
@@ -0,0 +1,21 @@
+meta {
+ name: api-multi-persist-coll-vars
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ // The folder runner rebuilds request.collectionVariables on every iteration
+ // from collection.root, so collection-var writes don't accumulate across
+ // folder requests the way env-var writes do (env-vars share one object —
+ // see packages/bruno-electron/src/ipc/network/index.js:1397).
+ // This spec covers the supported shape: one script persisting multiple keys.
+ bru.setCollectionVar("multiple-persist-coll-vars-key1", "value1");
+ bru.setCollectionVar("multiple-persist-coll-vars-key2", "value2");
+}
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-default-persistence.bru b/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-default-persistence.bru
new file mode 100644
index 000000000..a81af4515
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-default-persistence.bru
@@ -0,0 +1,15 @@
+meta {
+ name: api-setCollectionVar-default-persistence
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("token", "secret");
+}
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-typed.bru b/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-typed.bru
new file mode 100644
index 000000000..70d6849d1
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/api-setCollectionVar-typed.bru
@@ -0,0 +1,21 @@
+meta {
+ name: api-setCollectionVar-typed
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:pre-request {
+ // Typed values persist with their dataType annotation in collection.bru's
+ // vars:pre-request block.
+ bru.setCollectionVar("coll_num", 42);
+ bru.setCollectionVar("coll_bool", true);
+ bru.setCollectionVar("coll_obj", { k: 1 });
+ // Plain string — no dataType materialized.
+ bru.setCollectionVar("coll_str", "hello");
+}
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/bruno.json b/tests/environments/api-setCollectionVar/fixtures/collection/bruno.json
new file mode 100644
index 000000000..78042f94a
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/bruno.json
@@ -0,0 +1,5 @@
+{
+ "version": "1",
+ "name": "collection",
+ "type": "collection"
+}
diff --git a/tests/environments/api-setCollectionVar/fixtures/collection/collection.bru b/tests/environments/api-setCollectionVar/fixtures/collection/collection.bru
new file mode 100644
index 000000000..f8eb4a34f
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/fixtures/collection/collection.bru
@@ -0,0 +1,8 @@
+meta {
+ name: collection
+}
+
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+ existingCollVar: original-coll-value
+}
diff --git a/tests/environments/api-setCollectionVar/init-user-data/collection-security.json b/tests/environments/api-setCollectionVar/init-user-data/collection-security.json
new file mode 100644
index 000000000..89dc2bfff
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/api-setCollectionVar/init-user-data/preferences.json b/tests/environments/api-setCollectionVar/init-user-data/preferences.json
new file mode 100644
index 000000000..250e6e472
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}"
+ ]
+}
diff --git a/tests/environments/api-setCollectionVar/multiple-persist-vars.spec.ts b/tests/environments/api-setCollectionVar/multiple-persist-vars.spec.ts
new file mode 100644
index 000000000..e4dbc9f8c
--- /dev/null
+++ b/tests/environments/api-setCollectionVar/multiple-persist-vars.spec.ts
@@ -0,0 +1,51 @@
+import fs from 'fs';
+import path from 'path';
+import { test, expect } from '../../../playwright';
+import { sendRequest } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const COLLECTION_FILE_ORIGINAL = `meta {
+ name: collection
+}
+
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+ existingCollVar: original-coll-value
+}
+`;
+
+test.describe.serial('bru.setCollectionVar multiple persistent variables', () => {
+ test.afterEach(async ({ collectionFixturePath }) => {
+ if (collectionFixturePath) {
+ const collectionBru = path.join(collectionFixturePath, 'collection.bru');
+ fs.writeFileSync(collectionBru, COLLECTION_FILE_ORIGINAL, 'utf8');
+ }
+ });
+
+ test('a single script can persist multiple collection vars', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await locators.sidebar.collection('collection').click();
+ await page.getByText('api-multi-persist-coll-vars', { exact: true }).click();
+ await sendRequest(page, 200);
+
+ await test.step('Both vars appear in the collection vars tab', async () => {
+ await locators.sidebar.collection('collection').click();
+ await locators.paneTabs.collectionSettingsTab('vars').click();
+
+ await expect(locators.environment.variableRowByName('multiple-persist-coll-vars-key1')).toBeVisible();
+ await expect(locators.environment.variableValue('multiple-persist-coll-vars-key1')).toContainText('value1');
+ await expect(locators.environment.variableRowByName('multiple-persist-coll-vars-key2')).toBeVisible();
+ await expect(locators.environment.variableValue('multiple-persist-coll-vars-key2')).toContainText('value2');
+ });
+
+ await test.step('Both vars are persisted to collection.bru', async () => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'collection.bru');
+ await expect.poll(() => fs.readFileSync(collectionBruPath, 'utf8'), { timeout: 10000 })
+ .toMatch(/multiple-persist-coll-vars-key1:\s*value1[\s\S]*multiple-persist-coll-vars-key2:\s*value2/);
+ });
+ });
+});
diff --git a/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts b/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts
new file mode 100644
index 000000000..4bfabd493
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/api-setEnvVar-secret.spec.ts
@@ -0,0 +1,351 @@
+import path from 'path';
+import fs from 'fs';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import {
+ openCollection,
+ openRequest,
+ sendRequest,
+ selectEnvironment,
+ expectResponseContains,
+ waitForReadyPage,
+ openEnvironmentConfigTab,
+ closeEnvironmentPanel
+} from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+import { waitForCollectionMount } from '../../utils/page/mounting';
+
+const NEW_VALUE = 'NEW_VALUE_collection_env_e2e_42';
+const INITIAL_VALUE = 'INITIAL_VALUE_collection_env_e2e_001';
+const initUserDataPath = path.join(__dirname, 'init-user-data');
+const fixturesPath = path.join(__dirname, 'fixtures', 'collections');
+
+type Format = {
+ label: 'bru' | 'yml';
+ subdir: string;
+ collectionName: string;
+ envFile: string;
+ expectInitial: (content: string) => void;
+ expectAfterScript: (content: string) => void;
+ envSettlePattern: RegExp;
+};
+
+const FORMATS: Format[] = [
+ {
+ label: 'bru',
+ subdir: 'bru',
+ collectionName: 'api-setEnvVar-secret-bru',
+ envFile: 'Local.bru',
+ envSettlePattern: /vars:secret\s*\[[^\]]*apiToken/,
+ expectInitial: (content) => {
+ expect(content).toMatch(/vars:secret\s*\[[^\]]*apiToken/);
+ expect(content).not.toContain(NEW_VALUE);
+ },
+ expectAfterScript: (content) => {
+ expect(content).toMatch(/vars:secret\s*\[[^\]]*apiToken/);
+ expect(content).not.toContain(NEW_VALUE);
+ expect(content).not.toMatch(/^\s*apiToken\s*:/m);
+ }
+ },
+ {
+ label: 'yml',
+ subdir: 'yml',
+ collectionName: 'api-setEnvVar-secret-yml',
+ envFile: 'Local.yml',
+ envSettlePattern: /name:\s*apiToken/,
+ expectInitial: (content) => {
+ expect(content).toMatch(/name:\s*apiToken/);
+ expect(content).toMatch(/secret:\s*true/);
+ expect(content).not.toContain(NEW_VALUE);
+ },
+ expectAfterScript: (content) => {
+ expect(content).toMatch(/name:\s*apiToken/);
+ expect(content).toMatch(/secret:\s*true/);
+ expect(content).not.toContain(NEW_VALUE);
+ }
+ }
+];
+
+const findApiTokenSecret = (secretsJson: any, collectionDir: string) => {
+ const collection = secretsJson.collections?.find((c: any) =>
+ path.normalize(c.path) === path.normalize(collectionDir)
+ );
+ const env = collection?.environments?.find((e: any) => e.name === 'Local');
+ return env?.secrets?.find((s: any) => s.name === 'apiToken');
+};
+
+test.describe('bru.setEnvVar(name, value) - secret variable persistence', () => {
+ for (const fmt of FORMATS) {
+ test(`(${fmt.label}) script-set secret encrypts to the secrets store and reaches a subsequent request via interpolation`, async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const collectionPath = await createTmpDir('collections');
+ await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
+
+ const envFilePath = path.join(collectionPath, fmt.subdir, 'environments', fmt.envFile);
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+ const collectionDir = path.join(collectionPath, fmt.subdir);
+
+ await test.step('Fixture sanity: env file declares apiToken as secret with no value; no secrets store yet', () => {
+ fmt.expectInitial(fs.readFileSync(envFilePath, 'utf8'));
+ expect(fs.existsSync(secretsPath)).toBe(false);
+ });
+
+ const app = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { collectionPath }
+ });
+ const page = await waitForReadyPage(app);
+ const locators = buildCommonLocators(page);
+
+ try {
+ await test.step('Open the setter request and select the "Local" environment', async () => {
+ await waitForCollectionMount(page, fmt.collectionName);
+ await openCollection(page, fmt.collectionName);
+ await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'collection');
+ });
+
+ await test.step('Env panel BEFORE script: eye toggle reveals an empty initial value', async () => {
+ await openEnvironmentConfigTab(page, 'collection');
+ const envTab = locators.environment.collectionEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror').first())
+ .toHaveClass(/CodeMirror-empty/);
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .not.toContainText(NEW_VALUE);
+
+ await closeEnvironmentPanel(page, 'collection');
+ });
+
+ await test.step('Run the request whose post-response script sets apiToken', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('On-disk env file: secret value is never written; secret marker preserved', async () => {
+ await expect.poll(() => fs.readFileSync(envFilePath, 'utf8'), { timeout: 5000 })
+ .toMatch(fmt.envSettlePattern);
+ fmt.expectAfterScript(fs.readFileSync(envFilePath, 'utf8'));
+ });
+
+ await test.step('Secrets store: encrypted apiToken entry is persisted under this collection', async () => {
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, collectionDir);
+ expect(secret).toBeDefined();
+ expect(typeof secret!.value).toBe('string');
+ expect(secret!.value.length).toBeGreaterThan(0);
+ expect(secret!.value).not.toContain(NEW_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
+ expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
+ });
+
+ await test.step('Env panel AFTER script: eye toggle reveals NEW_VALUE, no draft icon', async () => {
+ await openEnvironmentConfigTab(page, 'collection');
+ const envTab = locators.environment.collectionEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(NEW_VALUE);
+
+ await closeEnvironmentPanel(page, 'collection');
+ });
+
+ await test.step('Interpolation: a subsequent request resolves {{apiToken}} to the new value', async () => {
+ await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
+ test(`(${fmt.label}) script overwrites a previously-set secret, encrypted store entry changes`, async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const collectionPath = await createTmpDir('collections');
+ await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
+
+ const envFilePath = path.join(collectionPath, fmt.subdir, 'environments', fmt.envFile);
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+ const collectionDir = path.join(collectionPath, fmt.subdir);
+
+ const app = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { collectionPath }
+ });
+ const page = await waitForReadyPage(app);
+ const locators = buildCommonLocators(page);
+
+ try {
+ await test.step('Open the seed-secret request and select the "Local" environment', async () => {
+ await waitForCollectionMount(page, fmt.collectionName);
+ await openCollection(page, fmt.collectionName);
+ await openRequest(page, fmt.collectionName, 'seed-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'collection');
+ });
+
+ await test.step('Seed the secret: post-response script sets apiToken to INITIAL_VALUE', async () => {
+ await sendRequest(page, 200);
+ });
+
+ let initialEncryptedValue = '';
+ await test.step('After seed: env panel shows INITIAL_VALUE; encrypted entry persisted', async () => {
+ await openEnvironmentConfigTab(page, 'collection');
+ const envTab = locators.environment.collectionEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(INITIAL_VALUE);
+ await closeEnvironmentPanel(page, 'collection');
+
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, collectionDir);
+ expect(secret!.value).not.toContain(INITIAL_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
+ expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
+ initialEncryptedValue = secret!.value;
+ });
+
+ await test.step('Overwrite: open set-secret and run it (script writes NEW_VALUE over INITIAL_VALUE)', async () => {
+ await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
+ await sendRequest(page, 200);
+ });
+
+ await test.step('After overwrite: env panel shows NEW_VALUE (not INITIAL_VALUE); encrypted entry changed; .bru/.yml still clean', async () => {
+ await openEnvironmentConfigTab(page, 'collection');
+ const envTab = locators.environment.collectionEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(NEW_VALUE);
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .not.toContainText(INITIAL_VALUE);
+ await closeEnvironmentPanel(page, 'collection');
+
+ // Encrypted blob on disk changed — proves the secrets store was rewritten, not stale-cached.
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ return secret?.value;
+ }, { timeout: 5000 }).not.toBe(initialEncryptedValue);
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, collectionDir);
+ expect(secret!.value).not.toContain(NEW_VALUE);
+ expect(secret!.value.length).toBeGreaterThan(0);
+ expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
+
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ expect(content).not.toContain(NEW_VALUE);
+ expect(content).not.toContain(INITIAL_VALUE);
+ expect(content).not.toContain(secret!.value);
+ fmt.expectAfterScript(content);
+ });
+
+ await test.step('Interpolation: a subsequent request resolves {{apiToken}} to NEW_VALUE', async () => {
+ await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+ }
+
+ test('persisted secret survives app restart and decrypts on the next launch', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ // Encryption pathway is format-agnostic; one format is enough to prove the round-trip.
+ const fmt = FORMATS[0];
+ const userDataPath = await createTmpDir('userdata');
+ const collectionPath = await createTmpDir('collections');
+ await fs.promises.cp(fixturesPath, collectionPath, { recursive: true });
+
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+ const collectionDir = path.join(collectionPath, fmt.subdir);
+
+ let blobBeforeRestart = '';
+ const firstApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { collectionPath }
+ });
+ try {
+ const page = await waitForReadyPage(firstApp);
+
+ await test.step('First launch: run set-secret and confirm the encrypted blob is on disk', async () => {
+ await waitForCollectionMount(page, fmt.collectionName);
+ await openCollection(page, fmt.collectionName);
+ await openRequest(page, fmt.collectionName, 'set-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'collection');
+ await sendRequest(page, 200);
+
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ blobBeforeRestart = secret!.value;
+ });
+ } finally {
+ await closeElectronApp(firstApp);
+ }
+
+ const secondApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { collectionPath }
+ });
+ try {
+ const page = await waitForReadyPage(secondApp);
+
+ await test.step('Second launch: encrypted blob unchanged on disk', async () => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), collectionDir);
+ expect(secret!.value).toBe(blobBeforeRestart);
+ });
+
+ await test.step('Decryption round-trip: read-secret interpolates {{apiToken}} to the original plaintext', async () => {
+ await waitForCollectionMount(page, fmt.collectionName);
+ await openCollection(page, fmt.collectionName);
+ await selectEnvironment(page, 'Local', 'collection');
+ await openRequest(page, fmt.collectionName, 'read-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(secondApp);
+ }
+ });
+});
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/bruno.json b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/bruno.json
new file mode 100644
index 000000000..ab4310e97
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "api-setEnvVar-secret-bru",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/environments/Local.bru b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/environments/Local.bru
new file mode 100644
index 000000000..b7c9fafbb
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/environments/Local.bru
@@ -0,0 +1,6 @@
+vars {
+ baseUrl: http://localhost:8081
+}
+vars:secret [
+ apiToken
+]
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/read-secret.bru b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/read-secret.bru
new file mode 100644
index 000000000..81cba9f98
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/read-secret.bru
@@ -0,0 +1,11 @@
+meta {
+ name: read-secret
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/query?token={{apiToken}}
+ body: none
+ auth: none
+}
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/seed-secret.bru b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/seed-secret.bru
new file mode 100644
index 000000000..8deab8bc9
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/seed-secret.bru
@@ -0,0 +1,15 @@
+meta {
+ name: seed-secret
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("apiToken", "INITIAL_VALUE_collection_env_e2e_001");
+}
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/set-secret.bru b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/set-secret.bru
new file mode 100644
index 000000000..cd73fce21
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/bru/set-secret.bru
@@ -0,0 +1,15 @@
+meta {
+ name: set-secret
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("apiToken", "NEW_VALUE_collection_env_e2e_42");
+}
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/environments/Local.yml b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/environments/Local.yml
new file mode 100644
index 000000000..64956df87
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/environments/Local.yml
@@ -0,0 +1,6 @@
+name: Local
+variables:
+ - name: baseUrl
+ value: http://localhost:8081
+ - secret: true
+ name: apiToken
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/opencollection.yml b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/opencollection.yml
new file mode 100644
index 000000000..665cb0ed0
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/opencollection.yml
@@ -0,0 +1,3 @@
+opencollection: "1.0.0"
+info:
+ name: api-setEnvVar-secret-yml
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/read-secret.yml b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/read-secret.yml
new file mode 100644
index 000000000..efe95ee72
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/read-secret.yml
@@ -0,0 +1,8 @@
+info:
+ name: read-secret
+ type: http
+ seq: 2
+
+http:
+ method: GET
+ url: '{{baseUrl}}/query?token={{apiToken}}'
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/seed-secret.yml b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/seed-secret.yml
new file mode 100644
index 000000000..599cd1b77
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/seed-secret.yml
@@ -0,0 +1,14 @@
+info:
+ name: seed-secret
+ type: http
+ seq: 3
+
+http:
+ method: GET
+ url: '{{baseUrl}}/ping'
+
+runtime:
+ scripts:
+ - type: after-response
+ code: |-
+ bru.setEnvVar("apiToken", "INITIAL_VALUE_collection_env_e2e_001");
diff --git a/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/set-secret.yml b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/set-secret.yml
new file mode 100644
index 000000000..0558c06cf
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/fixtures/collections/yml/set-secret.yml
@@ -0,0 +1,14 @@
+info:
+ name: set-secret
+ type: http
+ seq: 1
+
+http:
+ method: GET
+ url: '{{baseUrl}}/ping'
+
+runtime:
+ scripts:
+ - type: after-response
+ code: |-
+ bru.setEnvVar("apiToken", "NEW_VALUE_collection_env_e2e_42");
diff --git a/tests/environments/api-setEnvVar-secret/init-user-data/collection-security.json b/tests/environments/api-setEnvVar-secret/init-user-data/collection-security.json
new file mode 100644
index 000000000..654681d7a
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/init-user-data/collection-security.json
@@ -0,0 +1,16 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/bru",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ },
+ {
+ "path": "{{collectionPath}}/yml",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/api-setEnvVar-secret/init-user-data/preferences.json b/tests/environments/api-setEnvVar-secret/init-user-data/preferences.json
new file mode 100644
index 000000000..96315da3c
--- /dev/null
+++ b/tests/environments/api-setEnvVar-secret/init-user-data/preferences.json
@@ -0,0 +1,6 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/bru",
+ "{{collectionPath}}/yml"
+ ]
+}
diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts
index 94fb0621f..6c5c748a4 100644
--- a/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts
+++ b/tests/environments/api-setEnvVar/api-setEnvVar-with-persist.spec.ts
@@ -1,63 +1,48 @@
-import { test, expect, closeElectronApp } from '../../../playwright';
-import { sendRequest, waitForReadyPage } from '../../utils/page';
+import fs from 'fs';
+import path from 'path';
+import { test, expect } from '../../../playwright';
+import { sendRequest, setSandboxMode } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
-test.describe.serial('bru.setEnvVar(name, value, { persist: true })', () => {
- test('set env var with persist using script', async ({ pageWithUserData: page, restartApp }) => {
- // Select the collection and request
- await page.locator('#sidebar-collection-name').click();
- await page.getByText('api-setEnvVar-with-persist', { exact: true }).click();
+test.describe('bru.setEnvVar(name, value, { persist: true }) - legacy arg', () => {
+ test('legacy persist flag is silently ignored and the var persists in both safe and developer mode', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const stageBruPath = path.join(collectionFixturePath!, 'environments', 'Stage.bru');
+ const stageOriginal = fs.readFileSync(stageBruPath, 'utf8');
+ const locators = buildCommonLocators(page);
- // open environment dropdown
- await page.getByTestId('environment-selector-trigger').click();
+ const runAndVerify = async () => {
+ await locators.sidebar.collection('collection').click();
+ // Substring `hasText` would also match `api-setEnvVar-with-persist-typed`.
+ await locators.sidebar.collectionsContainer()
+ .getByText('api-setEnvVar-with-persist', { exact: true })
+ .click();
- // select stage environment
- await expect(page.locator('.environment-list .dropdown-item', { hasText: 'Stage' })).toBeVisible();
- await page.locator('.environment-list .dropdown-item', { hasText: 'Stage' }).click();
- await expect(page.locator('.current-environment', { hasText: 'Stage' })).toBeVisible();
+ await locators.environment.selector().click();
+ await expect(locators.environment.listOption('Stage')).toBeVisible();
+ await locators.environment.listOption('Stage').click();
+ await expect(locators.environment.currentEnvironment()).toContainText('Stage');
- // Send the request
- await sendRequest(page, 200);
+ await sendRequest(page, 200);
- // confirm that the environment variable is set
- await page.getByTestId('environment-selector-trigger').hover();
- await page.getByTestId('environment-selector-trigger').click();
- // open environment configuration
+ await expect
+ .poll(() => fs.readFileSync(stageBruPath, 'utf8'), { timeout: 5000 })
+ .toMatch(/legacy_persist_var:\s*from-legacy-flag/);
+ };
- await page.locator('#configure-env').waitFor({ state: 'visible' });
- await page.locator('#configure-env').dispatchEvent('click');
+ await test.step('safe mode (quickjs)', async () => {
+ await setSandboxMode(page, 'collection', 'safe');
+ await runAndVerify();
+ });
- const envTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Environments' }) });
- await expect(envTab).toBeVisible();
+ // Reset so the developer-mode pass can't trivially match the safe-mode write.
+ fs.writeFileSync(stageBruPath, stageOriginal, 'utf8');
- await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
- await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
- await envTab.hover();
- await envTab.getByTestId('request-tab-close-icon').click({ force: true });
-
- // we restart the app to confirm that the environment variable is persisted
- const newApp = await restartApp();
- const newPage = await waitForReadyPage(newApp);
-
- // select the collection and request
- await newPage.locator('#sidebar-collection-name').click();
- await newPage.getByText('api-setEnvVar-with-persist', { exact: true }).click();
-
- // open environment dropdown
- await newPage.getByTestId('environment-selector-trigger').click();
- await newPage.locator('#configure-env').waitFor({ state: 'visible' });
- await newPage.locator('#configure-env').dispatchEvent('click');
-
- const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' });
- await expect(newEnvTab).toBeVisible();
-
- await newPage.locator('.environment-item', { hasText: 'Stage' }).click();
-
- await expect(newPage.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
- await expect(newPage.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
-
- await newEnvTab.hover();
- await newEnvTab.getByTestId('request-tab-close-icon').click({ force: true });
-
- await closeElectronApp(newApp);
+ await test.step('developer mode (nodevm)', async () => {
+ await setSandboxMode(page, 'collection', 'developer');
+ await runAndVerify();
+ });
});
});
diff --git a/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts
deleted file mode 100644
index 2ebd3c54f..000000000
--- a/tests/environments/api-setEnvVar/api-setEnvVar-without-persist.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { test, expect, closeElectronApp } from '../../../playwright';
-import { sendRequest, waitForReadyPage } from '../../utils/page';
-
-test.describe.serial('bru.setEnvVar(name, value)', () => {
- test('set env var using script', async ({ pageWithUserData: page, restartApp }) => {
- // Select the collection and request
- await page.locator('#sidebar-collection-name').click();
- await page.getByText('api-setEnvVar-without-persist', { exact: true }).click();
-
- // open environment dropdown
- await page.getByTestId('environment-selector-trigger').click();
-
- // select stage environment
- await expect(page.locator('.environment-list .dropdown-item', { hasText: 'Stage' })).toBeVisible();
- await page.locator('.environment-list .dropdown-item', { hasText: 'Stage' }).click();
- await expect(page.locator('.current-environment', { hasText: 'Stage' })).toBeVisible();
-
- // Send the request
- await sendRequest(page, 200);
-
- // confirm that the environment variable is set
- await page.getByTestId('environment-selector-trigger').hover();
- await page.getByTestId('environment-selector-trigger').click();
- await page.locator('#configure-env').waitFor({ state: 'visible' });
- await page.locator('#configure-env').dispatchEvent('click');
-
- const envTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Environments' }) });
- await expect(envTab).toBeVisible();
-
- await expect(page.getByRole('row', { name: 'token' }).getByRole('cell').nth(1)).toBeVisible();
- await expect(page.getByRole('row', { name: 'secret' }).getByRole('cell').nth(2)).toBeVisible();
- await envTab.hover();
- await envTab.getByTestId('request-tab-close-icon').click({ force: true });
-
- // we restart the app to confirm that the environment variable is not persisted
- const newApp = await restartApp();
- const newPage = await waitForReadyPage(newApp);
-
- // select the collection and request
- await newPage.locator('#sidebar-collection-name').click();
- await newPage.getByText('api-setEnvVar-without-persist', { exact: true }).click();
-
- // open environment dropdown
- await newPage.getByTestId('environment-selector-trigger').hover();
- await newPage.getByTestId('environment-selector-trigger').click();
- await newPage.locator('#configure-env').waitFor({ state: 'visible' });
- await newPage.locator('#configure-env').dispatchEvent('click');
-
- const newEnvTab = newPage.locator('.request-tab').filter({ has: newPage.locator('.tab-label', { hasText: 'Environments' }) });
- await expect(newEnvTab).toBeVisible();
-
- await newPage.locator('.environment-item', { hasText: 'Stage' }).click();
-
- await expect(newPage.locator('.table-container tbody')).not.toContainText('token');
-
- await newEnvTab.hover();
- await newEnvTab.getByTestId('request-tab-close-icon').click({ force: true });
- await closeElectronApp(newApp);
- });
-});
diff --git a/tests/environments/api-setEnvVar/api-setEnvVar.spec.ts b/tests/environments/api-setEnvVar/api-setEnvVar.spec.ts
new file mode 100644
index 000000000..8f9271a9f
--- /dev/null
+++ b/tests/environments/api-setEnvVar/api-setEnvVar.spec.ts
@@ -0,0 +1,82 @@
+import fs from 'fs';
+import path from 'path';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import { sendRequest, waitForReadyPage } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+test.describe('bru.setEnvVar(name, value)', () => {
+ test('set env var using script persists by default across restart', async ({ pageWithUserData: page, restartApp, collectionFixturePath }) => {
+ const stageBruPath = path.join(collectionFixturePath!, 'environments', 'Stage.bru');
+ const locators = buildCommonLocators(page);
+ const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' });
+
+ const selectStage = async () => {
+ await locators.environment.selector().click();
+ await expect(locators.environment.listOption('Stage')).toBeVisible();
+ await locators.environment.listOption('Stage').click();
+ await expect(locators.environment.currentEnvironment()).toContainText('Stage');
+ };
+
+ const openEnvEditor = async () => {
+ await locators.environment.selector().hover();
+ await locators.environment.selector().click();
+ await locators.environment.configureButton().waitFor({ state: 'visible' });
+ await locators.environment.configureButton().dispatchEvent('click');
+ await expect(envTab).toBeVisible();
+ };
+
+ // Select the collection and request
+ await page.locator('#sidebar-collection-name').click();
+ await page.getByText('api-setEnvVar', { exact: true }).click();
+
+ await selectStage();
+ await sendRequest(page, 200);
+
+ // Verify the script-set var is visible in the env editor before restart.
+ await openEnvEditor();
+ const tokenRow = locators.environment.varRow('token');
+ await tokenRow.scrollIntoViewIfNeeded();
+ await expect(tokenRow).toBeVisible();
+ await expect(locators.environment.varRowLine('token')).toHaveText('secret');
+
+ // On-disk env file: setEnvVar persisted `token` to Stage.bru.
+ await expect.poll(() => fs.readFileSync(stageBruPath, 'utf8'), { timeout: 5000 })
+ .toMatch(/token:\s*secret/);
+
+ await envTab.hover();
+ await envTab.getByTestId('request-tab-close-icon').click({ force: true });
+
+ // Restart to confirm the var was persisted to disk (default behavior in v4).
+ const newApp = await restartApp();
+ const newPage = await waitForReadyPage(newApp);
+ const newLocators = buildCommonLocators(newPage);
+ const newEnvTab = newPage.locator('.request-tab').filter({ hasText: 'Environments' });
+
+ await newPage.locator('#sidebar-collection-name').click();
+ await newPage.getByText('api-setEnvVar', { exact: true }).click();
+
+ // Re-select Stage — active env isn't guaranteed to persist across restart.
+ await newLocators.environment.selector().click();
+ await expect(newLocators.environment.listOption('Stage')).toBeVisible();
+ await newLocators.environment.listOption('Stage').click();
+ await expect(newLocators.environment.currentEnvironment()).toContainText('Stage');
+
+ await newLocators.environment.selector().hover();
+ await newLocators.environment.selector().click();
+ await newLocators.environment.configureButton().waitFor({ state: 'visible' });
+ await newLocators.environment.configureButton().dispatchEvent('click');
+ await expect(newEnvTab).toBeVisible();
+
+ const newTokenRow = newLocators.environment.varRow('token');
+ await newTokenRow.scrollIntoViewIfNeeded();
+ await expect(newTokenRow).toBeVisible();
+ await expect(newLocators.environment.varRowLine('token')).toHaveText('secret');
+
+ // On-disk env file survived the restart unchanged.
+ expect(fs.readFileSync(stageBruPath, 'utf8')).toMatch(/token:\s*secret/);
+
+ await newEnvTab.hover();
+ await newEnvTab.getByTestId('request-tab-close-icon').click({ force: true });
+ await closeElectronApp(newApp);
+ });
+});
diff --git a/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-with-persist.bru b/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-with-persist.bru
index f56119ae9..95eb20ff2 100644
--- a/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-with-persist.bru
+++ b/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-with-persist.bru
@@ -1,7 +1,7 @@
meta {
name: api-setEnvVar-with-persist
type: http
- seq: 1
+ seq: 2
}
get {
@@ -11,5 +11,5 @@ get {
}
script:pre-request {
- bru.setEnvVar("token", "secret", { persist: true });
-}
\ No newline at end of file
+ bru.setEnvVar("legacy_persist_var", "from-legacy-flag", { persist: true });
+}
diff --git a/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-without-persist.bru b/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar.bru
similarity index 79%
rename from tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-without-persist.bru
rename to tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar.bru
index 791788bc3..0c0b7b2a8 100644
--- a/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar-without-persist.bru
+++ b/tests/environments/api-setEnvVar/fixtures/collection/api-setEnvVar.bru
@@ -1,5 +1,5 @@
meta {
- name: api-setEnvVar-without-persist
+ name: api-setEnvVar
type: http
seq: 1
}
diff --git a/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-1.bru b/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-1.bru
index af7e12537..ff4b95615 100644
--- a/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-1.bru
+++ b/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-1.bru
@@ -11,5 +11,5 @@ get {
}
script:pre-request {
- bru.setEnvVar("multiple-persist-vars-key1", "value1", { persist: true });
+ bru.setEnvVar("multiple-persist-vars-key1", "value1");
}
diff --git a/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-2.bru b/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-2.bru
index b83f5c5d1..a1bf51c8d 100644
--- a/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-2.bru
+++ b/tests/environments/api-setEnvVar/fixtures/collection/multiple-persist-vars-folder/multiple-persist-vars-2.bru
@@ -11,5 +11,5 @@ get {
}
script:pre-request {
- bru.setEnvVar("multiple-persist-vars-key2", "value2", { persist: true });
+ bru.setEnvVar("multiple-persist-vars-key2", "value2");
}
diff --git a/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts b/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts
index 6db9def9f..f0b386007 100644
--- a/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts
+++ b/tests/environments/api-setEnvVar/multiple-persist-vars.spec.ts
@@ -55,9 +55,15 @@ test.describe.serial('bru.setEnvVar multiple persistent variables', () => {
// Ensure we're in the correct collection context before selecting the folder
await expect(page.locator('#sidebar-collection-name', { hasText: 'collection' })).toBeVisible();
- // Hover on the folder and open context menu
- await page.getByText('multiple-persist-vars-folder', { exact: true }).hover();
- await page.locator('.collection-item-name').filter({ hasText: 'multiple-persist-vars-folder' }).locator('.menu-icon').click();
+ // Re-hover on each poll: CSS `:hover` reveals `.menu-icon`, but the cursor
+ // move between hover() and click() can lose the reveal.
+ const folderRow = page.locator('.collection-item-name').filter({ hasText: 'multiple-persist-vars-folder' });
+ const menuIcon = folderRow.locator('.menu-icon');
+ await expect(async () => {
+ await folderRow.hover();
+ await expect(menuIcon).toBeVisible({ timeout: 1000 });
+ }).toPass({ timeout: 10000 });
+ await menuIcon.click();
// Click on Run option
await page.getByText('Run', { exact: true }).click();
diff --git a/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts b/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts
new file mode 100644
index 000000000..a16c29c1b
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/api-setGlobalEnvVar-secret.spec.ts
@@ -0,0 +1,305 @@
+import path from 'path';
+import fs from 'fs';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import {
+ openCollection,
+ openRequest,
+ sendRequest,
+ selectEnvironment,
+ expectResponseContains,
+ waitForReadyPage,
+ openEnvironmentConfigTab,
+ closeEnvironmentPanel
+} from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+import { waitForCollectionMount } from '../../utils/page/mounting';
+
+const NEW_VALUE = 'NEW_VALUE_global_env_e2e_42';
+const INITIAL_VALUE = 'INITIAL_VALUE_global_env_e2e_001';
+const initUserDataPath = path.join(__dirname, 'init-user-data');
+const workspaceFixturePath = path.join(__dirname, 'fixtures', 'workspace');
+
+const findApiTokenSecret = (secretsJson: any, workspaceDir: string) => {
+ const collection = secretsJson.collections?.find((c: any) =>
+ path.normalize(c.path) === path.normalize(workspaceDir)
+ );
+ const env = collection?.environments?.find((e: any) => e.name === 'Local');
+ return env?.secrets?.find((s: any) => s.name === 'apiToken');
+};
+
+test.describe('bru.setGlobalEnvVar(name, value) - secret variable persistence (workspace mode)', () => {
+ test('script-set global secret encrypts to the secrets store and reaches a subsequent request via interpolation', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const workspacePath = await createTmpDir('workspace');
+ await fs.promises.cp(workspaceFixturePath, workspacePath, { recursive: true });
+
+ const envFilePath = path.join(workspacePath, 'environments', 'Local.yml');
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+
+ await test.step('Fixture sanity: global env yml declares apiToken as secret; no secrets store yet', () => {
+ const initial = fs.readFileSync(envFilePath, 'utf8');
+ expect(initial).toMatch(/name:\s*apiToken/);
+ expect(initial).toMatch(/secret:\s*true/);
+ expect(initial).not.toContain(NEW_VALUE);
+ expect(fs.existsSync(secretsPath)).toBe(false);
+ });
+
+ const app = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ const page = await waitForReadyPage(app);
+ const locators = buildCommonLocators(page);
+
+ try {
+ await test.step('Open the setter request and select the "Local" global environment', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ // `persist: true` (double-click) pins the tab — otherwise opening the env config
+ // tab below replaces this preview tab, and the subsequent sendRequest has no target.
+ await openRequest(page, 'Test Collection', 'set-global-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'global');
+ });
+
+ await test.step('Global env panel BEFORE script: eye toggle reveals an empty initial value', async () => {
+ await openEnvironmentConfigTab(page, 'global');
+ const envTab = locators.environment.globalEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror').first())
+ .toHaveClass(/CodeMirror-empty/);
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .not.toContainText(NEW_VALUE);
+
+ await closeEnvironmentPanel(page, 'global');
+ });
+
+ await test.step('Run the request whose post-response script sets apiToken', async () => {
+ await sendRequest(page, 200);
+ });
+
+ await test.step('On-disk global env .yml: never contains plaintext', async () => {
+ await expect.poll(() => fs.readFileSync(envFilePath, 'utf8'), { timeout: 5000 })
+ .toMatch(/name:\s*apiToken/);
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ expect(content).not.toContain(NEW_VALUE);
+ expect(content).toMatch(/secret:\s*true/);
+ });
+
+ await test.step('Secrets store: encrypted apiToken entry is persisted under this workspace', async () => {
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, workspacePath);
+ expect(secret).toBeDefined();
+ expect(typeof secret!.value).toBe('string');
+ expect(secret!.value.length).toBeGreaterThan(0);
+ expect(secret!.value).not.toContain(NEW_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
+ expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
+ });
+
+ await test.step('Global env panel AFTER script: eye toggle reveals NEW_VALUE, no draft icon', async () => {
+ await openEnvironmentConfigTab(page, 'global');
+ const envTab = locators.environment.globalEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(NEW_VALUE);
+
+ await closeEnvironmentPanel(page, 'global');
+ });
+
+ await test.step('Interpolation: a subsequent request resolves {{apiToken}} to the new value', async () => {
+ await openRequest(page, 'Test Collection', 'read-global-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
+ test('script overwrites a previously-set global secret, encrypted store entry changes', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const workspacePath = await createTmpDir('workspace');
+ await fs.promises.cp(workspaceFixturePath, workspacePath, { recursive: true });
+
+ const envFilePath = path.join(workspacePath, 'environments', 'Local.yml');
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+
+ const app = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ const page = await waitForReadyPage(app);
+ const locators = buildCommonLocators(page);
+
+ try {
+ await test.step('Open seed-global-secret and select the "Local" global environment', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await openRequest(page, 'Test Collection', 'seed-global-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'global');
+ });
+
+ await test.step('Seed: post-response script sets apiToken to INITIAL_VALUE', async () => {
+ await sendRequest(page, 200);
+ });
+
+ let initialEncryptedValue = '';
+ await test.step('After seed: global env panel shows INITIAL_VALUE; encrypted entry persisted', async () => {
+ await openEnvironmentConfigTab(page, 'global');
+ const envTab = locators.environment.globalEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(INITIAL_VALUE);
+ await closeEnvironmentPanel(page, 'global');
+
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, workspacePath);
+ expect(secret!.value).not.toContain(INITIAL_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
+ expect(fs.readFileSync(envFilePath, 'utf8')).not.toContain(secret!.value);
+ initialEncryptedValue = secret!.value;
+ });
+
+ await test.step('Overwrite: open set-global-secret and run it (NEW_VALUE replaces INITIAL_VALUE)', async () => {
+ await openRequest(page, 'Test Collection', 'set-global-secret', { persist: true });
+ await sendRequest(page, 200);
+ });
+
+ await test.step('After overwrite: panel shows NEW_VALUE (not INITIAL_VALUE); encrypted entry changed; .yml still clean', async () => {
+ await openEnvironmentConfigTab(page, 'global');
+ const envTab = locators.environment.globalEnvTab();
+ await locators.environment.secretsTab().click();
+ await expect(locators.environment.varRow('apiToken')).toBeVisible();
+ await expect(envTab.locator('.close-gradient')).not.toHaveClass(/has-changes/);
+
+ await locators.environment.varRowEyeToggle('apiToken').click();
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .toContainText(NEW_VALUE);
+ await expect(locators.environment.varRow('apiToken').locator('.CodeMirror'))
+ .not.toContainText(INITIAL_VALUE);
+ await closeEnvironmentPanel(page, 'global');
+
+ // Encrypted blob on disk changed — proves the secrets store was rewritten, not stale-cached.
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ return secret?.value;
+ }, { timeout: 5000 }).not.toBe(initialEncryptedValue);
+
+ const secretsJson = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
+ const secret = findApiTokenSecret(secretsJson, workspacePath);
+ expect(secret!.value).not.toContain(NEW_VALUE);
+ expect(secret!.value.length).toBeGreaterThan(0);
+ expect(JSON.stringify(secretsJson)).not.toContain(NEW_VALUE);
+ expect(JSON.stringify(secretsJson)).not.toContain(INITIAL_VALUE);
+
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ expect(content).not.toContain(NEW_VALUE);
+ expect(content).not.toContain(INITIAL_VALUE);
+ expect(content).not.toContain(secret!.value);
+ expect(content).toMatch(/secret:\s*true/);
+ });
+
+ await test.step('Interpolation: a subsequent request resolves {{apiToken}} to NEW_VALUE', async () => {
+ await openRequest(page, 'Test Collection', 'read-global-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
+ test('persisted global secret survives app restart and decrypts on the next launch', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const workspacePath = await createTmpDir('workspace');
+ await fs.promises.cp(workspaceFixturePath, workspacePath, { recursive: true });
+
+ const secretsPath = path.join(userDataPath, 'secrets.json');
+
+ let blobBeforeRestart = '';
+ const firstApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(firstApp);
+
+ await test.step('First launch: run set-global-secret and confirm the encrypted blob is on disk', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await openRequest(page, 'Test Collection', 'set-global-secret', { persist: true });
+ await selectEnvironment(page, 'Local', 'global');
+ await sendRequest(page, 200);
+
+ await expect.poll(() => fs.existsSync(secretsPath), { timeout: 5000 }).toBe(true);
+ await expect.poll(() => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ return secret?.value ?? '';
+ }, { timeout: 5000 }).not.toBe('');
+
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ blobBeforeRestart = secret!.value;
+ });
+ } finally {
+ await closeElectronApp(firstApp);
+ }
+
+ const secondApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(secondApp);
+
+ await test.step('Second launch: encrypted blob unchanged on disk', async () => {
+ const secret = findApiTokenSecret(JSON.parse(fs.readFileSync(secretsPath, 'utf8')), workspacePath);
+ expect(secret!.value).toBe(blobBeforeRestart);
+ });
+
+ await test.step('Decryption round-trip: read-global-secret interpolates {{apiToken}} to the original plaintext', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await selectEnvironment(page, 'Local', 'global');
+ await openRequest(page, 'Test Collection', 'read-global-secret', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(secondApp);
+ }
+ });
+});
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/bruno.json b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/bruno.json
new file mode 100644
index 000000000..d942e260d
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "Test Collection",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/read-global-secret.bru b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/read-global-secret.bru
new file mode 100644
index 000000000..ffd7a4f17
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/read-global-secret.bru
@@ -0,0 +1,11 @@
+meta {
+ name: read-global-secret
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/query?token={{apiToken}}
+ body: none
+ auth: none
+}
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/seed-global-secret.bru b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/seed-global-secret.bru
new file mode 100644
index 000000000..4780a3ca6
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/seed-global-secret.bru
@@ -0,0 +1,15 @@
+meta {
+ name: seed-global-secret
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("apiToken", "INITIAL_VALUE_global_env_e2e_001");
+}
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/set-global-secret.bru b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/set-global-secret.bru
new file mode 100644
index 000000000..cee091f9d
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/collections/test-collection/set-global-secret.bru
@@ -0,0 +1,15 @@
+meta {
+ name: set-global-secret
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("apiToken", "NEW_VALUE_global_env_e2e_42");
+}
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/environments/Local.yml b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/environments/Local.yml
new file mode 100644
index 000000000..19cec5a8d
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/environments/Local.yml
@@ -0,0 +1,10 @@
+name: Local
+variables:
+ - name: baseUrl
+ value: http://localhost:8081
+ enabled: true
+ secret: false
+ - name: apiToken
+ value: ""
+ enabled: true
+ secret: true
diff --git a/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/workspace.yml b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/workspace.yml
new file mode 100644
index 000000000..4a584a119
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/fixtures/workspace/workspace.yml
@@ -0,0 +1,12 @@
+opencollection: 1.0.0
+info:
+ name: "My Workspace"
+ type: workspace
+
+collections:
+ - name: "Test Collection"
+ path: "collections/test-collection"
+
+specs:
+
+docs: ''
diff --git a/tests/environments/api-setGlobalEnvVar-secret/init-user-data/collection-security.json b/tests/environments/api-setGlobalEnvVar-secret/init-user-data/collection-security.json
new file mode 100644
index 000000000..4c301aeb2
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{workspacePath}}/collections/test-collection",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/api-setGlobalEnvVar-secret/init-user-data/preferences.json b/tests/environments/api-setGlobalEnvVar-secret/init-user-data/preferences.json
new file mode 100644
index 000000000..c2cf40f80
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar-secret/init-user-data/preferences.json
@@ -0,0 +1,7 @@
+{
+ "preferences": {
+ "general": {
+ "defaultWorkspacePath": "{{workspacePath}}"
+ }
+ }
+}
diff --git a/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-restart.spec.ts b/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-restart.spec.ts
new file mode 100644
index 000000000..59f119e6a
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-restart.spec.ts
@@ -0,0 +1,86 @@
+import path from 'path';
+import fs from 'fs';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import {
+ openCollection,
+ openRequest,
+ sendRequest,
+ selectEnvironment,
+ expectResponseContains,
+ waitForReadyPage
+} from '../../utils/page';
+import { waitForCollectionMount } from '../../utils/page/mounting';
+
+const NEW_VALUE = 'VALUE_global_nonsecret_e2e_42';
+const initUserDataPath = path.join(__dirname, 'init-user-data');
+const workspaceFixturePath = path.join(__dirname, 'fixtures', 'workspace');
+
+test.describe('bru.setGlobalEnvVar(name, value) - non-secret variable persistence (workspace mode)', () => {
+ test('persisted non-secret global var survives app restart and interpolates on the next launch', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const workspacePath = await createTmpDir('workspace');
+ await fs.promises.cp(workspaceFixturePath, workspacePath, { recursive: true });
+
+ const envFilePath = path.join(workspacePath, 'environments', 'Local.yml');
+
+ await test.step('Fixture sanity: global env yml declares globalToken (non-secret) with empty value', () => {
+ const initial = fs.readFileSync(envFilePath, 'utf8');
+ expect(initial).toMatch(/name:\s*globalToken/);
+ expect(initial).toMatch(/secret:\s*false/);
+ expect(initial).not.toContain(NEW_VALUE);
+ });
+
+ const firstApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(firstApp);
+
+ await test.step('First launch: run set-global-var; non-secret value is written into the env yml on disk', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await openRequest(page, 'Test Collection', 'set-global-var', { persist: true });
+ await selectEnvironment(page, 'Local', 'global');
+ await sendRequest(page, 200);
+
+ await expect.poll(() => fs.readFileSync(envFilePath, 'utf8'), { timeout: 5000 })
+ .toContain(NEW_VALUE);
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ expect(content).toMatch(/name:\s*globalToken/);
+ });
+ } finally {
+ await closeElectronApp(firstApp);
+ }
+
+ const contentBeforeRestart = fs.readFileSync(envFilePath, 'utf8');
+
+ const secondApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(secondApp);
+
+ await test.step('Second launch: env yml on disk is byte-identical to pre-restart', () => {
+ expect(fs.readFileSync(envFilePath, 'utf8')).toBe(contentBeforeRestart);
+ });
+
+ await test.step('Interpolation after restart: read-global-var resolves {{globalToken}} to the persisted value', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await selectEnvironment(page, 'Local', 'global');
+ await openRequest(page, 'Test Collection', 'read-global-var', { persist: true });
+ await sendRequest(page, 200);
+ await expectResponseContains(page, [NEW_VALUE]);
+ });
+ } finally {
+ await closeElectronApp(secondApp);
+ }
+ });
+});
diff --git a/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-typed-restart.spec.ts b/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-typed-restart.spec.ts
new file mode 100644
index 000000000..1fd334377
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/api-setGlobalEnvVar-typed-restart.spec.ts
@@ -0,0 +1,101 @@
+import path from 'path';
+import fs from 'fs';
+import { test, expect, closeElectronApp } from '../../../playwright';
+import {
+ openCollection,
+ openRequest,
+ sendRequest,
+ selectEnvironment,
+ waitForReadyPage
+} from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+import { waitForCollectionMount } from '../../utils/page/mounting';
+
+const initUserDataPath = path.join(__dirname, 'init-user-data');
+const workspaceFixturePath = path.join(__dirname, 'fixtures', 'workspace');
+
+test.describe('bru.setGlobalEnvVar(name, value) - typed value persistence (workspace mode)', () => {
+ test('persists number/boolean/object/string global vars with correct dataType across restart', async ({
+ launchElectronApp,
+ createTmpDir
+ }) => {
+ const userDataPath = await createTmpDir('userdata');
+ const workspacePath = await createTmpDir('workspace');
+ await fs.promises.cp(workspaceFixturePath, workspacePath, { recursive: true });
+
+ const envFilePath = path.join(workspacePath, 'environments', 'Local.yml');
+
+ const firstApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(firstApp);
+
+ await test.step('First launch: run set-typed-global-vars; Local.yml gains typed entries with `type` annotations', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await openRequest(page, 'Test Collection', 'set-typed-global-vars', { persist: true });
+ await selectEnvironment(page, 'Local', 'global');
+ await sendRequest(page, 200);
+
+ // The YAML serializer emits typed values as { value: { type, data } } objects —
+ // see packages/bruno-filestore/src/formats/yml/common/datatype.ts:34.
+ await expect.poll(() => fs.readFileSync(envFilePath, 'utf8'), { timeout: 5000 })
+ .toMatch(/name:\s*global_num[\s\S]*?type:\s*number[\s\S]*?data:\s*['"]?42/);
+ const content = fs.readFileSync(envFilePath, 'utf8');
+
+ expect(content).toMatch(/name:\s*global_bool[\s\S]*?type:\s*boolean[\s\S]*?data:\s*['"]?true/);
+ expect(content).toMatch(/name:\s*global_obj[\s\S]*?type:\s*object[\s\S]*?data:[\s\S]*?scope/);
+ // 'string' is the implicit default — the serializer emits a raw string value, no `type:` block.
+ expect(content).toMatch(/name:\s*global_str[\s\S]*?value:\s*hello/);
+ expect(content).not.toMatch(/name:\s*global_str[\s\S]*?type:\s*string/);
+ });
+ } finally {
+ await closeElectronApp(firstApp);
+ }
+
+ const contentBeforeRestart = fs.readFileSync(envFilePath, 'utf8');
+
+ const secondApp = await launchElectronApp({
+ initUserDataPath,
+ userDataPath,
+ templateVars: { workspacePath }
+ });
+ try {
+ const page = await waitForReadyPage(secondApp);
+
+ await test.step('Second launch: Local.yml on disk is byte-identical to pre-restart', () => {
+ expect(fs.readFileSync(envFilePath, 'utf8')).toBe(contentBeforeRestart);
+ });
+
+ await test.step('Second launch: the global env editor reflects the persisted dataTypes', async () => {
+ await waitForCollectionMount(page, 'Test Collection');
+ await openCollection(page, 'Test Collection');
+ await selectEnvironment(page, 'Local', 'global');
+
+ // Open the Global Environments config tab via the env selector → configure button.
+ const locators = buildCommonLocators(page);
+ await locators.environment.selector().click();
+ await locators.environment.globalTab().click();
+ await locators.environment.configureButton().waitFor({ state: 'visible' });
+ await locators.environment.configureButton().dispatchEvent('click');
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
+
+ const numRow = locators.environment.variableRowByName('global_num');
+ const boolRow = locators.environment.variableRowByName('global_bool');
+ const objRow = locators.environment.variableRowByName('global_obj');
+ const strRow = locators.environment.variableRowByName('global_str');
+
+ await expect(numRow).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(numRow)).toHaveText('number', { timeout: 5000 });
+ await expect(locators.dataTypeSelector.typeLabel(boolRow)).toHaveText('boolean');
+ await expect(locators.dataTypeSelector.typeLabel(objRow)).toHaveText('object');
+ await expect(locators.dataTypeSelector.typeLabel(strRow)).toHaveText('string');
+ });
+ } finally {
+ await closeElectronApp(secondApp);
+ }
+ });
+});
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/bruno.json b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/bruno.json
new file mode 100644
index 000000000..d942e260d
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "Test Collection",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/read-global-var.bru b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/read-global-var.bru
new file mode 100644
index 000000000..665f27b97
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/read-global-var.bru
@@ -0,0 +1,11 @@
+meta {
+ name: read-global-var
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/query?token={{globalToken}}
+ body: none
+ auth: none
+}
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-global-var.bru b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-global-var.bru
new file mode 100644
index 000000000..d0f4f07a5
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-global-var.bru
@@ -0,0 +1,15 @@
+meta {
+ name: set-global-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("globalToken", "VALUE_global_nonsecret_e2e_42");
+}
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-typed-global-vars.bru b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-typed-global-vars.bru
new file mode 100644
index 000000000..d78510efa
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/collections/test-collection/set-typed-global-vars.bru
@@ -0,0 +1,20 @@
+meta {
+ name: set-typed-global-vars
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ // Typed values persist with their dataType annotation in Local.yml's variables list.
+ bru.setGlobalEnvVar("global_num", 42);
+ bru.setGlobalEnvVar("global_bool", true);
+ bru.setGlobalEnvVar("global_obj", { scope: "global" });
+ // Plain string — no dataType materialized.
+ bru.setGlobalEnvVar("global_str", "hello");
+}
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/environments/Local.yml b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/environments/Local.yml
new file mode 100644
index 000000000..fb613ac25
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/environments/Local.yml
@@ -0,0 +1,10 @@
+name: Local
+variables:
+ - name: baseUrl
+ value: http://localhost:8081
+ enabled: true
+ secret: false
+ - name: globalToken
+ value: ""
+ enabled: true
+ secret: false
diff --git a/tests/environments/api-setGlobalEnvVar/fixtures/workspace/workspace.yml b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/workspace.yml
new file mode 100644
index 000000000..4a584a119
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/fixtures/workspace/workspace.yml
@@ -0,0 +1,12 @@
+opencollection: 1.0.0
+info:
+ name: "My Workspace"
+ type: workspace
+
+collections:
+ - name: "Test Collection"
+ path: "collections/test-collection"
+
+specs:
+
+docs: ''
diff --git a/tests/environments/api-setGlobalEnvVar/init-user-data/collection-security.json b/tests/environments/api-setGlobalEnvVar/init-user-data/collection-security.json
new file mode 100644
index 000000000..4c301aeb2
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{workspacePath}}/collections/test-collection",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/api-setGlobalEnvVar/init-user-data/preferences.json b/tests/environments/api-setGlobalEnvVar/init-user-data/preferences.json
new file mode 100644
index 000000000..c2cf40f80
--- /dev/null
+++ b/tests/environments/api-setGlobalEnvVar/init-user-data/preferences.json
@@ -0,0 +1,7 @@
+{
+ "preferences": {
+ "general": {
+ "defaultWorkspacePath": "{{workspacePath}}"
+ }
+ }
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/collection-vars-draft-merge-with-script.spec.ts b/tests/environments/collection-vars-draft-merge-with-script/collection-vars-draft-merge-with-script.spec.ts
new file mode 100644
index 000000000..256678f35
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/collection-vars-draft-merge-with-script.spec.ts
@@ -0,0 +1,62 @@
+import { test, expect } from '../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, sendRequest, selectEnvironment } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Collection variables draft merge with script-set variables', () => {
+ test('preserves unsaved draft edits when script sets a new collection variable', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'collection-vars-draft-merge-test');
+ await selectEnvironment(page, 'Test');
+
+ await test.step('Open collection settings and edit existingCollVar (create draft)', async () => {
+ await locators.sidebar.collection('collection-vars-draft-merge-test').click();
+ await locators.paneTabs.collectionSettingsTab('vars').click();
+ await expect(locators.environment.variableRowByName('existingCollVar')).toBeVisible();
+ await locators.environment.variableValue('existingCollVar').click();
+ await page.keyboard.press(selectAllShortcut);
+ await page.keyboard.type('draft-edited-coll-value');
+
+ // Wait for draft debounce
+ await page.waitForTimeout(500);
+ });
+
+ await test.step('Open request and send it', async () => {
+ await locators.sidebar.request('set-collection-var').click();
+ await expect(locators.tabs.requestTab('set-collection-var')).toBeVisible();
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Verify draft edit and script var in collection settings UI', async () => {
+ await locators.sidebar.collection('collection-vars-draft-merge-test').click();
+ await locators.paneTabs.collectionSettingsTab('vars').click();
+
+ await expect(locators.environment.variableRowByName('existingCollVar')).toBeVisible();
+ await expect(locators.environment.variableValue('existingCollVar')).toContainText('draft-edited-coll-value');
+
+ await expect(locators.environment.variableRowByName('scriptCollVar')).toBeVisible();
+ await expect(locators.environment.variableValue('scriptCollVar')).toContainText('from-script-789');
+ });
+
+ await test.step('Verify script var persisted to collection.bru', async () => {
+ const collectionBruPath = path.join(
+ collectionFixturePath!,
+ 'collection-vars-draft-merge-test',
+ 'collection.bru'
+ );
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return content.includes('scriptCollVar') && content.includes('from-script-789');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+ });
+});
diff --git a/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/bruno.json b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/bruno.json
new file mode 100644
index 000000000..e50616574
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "collection-vars-draft-merge-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/collection.bru b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/collection.bru
new file mode 100644
index 000000000..8fad1498f
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/collection.bru
@@ -0,0 +1,4 @@
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+ existingCollVar: original-coll-value
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/environments/Test.bru b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/set-collection-var.bru b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/set-collection-var.bru
new file mode 100644
index 000000000..34fe8a3d1
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/fixtures/collections/collection-vars-draft-merge-test/set-collection-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-collection-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("scriptCollVar", "from-script-789");
+}
+
+tests {
+ test("should set collection var", function() {
+ const val = bru.getCollectionVar("scriptCollVar");
+ expect(val).to.equal("from-script-789");
+ });
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/init-user-data/collection-security.json b/tests/environments/collection-vars-draft-merge-with-script/init-user-data/collection-security.json
new file mode 100644
index 000000000..6e67c1a8d
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/collection-vars-draft-merge-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/collection-vars-draft-merge-with-script/init-user-data/preferences.json b/tests/environments/collection-vars-draft-merge-with-script/init-user-data/preferences.json
new file mode 100644
index 000000000..58871be58
--- /dev/null
+++ b/tests/environments/collection-vars-draft-merge-with-script/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/collection-vars-draft-merge-test"
+ ]
+}
diff --git a/tests/environments/datatype-preservation/datatype-preservation.spec.ts b/tests/environments/datatype-preservation/datatype-preservation.spec.ts
index ce897b955..8f01ad6d5 100644
--- a/tests/environments/datatype-preservation/datatype-preservation.spec.ts
+++ b/tests/environments/datatype-preservation/datatype-preservation.spec.ts
@@ -204,6 +204,17 @@ for (const { format, collectionName } of FORMATS) {
await fileChooser.setFiles(exportedFile);
});
+ await test.step(`Select the imported "${IMPORTED_ENV_NAME}" in the env editor sidebar`, async () => {
+ // After import, the editor stays on whichever env was previously selected.
+ // Explicitly switch to the imported env so the assertions exercise its rendering.
+ const importedItem = page
+ .locator('.environments-list .environment-item')
+ .filter({ hasText: IMPORTED_ENV_NAME });
+ await expect(importedItem).toBeVisible();
+ await importedItem.click();
+ await expect(importedItem).toHaveClass(/\bactive\b/);
+ });
+
await test.step('Verify the imported env editor shows datatypes correctly', async () => {
await expect(locators.tabs.activeRequestTab()).toContainText('Environments');
diff --git a/tests/environments/draft-merge-with-script/draft-merge-with-script.spec.ts b/tests/environments/draft-merge-with-script/draft-merge-with-script.spec.ts
new file mode 100644
index 000000000..4267369b5
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/draft-merge-with-script.spec.ts
@@ -0,0 +1,55 @@
+import { test, expect } from '../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, openEnvironmentSelector, sendRequest } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const PERSISTENCE_TIMEOUT = 10000;
+const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
+
+test.describe('Draft environment merge with script-set variables', () => {
+ test('preserves unsaved draft edits when script sets a new variable', async ({ pageWithUserData: page, collectionFixturePath }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'draft-merge-test');
+ await selectEnvironment(page, 'Test');
+
+ await test.step('Edit existingVar in environment UI (create draft)', async () => {
+ await openEnvironmentSelector(page, 'collection');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.collectionEnvTab()).toBeVisible();
+
+ await expect(locators.environment.variableRowByName('existingVar')).toBeVisible();
+ await locators.environment.variableValue('existingVar').click();
+ await page.keyboard.press(selectAllShortcut);
+ await page.keyboard.type('draft-edited-value');
+
+ await expect(locators.environment.collectionEnvTab().locator('.close-gradient'))
+ .toHaveClass(/has-changes/);
+ });
+
+ await test.step('Open request and send it', async () => {
+ await locators.sidebar.request('set-env-var').click();
+ await expect(locators.tabs.requestTab('set-env-var')).toBeVisible();
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Verify both values in environment UI', async () => {
+ await locators.environment.collectionEnvTab().click();
+
+ await expect(locators.environment.variableRowByName('existingVar')).toBeVisible();
+ await expect(locators.environment.variableValue('existingVar')).toContainText('draft-edited-value');
+
+ await expect(locators.environment.variableRowByName('scriptToken')).toBeVisible();
+ await expect(locators.environment.variableValue('scriptToken')).toContainText('from-script-123');
+ });
+
+ await test.step('Verify both values persisted to disk', async () => {
+ const envFilePath = path.join(collectionFixturePath!, 'draft-merge-test', 'environments', 'Test.bru');
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ return content.includes('draft-edited-value') && content.includes('from-script-123');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+ });
+});
diff --git a/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/bruno.json b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/bruno.json
new file mode 100644
index 000000000..c45a9b72e
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "draft-merge-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/environments/Test.bru b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/environments/Test.bru
new file mode 100644
index 000000000..87a7e0c36
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/environments/Test.bru
@@ -0,0 +1,4 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+ existingVar: original-value
+}
diff --git a/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/set-env-var.bru b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/set-env-var.bru
new file mode 100644
index 000000000..62a84a775
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/fixtures/collections/draft-merge-test/set-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-env-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("scriptToken", "from-script-123");
+}
+
+tests {
+ test("should set env var", function() {
+ const val = bru.getEnvVar("scriptToken");
+ expect(val).to.equal("from-script-123");
+ });
+}
diff --git a/tests/environments/draft-merge-with-script/init-user-data/collection-security.json b/tests/environments/draft-merge-with-script/init-user-data/collection-security.json
new file mode 100644
index 000000000..0c3b41ffa
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/draft-merge-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/draft-merge-with-script/init-user-data/preferences.json b/tests/environments/draft-merge-with-script/init-user-data/preferences.json
new file mode 100644
index 000000000..a6e59ad30
--- /dev/null
+++ b/tests/environments/draft-merge-with-script/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/draft-merge-test"
+ ]
+}
diff --git a/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/bruno.json b/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/bruno.json
new file mode 100644
index 000000000..1673ba6ed
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "global-env-draft-merge-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/set-global-env-var.bru b/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/set-global-env-var.bru
new file mode 100644
index 000000000..4aa933c17
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/fixtures/collections/global-env-draft-merge-test/set-global-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-global-env-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("scriptGlobalToken", "from-script-456");
+}
+
+tests {
+ test("should set global env var", function() {
+ const val = bru.getGlobalEnvVar("scriptGlobalToken");
+ expect(val).to.equal("from-script-456");
+ });
+}
diff --git a/tests/environments/global-env-draft-merge-with-script/global-env-draft-merge-with-script.spec.ts b/tests/environments/global-env-draft-merge-with-script/global-env-draft-merge-with-script.spec.ts
new file mode 100644
index 000000000..2322715a1
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/global-env-draft-merge-with-script.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from '../../../playwright';
+import { openCollection, sendRequest, openEnvironmentSelector } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
+
+const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
+
+test.describe('Global environment draft merge with script-set variables', () => {
+ test('preserves unsaved draft edits when script sets a new global env variable', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'global-env-draft-merge-test');
+
+ await test.step('Edit existingVar in global environment UI (create draft)', async () => {
+ await openEnvironmentSelector(page, 'global');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
+
+ await expect(locators.environment.variableRowByName('existingVar')).toBeVisible();
+ await locators.environment.variableValue('existingVar').click();
+ await page.keyboard.press(selectAllShortcut);
+ await page.keyboard.type('draft-edited-global-value');
+
+ await expect(locators.environment.globalEnvTab().locator('.close-gradient'))
+ .toHaveClass(/has-changes/);
+ });
+
+ await test.step('Open request and send it', async () => {
+ await locators.sidebar.request('set-global-env-var').click();
+ await expect(locators.tabs.requestTab('set-global-env-var')).toBeVisible();
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Verify both draft edit and script variable in global env UI', async () => {
+ await locators.environment.globalEnvTab().click();
+
+ await expect(locators.environment.variableRowByName('existingVar')).toBeVisible();
+ await expect(locators.environment.variableValue('existingVar')).toContainText('draft-edited-global-value');
+
+ await expect(locators.environment.variableRowByName('scriptGlobalToken')).toBeVisible();
+ await expect(locators.environment.variableValue('scriptGlobalToken')).toContainText('from-script-456');
+
+ await expect(locators.environment.variableRowByName('baseUrl')).toBeVisible();
+ await expect(locators.environment.variableValue('baseUrl')).toContainText('https://testbench-sanity.usebruno.com');
+ });
+ });
+});
diff --git a/tests/environments/global-env-draft-merge-with-script/init-user-data/collection-security.json b/tests/environments/global-env-draft-merge-with-script/init-user-data/collection-security.json
new file mode 100644
index 000000000..eb413c868
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/global-env-draft-merge-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/environments/global-env-draft-merge-with-script/init-user-data/global-environments.json b/tests/environments/global-env-draft-merge-with-script/init-user-data/global-environments.json
new file mode 100644
index 000000000..1287d70ea
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/init-user-data/global-environments.json
@@ -0,0 +1,27 @@
+{
+ "environments": [
+ {
+ "uid": "RrPsTcwRnHMv3yljQO3ex",
+ "name": "global",
+ "variables": [
+ {
+ "uid": "VXKOZdkYw0DyI4mlhn6Wr",
+ "name": "baseUrl",
+ "value": "https://testbench-sanity.usebruno.com",
+ "type": "text",
+ "secret": false,
+ "enabled": true
+ },
+ {
+ "uid": "NTwrSscXsaeh4uee6ocJN",
+ "name": "existingVar",
+ "value": "original-value",
+ "type": "text",
+ "secret": false,
+ "enabled": true
+ }
+ ]
+ }
+ ],
+ "activeGlobalEnvironmentUid": "RrPsTcwRnHMv3yljQO3ex"
+}
diff --git a/tests/environments/global-env-draft-merge-with-script/init-user-data/preferences.json b/tests/environments/global-env-draft-merge-with-script/init-user-data/preferences.json
new file mode 100644
index 000000000..fad3b1510
--- /dev/null
+++ b/tests/environments/global-env-draft-merge-with-script/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/global-env-draft-merge-test"
+ ]
+}
diff --git a/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts b/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts
index db84c9910..48acb5ddf 100644
--- a/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts
+++ b/tests/environments/update-global-environment-via-script/global-env-update-via-script.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
+import { buildCommonLocators } from '../../utils/page/locators';
test.describe('Global Environment Variable Update via Script', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
@@ -9,54 +10,58 @@ test.describe('Global Environment Variable Update via Script', () => {
test('should update global environment values via script and verify the changes', async ({
pageWithUserData: page
}) => {
+ const locators = buildCommonLocators(page);
+
await test.step('Open the collection from sidebar', async () => {
- await page.locator('#sidebar-collection-name').filter({ hasText: 'Global Environment Update' }).click();
+ await locators.sidebar.collection('Global Environment Update').click();
});
await test.step('Open the test request that has a pre-request script', async () => {
await page.locator('.collection-name', { hasText: 'Global Environment Update' }).click();
- await page.locator('.collection-item-name', { hasText: 'Test Request' }).click();
+ await locators.sidebar.request('Test Request').click();
});
await test.step('Run the request', async () => {
- await page.getByTestId('send-arrow-icon').click();
+ await locators.request.sendButton().click();
});
await test.step('Open the Global Environment Config tab', async () => {
- await page.getByTestId('environment-selector-trigger').click();
- await page.getByTestId('env-tab-global').click();
+ await locators.environment.selector().click();
+ await locators.environment.globalTab().click();
await page.getByText('Configure', { exact: true }).click();
-
- const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
- await expect(envTab).toBeVisible();
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
});
- await test.step('Verify that the value of "existingEnvEnabled" is updated by the pre-request script', async () => {
- const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvEnabled"]') });
- const value = await row.locator('.CodeMirror-line').first().textContent();
- await expect(value).toContain('newExistingEnvEnabledValue');
+ await test.step('"existingEnvEnabled" is updated by the pre-request script', async () => {
+ await expect(
+ locators.environment.varRowsByValue('existingEnvEnabled', 'newExistingEnvEnabledValue')
+ ).toHaveCount(1);
});
- await test.step('Verify that the value of "existingEnvDisabled" is updated by the pre-request script', async () => {
- const row = page.locator('tbody tr').filter({ has: page.locator('input[value="existingEnvDisabled"]') });
- const value = await row.locator('.CodeMirror-line').first().textContent();
- await expect(value).toContain('newExistingEnvDisabledValue');
+ await test.step('"existingEnvDisabled" — disabled slot preserved, script write creates a new enabled slot', async () => {
+ await expect(locators.environment.varRow('existingEnvDisabled')).toHaveCount(2);
+ await expect(
+ locators.environment.varRowsByValue('existingEnvDisabled', 'newExistingEnvDisabledValue')
+ ).toHaveCount(1);
+ await expect(
+ locators.environment.varRowsByValue('existingEnvDisabled', /^existingEnvDisabledValue$/)
+ ).toHaveCount(1);
});
- await test.step('Verify that a new env variable "newEnv" is added by the pre-request script to the global environment', async () => {
- const row = page.locator('tbody tr').filter({ has: page.locator('input[value="newEnv"]') });
- const value = await row.locator('.CodeMirror-line').first().textContent();
- await expect(value).toContain('newEnvValue');
+ await test.step('"newEnv" is added by the pre-request script', async () => {
+ await expect(
+ locators.environment.varRowsByValue('newEnv', 'newEnvValue')
+ ).toHaveCount(1);
});
- await test.step('Verify that the value of "baseUrl" is unchanged.', async () => {
- const row = page.locator('tbody tr').filter({ has: page.locator('input[value="baseUrl"]') });
- const value = await row.locator('.CodeMirror-line').first().textContent();
- await expect(value).toContain('https://echo.usebruno.com');
+ await test.step('"baseUrl" is unchanged', async () => {
+ await expect(
+ locators.environment.varRowsByValue('baseUrl', 'https://echo.usebruno.com')
+ ).toHaveCount(1);
});
- await test.step('Close the global environment config tab.', async () => {
- const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' });
+ await test.step('Close the global environment config tab', async () => {
+ const envTab = locators.environment.globalEnvTab();
await envTab.hover();
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
});
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/collection-vars-draft-isolation.spec.ts b/tests/scripting/bru-api/collection-vars-draft-isolation/collection-vars-draft-isolation.spec.ts
new file mode 100644
index 000000000..20c868fac
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/collection-vars-draft-isolation.spec.ts
@@ -0,0 +1,64 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, sendRequest } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+
+const PERSISTENCE_TIMEOUT = 10000;
+const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
+
+test.describe('Collection vars script persistence does not leak draft headers', () => {
+ test('draft header edits are not persisted when script sets a collection variable', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const locators = buildCommonLocators(page);
+ const collectionBruPath = path.join(collectionFixturePath!, 'draft-isolation-test', 'collection.bru');
+
+ await openCollection(page, 'draft-isolation-test');
+ await selectEnvironment(page, 'Test');
+
+ // Read the original file to confirm the initial header value
+ const originalContent = fs.readFileSync(collectionBruPath, 'utf8');
+ expect(originalContent).toContain('X-Custom-Header: original-value');
+
+ await test.step('Open collection settings and edit header (create draft)', async () => {
+ await locators.sidebar.collection('draft-isolation-test').click();
+ await locators.paneTabs.collectionSettingsTab('headers').click();
+
+ const headerRow = page.locator('tbody tr').filter({
+ hasText: 'X-Custom-Header'
+ });
+ await expect(headerRow).toBeVisible();
+
+ const valueCellEditor = headerRow.locator('.CodeMirror').nth(1);
+ await valueCellEditor.click();
+ await page.keyboard.press(selectAllShortcut);
+ await page.keyboard.type('draft-edited-value');
+
+ await expect(locators.tabs.collectionSettingsTab().locator('.close-gradient'))
+ .toHaveClass(/has-changes/);
+ });
+
+ await test.step('Open request and send it (script sets a collection var)', async () => {
+ await locators.sidebar.request('set-collection-var').click();
+ await expect(locators.tabs.requestTab('set-collection-var')).toBeVisible();
+ await sendRequest(page, 200);
+ });
+
+ await test.step('Verify script collection var persisted to collection.bru', async () => {
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return content.includes('scriptVar') && content.includes('from-script');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('Verify draft header edit was NOT persisted to collection.bru', async () => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ // The original header value should still be on disk
+ expect(content).toContain('X-Custom-Header: original-value');
+ // The draft edit should NOT be on disk
+ expect(content).not.toContain('draft-edited-value');
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/bruno.json b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/bruno.json
new file mode 100644
index 000000000..858bb01ed
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "draft-isolation-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/collection.bru b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/collection.bru
new file mode 100644
index 000000000..c45b7db78
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/collection.bru
@@ -0,0 +1,7 @@
+headers {
+ X-Custom-Header: original-value
+}
+
+vars:pre-request {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/environments/Test.bru b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/set-collection-var.bru b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/set-collection-var.bru
new file mode 100644
index 000000000..2d8189cc6
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/fixtures/collections/draft-isolation-test/set-collection-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-collection-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("scriptVar", "from-script");
+}
+
+tests {
+ test("should set collection var", function() {
+ const val = bru.getCollectionVar("scriptVar");
+ expect(val).to.equal("from-script");
+ });
+}
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/collection-security.json b/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/collection-security.json
new file mode 100644
index 000000000..bd48004a9
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/draft-isolation-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/preferences.json b/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/preferences.json
new file mode 100644
index 000000000..c92de842f
--- /dev/null
+++ b/tests/scripting/bru-api/collection-vars-draft-isolation/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/draft-isolation-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/bruno.json b/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/bruno.json
new file mode 100644
index 000000000..85050789a
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "global-env-var-persistence-typed-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/set-typed-global-env-vars.bru b/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/set-typed-global-env-vars.bru
new file mode 100644
index 000000000..4eaab0c99
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/fixtures/collections/global-env-var-persistence-typed-test/set-typed-global-env-vars.bru
@@ -0,0 +1,25 @@
+meta {
+ name: set-typed-global-env-vars
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://testbench-sanity.usebruno.com/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("globalNum", 99);
+ bru.setGlobalEnvVar("globalBool", false);
+ bru.setGlobalEnvVar("globalObj", { tier: "premium", limit: 1000 });
+}
+
+tests {
+ test("typed global env vars are readable as their original types", function() {
+ expect(bru.getGlobalEnvVar("globalNum")).to.equal(99);
+ expect(bru.getGlobalEnvVar("globalBool")).to.equal(false);
+ expect(bru.getGlobalEnvVar("globalObj")).to.deep.equal({ tier: "premium", limit: 1000 });
+ });
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/global-env-var-persistence-typed.spec.ts b/tests/scripting/bru-api/global-env-var-persistence-typed/global-env-var-persistence-typed.spec.ts
new file mode 100644
index 000000000..3ff97571b
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/global-env-var-persistence-typed.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from '../../../../playwright';
+import { openCollection, sendRequest, openEnvironmentSelector } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+
+test.describe('Script-driven typed global env variable persistence', () => {
+ test('bru.setGlobalEnvVar() persists number/boolean/object with correct type labels', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'global-env-var-persistence-typed-test');
+ await locators.sidebar.request('set-typed-global-env-vars').click();
+ await sendRequest(page, 200);
+
+ await test.step('Open global environment config', async () => {
+ await openEnvironmentSelector(page, 'global');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
+ });
+
+ await test.step('Verify globalNum has dataType=number and value 99', async () => {
+ const row = locators.environment.variableRowByName('globalNum');
+ await expect(row).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(row)).toHaveText('number');
+ await expect(locators.environment.variableValue('globalNum')).toContainText('99');
+ });
+
+ await test.step('Verify globalBool has dataType=boolean and value false', async () => {
+ const row = locators.environment.variableRowByName('globalBool');
+ await expect(row).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(row)).toHaveText('boolean');
+ await expect(locators.environment.variableValue('globalBool')).toContainText('false');
+ });
+
+ await test.step('Verify globalObj has dataType=object containing the serialized JSON', async () => {
+ const row = locators.environment.variableRowByName('globalObj');
+ await expect(row).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(row)).toHaveText('object');
+ await expect(locators.environment.variableValue('globalObj')).toContainText('tier');
+ await expect(locators.environment.variableValue('globalObj')).toContainText('premium');
+ });
+
+ await test.step('Verify baseUrl (string, untouched) still has no special type label', async () => {
+ const row = locators.environment.variableRowByName('baseUrl');
+ await expect(row).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(row)).toHaveText('string');
+ });
+
+ await test.step('Close global environment config', async () => {
+ await locators.environment.globalEnvTab().hover();
+ await locators.environment.globalEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/collection-security.json b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/collection-security.json
new file mode 100644
index 000000000..d0f4f0d95
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/global-env-var-persistence-typed-test",
+ "securityConfig": {
+ "jsSandboxMode": "developer"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/global-environments.json b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/global-environments.json
new file mode 100644
index 000000000..b66ec96cd
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/global-environments.json
@@ -0,0 +1,19 @@
+{
+ "environments": [
+ {
+ "uid": "GlobEnvTypedUid000001",
+ "name": "global",
+ "variables": [
+ {
+ "uid": "GlobEnvTypedVar000001",
+ "name": "baseUrl",
+ "value": "https://testbench-sanity.usebruno.com",
+ "type": "text",
+ "secret": false,
+ "enabled": true
+ }
+ ]
+ }
+ ],
+ "activeGlobalEnvironmentUid": "GlobEnvTypedUid000001"
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/preferences.json b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/preferences.json
new file mode 100644
index 000000000..c429e0f2a
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence-typed/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/global-env-var-persistence-typed-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/bruno.json b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/bruno.json
new file mode 100644
index 000000000..d2f4ea0f1
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "global-env-var-persistence-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/delete-global-env-var.bru b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/delete-global-env-var.bru
new file mode 100644
index 000000000..6c1c561e5
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/delete-global-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: delete-global-env-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.deleteGlobalEnvVar("toBeDeleted");
+}
+
+tests {
+ test("should have deleted global env var", function() {
+ const val = bru.getGlobalEnvVar("toBeDeleted");
+ expect(val).to.be.undefined;
+ });
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/set-global-env-var.bru b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/set-global-env-var.bru
new file mode 100644
index 000000000..4b541c9de
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/fixtures/collections/global-env-var-persistence-test/set-global-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-global-env-var
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar("newGlobalVar", "new-global-value");
+}
+
+tests {
+ test("should have set new global env var", function() {
+ const val = bru.getGlobalEnvVar("newGlobalVar");
+ expect(val).to.equal("new-global-value");
+ });
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/global-env-var-persistence.spec.ts b/tests/scripting/bru-api/global-env-var-persistence/global-env-var-persistence.spec.ts
new file mode 100644
index 000000000..25ae35694
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/global-env-var-persistence.spec.ts
@@ -0,0 +1,66 @@
+import { test, expect } from '../../../../playwright';
+import { openCollection, sendRequest, openEnvironmentSelector } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+
+test.describe('Global environment variable persistence via script', () => {
+ test('bru.deleteGlobalEnvVar() removes variable from global environment', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'global-env-var-persistence-test');
+ await locators.sidebar.request('delete-global-env-var').click();
+ await sendRequest(page, 200);
+
+ await test.step('Open global environment config', async () => {
+ await openEnvironmentSelector(page, 'global');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
+ });
+
+ await test.step('Verify "toBeDeleted" is removed', async () => {
+ await expect(locators.environment.variableRowByName('toBeDeleted')).not.toBeVisible();
+ });
+
+ await test.step('Verify "baseUrl" still exists with original value', async () => {
+ await expect(locators.environment.variableRowByName('baseUrl')).toBeVisible();
+ await expect(locators.environment.variableValue('baseUrl')).toContainText('https://testbench-sanity.usebruno.com');
+ });
+
+ await test.step('Close global environment config', async () => {
+ await locators.environment.globalEnvTab().hover();
+ await locators.environment.globalEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+ });
+
+ test('bru.setGlobalEnvVar() adds new variable to global environment', async ({
+ pageWithUserData: page
+ }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'global-env-var-persistence-test');
+ await locators.sidebar.request('set-global-env-var').click();
+ await sendRequest(page, 200);
+
+ await test.step('Open global environment config', async () => {
+ await openEnvironmentSelector(page, 'global');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.globalEnvTab()).toBeVisible();
+ });
+
+ await test.step('Verify "newGlobalVar" is added with correct value', async () => {
+ await expect(locators.environment.variableRowByName('newGlobalVar')).toBeVisible();
+ await expect(locators.environment.variableValue('newGlobalVar')).toContainText('new-global-value');
+ });
+
+ await test.step('Verify "baseUrl" still exists with original value', async () => {
+ await expect(locators.environment.variableRowByName('baseUrl')).toBeVisible();
+ await expect(locators.environment.variableValue('baseUrl')).toContainText('https://testbench-sanity.usebruno.com');
+ });
+
+ await test.step('Close global environment config', async () => {
+ await locators.environment.globalEnvTab().hover();
+ await locators.environment.globalEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/global-env-var-persistence/init-user-data/collection-security.json b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/collection-security.json
new file mode 100644
index 000000000..b61254734
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/global-env-var-persistence-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/init-user-data/global-environments.json b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/global-environments.json
new file mode 100644
index 000000000..772376528
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/global-environments.json
@@ -0,0 +1,27 @@
+{
+ "environments": [
+ {
+ "uid": "RrPsTcwRnHMv3yljQO3ex",
+ "name": "global",
+ "variables": [
+ {
+ "uid": "VXKOZdkYw0DyI4mlhn6Wr",
+ "name": "baseUrl",
+ "value": "https://testbench-sanity.usebruno.com",
+ "type": "text",
+ "secret": false,
+ "enabled": true
+ },
+ {
+ "uid": "NTwrSscXsaeh4uee6ocJN",
+ "name": "toBeDeleted",
+ "value": "this-should-be-removed",
+ "type": "text",
+ "secret": false,
+ "enabled": true
+ }
+ ]
+ }
+ ],
+ "activeGlobalEnvironmentUid": "RrPsTcwRnHMv3yljQO3ex"
+}
diff --git a/tests/scripting/bru-api/global-env-var-persistence/init-user-data/preferences.json b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/preferences.json
new file mode 100644
index 000000000..b0b7a8c80
--- /dev/null
+++ b/tests/scripting/bru-api/global-env-var-persistence/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/global-env-var-persistence-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/bruno.json b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/bruno.json
new file mode 100644
index 000000000..13941c56d
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "variable-persistence-multi-request-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/environments/Test.bru b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-1-write.bru b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-1-write.bru
new file mode 100644
index 000000000..f349fd60b
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-1-write.bru
@@ -0,0 +1,21 @@
+meta {
+ name: req-1-write
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("counter", "1");
+}
+
+tests {
+ test("request 1 wrote counter=1", function() {
+ expect(bru.getEnvVar("counter")).to.equal("1");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-2-read-and-write.bru b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-2-read-and-write.bru
new file mode 100644
index 000000000..078db8acb
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/fixtures/collections/variable-persistence-multi-request-test/req-2-read-and-write.bru
@@ -0,0 +1,29 @@
+meta {
+ name: req-2-read-and-write
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ // Request 2 must observe request 1's persistence — proves the baseline cleared between requests
+ // (otherwise this read would see undefined, the request-1-era pre-write snapshot).
+ const prev = bru.getEnvVar("counter");
+ bru.setEnvVar("seenInReq2", prev);
+ bru.setEnvVar("counter", "2");
+}
+
+tests {
+ test("request 2 observed request 1's write", function() {
+ expect(bru.getEnvVar("seenInReq2")).to.equal("1");
+ });
+
+ test("request 2 wrote counter=2", function() {
+ expect(bru.getEnvVar("counter")).to.equal("2");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/collection-security.json b/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/collection-security.json
new file mode 100644
index 000000000..b5e985abd
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/variable-persistence-multi-request-test",
+ "securityConfig": {
+ "jsSandboxMode": "developer"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/preferences.json b/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/preferences.json
new file mode 100644
index 000000000..6e1591862
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/variable-persistence-multi-request-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-multi-request/variable-persistence-multi-request.spec.ts b/tests/scripting/bru-api/variable-persistence-multi-request/variable-persistence-multi-request.spec.ts
new file mode 100644
index 000000000..69f64f0dd
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-multi-request/variable-persistence-multi-request.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment } from '../../../utils/page';
+import { runCollection, validateRunnerResults } from '../../../utils/page/runner';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Script variable persistence across requests (baseline-clear between requests)', () => {
+ test('request 2 observes request 1\'s write; final disk state reflects last write', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ await openCollection(page, 'variable-persistence-multi-request-test');
+ await selectEnvironment(page, 'Test');
+ await runCollection(page, 'variable-persistence-multi-request-test');
+
+ // Both requests should pass: req-1 sets counter=1, req-2 reads counter (must see "1"),
+ // then sets counter=2. If the baseline didn't clear between requests, req-2's read
+ // would see undefined / wrong value and one of its tests would fail.
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0
+ });
+
+ await test.step('final disk state reflects the last write (counter=2)', async () => {
+ const envFilePath = path.join(
+ collectionFixturePath!,
+ 'variable-persistence-multi-request-test',
+ 'environments',
+ 'Test.bru'
+ );
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ // Must contain counter=2 (the last write wins) and seenInReq2=1 (request 2 observed request 1).
+ return content.includes('counter:') && content.includes('2')
+ && content.includes('seenInReq2:') && content.includes('1');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/bruno.json b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/bruno.json
new file mode 100644
index 000000000..3bca10878
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "variable-persistence-safe-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/environments/Test.bru b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-collection-var.bru b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-collection-var.bru
new file mode 100644
index 000000000..d0444b158
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-collection-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-collection-var
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("persistedCollectionToken", "collection-value-456");
+}
+
+tests {
+ test("should set collection var", function() {
+ const val = bru.getCollectionVar("persistedCollectionToken");
+ expect(val).to.equal("collection-value-456");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-env-var.bru b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-env-var.bru
new file mode 100644
index 000000000..ea8f0c82c
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/fixtures/collections/variable-persistence-safe-test/set-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-env-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("persistedToken", "test-value-123");
+}
+
+tests {
+ test("should set env var", function() {
+ const val = bru.getEnvVar("persistedToken");
+ expect(val).to.equal("test-value-123");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/collection-security.json b/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/collection-security.json
new file mode 100644
index 000000000..5b1a957af
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/variable-persistence-safe-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/preferences.json b/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/preferences.json
new file mode 100644
index 000000000..a6af104dd
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/variable-persistence-safe-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-safe-mode/variable-persistence-safe-mode.spec.ts b/tests/scripting/bru-api/variable-persistence-safe-mode/variable-persistence-safe-mode.spec.ts
new file mode 100644
index 000000000..6b6840c05
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-safe-mode/variable-persistence-safe-mode.spec.ts
@@ -0,0 +1,52 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, openEnvironmentSelector } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+import { runCollection, validateRunnerResults } from '../../../utils/page/runner';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Script variable persistence to disk (safe mode)', () => {
+ test('persists env var and collection var in safe mode', async ({ pageWithUserData: page, collectionFixturePath }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'variable-persistence-safe-test');
+ await selectEnvironment(page, 'Test');
+ await runCollection(page, 'variable-persistence-safe-test');
+
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0
+ });
+
+ await test.step('Verify env var visible in environment UI', async () => {
+ await openEnvironmentSelector(page, 'collection');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.collectionEnvTab()).toBeVisible();
+
+ await expect(locators.environment.variableRowByName('persistedToken')).toBeVisible();
+ await expect(locators.environment.variableValue('persistedToken')).toContainText('test-value-123');
+
+ await locators.environment.collectionEnvTab().hover();
+ await locators.environment.collectionEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+
+ await test.step('Verify env var persisted to environments/Test.bru', async () => {
+ const envFilePath = path.join(collectionFixturePath!, 'variable-persistence-safe-test', 'environments', 'Test.bru');
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ return content.includes('persistedToken') && content.includes('test-value-123');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('Verify collection var persisted to collection.bru', async () => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'variable-persistence-safe-test', 'collection.bru');
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return content.includes('persistedCollectionToken') && content.includes('collection-value-456');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/bruno.json b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/bruno.json
new file mode 100644
index 000000000..018d29ac7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "variable-persistence-typed-safe-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/environments/Test.bru b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-collection-vars.bru b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-collection-vars.bru
new file mode 100644
index 000000000..c7b3c8aab
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-collection-vars.bru
@@ -0,0 +1,25 @@
+meta {
+ name: set-typed-collection-vars
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://testbench-sanity.usebruno.com/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("collNum", 7);
+ bru.setCollectionVar("collBool", false);
+ bru.setCollectionVar("collObj", { region: "eu", retries: 3 });
+}
+
+tests {
+ test("typed collection vars are readable as their original types", function() {
+ expect(bru.getCollectionVar("collNum")).to.equal(7);
+ expect(bru.getCollectionVar("collBool")).to.equal(false);
+ expect(bru.getCollectionVar("collObj")).to.deep.equal({ region: "eu", retries: 3 });
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-env-vars.bru b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-env-vars.bru
new file mode 100644
index 000000000..7b9a898b5
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/fixtures/collections/variable-persistence-typed-safe-test/set-typed-env-vars.bru
@@ -0,0 +1,25 @@
+meta {
+ name: set-typed-env-vars
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://testbench-sanity.usebruno.com/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("envNum", 42);
+ bru.setEnvVar("envBool", true);
+ bru.setEnvVar("envObj", { port: 3000, ssl: true });
+}
+
+tests {
+ test("typed env vars are readable as their original types", function() {
+ expect(bru.getEnvVar("envNum")).to.equal(42);
+ expect(bru.getEnvVar("envBool")).to.equal(true);
+ expect(bru.getEnvVar("envObj")).to.deep.equal({ port: 3000, ssl: true });
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/collection-security.json b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/collection-security.json
new file mode 100644
index 000000000..999214bdf
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/variable-persistence-typed-safe-test",
+ "securityConfig": {
+ "jsSandboxMode": "safe"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/preferences.json b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/preferences.json
new file mode 100644
index 000000000..710c9405a
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/variable-persistence-typed-safe-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed-safe-mode/variable-persistence-typed-safe-mode.spec.ts b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/variable-persistence-typed-safe-mode.spec.ts
new file mode 100644
index 000000000..ef38c3754
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed-safe-mode/variable-persistence-typed-safe-mode.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, openEnvironmentSelector, closeEnvironmentPanel } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+import { runCollection, validateRunnerResults } from '../../../utils/page/runner';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Script-driven typed variable persistence to disk (safe mode / QuickJS)', () => {
+ test('QuickJS shim preserves number/boolean/object across the host boundary and to disk', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const locators = buildCommonLocators(page);
+ const COLLECTION_NAME = 'variable-persistence-typed-safe-test';
+
+ await openCollection(page, COLLECTION_NAME);
+ await selectEnvironment(page, 'Test');
+ await runCollection(page, COLLECTION_NAME);
+
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0
+ });
+
+ const envFilePath = path.join(collectionFixturePath!, COLLECTION_NAME, 'environments', 'Test.bru');
+ const collectionBruPath = path.join(collectionFixturePath!, COLLECTION_NAME, 'collection.bru');
+
+ await test.step('environments/Test.bru contains @number/@boolean/@object annotations for env vars', async () => {
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ return (
+ /@number\s+envNum:\s*42/.test(content)
+ && /@boolean\s+envBool:\s*true/.test(content)
+ && /@object\s+envObj:/.test(content)
+ && content.includes('"port"')
+ && content.includes('3000')
+ );
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('collection.bru contains @number/@boolean/@object annotations for collection vars', async () => {
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return (
+ /@number\s+collNum:\s*7/.test(content)
+ && /@boolean\s+collBool:\s*false/.test(content)
+ && /@object\s+collObj:/.test(content)
+ && content.includes('"region"')
+ && content.includes('"eu"')
+ );
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('env editor shows the correct type label per row', async () => {
+ await openEnvironmentSelector(page, 'collection');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.collectionEnvTab()).toBeVisible();
+
+ const numRow = locators.environment.variableRowByName('envNum');
+ const boolRow = locators.environment.variableRowByName('envBool');
+ const objRow = locators.environment.variableRowByName('envObj');
+
+ await expect(numRow).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(numRow)).toHaveText('number');
+ await expect(locators.dataTypeSelector.typeLabel(boolRow)).toHaveText('boolean');
+ await expect(locators.dataTypeSelector.typeLabel(objRow)).toHaveText('object');
+
+ await closeEnvironmentPanel(page, 'collection');
+ await expect(locators.environment.collectionEnvTab()).not.toBeVisible();
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/bruno.json b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/bruno.json
new file mode 100644
index 000000000..dade19dbd
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "variable-persistence-typed-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/environments/Test.bru b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-collection-vars.bru b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-collection-vars.bru
new file mode 100644
index 000000000..c7b3c8aab
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-collection-vars.bru
@@ -0,0 +1,25 @@
+meta {
+ name: set-typed-collection-vars
+ type: http
+ seq: 2
+}
+
+get {
+ url: https://testbench-sanity.usebruno.com/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("collNum", 7);
+ bru.setCollectionVar("collBool", false);
+ bru.setCollectionVar("collObj", { region: "eu", retries: 3 });
+}
+
+tests {
+ test("typed collection vars are readable as their original types", function() {
+ expect(bru.getCollectionVar("collNum")).to.equal(7);
+ expect(bru.getCollectionVar("collBool")).to.equal(false);
+ expect(bru.getCollectionVar("collObj")).to.deep.equal({ region: "eu", retries: 3 });
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-env-vars.bru b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-env-vars.bru
new file mode 100644
index 000000000..7b9a898b5
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/fixtures/collections/variable-persistence-typed-test/set-typed-env-vars.bru
@@ -0,0 +1,25 @@
+meta {
+ name: set-typed-env-vars
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://testbench-sanity.usebruno.com/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("envNum", 42);
+ bru.setEnvVar("envBool", true);
+ bru.setEnvVar("envObj", { port: 3000, ssl: true });
+}
+
+tests {
+ test("typed env vars are readable as their original types", function() {
+ expect(bru.getEnvVar("envNum")).to.equal(42);
+ expect(bru.getEnvVar("envBool")).to.equal(true);
+ expect(bru.getEnvVar("envObj")).to.deep.equal({ port: 3000, ssl: true });
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/init-user-data/collection-security.json b/tests/scripting/bru-api/variable-persistence-typed/init-user-data/collection-security.json
new file mode 100644
index 000000000..11d888458
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/variable-persistence-typed-test",
+ "securityConfig": {
+ "jsSandboxMode": "developer"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/init-user-data/preferences.json b/tests/scripting/bru-api/variable-persistence-typed/init-user-data/preferences.json
new file mode 100644
index 000000000..f47822249
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/variable-persistence-typed-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence-typed/variable-persistence-typed.spec.ts b/tests/scripting/bru-api/variable-persistence-typed/variable-persistence-typed.spec.ts
new file mode 100644
index 000000000..40e25f417
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence-typed/variable-persistence-typed.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, openEnvironmentSelector } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+import { runCollection, validateRunnerResults } from '../../../utils/page/runner';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Script-driven typed variable persistence to disk (developer mode)', () => {
+ test('persists number/boolean/object env + collection vars with dataType annotations', async ({
+ pageWithUserData: page,
+ collectionFixturePath
+ }) => {
+ const locators = buildCommonLocators(page);
+ const COLLECTION_NAME = 'variable-persistence-typed-test';
+
+ await openCollection(page, COLLECTION_NAME);
+ await selectEnvironment(page, 'Test');
+ await runCollection(page, COLLECTION_NAME);
+
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0
+ });
+
+ const envFilePath = path.join(collectionFixturePath!, COLLECTION_NAME, 'environments', 'Test.bru');
+ const collectionBruPath = path.join(collectionFixturePath!, COLLECTION_NAME, 'collection.bru');
+
+ await test.step('environments/Test.bru contains @number/@boolean/@object annotations for env vars', async () => {
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ return (
+ /@number\s+envNum:\s*42/.test(content)
+ && /@boolean\s+envBool:\s*true/.test(content)
+ && /@object\s+envObj:/.test(content)
+ && content.includes('"port"')
+ && content.includes('3000')
+ );
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('collection.bru contains @number/@boolean/@object annotations for collection vars', async () => {
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return (
+ /@number\s+collNum:\s*7/.test(content)
+ && /@boolean\s+collBool:\s*false/.test(content)
+ && /@object\s+collObj:/.test(content)
+ && content.includes('"region"')
+ && content.includes('"eu"')
+ );
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('env editor shows the correct type label per row', async () => {
+ await openEnvironmentSelector(page, 'collection');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.collectionEnvTab()).toBeVisible();
+
+ const numRow = locators.environment.variableRowByName('envNum');
+ const boolRow = locators.environment.variableRowByName('envBool');
+ const objRow = locators.environment.variableRowByName('envObj');
+
+ await expect(numRow).toBeVisible();
+ await expect(locators.dataTypeSelector.typeLabel(numRow)).toHaveText('number');
+ await expect(locators.dataTypeSelector.typeLabel(boolRow)).toHaveText('boolean');
+ await expect(locators.dataTypeSelector.typeLabel(objRow)).toHaveText('object');
+
+ await locators.environment.collectionEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+ });
+});
diff --git a/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/bruno.json b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/bruno.json
new file mode 100644
index 000000000..a723d8831
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "variable-persistence-test",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/environments/Test.bru b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/environments/Test.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/environments/Test.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-collection-var.bru b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-collection-var.bru
new file mode 100644
index 000000000..d0444b158
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-collection-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-collection-var
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setCollectionVar("persistedCollectionToken", "collection-value-456");
+}
+
+tests {
+ test("should set collection var", function() {
+ const val = bru.getCollectionVar("persistedCollectionToken");
+ expect(val).to.equal("collection-value-456");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-env-var.bru b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-env-var.bru
new file mode 100644
index 000000000..ea8f0c82c
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/fixtures/collections/variable-persistence-test/set-env-var.bru
@@ -0,0 +1,22 @@
+meta {
+ name: set-env-var
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setEnvVar("persistedToken", "test-value-123");
+}
+
+tests {
+ test("should set env var", function() {
+ const val = bru.getEnvVar("persistedToken");
+ expect(val).to.equal("test-value-123");
+ });
+}
diff --git a/tests/scripting/bru-api/variable-persistence/init-user-data/collection-security.json b/tests/scripting/bru-api/variable-persistence/init-user-data/collection-security.json
new file mode 100644
index 000000000..7f9a75f82
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/init-user-data/collection-security.json
@@ -0,0 +1,10 @@
+{
+ "collections": [
+ {
+ "path": "{{collectionPath}}/variable-persistence-test",
+ "securityConfig": {
+ "jsSandboxMode": "developer"
+ }
+ }
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence/init-user-data/preferences.json b/tests/scripting/bru-api/variable-persistence/init-user-data/preferences.json
new file mode 100644
index 000000000..c403362ee
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/init-user-data/preferences.json
@@ -0,0 +1,5 @@
+{
+ "lastOpenedCollections": [
+ "{{collectionPath}}/variable-persistence-test"
+ ]
+}
diff --git a/tests/scripting/bru-api/variable-persistence/variable-persistence.spec.ts b/tests/scripting/bru-api/variable-persistence/variable-persistence.spec.ts
new file mode 100644
index 000000000..5f2075385
--- /dev/null
+++ b/tests/scripting/bru-api/variable-persistence/variable-persistence.spec.ts
@@ -0,0 +1,52 @@
+import { test, expect } from '../../../../playwright';
+import fs from 'fs';
+import path from 'path';
+import { openCollection, selectEnvironment, openEnvironmentSelector } from '../../../utils/page';
+import { buildCommonLocators } from '../../../utils/page/locators';
+import { runCollection, validateRunnerResults } from '../../../utils/page/runner';
+
+const PERSISTENCE_TIMEOUT = 10000;
+
+test.describe('Script variable persistence to disk (developer mode)', () => {
+ test('persists env var and collection var', async ({ pageWithUserData: page, collectionFixturePath }) => {
+ const locators = buildCommonLocators(page);
+
+ await openCollection(page, 'variable-persistence-test');
+ await selectEnvironment(page, 'Test');
+ await runCollection(page, 'variable-persistence-test');
+
+ await validateRunnerResults(page, {
+ totalRequests: 2,
+ passed: 2,
+ failed: 0
+ });
+
+ await test.step('Verify env var visible in environment UI', async () => {
+ await openEnvironmentSelector(page, 'collection');
+ await locators.environment.configureButton().click();
+ await expect(locators.environment.collectionEnvTab()).toBeVisible();
+
+ await expect(locators.environment.variableRowByName('persistedToken')).toBeVisible();
+ await expect(locators.environment.variableValue('persistedToken')).toContainText('test-value-123');
+
+ await locators.environment.collectionEnvTab().hover();
+ await locators.environment.collectionEnvTab().getByTestId('request-tab-close-icon').click({ force: true });
+ });
+
+ await test.step('Verify env var persisted to environments/Test.bru', async () => {
+ const envFilePath = path.join(collectionFixturePath!, 'variable-persistence-test', 'environments', 'Test.bru');
+ await expect.poll(() => {
+ const content = fs.readFileSync(envFilePath, 'utf8');
+ return content.includes('persistedToken') && content.includes('test-value-123');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+
+ await test.step('Verify collection var persisted to collection.bru', async () => {
+ const collectionBruPath = path.join(collectionFixturePath!, 'variable-persistence-test', 'collection.bru');
+ await expect.poll(() => {
+ const content = fs.readFileSync(collectionBruPath, 'utf8');
+ return content.includes('persistedCollectionToken') && content.includes('collection-value-456');
+ }, { timeout: PERSISTENCE_TIMEOUT }).toBe(true);
+ });
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index 063bdb022..d576795c3 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -635,6 +635,31 @@ const openEnvironmentSelector = async (page: Page, type: EnvironmentType = 'coll
});
};
+/**
+ * Open the configuration tab for the currently active environment (collection or global).
+ * Combines opening the env selector dropdown + clicking the "configure" button + waiting
+ * for the resulting env config tab to appear. Use this when the test needs to interact
+ * with the env variable rows (read a value, toggle the secret eye, etc.).
+ * @param page - The page object
+ * @param type - The type of environment configuration tab to open
+ */
+const openEnvironmentConfigTab = async (page: Page, type: EnvironmentType = 'collection') => {
+ await test.step(`Open ${type} environment configuration tab`, async () => {
+ await openEnvironmentSelector(page, type);
+
+ const locators = buildCommonLocators(page);
+ // `waitFor` + `dispatchEvent` keeps the click stable when the dropdown is mid-transition;
+ // the menu item briefly intercepts pointer events during the open animation.
+ await locators.environment.configureButton().waitFor({ state: 'visible' });
+ await locators.environment.configureButton().dispatchEvent('click');
+
+ const envTab = type === 'global'
+ ? locators.environment.globalEnvTab()
+ : locators.environment.collectionEnvTab();
+ await expect(envTab).toBeVisible();
+ });
+};
+
/**
* Create a new environment
* @param page - The page object
@@ -2198,6 +2223,7 @@ export {
removeCollection,
createFolder,
openEnvironmentSelector,
+ openEnvironmentConfigTab,
createEnvironment,
addEnvironmentVariable,
addEnvironmentVariables,
diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts
index c0b30d4d0..485e4da4d 100644
--- a/tests/utils/page/locators.ts
+++ b/tests/utils/page/locators.ts
@@ -76,23 +76,44 @@ export const buildCommonLocators = (page: Page) => ({
currentEnvironment: () => page.locator('.current-environment'),
configureButton: () => page.locator('#configure-env'),
saveButton: () => page.getByTestId('save-env'),
- varRow: (name: string) => page.locator(`[data-testid="env-var-row-${name}"]`),
+ varRow: (name: string) => page.getByTestId(`env-var-row-${name}`),
+ // Prefix match — keep as a CSS selector since getByTestId is exact-match only.
varRows: () => page.locator('tbody tr[data-testid^="env-var-row-"]'),
+ // Rows for `name` whose CodeMirror value matches `value`. Useful when two rows
+ // share a name (e.g. enabled + disabled twins after a script write).
+ varRowsByValue: (name: string, value: string | RegExp) =>
+ page.getByTestId(`env-var-row-${name}`)
+ .filter({ has: page.locator('.CodeMirror-line', { hasText: value }) }),
// Each env-var row has an `enabled` and a `secret` checkbox; target the latter
// by its `.secret` name (the formik index is dynamic).
- varRowSecretCheckbox: (name: string) => page.locator(`[data-testid="env-var-row-${name}"]`).locator('input[name$=".secret"]'),
- varRowLine: (name: string) => page.locator(`[data-testid="env-var-row-${name}"] .CodeMirror-line`).first(),
- addVariableButton: () => page.locator('button[data-testid="add-variable"]'),
+ varRowSecretCheckbox: (name: string) => page.getByTestId(`env-var-row-${name}`).locator('input[name$=".secret"]'),
+ // Eye icon that masks/reveals a secret variable's value.
+ varRowEyeToggle: (name: string) => page.getByTestId(`env-var-row-${name}`).getByTestId('secret-reveal-toggle'),
+ varRowLine: (name: string) => page.getByTestId(`env-var-row-${name}`).locator('.CodeMirror-line').first(),
+ addVariableButton: () => page.getByTestId('add-variable'),
variableNameInput: (index: number) => page.locator(`input[name="${index}.name"]`),
variableSecretCheckbox: (index: number) => page.locator(`input[name="${index}.secret"]`),
variableRow: (index: number) => page.locator('tr').filter({ has: page.locator(`input[name="${index}.name"]`) }),
+ variableRowByName: (name: string) => page.locator('tbody tr').filter({ has: page.locator(`input[value="${name}"]`) }),
+ // Targets the `.CodeMirror` wrapper (not `.CodeMirror-line`) so single-line and
+ // multi-line values (e.g. formatted JSON for @object vars) are both covered —
+ // CodeMirror renders each visual line as a separate `.CodeMirror-line`, so
+ // matching on the wrapper is the only way to get the full concatenated text.
+ variableValue: (name: string) => page.locator('tbody tr').filter({ has: page.locator(`input[value="${name}"]`) }).locator('.CodeMirror').first(),
createEnvButton: () => page.locator('button[id="create-env"]'),
envNameInput: () => page.locator('input[name="name"]'),
// Variables and secrets each live on their own tab in the environment editor.
variablesTab: () => page.getByTestId('responsive-tab-variables'),
secretsTab: () => page.getByTestId('responsive-tab-secrets'),
saveTab: () => page.getByTestId('save-env'),
- saveAll: () => page.getByTestId('save-all-env')
+ saveAll: () => page.getByTestId('save-all-env'),
+ collectionEnvTab: () => page.locator('.request-tab').filter({ hasText: /^Environments$/ }),
+ globalEnvTab: () => page.locator('.request-tab').filter({ hasText: /^Global Environments$/ }),
+ unsavedModal: {
+ closeWithoutSave: () => page.getByTestId('env-unsaved-close-without-save'),
+ cancel: () => page.getByTestId('env-unsaved-cancel'),
+ saveAndClose: () => page.getByTestId('env-unsaved-save-and-close')
+ }
},
codeMirror: {
byTestId: (testId: string) => page.getByTestId(testId).locator('.CodeMirror').first()
diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts
index 7e69504aa..1da8df4c3 100644
--- a/tests/utils/page/runner.ts
+++ b/tests/utils/page/runner.ts
@@ -49,9 +49,13 @@ export const openRunnerTab = async (page: Page, collectionName: string) => {
const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
await collectionContainer.waitFor({ state: 'visible' });
+ // Re-hover on each poll: CSS `:hover` reveals `.collection-actions`, but sidebar
+ // re-renders can shift the row out from under a one-shot hover().
const actionsContainer = collectionContainer.locator('.collection-actions');
- await collectionContainer.hover();
- await actionsContainer.waitFor({ state: 'visible' });
+ await expect(async () => {
+ await collectionContainer.hover();
+ await expect(actionsContainer).toBeVisible({ timeout: 1000 });
+ }).toPass({ timeout: 10000 });
const icon = actionsContainer.locator('.icon');
await icon.waitFor({ state: 'visible', timeout: 5000 });
@@ -81,9 +85,13 @@ export const runCollection = async (page: Page, collectionName: string) => {
await collectionContainer.waitFor({ state: 'visible' });
// Open collection actions menu - hover first to reveal the hidden actions button
+ // Re-hover on each poll: CSS `:hover` reveals `.collection-actions`, but sidebar
+ // re-renders can shift the row out from under a one-shot hover().
const actionsContainer = collectionContainer.locator('.collection-actions');
- await collectionContainer.hover();
- await actionsContainer.waitFor({ state: 'visible' });
+ await expect(async () => {
+ await collectionContainer.hover();
+ await expect(actionsContainer).toBeVisible({ timeout: 1000 });
+ }).toPass({ timeout: 10000 });
const icon = actionsContainer.locator('.icon');
await icon.waitFor({ state: 'visible', timeout: 5000 });