diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index ab007c662..ec60c9d59 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -96,6 +96,9 @@ 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/index.js b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js new file mode 100644 index 000000000..01636a86b --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/PromptVariables/PromptVariablesModal/index.js @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import Portal from 'components/Portal'; +import Modal from 'components/Modal'; + +export default function PromptVariablesModal({ title = 'Input Required', prompts, onSubmit, onCancel }) { + const [values, setValues] = useState({}); + + const handleChange = (prompt, value) => { + setValues((prev) => ({ ...prev, [prompt]: value })); + }; + + if (!prompts?.length) { + return null; + } + + return ( + + onSubmit(values)} + handleCancel={onCancel} + > + {prompts.map((prompt) => ( + + {prompt}: + handleChange(prompt, e.target.value)} + autoFocus + /> + + ))} + + + ); +} diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js index 57b8d4987..0adf7b19f 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js @@ -50,6 +50,9 @@ 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/globalStyles.js b/packages/bruno-app/src/globalStyles.js index 7d820c41f..c12d2e563 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -237,6 +237,9 @@ const GlobalStyle = createGlobalStyle` .cm-variable-invalid { color: ${(props) => props.theme.codemirror.variable.invalid}; } + .cm-variable-prompt { + color: ${(props) => props.theme.codemirror.variable.prompt}; + } } .CodeMirror-brunoVarInfo { color: ${(props) => props.theme.codemirror.variable.info.color}; diff --git a/packages/bruno-app/src/pages/Main.js b/packages/bruno-app/src/pages/Main.js index ba7b3289e..40acfa4cd 100644 --- a/packages/bruno-app/src/pages/Main.js +++ b/packages/bruno-app/src/pages/Main.js @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { AppProvider } from 'providers/App'; import { ToastProvider } from 'providers/Toaster'; import { HotkeysProvider } from 'providers/Hotkeys'; +import { PromptVariablesProvider } from 'providers/PromptVariables'; import ReduxStore from 'providers/ReduxStore'; import ThemeProvider from 'providers/Theme/index'; @@ -44,11 +45,13 @@ function Main({ children }) { - - - {children} - - + + + + {children} + + + @@ -57,5 +60,3 @@ function Main({ children }) { } export default Main; - - diff --git a/packages/bruno-app/src/providers/PromptVariables/index.js b/packages/bruno-app/src/providers/PromptVariables/index.js new file mode 100644 index 000000000..9b1638a3a --- /dev/null +++ b/packages/bruno-app/src/providers/PromptVariables/index.js @@ -0,0 +1,72 @@ +import PromptVariablesModal from 'components/RequestPane/PromptVariables/PromptVariablesModal'; +import React, { createContext, useCallback, useState } from 'react'; +import { toast } from 'react-hot-toast'; + +const PromptVariablesContext = createContext(); + +export function PromptVariablesProvider({ children }) { + const [modalState, setModalState] = useState({ open: false, prompts: [], resolve: null, reject: null }); + + 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); + } + }); + }, []); + + // Expose globally for non-component code (e.g., Redux thunks) + if (typeof window !== 'undefined') { + window.promptForVariables = async (prompts) => { + try { + return await prompt(prompts); + } catch (err) { + if (err !== 'cancelled') console.error('window.promptForVariables encountered an error:', err); + throw err; + } + }; + } + + const handleSubmit = (values) => { + try { + modalState.resolve(values); + } catch (err) { + console.error('PromptVariablesProvider: Error resolving prompt values:', err); + } + setModalState({ open: false, prompts: [], resolve: null, reject: null }); + }; + + const handleCancel = () => { + try { + modalState.reject('cancelled'); + } catch (err) { + console.error('PromptVariablesProvider: Error rejecting prompt:', err); + } + 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; + } +} + +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 9e58f15f8..a904e29ad 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1,5 +1,5 @@ import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema'; -import { parseQueryParams } from '@usebruno/common/utils'; +import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils'; import cloneDeep from 'lodash/cloneDeep'; import filter from 'lodash/filter'; import find from 'lodash/find'; @@ -397,6 +397,29 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => { 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); + } + } + } + await dispatch( updateResponsePaneScrollPosition({ uid: state.tabs.activeTabUid, diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 34cb27874..8a3498fdb 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -287,6 +287,7 @@ const darkTheme = { variable: { valid: 'rgb(11 178 126)', invalid: '#f06f57', + prompt: 'dodgerblue', 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 794925883..5ff09588d 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -288,6 +288,7 @@ const lightTheme = { variable: { valid: '#047857', invalid: 'rgb(185, 28, 28)', + prompt: 'dodgerblue', info: { color: 'rgb(52, 52, 52)', bg: 'white', diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js index 9f2624e88..39b1cc1e4 100644 --- a/packages/bruno-app/src/utils/common/codemirror.js +++ b/packages/bruno-app/src/utils/common/codemirror.js @@ -30,6 +30,12 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa while ((ch = stream.next()) != null) { if (ch === '}' && stream.peek() === '}') { stream.eat('}'); + + // Prompt variable: starts with '?' + if (word.startsWith('?')) { + return `variable-prompt`; + } + // Check if it's a mock variable (starts with $) and exists in mockDataFunctions const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1)); const found = isMockVariable || pathFoundInVariables(word, variables); diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 171a1a659..1f7848fb7 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -25,7 +25,7 @@ const { NtlmClient } = require('axios-ntlm'); const { addDigestInterceptor } = require('@usebruno/requests'); const { getCACertificates } = require('@usebruno/requests'); const { getOAuth2Token } = require('../utils/oauth2'); -const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils; +const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils; const onConsoleLog = (type, args) => { console[type](...args); @@ -74,6 +74,38 @@ const runSingleRequest = async function ( request = await prepareRequest(item, collection); + // Detect prompt variables before proceeding + const promptVars = extractPromptVariables(request); + 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})`)); + return { + test: { + filename: relativeItemPathname + }, + request: { + method: request.method, + url: request.url, + headers: request.headers, + data: request.data + }, + response: { + status: 'skipped', + statusText: errorMsg, + data: null, + responseTime: 0 + }, + error: null, + status: 'skipped', + skipped: true, + assertionResults: [], + testResults: [], + preRequestTestResults: [], + postResponseTestResults: [], + shouldStopRunnerExecution + }; + } + request.__bruno__executionMode = 'cli'; const scriptingConfig = get(brunoConfig, 'scripts', {}); diff --git a/packages/bruno-common/src/utils/index.ts b/packages/bruno-common/src/utils/index.ts index 94e8cc2cd..6042abb52 100644 --- a/packages/bruno-common/src/utils/index.ts +++ b/packages/bruno-common/src/utils/index.ts @@ -11,3 +11,8 @@ export { export { patternHasher } from './template-hasher'; + +export { + extractPromptVariables, + extractPromptVariablesFromString +} from './prompt-variables'; diff --git a/packages/bruno-common/src/utils/prompt-variables.spec.ts b/packages/bruno-common/src/utils/prompt-variables.spec.ts new file mode 100644 index 000000000..6bdf960da --- /dev/null +++ b/packages/bruno-common/src/utils/prompt-variables.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from '@jest/globals'; + +import { extractPromptVariables, extractPromptVariablesFromString } from './prompt-variables'; + +describe('prompt variable utils', () => { + describe('extractPromptVariablesFromString', () => { + it('should extract prompt variables', () => { + expect(extractPromptVariablesFromString('Hello {{?world}}')).toEqual(['world']); + expect(extractPromptVariablesFromString('No prompts here')).toEqual([]); + expect(extractPromptVariablesFromString('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']); + }); + + it('should deduplicate prompt variables', () => { + // Strings + expect(extractPromptVariables('{{?world}} prompt here Hello {{?world}}')).toEqual(['world']); + expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string plus another {{?one}}')).toEqual(['prompts', 'one']); + }); + }); + + describe('extractPromptVariables', () => { + it('should extract prompt variables from strings', () => { + expect(extractPromptVariables('Hello {{?world}}')).toEqual(['world']); + expect(extractPromptVariables('No prompts here')).toEqual([]); + expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']); + }); + + it('should extract prompt variables from objects', () => { + expect(extractPromptVariables({ text: 'Hello {{?world}}' })).toEqual(['world']); + expect(extractPromptVariables({ noPrompt: 'No prompt here' })).toEqual([]); + expect(extractPromptVariables({ prompt1: 'Hello {{?world}}', prompt2: 'Another {{?test}}' })).toEqual(['world', 'test']); + }); + + it('should extract prompt variables from arrays', () => { + // Strings + expect(extractPromptVariables(['No prompts here', 'Hello {{?world}}'])).toEqual(['world']); + expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?test}} string'])).toEqual(['prompts', 'one', 'test']); + + // Objects + expect(extractPromptVariables([{ prompt: 'Hello {{?world}}', noprompt: 'No prompt here' }, { noprompt: '' }])).toEqual(['world']); + + // Nested arrays + expect(extractPromptVariables(['Prompt {{?here}}', ['Hello {{?world}}', 'Another {{?test}} string']])).toEqual(['here', 'world', 'test']); + + // Mixed data types + expect(extractPromptVariables([{ text: 'Multiple {{?prompts}} in {{?one}} string', noPrompt: 'No prompt here' }, ['Another {{?test}} string', { prompt: '{{?nested}}', no: 'prompt' }]])).toEqual(['prompts', 'one', 'test', 'nested']); + }); + + it('should deduplicate prompt variables', () => { + // Strings + expect(extractPromptVariables(['{{?world}} prompt here', 'Hello {{?world}}'])).toEqual(['world']); + expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?one}} string'])).toEqual(['prompts', 'one']); + }); + }); +}); diff --git a/packages/bruno-common/src/utils/prompt-variables.ts b/packages/bruno-common/src/utils/prompt-variables.ts new file mode 100644 index 000000000..a3b1a2f0d --- /dev/null +++ b/packages/bruno-common/src/utils/prompt-variables.ts @@ -0,0 +1,45 @@ +/** + * Extract prompt variables matching {{?}} from a string. + * @param {string} str - The input string. + * @returns {string[]} - An array of extracted prompt variables. + */ +export const extractPromptVariablesFromString = (str: string): string[] => { + const regex = /{{\?([^}]+)}}/g; + const prompts = new Set(); + let match; + while ((match = regex.exec(str)) !== null) { + prompts.add(match[1].trim()); + } + return Array.from(prompts); +}; + +/** + * Extract prompt variables from an object. + * @param {*} obj - The input object. + * @returns {string[]} - An array of extracted prompt variables. + */ +export function extractPromptVariables(obj: any): string[] { + const prompts = new Set(); + try { + if (typeof obj === 'string') { + // Extract prompt variables from strings + const extracted = extractPromptVariablesFromString(obj); + extracted.forEach((prompt) => prompts.add(prompt)); + } else if (Array.isArray(obj)) { + // Recursively extract from array elements + for (const item of obj) { + const extracted = extractPromptVariables(item); + extracted.forEach((prompt) => prompts.add(prompt)); + } + } else if (typeof obj === 'object' && obj !== null) { + // Recursively extract from object properties + for (const key in obj) { + const extracted = extractPromptVariables(obj[key]); + extracted.forEach((prompt) => prompts.add(prompt)); + } + } + } catch (error) { + console.error('Error extracting prompt variables:', error); + } + return Array.from(prompts); +} diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 611182a27..7b6d73891 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -11,6 +11,7 @@ const { each, get, extend, cloneDeep, merge } = require('lodash'); const { NtlmClient } = require('axios-ntlm'); const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js'); const { encodeUrl } = require('@usebruno/common').utils; +const { extractPromptVariables } = require('@usebruno/common').utils; const { interpolateString } = require('./interpolate-string'); const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper'); const { addDigestInterceptor } = require('@usebruno/requests'); @@ -1066,6 +1067,27 @@ const registerNetworkIpc = (mainWindow) => { continue; } + const promptVars = extractPromptVariables(request); + + if (promptVars.length > 0) { + mainWindow.webContents.send('main:run-folder-event', { + type: 'runner-request-skipped', + 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.', + data: null, + responseTime: 0, + headers: null + }, + ...eventData + }); + + currentRequestIndex++; + + continue; + } + const request = await prepareRequest(item, collection, abortController); request.__bruno__executionMode = 'runner';