From 8ec1925b9f2fe79e87de3e566ee3b31c2fc5b993 Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Mon, 17 Nov 2025 12:02:25 +0530 Subject: [PATCH] feat: add variable interpolation support for WebSocket requests (#6064) * feat: add variable interpolation support for WebSocket requests - Add WebSocket body interpolation in interpolateVars function - Interpolate URL, headers, and all messages in request.body.ws array with full variable context - Refactor sendWsRequest to use main process preparation (removes duplication) - Add mode property to wsRequest object for proper request type detection - Ensure consistent variable precedence matching HTTP/gRPC requests - Centralize all interpolation logic in main process via prepareWsRequest * Add Playwright tests for WebSocket variable interpolation - Add tests for URL interpolation (wss://echo.{{url}}.org) - Add tests for message content interpolation ({"test": "{{data}}"}) - Update test fixtures to use wss://echo.websocket.org echo server - Add WEBSOCKET_FLOWS.md documentation - Refactor queueWsMessage to handle variable interpolation in main process * removed ws flow documentation * chore: updated the network/index.js file to reduce merge conflicts by moving around code * fix: added collection and item to WsQueryUrl Editor to fix available variable highlight * chore: remove unnecessary whitespace in WebSocket event handlers --------- Co-authored-by: Sid --- .../RequestPane/WsQueryUrl/index.js | 2 + packages/bruno-app/src/utils/network/index.js | 72 ++++++------ .../src/ipc/network/interpolate-vars.js | 20 ++++ .../src/ipc/network/ws-event-handlers.js | 49 ++++++-- .../fixtures/collection/bruno.json | 10 ++ .../fixtures/collection/environments/Test.bru | 5 + .../collection/ws-interpolation-test.bru | 20 ++++ .../init-user-data/preferences.json | 10 ++ .../variable-interpolation.spec.ts | 109 ++++++++++++++++++ 9 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 tests/websockets/variable-interpolation/fixtures/collection/bruno.json create mode 100644 tests/websockets/variable-interpolation/fixtures/collection/environments/Test.bru create mode 100644 tests/websockets/variable-interpolation/fixtures/collection/ws-interpolation-test.bru create mode 100644 tests/websockets/variable-interpolation/init-user-data/preferences.json create mode 100644 tests/websockets/variable-interpolation/variable-interpolation.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js index f06102998..445434c14 100644 --- a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -104,6 +104,8 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { className="w-full" theme={displayedTheme} onRun={handleRun} + collection={collection} + item={item} />
{ - return new Promise(async (resolve, reject) => { - const ensureConnection = async () => { - const connectionStatus = await isWsConnectionActive(item.uid); - if (!connectionStatus.isActive) { - await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true }); - } - }; - const { request } = item.draft ? item.draft : item; - queueWsMessage(item, collection.uid, request.body.ws[0].content) - .then((initialState) => { - // Return an initial state object to update the UI - // The real response data will be handled by event listeners - resolve({ - ...initialState - }); - }) - .catch((err) => reject(err)); - await ensureConnection(); +export const sendWsRequest = async (item, collection, environment, runtimeVariables) => { + const ensureConnection = async () => { + const connectionStatus = await isWsConnectionActive(item.uid); + if (!connectionStatus.isActive) { + await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true }); + } + }; + + await ensureConnection(); + + // Use queueWsMessage helper to queue all messages with proper variable interpolation + const result = await queueWsMessage(item, collection, environment, runtimeVariables, null); + + if (result.success) { + return {}; + } else { + throw new Error(result.error || 'Failed to queue messages'); + } +}; + +/** + * Queues a message to an existing WebSocket connection with variable interpolation + * @param {Object} item - The request item + * @param {Object} collection - The collection object + * @param {Object} environment - The environment variables + * @param {Object} runtimeVariables - The runtime variables + * @param {string} messageContent - The message content to queue (or null to queue all messages) + * @returns {Promise} - The result of the queue operation + */ +export const queueWsMessage = async (item, collection, environment, runtimeVariables, messageContent) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:ws:queue-message', { + item, + collection, + environment, + runtimeVariables, + messageContent + }).then(resolve).catch(reject); }); }; @@ -287,20 +307,6 @@ export const startWsConnection = async (item, collection, environment, runtimeVa }); }; -/** - * Sends a message to an existing WebSocket connection - * @param {string} requestId - The request ID to send a message to - * @param {string} collectionUid - The collection ID the message is for - * @param {*} message - The message - * @returns {Promise} - The result of the send operation - */ -export const queueWsMessage = async (item, collectionUid, message) => { - return new Promise((resolve, reject) => { - const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:ws:queue-message', item.uid, collectionUid, message).then(resolve).catch(reject); - }); -}; - /** * Sends a message to an existing WebSocket connection * @param {string} requestId - The request ID to send a message to diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index 2437c7482..819277cc6 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -81,6 +81,26 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc }); request.body = JSON.parse(parsed); } + // Interpolate WebSocket message body + const isWsRequest = request.mode === 'ws'; + if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) { + request.body.ws.forEach((message) => { + if (message && message.content) { + // Try to detect if content is JSON for proper escaping + let isJson = false; + try { + JSON.parse(message.content); + isJson = true; + } catch (e) { + // Not JSON, treat as regular string + } + + message.content = _interpolate(message.content, { + escapeJSONStrings: isJson + }); + } + }); + } if (typeof contentType === 'string') { /* diff --git a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js index 9513f9a6c..25b4c38db 100644 --- a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -38,6 +38,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, mergeScripts(collection, request, requestTreePath, scriptFlow); mergeVars(collection, request, requestTreePath); mergeAuth(collection, request, requestTreePath); + request.globalEnvironmentVariables = collection?.globalEnvironmentVariables; + request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }); } each(get(collectionRoot, 'request.headers', []), (h) => { @@ -65,6 +67,7 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, let wsRequest = { uid: item.uid, + mode: request.body.mode, url: request.url, headers, processEnvVars, @@ -276,15 +279,43 @@ const registerWsEventHandlers = (window) => { } }); - ipcMain.handle('renderer:ws:queue-message', (event, requestId, collectionUid, message) => { - try { - wsClient.queueMessage(requestId, collectionUid, message); - return { success: true }; - } catch (error) { - console.error('Error queuing WebSocket message:', error); - return { success: false, error: error.message }; - } - }); + ipcMain.handle('renderer:ws:queue-message', + async (event, { item, collection, environment, runtimeVariables, messageContent }) => { + try { + const itemCopy = cloneDeep(item); + const preparedRequest = await prepareWsRequest(itemCopy, collection, environment, runtimeVariables, {}); + + // If messageContent is provided, find and queue that specific message (interpolated) + // Otherwise, queue all messages + if (messageContent !== undefined && messageContent !== null) { + // Find the message index in the original request + const originalMessages = itemCopy.draft?.request?.body?.ws || itemCopy.request?.body?.ws || []; + const messageIndex = originalMessages.findIndex((msg) => msg.content === messageContent); + + if (messageIndex >= 0 && preparedRequest.body?.ws?.[messageIndex]) { + // Queue the interpolated version of the specific message + wsClient.queueMessage(preparedRequest.uid, collection.uid, preparedRequest.body.ws[messageIndex].content); + } else { + // Message not found in request body, queue as-is (shouldn't happen in normal flow) + wsClient.queueMessage(preparedRequest.uid, collection.uid, messageContent); + } + } else { + // Queue all messages (they are already interpolated by prepareWsRequest -> interpolateVars) + if (preparedRequest.body && preparedRequest.body.ws && Array.isArray(preparedRequest.body.ws)) { + preparedRequest.body.ws + .filter((message) => message && message.content) + .forEach((message) => { + wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content); + }); + } + } + + return { success: true }; + } catch (error) { + console.error('Error queuing WebSocket message:', error); + return { success: false, error: error.message }; + } + }); // Send a message to an existing WebSocket connection ipcMain.handle('renderer:ws:send-message', (event, requestId, collectionUid, message) => { diff --git a/tests/websockets/variable-interpolation/fixtures/collection/bruno.json b/tests/websockets/variable-interpolation/fixtures/collection/bruno.json new file mode 100644 index 000000000..4c234e8d5 --- /dev/null +++ b/tests/websockets/variable-interpolation/fixtures/collection/bruno.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "name": "variable-interpolation", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} + diff --git a/tests/websockets/variable-interpolation/fixtures/collection/environments/Test.bru b/tests/websockets/variable-interpolation/fixtures/collection/environments/Test.bru new file mode 100644 index 000000000..96602df69 --- /dev/null +++ b/tests/websockets/variable-interpolation/fixtures/collection/environments/Test.bru @@ -0,0 +1,5 @@ +vars { + url: websocket + data: test-data +} + diff --git a/tests/websockets/variable-interpolation/fixtures/collection/ws-interpolation-test.bru b/tests/websockets/variable-interpolation/fixtures/collection/ws-interpolation-test.bru new file mode 100644 index 000000000..9e612ed5e --- /dev/null +++ b/tests/websockets/variable-interpolation/fixtures/collection/ws-interpolation-test.bru @@ -0,0 +1,20 @@ +meta { + name: ws-interpolation-test + type: ws + seq: 1 +} + +ws { + url: wss://echo.{{url}}.org + auth: inherit +} + +body:ws { + name: message 1 + content: ''' + { + "test": "{{data}}" + } + ''' +} + diff --git a/tests/websockets/variable-interpolation/init-user-data/preferences.json b/tests/websockets/variable-interpolation/init-user-data/preferences.json new file mode 100644 index 000000000..a9d9eb467 --- /dev/null +++ b/tests/websockets/variable-interpolation/init-user-data/preferences.json @@ -0,0 +1,10 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/websockets/variable-interpolation/fixtures/collection" + ], + "beta": { + "websocket": true + } +} + diff --git a/tests/websockets/variable-interpolation/variable-interpolation.spec.ts b/tests/websockets/variable-interpolation/variable-interpolation.spec.ts new file mode 100644 index 000000000..ca2bede78 --- /dev/null +++ b/tests/websockets/variable-interpolation/variable-interpolation.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '../../../playwright'; +import { buildWebsocketCommonLocators } from '../../utils/page/locators'; +import { closeAllCollections, openCollectionAndAcceptSandbox } from '../../utils/page'; + +const BRU_REQ_NAME = /^ws-interpolation-test$/; +const MAX_CONNECTION_TIME = 10000; // Increased timeout for external server + +test.describe.serial('WebSocket Variable Interpolation', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('interpolates variables in WebSocket URL', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + // Open the collection and accept sandbox modal if it appears + await openCollectionAndAcceptSandbox(page, 'variable-interpolation', 'safe'); + + // Open the request + await expect(page.getByTitle(BRU_REQ_NAME)).toBeVisible(); + await page.getByTitle(BRU_REQ_NAME).click(); + + // Select the test environment (which has url: websocket) + await page.locator('div.current-environment').click(); + await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible(); + await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click(); + await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible(); + + // Wait a bit for environment to be applied + await page.waitForTimeout(200); + + // Connect WebSocket + await locators.connectionControls.connect().click(); + + // Wait for connection to establish + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + // Verify the connection message shows interpolated URL + // The URL should be wss://echo.websocket.org (not wss://echo.{{url}}.org) + await expect(locators.messages().first().getByText(/Connected to wss:\/\/echo\.websocket\.org/)).toBeAttached({ + timeout: 2000 + }); + }); + + test('interpolates variables in WebSocket message content', async ({ pageWithUserData: page, restartApp }) => { + const locators = buildWebsocketCommonLocators(page); + + // Wait for collection to be visible (it should auto-load from preferences) + await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'variable-interpolation' })).toBeVisible({ timeout: 5000 }); + + // Check if sandbox modal is present and handle it + const sandboxModal = page.locator('.bruno-modal-card').filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'JavaScript Sandbox' }) }); + const isModalVisible = await sandboxModal.isVisible().catch(() => false); + + if (isModalVisible) { + // Accept sandbox modal + await sandboxModal.getByLabel('Safe Mode').check(); + await sandboxModal.locator('.bruno-modal-footer .submit').click(); + await sandboxModal.waitFor({ state: 'detached' }); + } else { + // Collection might already be open, just ensure it's clicked + await page.locator('#sidebar-collection-name').filter({ hasText: 'variable-interpolation' }).click(); + } + + // Wait a bit for any modals to fully close + await page.waitForTimeout(300); + + // Open the request + await expect(page.getByTitle(BRU_REQ_NAME)).toBeVisible(); + await page.getByTitle(BRU_REQ_NAME).click(); + + // Select the test environment (which has data: test-data) + await page.locator('div.current-environment').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click(); + + // Clear any previous messages + await locators.toolbar.clearResponse().click(); + + // Send the request (connect + send messages) + await locators.runner().click(); + + // Wait for connection + await expect(locators.connectionControls.disconnect()).toBeAttached({ + timeout: MAX_CONNECTION_TIME + }); + + // Wait a bit for messages to be sent and received (echo server echoes back) + await page.waitForTimeout(1000); + + // Verify the sent message contains interpolated value + // Should send {"test": "test-data"} (not {"test": "{{data}}"}) + const messages = locators.messages(); + + // Find the outgoing message with interpolated content + // The echo server will echo back the same message, so we should see it twice + const sentMessage = messages.filter({ hasText: 'test-data' }).first(); + await expect(sentMessage).toBeAttached({ timeout: 2000 }); + + // Verify the message content shows interpolated value, not literal variable + const messageText = await sentMessage.locator('.text-ellipsis').textContent(); + expect(messageText).toContain('test-data'); + expect(messageText).not.toContain('{{data}}'); + + // Verify JSON structure is correct + expect(messageText).toMatch(/\{[\s\S]*"test"[\s\S]*"test-data"[\s\S]*\}/); + }); +});