diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index 1e9d51211..aae8529cd 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -7,6 +7,7 @@ */ import { interpolate, mockDataFunctions, timeBasedDynamicVars } from '@usebruno/common'; +import { toDisplayString } from '@usebruno/common/utils'; import { getVariableScope, isVariableSecret, getAllVariables, findCollectionByUid, findItemInCollectionByItemUid } from 'utils/collections'; import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions'; import store from 'providers/ReduxStore'; @@ -95,7 +96,7 @@ const updateValueDisplay = (valueDisplay, value, isSecret, isMasked, isRevealed) } if (typeof value === 'object') { - valueDisplay.textContent = value === null ? 'null' : JSON.stringify(value, null, 2); + valueDisplay.textContent = value === null ? 'null' : toDisplayString(value, String(value)); return; } @@ -153,8 +154,10 @@ const getCopyButton = (getVariableValue, onCopyCallback) => { // Resolve the latest value at click time so edits/saves are reflected. const valueToCopy = typeof getVariableValue === 'function' ? getVariableValue() : getVariableValue; + const valueStr = toDisplayString(valueToCopy, String(valueToCopy)); + navigator.clipboard - .writeText(valueToCopy ?? '') + .writeText(valueStr) .then(() => { isCopied = true; copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index 412bd715d..412cfe9de 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -421,6 +421,28 @@ describe('renderVarInfo', () => { expect(copyButton.classList.contains('copy-success')).toBe(false); }); + it('should copy plain object values as formatted JSON', async () => { + const { copyButton } = setupRender({ apiKey: { host: 'localhost', port: 8080 } }); + + copyButton.click(); + await jest.runAllTimersAsync(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + JSON.stringify({ host: 'localhost', port: 8080 }, null, 2) + ); + }); + + it('should fall back to String() for circular objects and still write to clipboard', async () => { + const circular = {}; + circular.self = circular; + const { copyButton } = setupRender({ apiKey: circular }); + + copyButton.click(); + await jest.runAllTimersAsync(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(String(circular)); + }); + it('should log to the console when the variable value is not copied', async () => { const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' }); diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index f807245ed..99d6a7d14 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -38,3 +38,7 @@ export { BRUNO_VARIABLE_DATATYPES, isBrunoVariableDataType } from './datatype'; + +export { + toDisplayString +} from './string'; diff --git a/packages/bruno-common/src/utils/string.ts b/packages/bruno-common/src/utils/string.ts new file mode 100644 index 000000000..9b399d00d --- /dev/null +++ b/packages/bruno-common/src/utils/string.ts @@ -0,0 +1,15 @@ +export const toDisplayString = (value: unknown, fallback = '', indentation = 2): string => { + if (value == null || value === '') return fallback; + + if (typeof value === 'string') return value; + + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, indentation); + } catch { + return fallback; + } + } + + return String(value); +}; diff --git a/tests/variable-tooltip/variable-tooltip.spec.ts b/tests/variable-tooltip/variable-tooltip.spec.ts index 20fc367c9..a7c16171d 100644 --- a/tests/variable-tooltip/variable-tooltip.spec.ts +++ b/tests/variable-tooltip/variable-tooltip.spec.ts @@ -3,6 +3,8 @@ import { createCollection, closeAllCollections, createRequest, + createFolder, + expandFolder, createEnvironment, addEnvironmentVariables, saveEnvironment, @@ -634,4 +636,84 @@ test.describe('Variable Tooltip', () => { expect(updatedClipboard).toBe('original-copy-edited'); }); }); + + test('should copy pretty-printed JSON for an object-typed folder variable', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-object-copy-test'; + const folderName = 'objFolder'; + const objectValue = { city: 'NYC', zip: 10001 }; + + const expectedJson = JSON.stringify(objectValue, null, 2); + + await test.step('Create a folder with an object-typed folder variable', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-object-collection')); + await createFolder(page, folderName, collectionName); + + const locators = buildCommonLocators(page); + + // Open Folder Settings > Vars tab + const folderRow = locators.sidebar.folder(folderName); + await folderRow.dblclick(); + await locators.paneTabs.folderSettingsTab('vars').click(); + + // Add the variable row and type its value + const tableContainer = page.getByTestId('folder-vars-req').first(); + const lastRow = tableContainer.locator('tbody tr').last(); + const nameInput = lastRow.locator('input[type="text"]').first(); + await nameInput.click(); + await page.keyboard.type('objVar'); + + const namedRow = tableContainer.locator('tbody tr[data-row-name="objVar"]'); + await expect(namedRow).toBeVisible(); + + const valueEditor = namedRow.locator('[data-testid="column-value"] .CodeMirror').first(); + await valueEditor.click({ force: true }); + await expect(valueEditor).toHaveClass(/CodeMirror-focused/); + await page.keyboard.insertText(JSON.stringify(objectValue)); + + // Switch the variable's dataType from the default `string` to `object` + const typeTrigger = locators.dataTypeSelector.typeLabel(namedRow); + await typeTrigger.click(); + await locators.dataTypeSelector.menuItem('object').click(); + await expect(typeTrigger).toHaveText('object'); + + await page.getByRole('button', { name: 'Save', exact: true }).first().click(); + await page.waitForTimeout(500); + }); + + await test.step('Create request inside the folder referencing the object variable', async () => { + await expandFolder(page, folderName); + await createRequest(page, 'Object Copy Request', folderName, { inFolder: true }); + + const locators = buildCommonLocators(page); + await locators.sidebar.folderRequest(folderName, 'Object Copy Request').click(); + + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?data={{objVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('Tooltip shows pretty-printed JSON and copy button copies it verbatim', async () => { + const urlEditor = page.locator('#request-url .CodeMirror'); + const objVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'objVar' }).first(); + await objVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + await expect(tooltip.locator('.var-scope-badge')).toContainText('Folder'); + + // Parse back and deep-compare so the assertion isn't coupled to whitespace. + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await expect.poll(async () => JSON.parse((await valueDisplay.textContent()) ?? 'null')).toEqual(objectValue); + + const copyButton = tooltip.locator('.copy-button'); + await copyButton.click(); + + // Success state confirms writeText resolved before we read the clipboard. + await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toBe(expectedJson); + }); + }); });