diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index b63125eac..89e79fda1 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -132,7 +132,7 @@ const containsSecretVariableReferences = (rawValue, collection, item) => { return false; }; -const getCopyButton = (variableValue, onCopyCallback) => { +const getCopyButton = (getVariableValue, onCopyCallback) => { const copyButton = document.createElement('button'); copyButton.className = 'copy-button'; @@ -150,8 +150,11 @@ const getCopyButton = (variableValue, onCopyCallback) => { return; } + // Resolve the latest value at click time so edits/saves are reflected. + const valueToCopy = typeof getVariableValue === 'function' ? getVariableValue() : getVariableValue; + navigator.clipboard - .writeText(variableValue) + .writeText(valueToCopy ?? '') .then(() => { isCopied = true; copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT; @@ -415,6 +418,11 @@ export const renderVarInfo = (token, options) => { // Store original value for comparison and track editing state let originalValue = rawValue; let isEditing = false; + // Latest resolved value and mask state used by the copy button, eye toggle, and + // error-revert path. Updated after each successful save so subsequent redraws + // reflect the saved state. `??` preserves falsy-but-valid values like 0 / false. + let currentInterpolatedValue = variableValue ?? ''; + let currentShouldMaskValue = shouldMaskValue; cmEditor.setOption('extraKeys', { 'Enter': (cm) => { @@ -461,8 +469,8 @@ export const renderVarInfo = (token, options) => { // Update icon toggleButton.innerHTML = isRevealed ? EYE_OFF_ICON_SVG : EYE_ICON_SVG; - // Update display mode - updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed); + // Update display mode using live state so post-save values/masking are reflected. + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); // Update editor mode if (maskedEditor) { @@ -480,8 +488,9 @@ export const renderVarInfo = (token, options) => { iconsContainer.appendChild(toggleButton); } - // Copy button (copy actual value, not masked) - const copyButton = getCopyButton(variableValue || '', () => { + // Copy button (copy actual value, not masked). Uses a getter so it always + // reflects the latest saved value, not the value captured at popup creation. + const copyButton = getCopyButton(() => currentInterpolatedValue, () => { // Refocus the editor if it's currently in edit mode if (isEditing) { setTimeout(() => { @@ -555,18 +564,22 @@ export const renderVarInfo = (token, options) => { } } - // Re-interpolate the new value to show the resolved value in display + // Re-interpolate the new value to show the resolved value in display. + // Use `??` so falsy-but-valid values (0 / false / '') survive the assignment. const interpolatedValue = interpolate(newValue, allVariables); - // Check if the NEW value contains secret references + currentInterpolatedValue = interpolatedValue ?? ''; + // Check if the NEW value contains secret references and update live mask state const newHasSecretRefs = containsSecretVariableReferences(newValue, collection, item); - const newShouldMask = isSecret || newHasSecretRefs; - updateValueDisplay(valueDisplay, interpolatedValue, newShouldMask, isMasked, isRevealed); + currentShouldMaskValue = isSecret || newHasSecretRefs; + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); }) .catch((err) => { console.error('Failed to update variable:', err); - // Revert on error + // Revert on error to the last good state — currentInterpolatedValue and + // currentShouldMaskValue still hold pre-attempt values since the success + // block above never ran. cmEditor.setValue(originalValue); - updateValueDisplay(valueDisplay, variableValue, shouldMaskValue, isMasked, isRevealed); + updateValueDisplay(valueDisplay, currentInterpolatedValue, currentShouldMaskValue, isMasked, isRevealed); }); } }); diff --git a/tests/variable-tooltip/variable-tooltip.spec.ts b/tests/variable-tooltip/variable-tooltip.spec.ts index cf8eba4d8..20fc367c9 100644 --- a/tests/variable-tooltip/variable-tooltip.spec.ts +++ b/tests/variable-tooltip/variable-tooltip.spec.ts @@ -218,11 +218,7 @@ test.describe('Variable Tooltip', () => { // Click copy button await copyButton.click(); - // Should show success state (checkmark) - await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); - - // Wait for it to revert back to copy icon - await expect(copyButton.locator('svg rect')).toBeVisible(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 }).toBe('https://api.example.com/users/posts'); }); }); @@ -431,4 +427,211 @@ test.describe('Variable Tooltip', () => { await expect(tooltip.locator('.var-value-editable-display')).not.toBeVisible(); }); }); + + test('should keep tooltip open while editing when mouse leaves popup area', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-pin-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-pin-collection')); + + await createEnvironment(page, 'Pin Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'pinVar', value: 'pin-value' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Pin Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Pin Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{pinVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('Tooltip stays open and accepts input while mouse is outside popup', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const pinVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'pinVar' }).first(); + await pinVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + // Click value display to enter edit mode (this also pins the popup) + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + // Move mouse far outside the popup + await page.mouse.move(0, 0); + + // Type with a per-keystroke delay so the typing window spans past the internal + // 500ms hide timer. If the popup were not pinned, it would hide mid-typing and + // the keystrokes would never reach the editor — the assertion below would fail. + // This validates pinning via real editor activity instead of a fixed sleep. + await page.keyboard.press('End'); + await page.keyboard.type('-still-editable-after-mouse-left', { delay: 25 }); + + await expect(editor.locator('.CodeMirror-line')).toContainText( + 'pin-value-still-editable-after-mouse-left' + ); + await expect(tooltip).toBeVisible(); + }); + }); + + test('should persist subsequent edits while popup stays open', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-subsequent-edit-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-subsequent-collection')); + + await createEnvironment(page, 'Edit Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'editVar', value: 'initial' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Edit Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Edit Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{editVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('First edit saves via Enter and keeps popup open', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const editVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'editVar' }).first(); + await editVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await expect(valueDisplay).toContainText('initial'); + + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-one'); + + // Pressing Enter saves and keeps the popup open (does not click outside) + await page.keyboard.press('Enter'); + + // Display reflects the saved value, and tooltip is still visible + await expect(valueDisplay).toContainText('initial-one'); + await expect(tooltip).toBeVisible(); + }); + + await test.step('Second edit on the same popup also saves', async () => { + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-two'); + + await page.keyboard.press('Enter'); + + await expect(valueDisplay).toContainText('initial-one-two'); + }); + + await test.step('Reopen tooltip and verify the second edit persisted', async () => { + // Close the existing tooltip with an outside click, then re-hover to get a fresh one + await page.locator('body').click(); + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).not.toBeVisible(); + + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const editVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'editVar' }).first(); + await editVar.hover(); + + const newTooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(newTooltip).toBeVisible(); + await expect(newTooltip.locator('.var-value-editable-display')).toContainText('initial-one-two'); + }); + }); + + test('should copy latest value after editing within the same tooltip', async ({ page, createTmpDir }) => { + const collectionName = 'tooltip-copy-latest-test'; + + await test.step('Setup collection, environment variable, and request', async () => { + await createCollection(page, collectionName, await createTmpDir('tooltip-copy-latest-collection')); + + await createEnvironment(page, 'Copy Env', 'collection'); + await addEnvironmentVariables(page, [{ name: 'copyVar', value: 'original-copy' }]); + await saveEnvironment(page); + await closeEnvironmentPanel(page); + + await createRequest(page, 'Copy Test Request', collectionName); + await page.locator('.collection-item-name').filter({ hasText: 'Copy Test Request' }).click(); + const urlEditor = page.locator('#request-url .CodeMirror'); + await urlEditor.click(); + await page.keyboard.type('https://api.example.com?key={{copyVar}}'); + await page.keyboard.press(saveShortcut); + }); + + await test.step('Copy button copies the initial value', async () => { + await page.mouse.move(0, 0); + + const urlEditor = page.locator('#request-url .CodeMirror'); + const copyVar = urlEditor.locator('.cm-variable-valid').filter({ hasText: 'copyVar' }).first(); + await copyVar.hover(); + + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + 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 initialClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(initialClipboard).toBe('original-copy'); + + // Wait for the icon to revert so the next click is allowed + await expect(copyButton.locator('svg rect')).toBeVisible(); + }); + + await test.step('Edit value, save with Enter, then copy without re-hovering', async () => { + const tooltip = page.locator('.CodeMirror-brunoVarInfo').first(); + await expect(tooltip).toBeVisible(); + + const valueDisplay = tooltip.locator('.var-value-editable-display'); + await valueDisplay.click(); + + const editor = tooltip.locator('.var-value-editor .CodeMirror'); + await expect(editor).toBeVisible(); + + await page.keyboard.press('End'); + await page.keyboard.type('-edited'); + + await page.keyboard.press('Enter'); + + // Wait for the display to reflect the saved value before clicking copy + await expect(valueDisplay).toContainText('original-copy-edited'); + + const copyButton = tooltip.locator('.copy-button'); + await copyButton.click(); + + await expect(copyButton.locator('svg polyline')).toBeVisible({ timeout: 1000 }); + + const updatedClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(updatedClipboard).toBe('original-copy-edited'); + }); + }); });