feat: add persistent environment variable handling in IPC events and Bru class (#5172)

This commit is contained in:
Sanjai Kumar
2025-08-19 23:05:22 +05:30
committed by GitHub
parent 3e3e2e0563
commit e5a608f962
21 changed files with 346 additions and 14 deletions

View File

@@ -2,4 +2,4 @@ import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('ping', { exact: true }).click();
await page.getByText('No Environment').click();
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByText('ping2', { exact: true }).click();
await page.getByText('Env', { exact: true }).click();
await page.getByText('Stage', { exact: true }).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping2', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByText('Stage').click();
await newPage
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "collection",
"type": "collection"
}

View File

@@ -0,0 +1,4 @@
vars {
host: https://testbench-sanity.usebruno.com
persistent-env-test: persistent-env-test-value
}

View File

@@ -0,0 +1,3 @@
vars {
host: https://testbench-sanity.usebruno.com
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping2
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value");
}

View File

@@ -0,0 +1,15 @@
meta {
name: ping
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:pre-request {
bru.setEnvVar("persistent-env-test", "persistent-env-test-value", { persist: true });
}

View File

@@ -0,0 +1,6 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
]
}

View File

@@ -19,7 +19,7 @@ import {
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot } from 'providers/ReduxStore/slices/collections/actions';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -112,6 +112,10 @@ const useIpcEvents = () => {
dispatch(scriptEnvironmentUpdateEvent(val));
});
const removePersistentEnvVariablesUpdateListener = ipcRenderer.on('main:persistent-env-variables-update', (val) => {
dispatch(mergeAndPersistEnvironment(val));
});
const removeGlobalEnvironmentVariablesUpdateListener = ipcRenderer.on('main:global-environment-variables-update', (val) => {
dispatch(globalEnvironmentsUpdateEvent(val));
});
@@ -204,6 +208,7 @@ const useIpcEvents = () => {
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
};
}, [isElectron]);
};

View File

@@ -1050,6 +1050,68 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
});
};
export const mergeAndPersistEnvironment =
({ persistentEnvVariables, collectionUid }) =>
(_dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
const environmentUid = collection.activeEnvironmentUid;
if (!environmentUid) {
return reject(new Error('No active environment found'));
}
const collectionCopy = cloneDeep(collection);
const environment = findEnvironmentInCollection(collectionCopy, environmentUid);
if (!environment) {
return reject(new Error('Environment not found'));
}
// Only proceed if there are persistent variables to save
if (!persistentEnvVariables || Object.keys(persistentEnvVariables).length === 0) {
return resolve();
}
let existingVars = environment.variables || [];
let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => ({
uid: uuid(),
name,
value,
type: 'text',
enabled: true,
secret: false
}));
const merged = existingVars.map((v) => {
const found = normalizedNewVars.find((nv) => nv.name === v.name);
if (found) {
return { ...v, value: found.value };
}
return v;
});
normalizedNewVars.forEach((nv) => {
if (!merged.some((v) => v.name === nv.name)) {
merged.push(nv);
}
});
environment.variables = merged;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
.then(resolve)
.catch(reject);
});
};
export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();

View File

@@ -60,7 +60,8 @@ const STATIC_API_HINTS = {
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.setEnvVar(key, value)',
'bru.setEnvVar(key, value, options)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',

View File

@@ -468,6 +468,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
@@ -542,6 +547,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: result.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: result.globalEnvironmentVariables
});
@@ -583,6 +593,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
@@ -887,6 +902,11 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: testResults.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});

View File

