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 };