diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
index c16dbc9d9..76ca4d4a1 100644
--- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -1,6 +1,6 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
-import { IconTrash, IconAlertCircle } from '@tabler/icons';
+import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
@@ -41,7 +41,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
- value: Yup.string().trim().nullable()
+ value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
@@ -160,9 +160,24 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
+ readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
+ {typeof variable.value !== 'string' && (
+
+
+
+
+ )}
props.theme.colors.text.muted} !important;
+ }
+ }
+
.CodeMirror {
background: transparent;
height: fit-content;
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index 3665d08d9..e7e99846c 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -35,6 +35,7 @@ class MultiLineEditor extends Component {
brunoVarInfo: {
variables
},
+ readOnly: this.props.readOnly ? 'nocursor' : false,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
@@ -126,6 +127,9 @@ class MultiLineEditor extends Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
+ if (this.props.readOnly !== prevProps.readOnly && this.editor) {
+ this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
+ }
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
@@ -182,9 +186,10 @@ class MultiLineEditor extends Component {
};
render() {
+ const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
return (
-
+
{this.secretEye(this.props.isSecret)}
);
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index 8e4feecba..790454b52 100644
--- a/packages/bruno-schema/src/collections/index.js
+++ b/packages/bruno-schema/src/collections/index.js
@@ -4,7 +4,8 @@ const { uidSchema } = require('../common');
const environmentVariablesSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
- value: Yup.string().nullable(),
+ // Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
+ value: Yup.mixed().nullable(),
type: Yup.string().oneOf(['text']).required('type is required'),
enabled: Yup.boolean().defined(),
secret: Yup.boolean()
diff --git a/tests/collection/create/create-collection.spec.ts b/tests/collection/create/create-collection.spec.ts
index eec604ea1..56124a41f 100644
--- a/tests/collection/create/create-collection.spec.ts
+++ b/tests/collection/create/create-collection.spec.ts
@@ -1,32 +1,40 @@
import { test, expect } from '../../../playwright';
+import { closeAllCollections } from '../../utils/page';
-test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
- // Create a new collection
- await page.getByLabel('Create Collection').click();
- await page.getByLabel('Name').click();
- await page.getByLabel('Name').fill('test-collection');
- await page.getByLabel('Name').press('Tab');
- await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
- await page.getByText('test-collection').click();
+test.describe('Create collection', () => {
+ test.afterEach(async ({ page }) => {
+ // cleanup: close all collections
+ await closeAllCollections(page);
+ });
- // Select safe mode
- await page.getByLabel('Safe Mode').check();
- await page.getByRole('button', { name: 'Save' }).click();
+ test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
+ // Create a new collection
+ await page.getByLabel('Create Collection').click();
+ await page.getByLabel('Name').click();
+ await page.getByLabel('Name').fill('test-collection');
+ await page.getByLabel('Name').press('Tab');
+ await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+ await page.getByText('test-collection').click();
- // Create a new request
- await page.locator('#create-new-tab').getByRole('img').click();
- await page.getByPlaceholder('Request Name').fill('r1');
- await page.locator('#new-request-url .CodeMirror').click();
- await page.locator('textarea').fill('http://localhost:8081');
- await page.getByRole('button', { name: 'Create' }).click();
+ // Select safe mode
+ await page.getByLabel('Safe Mode').check();
+ await page.getByRole('button', { name: 'Save' }).click();
- // Send a request
- await page.locator('#request-url .CodeMirror').click();
- await page.locator('textarea').fill('/ping');
- await page.locator('#send-request').getByTitle('Save Request').click();
- await page.locator('#send-request').getByRole('img').nth(2).click();
+ // Create a new request
+ await page.locator('#create-new-tab').getByRole('img').click();
+ await page.getByPlaceholder('Request Name').fill('r1');
+ await page.locator('#new-request-url .CodeMirror').click();
+ await page.locator('textarea').fill('http://localhost:8081');
+ await page.getByRole('button', { name: 'Create' }).click();
- // Verify the response
- await expect(page.getByRole('main')).toContainText('200 OK');
+ // Send a request
+ await page.locator('#request-url .CodeMirror').click();
+ await page.locator('textarea').fill('/ping');
+ await page.locator('#send-request').getByTitle('Save Request').click();
+ await page.locator('#send-request').getByRole('img').nth(2).click();
+
+ // Verify the response
+ await expect(page.getByRole('main')).toContainText('200 OK');
+ });
});
\ No newline at end of file
diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
index 00f745fe2..13147d3e4 100644
--- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
+++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts
@@ -1,6 +1,12 @@
import { test, expect } from '../../../playwright';
+import { closeAllCollections } from '../../utils/page';
test.describe('Cross-Collection Drag and Drop for folder', () => {
+ test.afterEach(async ({ pageWithUserData: page }) => {
+ // cleanup: close all collections
+ await closeAllCollections(page);
+ });
+
test('Verify cross-collection folder drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
// Create first collection - click dropdown menu first
await page.locator('.dropdown-icon').click();
diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts
index 598ac858a..e1c0c832d 100644
--- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts
+++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts
@@ -1,6 +1,12 @@
import { test, expect } from '../../../playwright';
+import { closeAllCollections } from '../../utils/page';
test.describe('Cross-Collection Drag and Drop', () => {
+ test.afterEach(async ({ pageWithUserData: page }) => {
+ // cleanup: close all collections
+ await closeAllCollections(page);
+ });
+
test('Verify request drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
// Create first collection - click dropdown menu first
await page.locator('.dropdown-icon').click();
diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts
index c96a6f50b..d6088a0ca 100644
--- a/tests/collection/moving-requests/tag-persistence.spec.ts
+++ b/tests/collection/moving-requests/tag-persistence.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('Tag persistence', () => {
- test.afterAll(async ({ pageWithUserData: page }) => {
+ test.afterEach(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
@@ -65,6 +65,7 @@ test.describe('Tag persistence', () => {
// Click on r3 to verify the tag persisted after the move
await page.locator('.collection-item-name').filter({ hasText: 'r3' }).click();
+ await page.locator('.request-tab.active', { hasText: 'r3' }).waitFor({ state: 'visible' });
await page.getByRole('tab', { name: 'Settings' }).click();
// Verify the tag is still present after the move
@@ -149,6 +150,7 @@ test.describe('Tag persistence', () => {
// Click on r2 to verify the tag persisted after the move
await page.locator('.collection-item-name').filter({ hasText: 'r2' }).click();
+ await page.locator('.request-tab.active', { hasText: 'r2' }).waitFor({ state: 'visible' });
await page.getByRole('tab', { name: 'Settings' }).click();
await expect(page.getByRole('button', { name: 'smoke' })).toBeVisible();
});
diff --git a/tests/collection/open/open-multiple-collections.spec.ts b/tests/collection/open/open-multiple-collections.spec.ts
index 2647d6ef3..6865986ac 100644
--- a/tests/collection/open/open-multiple-collections.spec.ts
+++ b/tests/collection/open/open-multiple-collections.spec.ts
@@ -57,8 +57,8 @@ test.describe('Open Multiple Collections', () => {
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
- // Click on Open Collection(s) button
- await page.getByRole('button', { name: 'Open Collection' }).click();
+ // Click on Open Collection button
+ await page.locator('button').filter({ hasText: 'Open Collection' }).click();
// Wait for both collections to appear in the sidebar
const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1');
@@ -91,7 +91,7 @@ test.describe('Open Multiple Collections', () => {
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
- // Click on Open Collection(s) button
+ // Click on Open Collection button
await page.getByRole('button', { name: 'Open Collection' }).click();
// Verify no collections were opened
diff --git a/tests/global-environments/collection/bruno.json b/tests/global-environments/collection/bruno.json
new file mode 100644
index 000000000..3e0226eec
--- /dev/null
+++ b/tests/global-environments/collection/bruno.json
@@ -0,0 +1,6 @@
+{
+ "version": "1",
+ "name": "global-env-non-string",
+ "type": "collection"
+}
+
diff --git a/tests/global-environments/collection/set-global-nonstring.bru b/tests/global-environments/collection/set-global-nonstring.bru
new file mode 100644
index 000000000..111af755f
--- /dev/null
+++ b/tests/global-environments/collection/set-global-nonstring.bru
@@ -0,0 +1,16 @@
+meta {
+ name: set-global-nonstring
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://example.com
+ body: none
+ auth: none
+}
+
+script:post-response {
+ bru.setGlobalEnvVar('numericVar', 170001);
+ bru.setGlobalEnvVar('booleanVar', true);
+}
diff --git a/tests/global-environments/init-user-data/preferences.json b/tests/global-environments/init-user-data/preferences.json
new file mode 100644
index 000000000..ebe2cee9e
--- /dev/null
+++ b/tests/global-environments/init-user-data/preferences.json
@@ -0,0 +1,6 @@
+{
+ "maximized": false,
+ "lastOpenedCollections": [
+ "{{projectRoot}}/tests/global-environments/collection"
+ ]
+}
diff --git a/tests/global-environments/non-string-values.spec.ts b/tests/global-environments/non-string-values.spec.ts
new file mode 100644
index 000000000..9d0a2acd3
--- /dev/null
+++ b/tests/global-environments/non-string-values.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect } from '../../playwright';
+import { openCollectionAndAcceptSandbox, closeAllCollections } from '../utils/page';
+
+test.describe('Global Environment Variables - Non-string Values', () => {
+ test.afterAll(async ({ pageWithUserData: page }) => {
+ // Cleanup: close all collections
+ await closeAllCollections(page);
+ });
+
+ test('should seed non-string globals via request and verify read-only + tooltip', async ({
+ pageWithUserData: page
+ }) => {
+ await openCollectionAndAcceptSandbox(page, 'global-env-non-string', 'safe');
+
+ await test.step('Create a new global environment with a string variable', async () => {
+ await page.getByTestId('environment-selector-trigger').click();
+ await page.getByTestId('env-tab-global').click();
+
+ // Create a new global environment
+ await page.getByRole('button', { name: 'Create' }).click();
+ await page.locator('#environment-name').click();
+ await page.locator('#environment-name').fill('Test Env');
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Add a string variable.
+ await page.getByTestId('add-variable').click();
+ const newRow = page.locator('tbody tr').last();
+ await newRow.locator('input[name$=".name"]').fill('stringVar');
+ await newRow.locator('.CodeMirror').click();
+ await page.keyboard.type('hello world');
+
+ // Save
+ await page.getByTestId('save-env').click();
+
+ // Verify that the string variable value is saved and displayed correctly.
+ await expect(newRow.locator('.CodeMirror-line').first()).toContainText('hello world');
+ // Close the environment modal
+ await page.locator('[data-test-id="modal-close-button"]').click();
+ });
+
+ // Request contains a script that sets the non-string global variables.
+ await test.step('Run the request to seed non-string global variables via post-script', async () => {
+ await page.getByText('set-global-nonstring').click();
+ await page.getByTestId('send-arrow-icon').click();
+
+ // wait for the response to arrive
+ await page.getByTestId('response-status-code').waitFor({ state: 'visible' });
+ await expect(page.getByTestId('response-status-code')).toHaveText(/200/);
+ });
+
+ await test.step('Re-open Global Environments to see the seeded variables', async () => {
+ await page.getByTestId('environment-selector-trigger').click();
+ await page.getByTestId('env-tab-global').click();
+ await page.getByRole('button', { name: 'Configure' }).click();
+ });
+
+ const envModal = page
+ .locator('.bruno-modal-card')
+ .filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'Global Environments' }) });
+
+ const numericInput = envModal.locator('input[value="numericVar"]');
+ const booleanInput = envModal.locator('input[value="booleanVar"]');
+ await expect(numericInput).toBeVisible();
+ await expect(booleanInput).toBeVisible();
+ const numericRow = numericInput.locator('xpath=ancestor::tr');
+ const booleanRow = booleanInput.locator('xpath=ancestor::tr');
+
+ await test.step('Verify that numericVar is read-only with tooltip', async () => {
+ // This value is set via a post-script (not user input). We verify that attempts to edit the input do not change the value, proving it is read-only in the UI.
+
+ // Verify the script-set value is rendered.
+ await expect(numericRow.locator('.CodeMirror-line').first()).toContainText(/170001/);
+
+ // Verify that typing into the input does not mutate the value.
+ await numericRow.locator('.CodeMirror').click();
+ await page.keyboard.type('999');
+ await expect(numericRow.locator('.CodeMirror-line').first()).toContainText(/170001/);
+
+ // Hovering over the info icon reveals the tooltip.
+ // It is anchored to the info icon element id, so hover/click reveals it reliably.
+ const infoIcon = page.locator('#numericVar-disabled-info-icon');
+ await infoIcon.hover();
+
+ // The tooltip explains why the field is locked.
+ const tooltip = page.locator('[role="tooltip"], .react-tooltip');
+ await expect(tooltip.first()).toBeVisible();
+ await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
+
+ // Hovering outside the tooltip should hide it.
+ await page.mouse.move(0, 0);
+ await expect(tooltip.first()).not.toBeVisible();
+
+ // Clicking the info icon reveals the tooltip.
+ await infoIcon.click();
+ await expect(tooltip.first()).toBeVisible();
+ await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
+ });
+
+ await test.step('Verify that booleanVar is read-only with tooltip', async () => {
+ // This value is also set via post-script.
+ await expect(booleanRow.locator('.CodeMirror-line').first()).toContainText(/true/);
+
+ // Verify that typing into the input does not mutate the value.
+ await booleanRow.locator('.CodeMirror').click();
+ await page.keyboard.type('false');
+ await expect(booleanRow.locator('.CodeMirror-line').first()).toContainText(/true/);
+
+ // Hovering over the info icon reveals the tooltip.
+ const infoIcon = page.locator('#booleanVar-disabled-info-icon');
+ await infoIcon.hover();
+
+ // The tooltip explains why the field is locked.
+ const tooltip = page.locator('[role="tooltip"], .react-tooltip');
+ await expect(tooltip.first()).toBeVisible();
+ await expect(tooltip.first()).toContainText('Non-string values set via scripts are read-only and can only be updated through scripts.');
+ });
+
+ await test.step('Verify that stringVar remains editable', async () => {
+ // Unlike script-managed values above, this one is user-managed.
+ const stringInput = envModal.locator('input[value="stringVar"]');
+ await expect(stringInput).toBeVisible();
+ const stringRow = stringInput.locator('xpath=ancestor::tr');
+
+ await expect(stringRow.locator('.CodeMirror-line').first()).toContainText('hello world');
+ await stringRow.locator('.CodeMirror').click();
+ await page.keyboard.type(' updated');
+
+ // Verify the user edit persists in the UI.
+ await expect(stringRow.locator('.CodeMirror-line').first()).toContainText('hello world updated');
+ // Close the environment modal
+ await page.locator('[data-test-id="modal-close-button"]').click();
+ });
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index e9f970488..50086a721 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -1,11 +1,48 @@
-const closeAllCollections = async (page) => {
- const numberOfCollections = await page.locator('.collection-name').count();
+import { test, expect } from '../../../playwright';
- for (let i = 0; i < numberOfCollections; i++) {
- await page.locator('.collection-name').first().locator('.collection-actions').click();
- await page.locator('.dropdown-item').getByText('Close').click();
- await page.getByRole('button', { name: 'Close' }).click();
- }
+/**
+ * Close all collections
+ * @param page - The page object
+ * @returns void
+ */
+const closeAllCollections = async (page) => {
+ await test.step('Close all collections', async () => {
+ const numberOfCollections = await page.locator('.collection-name').count();
+
+ for (let i = 0; i < numberOfCollections; i++) {
+ await page.locator('.collection-name').first().locator('.collection-actions').click();
+ await page.locator('.dropdown-item').getByText('Close').click();
+ // Wait for the close collection modal to be visible
+ await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'visible' });
+ await page.locator('.bruno-modal-footer .submit').click();
+ // Wait for the close collection modal to be hidden
+ await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'hidden' });
+ }
+
+ // Wait until no collections are left open
+ await expect(page.locator('.collection-name')).toHaveCount(0);
+ });
};
-export { closeAllCollections };
+/**
+ * Open a collection from the sidebar and accept the JavaScript Sandbox modal
+ * @param page - The page object
+ * @param collectionName - The name of the collection to open
+ * @param sandboxMode - The mode to accept the sandbox modal
+ * @returns void
+ */
+const openCollectionAndAcceptSandbox = async (page, collectionName: string, sandboxMode: 'safe' | 'developer' = 'safe') => {
+ await test.step(`Open collection "${collectionName}" and accept sandbox "${sandboxMode}" mode`, async () => {
+ await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click();
+
+ const sandboxModal = page
+ .locator('.bruno-modal-card')
+ .filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'JavaScript Sandbox' }) });
+
+ const modeLabel = sandboxMode === 'safe' ? 'Safe Mode' : 'Developer Mode';
+ await sandboxModal.getByLabel(modeLabel).check();
+ await sandboxModal.locator('.bruno-modal-footer .submit').click();
+ });
+};
+
+export { closeAllCollections, openCollectionAndAcceptSandbox };
|