@@ -18,7 +18,6 @@ class Bru {
this.collectionPath = collectionPath;
this.collectionName = collectionName;
this.sendRequest = sendRequest;
this.cookies = {
jar: () => {
const cookieJar = createCookieJar();
@@ -62,6 +61,8 @@ class Bru {
};
}
};
// Holds variables that are marked as persistent by scripts
this.persistentEnvVariables = {};
this.runner = {
skipRequest: () => {
this.skipRequest = true;
@@ -119,12 +120,25 @@ class Bru {
return this.interpolate(this.envVariables[key]);
}
setEnvVar(key, value) {
setEnvVar(key, value, options = {}) {
if (!key) {
throw new Error('Creating a env variable without specifying a name is not allowed.');
}
// When persist is true, only string values are allowed
if (options?.persist && typeof value !== 'string') {
throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
}
this.envVariables[key] = value;
if (options?.persist) {
this.persistentEnvVariables[key] = value
} else {
if (this.persistentEnvVariables[key]) {
delete this.persistentEnvVariables[key];
}
}
}
deleteEnvVar(key) {

View File

@@ -122,6 +122,7 @@ class ScriptRuntime {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
@@ -177,6 +178,7 @@ class ScriptRuntime {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
@@ -268,6 +270,7 @@ class ScriptRuntime {
return {
response,
envVariables: cleanJson(envVariables),
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
@@ -323,6 +326,7 @@ class ScriptRuntime {
return {
response,
envVariables: cleanJson(envVariables),
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),

View File

@@ -184,6 +184,7 @@ class TestRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest
};

View File

@@ -74,6 +74,7 @@ class VarsRuntime {
envVariables,
runtimeVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
error
};
}

View File

@@ -47,8 +47,8 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'getEnvVar', getEnvVar);
getEnvVar.dispose();
let setEnvVar = vm.newFunction('setEnvVar', function (key, value) {
bru.setEnvVar(vm.dump(key), vm.dump(value));
let setEnvVar = vm.newFunction('setEnvVar', function (key, value, options = {}) {
bru.setEnvVar(vm.dump(key), vm.dump(value), vm.dump(options));
});
vm.setProp(bruObject, 'setEnvVar', setEnvVar);
setEnvVar.dispose();

View File

@@ -1,6 +1,8 @@
const { describe, it, expect } = require('@jest/globals');
const TestRuntime = require('../src/runtime/test-runtime');
const ScriptRuntime = require('../src/runtime/script-runtime');
const Bru = require('../src/bru');
const VarsRuntime = require('../src/runtime/vars-runtime');
describe('runtime', () => {
describe('test-runtime', () => {
@@ -175,4 +177,73 @@ describe('runtime', () => {
});
});
});
describe('persistent environment variables validation', () => {
it('should throw error when trying to persist non-string values', async () => {
const script = `bru.setEnvVar('number', 42, {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received number for key "number".');
});
it('should throw error when trying to persist boolean values', async () => {
const script = `bru.setEnvVar('isActive', true, {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received boolean for key "isActive".');
});
it('should throw error when trying to persist object values', async () => {
const script = `bru.setEnvVar('config', {port: 3000}, {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received object for key "config".');
});
it('should throw error when trying to persist array values', async () => {
const script = `bru.setEnvVar('items', ['item1', 'item2'], {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
await expect(runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env))
.rejects.toThrow('Persistent environment variables must be strings. Received object for key "items".');
});
it('should allow string values when persist is true', async () => {
const script = `bru.setEnvVar('api_key', 'abc123', {persist: true});`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
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});
`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
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);`;
const runtime = new ScriptRuntime({ runtime: 'vm2' });
const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env);
expect(result.envVariables.number).toBe(42);
});
});
});

View File

@@ -11,6 +11,7 @@ export const test = baseTest.extend<
page: Page;
newPage: Page;
pageWithUserData: Page;
restartApp: (options?: { initUserDataPath?: string }) => Promise<ElectronApplication>;
},
{
createTmpDir: (tag?: string) => Promise<string>;
@@ -67,17 +68,22 @@ export const test = baseTest.extend<
args: [electronAppPath],
env: {
...process.env,
ELECTRON_USER_DATA_PATH: userDataPath,
ELECTRON_USER_DATA_PATH: userDataPath
}
});
const { workerIndex } = workerInfo;
app.process()?.stdout?.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
app.process()?.stderr?.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
const electronProcess = app.process();
if (electronProcess?.stdout) {
electronProcess.stdout.on('data', (data) => {
process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
}
if (electronProcess?.stderr) {
electronProcess.stderr.on('data', (error) => {
process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`));
});
}
apps.push(app);
return app;
@@ -157,6 +163,32 @@ export const test = baseTest.extend<
{ scope: 'worker' }
],
restartApp: async ({ launchElectronApp }, use, testInfo) => {
const appInstances: Array<{ app: ElectronApplication; initUserDataPath?: string }> = [];
await use(async ({ initUserDataPath } = {}) => {
// Get the test directory and check for init-user-data folder
const testDir = path.dirname(testInfo.file);
const defaultInitUserDataPath = path.join(testDir, 'init-user-data');
// Use provided initUserDataPath, or check if default path exists, or use undefined
let userDataPath = initUserDataPath;
if (!userDataPath) {
const hasInitUserData = await fs.promises.stat(defaultInitUserDataPath).catch(() => false);
userDataPath = hasInitUserData ? defaultInitUserDataPath : undefined;
}
const app = await launchElectronApp({ initUserDataPath: userDataPath });
appInstances.push({ app, initUserDataPath: userDataPath });
return app;
});
// Clean up all app instances
for (const { app } of appInstances) {
await app.context().close();
await app.close();
}
},
pageWithUserData: async ({ reuseOrLaunchElectronApp }, use, testInfo) => {
const testDir = path.dirname(testInfo.file);
const initUserDataPath = path.join(testDir, 'init-user-data');