From 48a09f6f50e25219e66c5423f27dde18ae0f1d4a Mon Sep 17 00:00:00 2001 From: Bijin Bruno Date: Mon, 17 Nov 2025 20:12:20 +0530 Subject: [PATCH] feat: enhance support for prompt variables --- .../components/CodeEditor/StyledWrapper.js | 3 - .../PromptVariablesModal/StyledWrapper.js | 9 ++ .../PromptVariablesModal/index.js | 39 +++-- .../RequestPane/QueryEditor/StyledWrapper.js | 3 - .../CollectionItem/GenerateCodeItem/index.js | 2 +- .../utils/snippet-generator.js | 43 +----- .../utils/snippet-generator.spec.js | 64 +++------ .../src/providers/PromptVariables/index.js | 52 +++---- .../ReduxStore/slices/collections/actions.js | 133 ++++++++++++++---- packages/bruno-app/src/themes/dark.js | 2 +- packages/bruno-app/src/themes/light.js | 2 +- .../auth-utils.js => utils/auth/index.js} | 2 +- .../auth/index.spec.js} | 4 +- .../bruno-app/src/utils/collections/index.js | 45 +++++- .../src/utils/collections/index.spec.js | 37 +++++ .../src/runner/run-single-request.js | 62 +++++++- .../src/ipc/network/cert-utils.js | 2 + .../bruno-electron/src/ipc/network/index.js | 72 ++++++++-- .../src/ipc/network/interpolate-string.js | 4 +- .../src/ipc/network/interpolate-vars.js | 3 +- .../src/ipc/network/prepare-grpc-request.js | 11 +- .../src/ipc/network/prepare-request.js | 2 + .../tests/network/index.spec.js | 4 +- packages/bruno-js/src/bru.js | 4 +- .../bruno-js/src/runtime/assert-runtime.js | 6 +- .../bruno-js/src/runtime/script-runtime.js | 8 +- packages/bruno-js/src/runtime/test-runtime.js | 3 +- packages/bruno-js/src/runtime/vars-runtime.js | 3 +- .../prompt-variables/fixtures/client.pfx | 0 .../fixtures/collection/bruno.json | 22 +++ .../fixtures/collection/collection.bru | 13 ++ .../collection/environments/local.bru | 4 + .../collection/http-folder/folder.bru | 22 +++ .../http-folder/http-request-without-ca.bru | 43 ++++++ .../http-folder/https-request-with-ca.bru | 17 +++ .../http-request-prompt-variables.spec.ts | 124 ++++++++++++++++ .../init-user-data/collection-security.json | 10 ++ .../init-user-data/global-environments.json | 27 ++++ .../init-user-data/preferences.json | 6 + .../init-user-data/ui-state-snapshot.json | 8 ++ 40 files changed, 708 insertions(+), 212 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/StyledWrapper.js rename packages/bruno-app/src/{components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js => utils/auth/index.js} (99%) rename packages/bruno-app/src/{components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js => utils/auth/index.spec.js} (97%) create mode 100644 packages/bruno-app/src/utils/collections/index.spec.js create mode 100644 tests/interpolation/prompt-variables/fixtures/client.pfx create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/bruno.json create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/collection.bru create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/environments/local.bru create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/http-folder/folder.bru create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/http-folder/http-request-without-ca.bru create mode 100644 tests/interpolation/prompt-variables/fixtures/collection/http-folder/https-request-with-ca.bru create mode 100644 tests/interpolation/prompt-variables/http-request-prompt-variables.spec.ts create mode 100644 tests/interpolation/prompt-variables/init-user-data/collection-security.json create mode 100644 tests/interpolation/prompt-variables/init-user-data/global-environments.json create mode 100644 tests/interpolation/prompt-variables/init-user-data/preferences.json create mode 100644 tests/interpolation/prompt-variables/init-user-data/ui-state-snapshot.json diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index ec60c9d59..ab007c662 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -96,9 +96,6 @@ const StyledWrapper = styled.div` .cm-variable-invalid { color: red; } - .cm-variable-prompt { - color: dodgerblue; - } .CodeMirror-search-hint { display: inline; diff --git a/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/StyledWrapper.js new file mode 100644 index 000000000..524909f3d --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + max-height: 60vh; + overflow-y: auto; + padding: 0 0.2rem; +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js index 01636a86b..32583c7fe 100644 --- a/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js +++ b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; +import StyledWrapper from './StyledWrapper'; +import { IconAlertTriangle } from '@tabler/icons'; export default function PromptVariablesModal({ title = 'Input Required', prompts, onSubmit, onCancel }) { const [values, setValues] = useState({}); @@ -16,25 +18,38 @@ export default function PromptVariablesModal({ title = 'Input Required', prompts return ( onSubmit(values)} handleCancel={onCancel} > - {prompts.map((prompt) => ( -
- - handleChange(prompt, e.target.value)} - autoFocus - /> + +
+ {prompts.map((prompt, index) => ( +
+ + handleChange(prompt, e.target.value)} + autoFocus={index === 0} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> +
+ ))}
- ))} +
); diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js index 0adf7b19f..57b8d4987 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js @@ -50,9 +50,6 @@ const StyledWrapper = styled.div` .cm-variable-invalid { color: red; } - .cm-variable-prompt { - color: blue; - } .CodeMirror-search-hint { display: inline; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index ea5939540..1ec70a16f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -12,7 +12,7 @@ import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index'; import { getLanguages } from 'utils/codegenerator/targets'; import { useSelector } from 'react-redux'; import { getAllVariables, getGlobalEnvironmentVariables } from 'utils/collections/index'; -import { resolveInheritedAuth } from './utils/auth-utils'; +import { resolveInheritedAuth } from 'utils/auth'; const TEMPLATE_VAR_PATTERN = /\{\{([^}]+)\}\}/; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index effc1c158..09a3dd818 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -1,47 +1,9 @@ import { buildHarRequest } from 'utils/codegenerator/har'; import { getAuthHeaders } from 'utils/codegenerator/auth'; -import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index'; +import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from 'utils/collections/index'; import { interpolateHeaders, interpolateBody } from './interpolation'; import { get } from 'lodash'; -// Merge headers from collection, folders, and request -const mergeHeaders = (collection, request, requestTreePath) => { - let headers = new Map(); - - // Add collection headers first - const collectionHeaders = collection?.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []); - collectionHeaders.forEach((header) => { - if (header.enabled) { - headers.set(header.name, header); - } - }); - - // Add folder headers next, traversing from root to leaf - if (requestTreePath && requestTreePath.length > 0) { - for (let i of requestTreePath) { - if (i.type === 'folder') { - const folderHeaders = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'root.request.headers', []); - folderHeaders.forEach((header) => { - if (header.enabled) { - headers.set(header.name, header); - } - }); - } - } - } - - // Add request headers last (they take precedence) - const requestHeaders = request.headers || []; - requestHeaders.forEach((header) => { - if (header.enabled) { - headers.set(header.name, header); - } - }); - - // Convert Map back to array - return Array.from(headers.values()); -}; - const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => { try { // Get HTTPSnippet dynamically so mocks can be applied in tests @@ -88,6 +50,5 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false }; export { - generateSnippet, - mergeHeaders + generateSnippet }; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index 43581b2b4..359fba0a7 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -45,19 +45,24 @@ jest.mock('utils/codegenerator/auth', () => ({ getAuthHeaders: jest.fn(() => []) })); -jest.mock('utils/collections/index', () => ({ - getAllVariables: jest.fn((collection) => ({ - ...collection?.globalEnvironmentVariables, - ...collection?.runtimeVariables, - ...collection?.processEnvVariables, - baseUrl: 'https://api.example.com', - apiKey: 'secret-key-123', - userId: '12345' - })), - getTreePathFromCollectionToItem: jest.fn(() => []) -})); +jest.mock('utils/collections/index', () => { + const actual = jest.requireActual('utils/collections/index'); -import { generateSnippet, mergeHeaders } from './snippet-generator'; + return { + ...actual, + getAllVariables: jest.fn((collection) => ({ + ...collection?.globalEnvironmentVariables, + ...collection?.runtimeVariables, + ...collection?.processEnvVariables, + baseUrl: 'https://api.example.com', + apiKey: 'secret-key-123', + userId: '12345' + })), + getTreePathFromCollectionToItem: jest.fn(() => []) + }; +}); + +import { generateSnippet } from './snippet-generator'; describe('Snippet Generator - Simple Tests', () => { @@ -424,41 +429,6 @@ describe('Snippet Generator - Simple Tests', () => { }); }); -describe('mergeHeaders', () => { - it('should include headers from collection, folder and request (with correct precedence)', () => { - const collection = { - root: { - request: { - headers: [ - { name: 'X-Collection', value: 'c', enabled: true } - ] - } - } - }; - - const folder = { - type: 'folder', - root: { - request: { - headers: [ - { name: 'X-Folder', value: 'f', enabled: true } - ] - } - } - }; - - const request = { - headers: [ - { name: 'X-Request', value: 'r', enabled: true } - ] - }; - - const headers = mergeHeaders(collection, request, [folder]); - const names = headers.map((h) => h.name); - expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request'])); - }); -}); - // Snippet should include inherited headers describe('generateSnippet – header inclusion in output', () => { it('should include collection and folder headers in generated snippet', () => { diff --git a/packages/bruno-app/src/providers/PromptVariables/index.js b/packages/bruno-app/src/providers/PromptVariables/index.js index 9b1638a3a..a0b02da97 100644 --- a/packages/bruno-app/src/providers/PromptVariables/index.js +++ b/packages/bruno-app/src/providers/PromptVariables/index.js @@ -1,6 +1,5 @@ import PromptVariablesModal from 'components/RequestPane/PromptVariables/PromptVariablesModal'; import React, { createContext, useCallback, useState } from 'react'; -import { toast } from 'react-hot-toast'; const PromptVariablesContext = createContext(); @@ -9,13 +8,7 @@ export function PromptVariablesProvider({ children }) { const prompt = useCallback((prompts) => { return new Promise((resolve, reject) => { - try { - setModalState({ open: true, prompts, resolve, reject }); - } catch (err) { - console.error('PromptVariablesProvider: Error opening prompt modal:', err); - toast.error('Prompt variable(s) detected, but prompt modal is not available. Please ensure PromptVariableProvider is mounted.'); - reject(err); - } + setModalState({ open: true, prompts, resolve, reject }); }); }, []); @@ -32,41 +25,28 @@ export function PromptVariablesProvider({ children }) { } const handleSubmit = (values) => { - try { - modalState.resolve(values); - } catch (err) { - console.error('PromptVariablesProvider: Error resolving prompt values:', err); - } + modalState.resolve(values); setModalState({ open: false, prompts: [], resolve: null, reject: null }); }; const handleCancel = () => { - try { - modalState.reject('cancelled'); - } catch (err) { - console.error('PromptVariablesProvider: Error rejecting prompt:', err); - } + modalState.reject('cancelled'); setModalState({ open: false, prompts: [], resolve: null, reject: null }); }; - try { - return ( - - {children} - {modalState.open && ( - - )} - - ); - } catch (err) { - console.error('PromptVariablesProvider: Error rendering provider or modal:', err); - return children; - } + return ( + + {children} + {modalState.open && ( + + )} + + ); } export default PromptVariablesProvider; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index a904e29ad..1d0c22a4b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -17,6 +17,7 @@ import { isItemAFolder, refreshUidsInItem, isItemARequest, + getAllVariables, transformRequestToSaveToFilesystem, transformCollectionRootToSave } from 'utils/collections'; @@ -52,7 +53,7 @@ import { import { each } from 'lodash'; import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; -import { parsePathParams, splitOnFirst } from 'utils/url/index'; +import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { getGlobalEnvironmentVariables, @@ -62,11 +63,14 @@ import { resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname, - transformFolderRootToSave + transformFolderRootToSave, + getTreePathFromCollectionToItem, + mergeHeaders } from 'utils/collections/index'; import { sanitizeName } from 'utils/common/regex'; import { buildPersistedEnvVariables } from 'utils/environments'; import { safeParseJSON, safeStringifyJSON } from 'utils/common/index'; +import { resolveInheritedAuth } from 'utils/auth'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { updateSettingsSelectedTab } from './index'; @@ -379,6 +383,76 @@ export const wsConnectOnly = (item, collectionUid) => (dispatch, getState) => { }); }; +/** + * Extract prompt variables from a request, collection, and environment variables. + * Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible + * + * @param {*} item + * @param {*} collection + * @returns {Promise} A promise that resolves with the prompt variables or null if no prompt variables are found + */ +const extractPromptVariablesForRequest = async (item, collection) => { + return new Promise(async (resolve, reject) => { + // Ensure window contains promptForVariables function + if (typeof window === 'undefined' || typeof window.promptForVariables !== 'function') { + console.error('Failed to initialize prompt variables: window.promptForVariables is not available. ' + + 'This may indicate an initialization issue with the app environment.'); + return resolve(null); + } + + const prompts = []; + const request = item.draft?.request ?? item.request ?? {}; + const allVariables = getAllVariables(collection, item); + const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []); + const requestTreePath = getTreePathFromCollectionToItem(collection, item); + // Get active headers from collection, folders, and request by priority order + const headers = mergeHeaders(collection, request, requestTreePath); + // Get request auth or inherited auth + const resolvedAuthRequest = resolveInheritedAuth(item, collection); + + for (let clientCert of clientCertConfig) { + const domain = interpolateUrl({ url: clientCert?.domain, variables: allVariables }); + + if (domain) { + const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + const requestUrl = interpolateUrl({ url: request.url, variables: allVariables }); + if (requestUrl.match(hostRegex)) { + prompts.push(...extractPromptVariables(clientCert)); + } + } + } + + // Attempt to extract unique prompt variables from anywhere in the request and environment variables. + prompts.push(...extractPromptVariables(allVariables)); + prompts.push(...extractPromptVariables(request.body?.[request.body.mode])); + prompts.push(...extractPromptVariables(headers)); + prompts.push(...extractPromptVariables(request.params)); + prompts.push(...extractPromptVariables(resolvedAuthRequest.auth)); + + // Remove duplicates + const uniquePrompts = Array.from(new Set(prompts)); + + // If no prompt variables are found, return null + if (!uniquePrompts?.length) { + return resolve(null); + } + + try { + // Prompt user for values if any prompt variables are found + const userValues = await window.promptForVariables(uniquePrompts); + const promptVariables = {}; + // Populate runtimeVariables with user input for prompt variables + for (const prompt of uniquePrompts) { + promptVariables[`?${prompt}`] = userValues[prompt] ?? ''; + } + + return resolve(promptVariables); + } catch (error) { + return reject(error); + } + }); +}; + export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const state = getState(); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; @@ -394,30 +468,24 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { const itemCopy = cloneDeep(item); + // add selected global env variables to the collection object + const globalEnvironmentVariables = getGlobalEnvironmentVariables({ + globalEnvironments, + activeGlobalEnvironmentUid + }); + collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables; + const requestUid = uuid(); itemCopy.requestUid = requestUid; - // Ensure window contains promptForVariables function - if (typeof window.promptForVariables === 'function') { - // Attempt to extract unique prompt variables from anywhere in the requestExpand commentComment on line R260ResolvedCode has comments. Press enter to view. - const uniquePrompts = extractPromptVariables(itemCopy.draft?.request ?? itemCopy.request); - - if (uniquePrompts?.length > 0) { - try { - // Prompt user for values if any prompt variables are found - let userValues = await window.promptForVariables(uniquePrompts); - - // Populate runtimeVariables with user input for prompt variables - for (const prompt of uniquePrompts) { - collectionCopy.runtimeVariables[`?${prompt}`] = userValues[prompt] ?? ''; - } - } catch (error) { - if (error === 'cancelled') { - return resolve(); // Resolve without error if user cancels prompt - } - reject(error); - } + try { + const promptVariables = await extractPromptVariablesForRequest(itemCopy, collectionCopy); + collectionCopy.promptVariables = promptVariables ?? {}; + } catch (error) { + if (error === 'cancelled') { + return resolve(); // Resolve without error if user cancels prompt } + return reject(error); } await dispatch( @@ -435,13 +503,6 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { }) ); - // add selected global env variables to the collection object - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ - globalEnvironments, - activeGlobalEnvironmentUid - }); - collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables; - const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid); const isGrpcRequest = itemCopy.type === 'grpc-request'; const isWsRequest = itemCopy.type === 'ws-request'; @@ -1379,7 +1440,7 @@ export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async const collection = findCollectionByUid(state.collections.collections, collectionUid); const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments; - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { if (!collection) { return reject(new Error('Collection not found')); } @@ -1396,6 +1457,18 @@ export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid); const runtimeVariables = collectionCopy.runtimeVariables; + try { + const promptVariables = await extractPromptVariablesForRequest(itemCopy, collectionCopy); + if (promptVariables) { + collectionCopy.promptVariables = promptVariables; + } + } catch (error) { + if (error === 'cancelled') { + return resolve(); // Resolve without error if user cancels prompt + } + return reject(error); + } + const { ipcRenderer } = window; ipcRenderer .invoke('grpc:load-methods-reflection', { diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 8a3498fdb..3b696a49e 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -287,7 +287,7 @@ const darkTheme = { variable: { valid: 'rgb(11 178 126)', invalid: '#f06f57', - prompt: 'dodgerblue', + prompt: '#3D8DF5', info: { color: '#ce9178', bg: 'rgb(48,48,49)', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 5ff09588d..8563c2a0a 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -288,7 +288,7 @@ const lightTheme = { variable: { valid: '#047857', invalid: 'rgb(185, 28, 28)', - prompt: 'dodgerblue', + prompt: '#186ADE', info: { color: 'rgb(52, 52, 52)', bg: 'white', diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js b/packages/bruno-app/src/utils/auth/index.js similarity index 99% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js rename to packages/bruno-app/src/utils/auth/index.js index 45e396625..b479b63c4 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js +++ b/packages/bruno-app/src/utils/auth/index.js @@ -40,4 +40,4 @@ export const resolveInheritedAuth = (item, collection) => { ...mergedRequest, auth: effectiveAuth }; -}; \ No newline at end of file +}; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js b/packages/bruno-app/src/utils/auth/index.spec.js similarity index 97% rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js rename to packages/bruno-app/src/utils/auth/index.spec.js index ad5afc3e6..dfdde956f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js +++ b/packages/bruno-app/src/utils/auth/index.spec.js @@ -1,4 +1,4 @@ -import { resolveInheritedAuth } from './auth-utils'; +import { resolveInheritedAuth } from './index'; jest.mock('utils/collections/index', () => ({ getTreePathFromCollectionToItem: (collection, item) => { @@ -76,4 +76,4 @@ describe('auth-utils.resolveInheritedAuth', () => { expect(resolved.auth.mode).toBe('basic'); expect(resolved.auth.basic.username).toBe('override'); }); -}); \ No newline at end of file +}); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 160b05c81..e9aea695b 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1161,11 +1161,12 @@ export const getAllVariables = (collection, item) => { const pathParams = getPathParams(item); const { globalEnvironmentVariables = {} } = collection; - const { processEnvVariables = {}, runtimeVariables = {} } = collection; + const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {} } = collection; const mergedVariables = { ...folderVariables, ...requestVariables, - ...runtimeVariables + ...runtimeVariables, + ...promptVariables }; const mergedVariablesGlobal = { @@ -1174,6 +1175,7 @@ export const getAllVariables = (collection, item) => { ...folderVariables, ...requestVariables, ...runtimeVariables, + ...promptVariables } const maskedEnvVariables = getEnvironmentVariablesMasked(collection) || []; @@ -1194,6 +1196,7 @@ export const getAllVariables = (collection, item) => { ...requestVariables, ...oauth2CredentialVariables, ...runtimeVariables, + ...promptVariables, pathParams: { ...pathParams }, @@ -1206,6 +1209,44 @@ export const getAllVariables = (collection, item) => { }; }; +// Merge headers from collection, folders, and request +export const mergeHeaders = (collection, request, requestTreePath) => { + let headers = new Map(); + + // Add collection headers first + const collectionHeaders = collection?.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []); + collectionHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + + // Add folder headers next, traversing from root to leaf + if (requestTreePath && requestTreePath.length > 0) { + for (let i of requestTreePath) { + if (i.type === 'folder') { + const folderHeaders = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'root.request.headers', []); + folderHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + } + } + } + + // Add request headers last (they take precedence) + const requestHeaders = request.headers || []; + requestHeaders.forEach((header) => { + if (header.enabled) { + headers.set(header.name, header); + } + }); + + // Convert Map back to array + return Array.from(headers.values()); +}; + export const maskInputValue = (value) => { if (!value || typeof value !== 'string') { return ''; diff --git a/packages/bruno-app/src/utils/collections/index.spec.js b/packages/bruno-app/src/utils/collections/index.spec.js new file mode 100644 index 000000000..7ff987b1e --- /dev/null +++ b/packages/bruno-app/src/utils/collections/index.spec.js @@ -0,0 +1,37 @@ +const { describe, it, expect } = require('@jest/globals'); +import { mergeHeaders } from './index'; + +describe('mergeHeaders', () => { + it('should include headers from collection, folder and request (with correct precedence)', () => { + const collection = { + root: { + request: { + headers: [ + { name: 'X-Collection', value: 'c', enabled: true } + ] + } + } + }; + + const folder = { + type: 'folder', + root: { + request: { + headers: [ + { name: 'X-Folder', value: 'f', enabled: true } + ] + } + } + }; + + const request = { + headers: [ + { name: 'X-Request', value: 'r', enabled: true } + ] + }; + + const headers = mergeHeaders(collection, request, [folder]); + const names = headers.map((h) => h.name); + expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request'])); + }); +}); diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 1f7848fb7..deb326294 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -31,6 +31,59 @@ const onConsoleLog = (type, args) => { console[type](...args); }; +const getCACertHostRegex = (domain) => { + return '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); +}; + +/** + * Extract prompt variables from a request + * Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible + * Note: TO BE CALLED ONLY AFTER THE PREPARE REQUEST + * + * @param {*} request - request object built by prepareRequest + * @returns {string[]} An array of extracted prompt variables + */ +const extractPromptVariablesForRequest = ({ request, collection, envVariables, runtimeVariables, processEnvVars, brunoConfig }) => { + const { vars, collectionVariables, folderVariables, requestVariables, ...requestObj } = request; + + const allVariables = { + ...envVariables, + ...collectionVariables, + ...folderVariables, + ...requestVariables, + ...runtimeVariables, + process: { + env: { + ...processEnvVars + } + } + }; + + const prompts = extractPromptVariables(requestObj); + prompts.push(...extractPromptVariables(allVariables)); + + const interpolationOptions = { + envVars: envVariables, + runtimeVariables, + processEnvVars + }; + + // client certificate config + const clientCertConfig = get(brunoConfig, 'clientCertificates.certs', []); + for (let clientCert of clientCertConfig) { + const domain = interpolateString(clientCert?.domain, interpolationOptions); + if (domain) { + const hostRegex = getCACertHostRegex(domain); + if (request.url.match(hostRegex)) { + prompts.push(...extractPromptVariables(clientCert)); + } + } + } + + // return unique prompt variables + return Array.from(new Set(prompts)); +}; + const runSingleRequest = async function ( item, collectionPath, @@ -75,10 +128,11 @@ const runSingleRequest = async function ( request = await prepareRequest(item, collection); // Detect prompt variables before proceeding - const promptVars = extractPromptVariables(request); + const promptVars = extractPromptVariablesForRequest({ request, collection, envVariables, runtimeVariables, processEnvVars, brunoConfig }); + if (promptVars.length > 0) { - const errorMsg = 'Prompt variables detected in request. CLI execution is not supported for requests with prompt variables.'; - console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${errorMsg})`)); + const errorMsg = `Prompt variables detected in request. CLI execution is not supported for requests with prompt variables. \nPrompts: ${promptVars.join(', ')}`; + console.log(chalk.yellow(stripExtension(relativeItemPathname) + ' Skipped:') + chalk.dim(` (${errorMsg})`)); return { test: { filename: relativeItemPathname @@ -204,7 +258,7 @@ const runSingleRequest = async function ( const domain = interpolateString(clientCert?.domain, interpolationOptions); const type = clientCert?.type || 'cert'; if (domain) { - const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*'); + const hostRegex = getCACertHostRegex(domain); if (request.url.match(hostRegex)) { if (type === 'cert') { try { diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index b55754b7b..61f72a7f3 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -41,11 +41,13 @@ const getCertsAndProxyConfig = async ({ httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount; httpsAgentRequestFields['ca'] = caCertificates || []; + const { promptVariables } = collection; const brunoConfig = getBrunoConfig(collectionUid, collection); const interpolationOptions = { globalEnvironmentVariables, envVars, runtimeVariables, + promptVariables, processEnvVars }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 7b6d73891..5c7a6dafa 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -114,6 +114,7 @@ const configureRequest = async ( request.maxRedirects = 0; + const { promptVariables = {} } = collection; let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig; let axiosInstance = makeAxiosInstance({ proxyMode, @@ -134,7 +135,7 @@ const configureRequest = async ( let credentials, credentialsId, oauth2Url, debugInfo; switch (grantType) { case 'authorization_code': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { @@ -150,7 +151,7 @@ const configureRequest = async ( } break; case 'implicit': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingImplicitGrant({ request: requestCopy, collectionUid })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { @@ -166,7 +167,7 @@ const configureRequest = async ( } break; case 'client_credentials': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { @@ -182,7 +183,7 @@ const configureRequest = async ( } break; case 'password': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { @@ -385,7 +386,8 @@ const registerNetworkIpc = (mainWindow) => { ) => { // run pre-request script let scriptResult; - const collectionName = collection?.name + const { promptVariables = {}, name: collectionName } = collection; + const requestScript = get(request, 'script.req'); if (requestScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); @@ -426,7 +428,7 @@ const registerNetworkIpc = (mainWindow) => { } // interpolate variables inside request - interpolateVars(request, envVars, runtimeVariables, processEnvVars); + interpolateVars(request, envVars, runtimeVariables, processEnvVars, promptVariables); if (request.settings?.encodeUrl) { request.url = encodeUrl(request.url); @@ -913,6 +915,50 @@ const registerNetworkIpc = (mainWindow) => { } } + /** + * Extract prompt variables from a request + * Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible + * Note: TO BE CALLED ONLY AFTER THE PREPARE REQUEST + * + * @param {*} request - request object built by prepareRequest + * @returns {string[]} An array of extracted prompt variables + */ + const extractPromptVariablesForRequest = async ({ request, collection, envVars: collectionEnvironmentVars, runtimeVariables, processEnvVars }) => { + const { globalEnvironmentVariables, collectionVariables, folderVariables, requestVariables, ...requestObj } = request; + + const allVariables = { + ...globalEnvironmentVariables, + ...collectionEnvironmentVars, + ...collectionVariables, + ...folderVariables, + ...requestVariables, + ...runtimeVariables, + process: { + env: { + ...processEnvVars + } + } + }; + + const { interpolationOptions, ...certsAndProxyConfig } = await getCertsAndProxyConfig({ + collectionUid: collection.uid, + collection, + request, + envVars: collectionEnvironmentVars, + runtimeVariables, + processEnvVars, + collectionPath: collection.pathname, + globalEnvironmentVariables + }); + + const prompts = extractPromptVariables(requestObj); + prompts.push(...extractPromptVariables(allVariables)); + prompts.push(...extractPromptVariables(certsAndProxyConfig)); + + // return unique prompt variables + return Array.from(new Set(prompts)); + }; + // handler for sending http request ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => { const collectionUid = collection.uid; @@ -1067,7 +1113,12 @@ const registerNetworkIpc = (mainWindow) => { continue; } - const promptVars = extractPromptVariables(request); + const request = await prepareRequest(item, collection, abortController); + request.__bruno__executionMode = 'runner'; + + const requestUid = uuid(); + + const promptVars = await extractPromptVariablesForRequest({ request, collection, envVars, runtimeVariables, processEnvVars }); if (promptVars.length > 0) { mainWindow.webContents.send('main:run-folder-event', { @@ -1075,7 +1126,7 @@ const registerNetworkIpc = (mainWindow) => { error: 'Request has been skipped due to containing prompt variables', responseReceived: { status: 'skipped', - statusText: 'Prompt variables detected in request. Runner execution is not supported for requests with prompt variables.', + statusText: `Prompt variables detected in request. Runner execution is not supported for requests with prompt variables. \n Promps: ${promptVars.join(', ')}`, data: null, responseTime: 0, headers: null @@ -1088,11 +1139,6 @@ const registerNetworkIpc = (mainWindow) => { continue; } - const request = await prepareRequest(item, collection, abortController); - request.__bruno__executionMode = 'runner'; - - const requestUid = uuid(); - try { let preRequestScriptResult; let preRequestError = null; diff --git a/packages/bruno-electron/src/ipc/network/interpolate-string.js b/packages/bruno-electron/src/ipc/network/interpolate-string.js index 26bba8c42..0b19249ef 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-string.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-string.js @@ -1,7 +1,7 @@ const { forOwn, cloneDeep } = require('lodash'); const { interpolate } = require('@usebruno/common'); -const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVariables, processEnvVars }) => { +const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVariables, processEnvVars, promptVariables }) => { if (!str || !str.length || typeof str !== 'string') { return str; } @@ -9,6 +9,7 @@ const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVa processEnvVars = processEnvVars || {}; runtimeVariables = runtimeVariables || {}; globalEnvironmentVariables = globalEnvironmentVariables || {}; + promptVariables = promptVariables || {}; // we clone envVars because we don't want to modify the original object envVars = envVars ? cloneDeep(envVars) : {}; @@ -30,6 +31,7 @@ const interpolateString = (str, { globalEnvironmentVariables, envVars, runtimeVa ...globalEnvironmentVariables, ...envVars, ...runtimeVariables, + ...promptVariables, process: { env: { ...processEnvVars diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index 2437c7482..ec6aa3945 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -18,7 +18,7 @@ const getRawQueryString = (url) => { return queryIndex !== -1 ? url.slice(queryIndex) : ''; }; -const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => { +const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}, promptVariables = {}) => { const globalEnvironmentVariables = request?.globalEnvironmentVariables || {}; const oauth2CredentialVariables = request?.oauth2CredentialVariables || {}; const collectionVariables = request?.collectionVariables || {}; @@ -52,6 +52,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc ...requestVariables, ...oauth2CredentialVariables, ...runtimeVariables, + ...promptVariables, process: { env: { ...processEnvVars diff --git a/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js b/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js index 3471bea39..610a0ba14 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js @@ -18,6 +18,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable const collectionRoot = collection?.draft?.root ? get(collection, 'draft.root', {}) : get(collection, 'root', {}); const headers = {}; const url = request.url; + const { promptVariables = {} } = collection; const scriptFlow = collection?.brunoConfig?.scripts?.flow ?? 'sandwich'; const requestTreePath = getTreePathFromCollectionToItem(collection, item); @@ -28,6 +29,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable mergeVars(collection, request, requestTreePath); request.globalEnvironmentVariables = collection?.globalEnvironmentVariables; request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }); + request.promptVariables = promptVariables; } each(get(request, 'headers', []), (h) => { @@ -49,6 +51,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable processEnvVars, envVars, runtimeVariables, + promptVariables, body: request.body, protoPath: request.protoPath, // Add variable properties for interpolation @@ -68,7 +71,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable let credentials, credentialsId, oauth2Url, debugInfo; switch (grantType) { case 'authorization_code': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { @@ -82,7 +85,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable } break; case 'client_credentials': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { @@ -96,7 +99,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable } break; case 'password': - interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); + interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header') { @@ -112,7 +115,7 @@ const prepareGrpcRequest = async (item, collection, environment, runtimeVariable } } - interpolateVars(grpcRequest, envVars, runtimeVariables, processEnvVars); + interpolateVars(grpcRequest, envVars, runtimeVariables, processEnvVars, promptVariables); processHeaders(grpcRequest.headers); return grpcRequest; diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 5e37ac8ed..6c451a7f1 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -327,6 +327,7 @@ const prepareRequest = async (item, collection = {}, abortController) => { mergeAuth(collection, request, requestTreePath); request.globalEnvironmentVariables = collection?.globalEnvironmentVariables; request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials }); + request.promptVariables = collection?.promptVariables || {}; } @@ -463,6 +464,7 @@ const prepareRequest = async (item, collection = {}, abortController) => { axiosRequest.collectionVariables = request.collectionVariables; axiosRequest.folderVariables = request.folderVariables; axiosRequest.requestVariables = request.requestVariables; + axiosRequest.promptVariables = request.promptVariables; axiosRequest.globalEnvironmentVariables = request.globalEnvironmentVariables; axiosRequest.oauth2CredentialVariables = request.oauth2CredentialVariables; axiosRequest.assertions = request.assertions; diff --git a/packages/bruno-electron/tests/network/index.spec.js b/packages/bruno-electron/tests/network/index.spec.js index 02cf97f88..a01955386 100644 --- a/packages/bruno-electron/tests/network/index.spec.js +++ b/packages/bruno-electron/tests/network/index.spec.js @@ -3,13 +3,13 @@ const { configureRequest } = require('../../src/ipc/network/index'); describe('index: configureRequest', () => { it("Should add 'http://' to the URL if no protocol is specified", async () => { const request = { method: 'GET', url: 'test-domain', body: {} }; - await configureRequest(null, null, request, null, null, null, null); + await configureRequest(null, {}, request, null, null, null, null); expect(request.url).toEqual('http://test-domain'); }); it("Should NOT add 'http://' to the URL if a protocol is specified", async () => { const request = { method: 'GET', url: 'ftp://test-domain', body: {} }; - await configureRequest(null, null, request, null, null, null, null); + await configureRequest(null, {}, request, null, null, null, null); expect(request.url).toEqual('ftp://test-domain'); }); }); diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index 9e66ef654..25ed3fba0 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -7,9 +7,10 @@ const { jar: createCookieJar } = require('@usebruno/requests').cookies; const variableNameRegex = /^[\w-.]*$/; class Bru { - constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName) { + constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) { this.envVariables = envVariables || {}; this.runtimeVariables = runtimeVariables || {}; + this.promptVariables = promptVariables || {}; this.processEnvVars = cloneDeep(processEnvVars || {}); this.collectionVariables = collectionVariables || {}; this.folderVariables = folderVariables || {}; @@ -134,6 +135,7 @@ class Bru { ...this.requestVariables, ...this.oauth2CredentialVariables, ...this.runtimeVariables, + ...this.promptVariables, process: { env: { ...this.processEnvVars diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index 4e997d314..3625f2004 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -255,6 +255,7 @@ class AssertRuntime { return []; } + const promptVariables = request?.promptVariables || {}; const bru = new Bru( envVariables, runtimeVariables, @@ -263,7 +264,10 @@ class AssertRuntime { collectionVariables, folderVariables, requestVariables, - globalEnvironmentVariables + globalEnvironmentVariables, + {}, + undefined, + promptVariables ); const req = new BrunoRequest(request); const res = createResponseParser(response); diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 1c7b5aeb9..bf2707b8d 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -61,8 +61,9 @@ class ScriptRuntime { const collectionVariables = request?.collectionVariables || {}; const folderVariables = request?.folderVariables || {}; const requestVariables = request?.requestVariables || {}; + const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName); + const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); const req = new BrunoRequest(request); const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []); @@ -234,8 +235,9 @@ class ScriptRuntime { const collectionVariables = request?.collectionVariables || {}; const folderVariables = request?.folderVariables || {}; const requestVariables = request?.requestVariables || {}; - const assertionResults = request?.assertionResults || []; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName); + const promptVariables = request?.promptVariables || {}; + const assertionResults = request?.assertionResults || {}; + const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); const req = new BrunoRequest(request); const res = new BrunoResponse(response); const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 6f42d1b5b..3c89f83eb 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -61,8 +61,9 @@ class TestRuntime { const collectionVariables = request?.collectionVariables || {}; const folderVariables = request?.folderVariables || {}; const requestVariables = request?.requestVariables || {}; + const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName); + const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables); const req = new BrunoRequest(request); const res = new BrunoResponse(response); const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js index 05f502c2d..2469d0c79 100644 --- a/packages/bruno-js/src/runtime/vars-runtime.js +++ b/packages/bruno-js/src/runtime/vars-runtime.js @@ -35,7 +35,8 @@ class VarsRuntime { return; } - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables); + const promptVariables = request?.promptVariables || {}; + const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables); const req = new BrunoRequest(request); const res = createResponseParser(response); diff --git a/tests/interpolation/prompt-variables/fixtures/client.pfx b/tests/interpolation/prompt-variables/fixtures/client.pfx new file mode 100644 index 000000000..e69de29bb diff --git a/tests/interpolation/prompt-variables/fixtures/collection/bruno.json b/tests/interpolation/prompt-variables/fixtures/collection/bruno.json new file mode 100644 index 000000000..d6e156d89 --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/bruno.json @@ -0,0 +1,22 @@ +{ + "version": "1", + "name": "prompt-variables-interpolation", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ], + "size": 0.0008153915405273438, + "filesCount": 4, + "clientCertificates": { + "enabled": true, + "certs": [ + { + "domain": "localhost:8081", + "type": "pfx", + "pfxFilePath": "../client.pfx", + "passphrase": "{{?Enter Client CA Password}}" + } + ] + } +} \ No newline at end of file diff --git a/tests/interpolation/prompt-variables/fixtures/collection/collection.bru b/tests/interpolation/prompt-variables/fixtures/collection/collection.bru new file mode 100644 index 000000000..cfd684a5f --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/collection.bru @@ -0,0 +1,13 @@ +auth { + mode: basic +} + +auth:basic { + username: auth_unsername + password: {{?Enter Collection Auth Password}} +} + +vars:pre-request { + collectionVar: {{?Enter Collection Variable}} + ~collectionVarDisabled: {{?Should Not Prompt collectionVarDisabled}} +} diff --git a/tests/interpolation/prompt-variables/fixtures/collection/environments/local.bru b/tests/interpolation/prompt-variables/fixtures/collection/environments/local.bru new file mode 100644 index 000000000..8dda9e2b6 --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/environments/local.bru @@ -0,0 +1,4 @@ +vars { + collectionEnvVar: {{?Enter Collection Env Variable}} + ~collectionEnvVarDisabled: {{?Should Not Prompt collectionEnvVarDisabled}} +} diff --git a/tests/interpolation/prompt-variables/fixtures/collection/http-folder/folder.bru b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/folder.bru new file mode 100644 index 000000000..f4fe94680 --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/folder.bru @@ -0,0 +1,22 @@ +meta { + name: http-folder +} + +headers { + folderHeaderVar: {{?Enter Folder Header Variable}} + ~folderHeaderVarDisabled: {{?Should Not Prompt folderHeaderVarDisabled}} +} + +auth { + mode: basic +} + +auth:basic { + username: auth_username + password: {{?Enter Folder Auth Password}} +} + +vars:pre-request { + folderVar: {{?Enter Folder Variable}} + ~folderVarDisabled: {{?Should Not Prompt folderVarDisabled}} +} diff --git a/tests/interpolation/prompt-variables/fixtures/collection/http-folder/http-request-without-ca.bru b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/http-request-without-ca.bru new file mode 100644 index 000000000..1c27bf4f6 --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/http-request-without-ca.bru @@ -0,0 +1,43 @@ +meta { + name: http-request-without-ca + type: http + seq: 2 +} + +post { + url: http://localhost:8081/api/echo/json?query={{?Enter Query Variable}} + body: json + auth: inherit +} + +params:query { + query: {{?Enter Query Variable}} +} + +headers { + Content-Type: application/json + ~x-disabled-header: {{?Should Not Prompt request x-disabled-header}} +} + +body:json { + { + "body": "{{?Enter Body Variable}}", + "bodyNumber": {{?Enter Number Variable}}, + "bodyBoolean": {{?Enter Boolean Variable}}, + "repeat-1": "{{?Enter Body Variable}}", + "requestVar": "{{requestVar}}", + "folderVar": "{{folderVar}}", + "collectionVar": "{{collectionVar}}", + "collectionEnvVar": "{{collectionEnvVar}}", + "globalEnvVar": "{{globalEnvVar}}", + "folderHeader": "{{folderHeader}}" + } +} + +body:form-urlencoded { + formurlencoded: {{?Should Not Prompt body mode form-urlencoded}} +} + +vars:pre-request { + requestVar: {{?Enter Request Variable}} +} diff --git a/tests/interpolation/prompt-variables/fixtures/collection/http-folder/https-request-with-ca.bru b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/https-request-with-ca.bru new file mode 100644 index 000000000..02fdb490c --- /dev/null +++ b/tests/interpolation/prompt-variables/fixtures/collection/http-folder/https-request-with-ca.bru @@ -0,0 +1,17 @@ +meta { + name: https-request-with-ca + type: http + seq: 1 +} + +post { + url: https://localhost:8081/api/echo/json + body: json + auth: inherit +} + +body:json { + { + "body": "test" + } +} diff --git a/tests/interpolation/prompt-variables/http-request-prompt-variables.spec.ts b/tests/interpolation/prompt-variables/http-request-prompt-variables.spec.ts new file mode 100644 index 000000000..6f9a38146 --- /dev/null +++ b/tests/interpolation/prompt-variables/http-request-prompt-variables.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; + +test.describe('Prompt Variables Interpolation', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + // cleanup: close all collections + await closeAllCollections(page); + }); + + // without client certificate - no HTTPS + test('Verifying if the prompt variables are prompted correctly for the http request - without client certificate', async ({ pageWithUserData: page }) => { + let promptVariablesModal; + let promptInputs; + + await test.step('Open collection and navigate to the http request with prompt variables', async () => { + // Open collection and accept sandbox mode + await page.locator('#sidebar-collection-name').filter({ hasText: 'prompt-variables-interpolation' }).click(); + + // Navigate to the request + await page.locator('.collection-item-name').filter({ hasText: 'http-folder' }).click(); + await page.locator('.collection-item-name').filter({ hasText: 'http-request-without-ca' }).click(); + }); + + await test.step('Send the request and verify the prompt variables modal is visible', async () => { + // Send the request + await page.getByTestId('send-arrow-icon').click(); + + promptVariablesModal = page.getByRole('dialog').filter({ has: page.locator('.bruno-modal-header-title').getByText('Input Required') }); + await promptVariablesModal.waitFor({ state: 'visible' }); + }); + + await test.step('Verify duplicate prompt variables are not allowed', async () => { + // Enter the prompt variables + promptInputs = promptVariablesModal.getByTestId('prompt-variable-input-container'); + await expect(promptInputs).toHaveCount(11); + }); + + await test.step('Verify disabled / non selected modes prompt variables are not prompted', async () => { + // verify that any prompt added to the inactive fields starting with label "Should Not Prompt" are not displayed + // eg: 1. Headers - disabled or hierarchical overrides should not be displayed + // 2. Vars - respects hierarchical overrides, eg: request var > folder var > collection var > global env var + // 3. Body - only prompts from selected body mode should be displayed eg: json + // 4. Auth - only prompts from selected mode should be displayed eg: basic, respects hierarchical overrides + // 5. Client Cert - only prompts from current domain config should be displayed + await expect(promptInputs.filter({ hasText: 'Should Not Prompt', exact: true })).toHaveCount(0); + }); + + await test.step('Fill the prompt variables and send the request', async () => { + await promptInputs.filter({ hasText: 'Enter Query Variable' }).locator('input').fill('queryPromptValue'); + await promptInputs.filter({ hasText: 'Enter Body Variable' }).locator('input').fill('bodyPromptValue'); + await promptInputs.filter({ hasText: 'Enter Number Variable' }).locator('input').fill('123'); + await promptInputs.filter({ hasText: 'Enter Boolean Variable' }).locator('input').fill('true'); + await promptInputs.filter({ hasText: 'Enter Request Variable' }).locator('input').fill('requestVarPromptValue'); + await promptInputs.filter({ hasText: 'Enter Folder Variable' }).locator('input').fill('folderVarPromptValue'); + await promptInputs.filter({ hasText: 'Enter Collection Variable' }).locator('input').fill('collectionVarPromptValue'); + await promptInputs.filter({ hasText: 'Enter Collection Env Variable' }).locator('input').fill('collectionEnvVarPromptValue'); + await promptInputs.filter({ hasText: 'Enter Global Env Variable' }).locator('input').fill('globalEnvVarPromptValue'); + await promptInputs.filter({ hasText: 'Enter Folder Auth Password' }).locator('input').fill('folderAuthPasswordValue'); + await promptInputs.filter({ hasText: 'Enter Folder Header Variable' }).locator('input').fill('folderHeaderVarPromptValue'); + + // Submit the prompt variables + await promptVariablesModal.getByRole('button', { name: 'Continue' }).click(); + }); + + await test.step('Verify the request is sent with the correct variables', async () => { + // Verify the response status code + await expect(page.getByTestId('response-status-code')).toHaveText(/200/); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"folderVar": "folderVarPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"collectionVar": "collectionVarPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"collectionEnvVar": "collectionEnvVarPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"globalEnvVar": "globalEnvVarPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"requestVar": "requestVarPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"body": "bodyPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"repeat-1": "bodyPromptValue"').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"bodyNumber": 123').first()).toBeVisible(); + await expect(page.locator('.response-pane').locator('.CodeMirror-line').getByText('"bodyBoolean": true').first()).toBeVisible(); + }); + }); + + // with client certificate - HTTPS + test('Verifying if the prompt variables are prompted correctly for the http request - with client certificate', async ({ pageWithUserData: page }) => { + let promptVariablesModal; + let promptInputs; + + await test.step('Open collection and navigate to the http request with prompt variables', async () => { + // Open collection and accept sandbox mode + await page.locator('#sidebar-collection-name').filter({ hasText: 'prompt-variables-interpolation' }).click(); + + // Navigate to the request + await page.locator('.collection-item-name').filter({ hasText: 'http-folder' }).click(); + await page.locator('.collection-item-name').filter({ hasText: 'https-request-with-ca' }).click(); + }); + + await test.step('Send the request and verify the prompt variables modal is visible', async () => { + // Send the request + await page.getByTestId('send-arrow-icon').click(); + + promptVariablesModal = page.getByRole('dialog').filter({ has: page.locator('.bruno-modal-header-title').getByText('Input Required') }); + await promptVariablesModal.waitFor({ state: 'visible' }); + }); + + await test.step('Verify disabled / non selected modes prompt variables are not prompted', async () => { + promptInputs = promptVariablesModal.getByTestId('prompt-variable-input-container'); + // verify that any prompt added to the inactive fields starting with label "Should Not Prompt" are not displayed + // eg: 1. Headers - disabled or hierarchical overrides should not be displayed + // 2. Vars - respects hierarchical overrides, eg: request var > folder var > collection var > global env var + // 3. Body - only prompts from selected body mode should be displayed eg: json + // 4. Auth - only prompts from selected mode should be displayed eg: basic, respects hierarchical overrides + // 5. Client Cert - only prompts from current domain config should be displayed + await expect(promptInputs.filter({ hasText: 'Should Not Prompt', exact: true })).toHaveCount(0); + await expect(promptInputs.filter({ hasText: 'Enter Client CA Password', exact: true })).toHaveCount(1); + }); + + await test.step('Fill the prompt variables and send the request', async () => { + await promptInputs.filter({ hasText: 'Enter Client CA Password' }).locator('input').fill('clientCAPasswordValue'); + // leave the rest of the prompt variables empty + + // Submit the prompt variables + await promptVariablesModal.getByRole('button', { name: 'Continue' }).click(); + }); + + // @TODO: setup a valid certificate and server required to verify the request is sent with the correct variables + }); +}); diff --git a/tests/interpolation/prompt-variables/init-user-data/collection-security.json b/tests/interpolation/prompt-variables/init-user-data/collection-security.json new file mode 100644 index 000000000..1da5d2e2f --- /dev/null +++ b/tests/interpolation/prompt-variables/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/interpolation/prompt-variables/fixtures/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} \ No newline at end of file diff --git a/tests/interpolation/prompt-variables/init-user-data/global-environments.json b/tests/interpolation/prompt-variables/init-user-data/global-environments.json new file mode 100644 index 000000000..ddd3cda8a --- /dev/null +++ b/tests/interpolation/prompt-variables/init-user-data/global-environments.json @@ -0,0 +1,27 @@ +{ + "environments": [ + { + "uid": "FlaexlO7lcH7UtEpWsVyz", + "name": "E2E_Global", + "variables": [ + { + "uid": "lflBDSYBdHkUedYhBF4Ty", + "name": "globalEnvVar", + "value": "{{?Enter Global Env Variable}}", + "type": "text", + "secret": false, + "enabled": true + }, + { + "uid": "lflBDSYBdHkUedYhBF4Ty", + "name": "globalEnvVarDisabled", + "value": "{{?Should Not Prompt globalEnvVarDisabled}}", + "type": "text", + "secret": false, + "enabled": false + } + ] + } + ], + "activeGlobalEnvironmentUid": "FlaexlO7lcH7UtEpWsVyz" +} \ No newline at end of file diff --git a/tests/interpolation/prompt-variables/init-user-data/preferences.json b/tests/interpolation/prompt-variables/init-user-data/preferences.json new file mode 100644 index 000000000..6ced499c9 --- /dev/null +++ b/tests/interpolation/prompt-variables/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/interpolation/prompt-variables/fixtures/collection" + ] +} \ No newline at end of file diff --git a/tests/interpolation/prompt-variables/init-user-data/ui-state-snapshot.json b/tests/interpolation/prompt-variables/init-user-data/ui-state-snapshot.json new file mode 100644 index 000000000..e14888ed2 --- /dev/null +++ b/tests/interpolation/prompt-variables/init-user-data/ui-state-snapshot.json @@ -0,0 +1,8 @@ +{ + "collections": [ + { + "pathname": "{{projectRoot}}/tests/interpolation/prompt-variables/fixtures/collection", + "selectedEnvironment": "local" + } + ] +} \ No newline at end of file