diff --git a/packages/bruno-api-docs/e2e/tests/playground/playground-prompt-variables.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/playground-prompt-variables.spec.ts new file mode 100644 index 00000000..af993cd0 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/playground-prompt-variables.spec.ts @@ -0,0 +1,143 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { test, expect } from '../../playwright'; + +const VARS_PLAYGROUND = '/?fixture=vars#/?pg=1&dock=bottom'; + +test.describe('Playground prompt variables', () => { + test.beforeEach(async ({ page, playground }) => { + await page.goto(VARS_PLAYGROUND); + await playground.runner.waitFor({ state: 'visible' }); + await playground.openTreeItem(['Customers', 'Variables Demo']); + await playground.view.waitFor({ state: 'visible' }); + await playground.envSwitcher.selectEnvironment('Dev'); + }); + + const armRequest = async (page: Page, urlInput: Locator, url: string) => { + const sent: string[] = []; + await page.route('**/*', async (route: Route) => { + const target = route.request().url(); + if (target.startsWith('http') && !target.includes('127.0.0.1') && !target.includes('localhost')) { + sent.push(target); + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + return; + } + await route.continue(); + }); + + await urlInput.fill(url); + await expect(urlInput).toHaveValue(url); + return sent; + }; + + test('asks for a value before sending and interpolates what the reader typed', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + + const dialog = page.getByTestId('prompt-variables-modal-content'); + await expect(dialog).toBeVisible(); + await expect(page.getByText('Input Required')).toBeVisible(); + + await page.getByTestId('prompt-variable-input-0').fill('123456'); + await page.getByTestId('prompt-variables-submit').click(); + + await expect(dialog).toHaveCount(0); + await expect.poll(() => sent).toContain('https://api.example.com/otp/123456'); + }); + + test('submits on Enter, so the reader never has to reach for the mouse', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible(); + + await page.keyboard.type('654321'); + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0); + await expect.poll(() => sent).toContain('https://api.example.com/otp/654321'); + }); + + test('asks again on the next send, because answers are never kept', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + await page.getByTestId('prompt-variable-input-0').fill('111111'); + await page.getByTestId('prompt-variables-submit').click(); + await expect.poll(() => sent.length).toBe(1); + + await responsePane.send(); + + const dialog = page.getByTestId('prompt-variables-modal-content'); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId('prompt-variable-input-0')).toHaveValue(''); + + await page.getByTestId('prompt-variable-input-0').fill('222222'); + await page.getByTestId('prompt-variables-submit').click(); + await expect.poll(() => sent).toContain('https://api.example.com/otp/222222'); + }); + + test('sends nothing when the reader backs out', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + await page.getByTestId('prompt-variables-cancel').click(); + + await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0); + await expect(responsePane.emptyHint).toBeVisible(); + expect(sent).toEqual([]); + }); + + test('closes on Escape without sending', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible(); + await page.keyboard.press('Escape'); + + await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0); + await expect(responsePane.emptyHint).toBeVisible(); + expect(sent).toEqual([]); + }); + + test('puts the cursor in the first field so the reader can type straight away', async ({ page, playground, responsePane }) => { + await armRequest(page, playground.urlInput, 'https://api.example.com/{{?First}}/{{?Second}}'); + + await responsePane.send(); + await expect(page.getByTestId('prompt-variables-modal-content')).toBeVisible(); + + await page.keyboard.type('typed-without-clicking'); + await expect(page.getByTestId('prompt-variable-input-0')).toHaveValue('typed-without-clicking'); + }); + + test('asks for one field per prompt and sends both answers', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/{{?First}}/{{?Second}}'); + + await responsePane.send(); + + await expect(page.getByTestId('prompt-variable-input-container')).toHaveCount(2); + await page.getByTestId('prompt-variable-input-0').fill('one'); + await page.getByTestId('prompt-variable-input-1').fill('two'); + await page.getByTestId('prompt-variables-submit').click(); + + await expect.poll(() => sent).toContain('https://api.example.com/one/two'); + }); + + test('sends an empty string for a field left blank, rather than the token', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/otp/{{?OTP}}'); + + await responsePane.send(); + await page.getByTestId('prompt-variables-submit').click(); + + await expect.poll(() => sent).toContain('https://api.example.com/otp/'); + }); + + test('sends without asking when the request uses no prompt variables', async ({ page, playground, responsePane }) => { + const sent = await armRequest(page, playground.urlInput, 'https://api.example.com/plain'); + + await responsePane.send(); + + await expect.poll(() => sent).toContain('https://api.example.com/plain'); + await expect(page.getByTestId('prompt-variables-modal-content')).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx index dd5d1e50..df89dadb 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx @@ -10,6 +10,8 @@ import QueryBar from './QueryBar/QueryBar'; import RequestPane from './RequestPane/RequestPane'; import ResponsePane from './ResponsePane/ResponsePane'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { usePromptVariables } from './usePromptVariables'; +import { PromptVariablesModal } from '@/components/PromptVariablesModal/PromptVariablesModal'; import { updatePlaygroundItem, setPlaygroundResponse, @@ -37,6 +39,8 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec const itemUuid = (item as any).uuid; const response = useAppSelector((state) => selectPlaygroundResponse(state, itemUuid)); const [isLoading, setIsLoading] = useState(false); + const promptVariablesController = usePromptVariables(); + const { collect: collectPromptVariables } = promptVariablesController; // The request/response split is one draggable divider whose axis follows the // orientation: horizontal layout resizes width, vertical layout resizes height. const { size: paneSize, isResizing, containerRef, startResize } = useSplitPane(orientation); @@ -71,6 +75,7 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec const saveTimeoutRef = useRef(null); const pendingSaveRef = useRef<{ uuid: string; item: HttpRequest } | null>(null); + const sendInFlightRef = useRef(false); useEffect(() => { setEditableItem(item); @@ -104,7 +109,9 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec }, [dispatch]); const handleSendRequest = useCallback(async () => { - setIsLoading(true); + if (sendInFlightRef.current) return; + sendInFlightRef.current = true; + try { // Check both root level and config level for environments // TODO: Remove this @@ -113,11 +120,22 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec (env: any) => env.name === selectedEnvironment ); const { requestRunner } = await import('@/runner'); + + const prepared = await requestRunner.prepareRequest(editableItem, collection); + const promptNames = await requestRunner.collectPromptVariableNames({ + item: editableItem, collection, environment, prepared + }); + const promptVariables = await collectPromptVariables(promptNames); + if (!promptVariables) return; + + setIsLoading(true); const result = await requestRunner.runRequest({ item: editableItem, collection, environment, - runtimeVariables: {} + runtimeVariables: {}, + promptVariables, + prepared }); dispatch(setPlaygroundResponse({ uuid: itemUuid, response: result })); @@ -136,9 +154,10 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec } })); } finally { + sendInFlightRef.current = false; setIsLoading(false); } - }, [collection, editableItem, selectedEnvironment, itemUuid, dispatch]); + }, [collection, editableItem, selectedEnvironment, itemUuid, dispatch, collectPromptVariables]); return ( = ({ item, collec writable >
+ {promptVariablesController.pending && ( + + )} + {itemName} { + const box: { current: PromptVariablesController | null } = { current: null }; + + const Probe: React.FC = () => { + box.current = usePromptVariables(); + return null; + }; + + return { box, tree: }; +}; + +describe('asking the reader for prompt values', () => { + it('sends straight away when the request uses no prompts', async () => { + const { box, tree } = probeTree(); + useRenderToDom(tree); + const controller = box.current as PromptVariablesController; + + await expect(controller.collect([])).resolves.toEqual({}); + }); + + it('waits for the reader, then sends what they typed', async () => { + const { box, tree } = probeTree(); + useRenderToDom(tree); + const controller = box.current as PromptVariablesController; + + const pending = controller.collect(['OTP', 'Region']); + let settled = false; + void pending.then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + controller.submit({ OTP: '123456', Region: 'eu' }); + + await expect(pending).resolves.toEqual({ '?OTP': '123456', '?Region': 'eu' }); + }); + + it('asks again on the next send, because answers are never kept', async () => { + const { box, tree } = probeTree(); + useRenderToDom(tree); + const controller = box.current as PromptVariablesController; + + const first = controller.collect(['OTP']); + controller.submit({ OTP: '123456' }); + await expect(first).resolves.toEqual({ '?OTP': '123456' }); + + const second = controller.collect(['OTP']); + let settled = false; + void second.then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + controller.cancel(); + await expect(second).resolves.toBeNull(); + }); + + it('reports a dismissal so the send is abandoned rather than sent half-filled', async () => { + const { box, tree } = probeTree(); + useRenderToDom(tree); + const controller = box.current as PromptVariablesController; + + const pending = controller.collect(['OTP']); + controller.cancel(); + + await expect(pending).resolves.toBeNull(); + }); + + it('sends an empty string for a prompt the reader left blank', async () => { + const { box, tree } = probeTree(); + useRenderToDom(tree); + const controller = box.current as PromptVariablesController; + + const pending = controller.collect(['OTP']); + controller.submit({}); + + await expect(pending).resolves.toEqual({ '?OTP': '' }); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/usePromptVariables.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/usePromptVariables.ts new file mode 100644 index 00000000..bac97873 --- /dev/null +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/usePromptVariables.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { buildPromptVariableMap } from '@/utils/promptVariables'; +import type { Variables } from '@/runner/utils/variable-interpolator'; + +interface PendingPrompt { + names: string[]; +} + +export interface PromptVariablesController { + pending: PendingPrompt | null; + collect: (names: string[]) => Promise; + submit: (values: Record) => void; + cancel: () => void; +} + +export const usePromptVariables = (): PromptVariablesController => { + const [pending, setPending] = useState(null); + const resolveRef = useRef<((values: Record | null) => void) | null>(null); + + const settle = useCallback((values: Record | null) => { + const resolve = resolveRef.current; + resolveRef.current = null; + setPending(null); + resolve?.(values); + }, []); + + const collect = useCallback( + async (names: string[]): Promise => { + if (!names.length) return {}; + + settle(null); + + const values = await new Promise | null>((resolve) => { + resolveRef.current = resolve; + setPending({ names }); + }); + + return values ? buildPromptVariableMap(names, values) : null; + }, + [settle] + ); + + const submit = useCallback((values: Record) => settle(values), [settle]); + const cancel = useCallback(() => settle(null), [settle]); + + useEffect(() => () => resolveRef.current?.(null), []); + + return useMemo(() => ({ pending, collect, submit, cancel }), [pending, collect, submit, cancel]); +}; diff --git a/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.spec.tsx b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.spec.tsx new file mode 100644 index 00000000..5ddfe8e0 --- /dev/null +++ b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.spec.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, query, queryByTestId } from '@/test-utils/dom'; +import { PromptVariablesModal } from './PromptVariablesModal'; + +vi.mock('@/ui/Portal/Portal', () => ({ + Portal: ({ children }: { children: React.ReactNode }) => children +})); + +const dialogTree = (props: Partial> = {}) => ( + {}} + onCancel={() => {}} + {...props} + /> +); + +const fieldValues = (root: ReturnType, count: number) => + Array.from({ length: count }, (_, index) => + getByTestId(root, `prompt-variable-input-${index}`).getAttribute('value')); + +describe('the dialog that asks for prompt values', () => { + it('asks for one value per prompt, labelled with the prompt name', () => { + const root = useRenderToDom(dialogTree({ names: ['OTP', 'User id'] })); + + expect(root.querySelectorAll('[data-testid="prompt-variable-input-container"]')).toHaveLength(2); + expect(getByTestId(root, 'prompt-variable-label-0').text).toBe('OTP'); + expect(getByTestId(root, 'prompt-variable-label-1').text).toBe('User id'); + }); + + it('points each label at the field it names', () => { + const root = useRenderToDom(dialogTree({ names: ['OTP'] })); + + expect(getByTestId(root, 'prompt-variable-label-0').getAttribute('for')) + .toBe(getByTestId(root, 'prompt-variable-input-0').id); + }); + + it('starts every field empty on each send', () => { + const root = useRenderToDom(dialogTree({ names: ['OTP', 'Region'] })); + + expect(fieldValues(root, 2)).toEqual(['', '']); + }); + + it('matches the wording for the title and the two buttons', () => { + const root = useRenderToDom(dialogTree()); + + expect(getByTestId(root, 'prompt-variables-title').text).toBe('Input Required'); + expect(getByTestId(root, 'prompt-variables-submit').text).toBe('Continue'); + expect(getByTestId(root, 'prompt-variables-cancel').text).toBe('Cancel'); + }); + + it('submits on the confirm button and does nothing destructive on the other', () => { + const root = useRenderToDom(dialogTree()); + + expect(getByTestId(root, 'prompt-variables-submit').getAttribute('type')).toBe('submit'); + expect(getByTestId(root, 'prompt-variables-cancel').getAttribute('type')).toBe('button'); + }); + + it('uses the desktop placeholder and input attributes', () => { + const root = useRenderToDom(dialogTree()); + const field = getByTestId(root, 'prompt-variable-input-0'); + + expect(field.getAttribute('type')).toBe('text'); + expect(field.getAttribute('placeholder')).toBe('Enter value'); + expect(field.getAttribute('autoCorrect')).toBe('off'); + expect(field.getAttribute('spellcheck')).toBe('false'); + }); + + it('adds no explanatory copy of its own, matching the desktop dialog', () => { + const root = useRenderToDom(dialogTree()); + + expect(root.text).not.toContain('saved to the collection'); + }); + + it('renders as a dialog so assistive tech announces it', () => { + const root = useRenderToDom(dialogTree()); + const dialog = query(root, '[role="dialog"]'); + + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect(dialog.getAttribute('aria-label')).toBe('Input Required'); + }); + + it('renders nothing when the request needs no prompts', () => { + const root = useRenderToDom(dialogTree({ names: [] })); + + expect(queryByTestId(root, 'prompt-variables-modal-content')).toBeNull(); + }); + + it('shows no dialog while closed', () => { + const root = useRenderToDom(dialogTree({ open: false })); + + expect(root.querySelector('[role="dialog"]')).toBeNull(); + expect(queryByTestId(root, 'prompt-variables-modal-content')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.tsx b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.tsx new file mode 100644 index 00000000..8ca3e1b0 --- /dev/null +++ b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { StyledWrapper } from './StyledWrapper'; + +interface PromptVariablesModalProps { + open: boolean; + names: string[]; + onSubmit: (values: Record) => void; + onCancel: () => void; +} + +export const PromptVariablesModal: React.FC = ({ + open, + names, + onSubmit, + onCancel +}) => { + const [values, setValues] = useState>({}); + const firstFieldRef = useRef(null); + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + onSubmit(values); + }, + [values, onSubmit] + ); + + if (!names.length) return null; + + return ( + Input Required} + ariaLabel="Input Required" + initialFocusRef={firstFieldRef} + > +
+
+
+ {names.map((name, index) => ( +
+ + setValues((prev) => ({ ...prev, [name]: event.target.value }))} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + /> +
+ ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default PromptVariablesModal; diff --git a/packages/bruno-api-docs/src/components/PromptVariablesModal/StyledWrapper.ts b/packages/bruno-api-docs/src/components/PromptVariablesModal/StyledWrapper.ts new file mode 100644 index 00000000..ac3ae0a5 --- /dev/null +++ b/packages/bruno-api-docs/src/components/PromptVariablesModal/StyledWrapper.ts @@ -0,0 +1,108 @@ +import styled from '@emotion/styled'; +import { Modal } from '@/ui/Modal/Modal'; + +export const StyledWrapper = styled(Modal)` + .modal-dialog { + width: min(34rem, 100%); + } + + .prompt-variables-title { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-primary); + } + + .prompt-variables-body { + max-height: 60vh; + overflow-y: auto; + padding: 0 0.2rem; + } + + .prompt-variables-fields { + display: flex; + flex-direction: column; + gap: 1.25rem; + } + + .prompt-variable-label { + display: block; + font-weight: 500; + font-size: 0.8125rem; + color: var(--text-primary); + } + + input.prompt-variable-input { + display: block; + width: 100%; + margin-top: 0.5rem; + line-height: 1.5; + padding: 0.45rem; + border-radius: var(--oc-radius); + background-color: var(--oc-background-base); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.8125rem; + + &::placeholder { + color: var(--text-secondary); + opacity: 0.7; + } + + &:focus { + border-color: var(--oc-brand); + outline: none; + } + } + + .prompt-variables-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + + button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.875rem; + padding: 0 0.875rem; + border-radius: var(--oc-radius); + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.8125rem; + line-height: 1; + white-space: nowrap; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + } + + button.prompt-variables-cancel { + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + } + } + + button.prompt-variables-submit { + border: 1px solid var(--oc-brand); + background-color: var(--oc-brand); + color: var(--oc-background-base); + font-weight: 600; + + &:hover { + opacity: 0.92; + } + } + } +`; diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx index 746567d3..fe1dca39 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -232,6 +232,13 @@ describe('VariableInfoCard (editable)', () => { expect(root.querySelector(selector('reveal'))).not.toBeNull(); }); + it('offers no copy on a prompt variable, even though it is editable and empty', () => { + const root = useRenderToDom(editableCardTree('?OTP')); + expect(part(root, 'scope').text).toBe('Prompt'); + expect(part(root, 'value').text).toBe('(empty)'); + expect(root.querySelector(selector('copy'))).toBeNull(); + }); + it('never makes a read-only scope (process.env) editable', () => { const root = useRenderToDom(editableCardTree('process.env.HOME')); expect(part(root, 'note').text).toBe('read-only'); diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx index adb74426..43bdc3b9 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx @@ -24,6 +24,7 @@ interface VariableInfoCardProps { * from the playground, so it is not. `canEdit` is what tells them apart. */ const getReadOnlyNote = (scope: VariableScope, activeEnvName: string | null, canEdit: boolean): string | null => { + if (scope === 'prompt') return 'asked for on each send'; if (scope === 'process.env' || scope === 'oauth2') return 'read-only'; if (scope === '$secrets' && !canEdit) return 'read-only'; if (scope === 'undefined') return activeEnvName ? 'Variable is not defined' : 'No active environment'; @@ -213,7 +214,7 @@ export const VariableInfoCard: React.FC = ({ // The playground always offers copy, matching the app, even with nothing yet to // copy. The docs keep their original rule: a value, and never for a secret. - const showCopy = editable || (info.value !== '' && !info.secret); + const showCopy = info.scope !== 'prompt' && (editable || (info.value !== '' && !info.secret)); const icons = showCopy && (
diff --git a/packages/bruno-api-docs/src/constants/variable.ts b/packages/bruno-api-docs/src/constants/variable.ts index 7cbdf1ad..b62f261b 100644 --- a/packages/bruno-api-docs/src/constants/variable.ts +++ b/packages/bruno-api-docs/src/constants/variable.ts @@ -9,6 +9,7 @@ export const SCOPE_LABELS: Record = { 'dynamic': 'Dynamic', 'oauth2': 'OAuth2', '$secrets': 'Secret', + 'prompt': 'Prompt', 'undefined': 'Undefined' }; diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx new file mode 100644 index 00000000..7236e3d2 --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { describe, it, expect } from 'vitest'; +import { createOpenCollectionStore } from '@/store/store'; +import { setPlaygroundCollection } from '@/store/slices/playground'; +import { + ItemVariableResolverProvider, + VariableResolverProvider, + useResolvedVariables, + type VariableResolver +} from '@/hooks'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; + +const collection: any = { + request: { variables: [{ name: 'OTP', value: 'a-real-variable' }] }, + config: { environments: [] }, + items: [] +}; + +const probeTree = (store: ReturnType) => { + const box: { current: VariableResolver | null } = { current: null }; + + const Probe: React.FC = () => { + box.current = useResolvedVariables(); + return null; + }; + + return { + box, + tree: ( + + + + + + ) + }; +}; + +const newStore = () => { + const store = createOpenCollectionStore(); + store.dispatch(setPlaygroundCollection(collection)); + return store; +}; + +describe('looking a prompt up through the variable resolver', () => { + it('names it as a prompt rather than reporting an invalid variable name', () => { + const { box, tree } = probeTree(newStore()); + useRenderToDom(tree); + const resolver = box.current as VariableResolver; + + expect(resolver.lookup('?OTP').scope).toBe('prompt'); + expect(resolver.lookup('?OTP').valid).toBe(true); + }); + + it('keeps it separate from a real variable that shares the bare name', () => { + const { box, tree } = probeTree(newStore()); + useRenderToDom(tree); + const resolver = box.current as VariableResolver; + + expect(resolver.lookup('OTP').scope).toBe('collection'); + expect(resolver.lookup('OTP').value).toBe('a-real-variable'); + }); + + it('shows no value, because answers live only for the send that collected them', () => { + const { box, tree } = probeTree(newStore()); + useRenderToDom(tree); + + expect((box.current as VariableResolver).lookup('?OTP').value).toBe(''); + }); + + it('is not editable, matching the desktop app', () => { + const { box, tree } = probeTree(newStore()); + useRenderToDom(tree); + + expect((box.current as VariableResolver).lookup('?OTP').simpleString).toBe(false); + }); + + it('does not treat a padded token as a prompt, matching the tokenizer', () => { + const { box, tree } = probeTree(newStore()); + useRenderToDom(tree); + + expect((box.current as VariableResolver).lookup('? OTP').scope).not.toBe('prompt'); + }); + + it('still writes an ordinary variable that shares the bare name', () => { + const store = newStore(); + const { box, tree } = probeTree(store); + useRenderToDom(tree); + + (box.current as VariableResolver).updateVariable('OTP', 'changed'); + + const variables = store.getState().playground.collection?.request?.variables as { value: string }[]; + expect(variables[0].value).toBe('changed'); + }); +}); + +describe('a prompt token outside the playground', () => { + it('is named as a prompt even where no playground provider is mounted', () => { + const box: { current: VariableResolver | null } = { current: null }; + const Probe: React.FC = () => { + box.current = useResolvedVariables(); + return null; + }; + + useRenderToDom( + + + + + + ); + + expect((box.current as VariableResolver).lookup('?Region').scope).toBe('prompt'); + expect((box.current as VariableResolver).lookup('?Region').valid).toBe(true); + }); +}); diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx index f07b9d64..8d1c8beb 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx @@ -6,6 +6,7 @@ import type { Variable, SecretVariable } from '@opencollection/types/common/vari import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { selectDocsCollection } from '@/store/slices/docs'; import { setPlaygroundVariable } from '@/store/slices/playground'; +import { promptVariableName } from '@/utils/promptVariables'; import { selectActiveEnvName, selectShowVars } from '@/store/slices/env'; import { getRequestVariables, isFolder } from '@/utils/schemaHelpers'; import { getItemUuid } from '@/utils/itemUtils'; @@ -74,6 +75,10 @@ const lookupVariable = (rawName: string, model: ScopedVariableModel): VariableLo const name = (rawName ?? '').trim(); const base = { name, value: '', rawValue: '', secret: false, simpleString: false }; + if (promptVariableName(rawName ?? '') !== null) { + return { ...base, name: rawName, scope: 'prompt', valid: true }; + } + const special = detectSpecialScope(name); if (special === 'dynamic') return { ...base, scope: 'dynamic', valid: true, dynamicKind: classifyDynamic(name) }; if (special) return { ...base, scope: special, valid: true }; diff --git a/packages/bruno-api-docs/src/runner/collect-prompt-variables.spec.ts b/packages/bruno-api-docs/src/runner/collect-prompt-variables.spec.ts new file mode 100644 index 00000000..2250b200 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/collect-prompt-variables.spec.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Environment } from '@opencollection/types/config/environments'; +import { RequestRunner } from './index'; +import { parseYaml } from '@/utils/yamlUtils'; + +const collectionYaml = ` +opencollection: "1.0.0" +info: + name: "Prompt Collection" + version: "1.0.0" +request: + headers: + - name: "X-Collection" + value: "{{?Collection Header}}" + variables: + - name: "host" + value: "https://{{?Host}}" +items: + - name: "Folder" + type: "folder" + items: + - name: "Prompted" + type: "http" + method: "POST" + url: "{{host}}/users/{{?User id}}" + headers: + - name: "Content-Type" + value: "application/json" + - name: "X-Otp" + value: "{{?OTP}}" + - name: "X-Off" + value: "{{?Disabled Header}}" + disabled: true + params: + - name: "trace" + value: "{{?Trace}}" + type: "query" + - name: "off" + value: "{{?Disabled Param}}" + type: "query" + disabled: true + body: + type: "json" + data: | + {"token":"{{?Token}}"} + script: + req: | + bru.setVar('ignored', '{{?Never Asked}}'); + - name: "Plain" + type: "http" + method: "GET" + url: "https://api.example.com/health" + - name: "Form" + type: "http" + method: "POST" + url: "https://api.example.com/submit" + body: + type: "form-urlencoded" + data: + - name: "on" + value: "{{?Sent Field}}" + - name: "off" + value: "{{?Skipped Field}}" + disabled: true + - name: "Multipart" + type: "http" + method: "POST" + url: "https://api.example.com/upload" + body: + type: "multipart-form" + data: + - name: "on" + value: "{{?Sent Part}}" + - name: "off" + value: "{{?Skipped Part}}" + disabled: true + - name: "Variants" + type: "http" + method: "POST" + url: "https://api.example.com/variants" + body: + - title: "Chosen" + selected: true + body: + type: "form-urlencoded" + data: + - name: "on" + value: "{{?Chosen Field}}" + - name: "off" + value: "{{?Chosen Skipped}}" + disabled: true + - title: "Other" + body: + type: "form-urlencoded" + data: + - name: "x" + value: "{{?Other Variant}}" +`; + +const collection = parseYaml(collectionYaml) as any; +const folder = collection.items[0]; +const promptedRequest = folder.items[0]; +const plainRequest = folder.items[1]; +const formRequest = folder.items[2]; +const multipartRequest = folder.items[3]; +const variantRequest = folder.items[4]; + +const environment = { + name: 'Local', + variables: [ + { name: 'region', value: '{{?Region}}' }, + { name: 'host', value: 'https://fixed.example.com' } + ] +} as unknown as Environment; + +const runner = new RequestRunner(); + +describe('working out which prompts a request needs', () => { + it('finds prompts in the url, header values, query params and body', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection }); + + expect(names).toEqual(expect.arrayContaining(['User id', 'OTP', 'Trace', 'Token'])); + }); + + it('finds a prompt in a header inherited from the collection', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection }); + + expect(names).toContain('Collection Header'); + }); + + it('finds a prompt hidden inside a collection variable value', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection }); + + expect(names).toContain('Host'); + }); + + it('finds a prompt inside the selected environment', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).toContain('Region'); + }); + + it('leaves out the environment when none is selected', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection }); + + expect(names).not.toContain('Region'); + }); + + it('ignores prompts written in a script, which is never interpolated', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).not.toContain('Never Asked'); + }); + + it('does not ask for a value in a switched-off header, which the header merge drops', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).not.toContain('Disabled Header'); + expect(names).toEqual(expect.arrayContaining(['OTP', 'Trace'])); + }); + + it('does not ask for a value in a switched-off param, which never reaches the request', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).not.toContain('Disabled Param'); + expect(names).toEqual(expect.arrayContaining(['OTP', 'Trace'])); + }); + + it('does not ask for a prompt hidden in a variable that a narrower scope overrides', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).not.toContain('Host'); + expect(names).toContain('Region'); + }); + + it('lists each prompt once', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(new Set(names).size).toBe(names.length); + }); + + it('asks for nothing when a request uses no prompts', async () => { + const names = await runner.collectPromptVariableNames({ item: plainRequest, collection: { info: {} } as any }); + + expect(names).toEqual([]); + }); +}); + +describe('rows in a form body that the request has switched off', () => { + it('asks only for the form field that is switched on', async () => { + const names = await runner.collectPromptVariableNames({ item: formRequest, collection }); + + expect(names).toContain('Sent Field'); + expect(names).not.toContain('Skipped Field'); + }); + + it('asks only for the multipart field that is switched on', async () => { + const names = await runner.collectPromptVariableNames({ item: multipartRequest, collection }); + + expect(names).toContain('Sent Part'); + expect(names).not.toContain('Skipped Part'); + }); + + it('reads only the chosen body when the request offers several, and skips its switched-off row', async () => { + const names = await runner.collectPromptVariableNames({ item: variantRequest, collection }); + + expect(names).toContain('Chosen Field'); + expect(names).not.toContain('Chosen Skipped'); + expect(names).not.toContain('Other Variant'); + }); +}); + +describe('a header the request has switched off', () => { + const shadowCollection = { + info: { name: 'Shadow' }, + request: { headers: [{ name: 'X-Token', value: '{{?Collection Token}}' }] }, + items: [ + { + name: 'Shadowed', + type: 'http', + method: 'GET', + url: 'https://api.example.com/ping', + headers: [{ name: 'X-Token', value: 'mine', disabled: true }] + } + ] + } as any; + + it('still asks for a value the inherited header needs', async () => { + const names = await runner.collectPromptVariableNames({ + item: shadowCollection.items[0], collection: shadowCollection + }); + + expect(names).toContain('Collection Token'); + }); +}); + +describe('reusing the work a send has already done', () => { + it('gives the same answer whether or not the caller hands over a prepared request', async () => { + const prepared = await runner.prepareRequest(promptedRequest, collection); + + const fromScratch = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + const fromPrepared = await runner.collectPromptVariableNames({ + item: promptedRequest, collection, environment, prepared + }); + + expect(fromPrepared).toEqual(fromScratch); + }); + + it('does not merge the collection and folder settings a second time', async () => { + const prepared = await runner.prepareRequest(promptedRequest, collection); + const prepare = vi.spyOn(runner, 'prepareRequest'); + + await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment, prepared }); + + expect(prepare).not.toHaveBeenCalled(); + prepare.mockRestore(); + }); +}); + +describe('matching the desktop scan order', () => { + it('lists prompts in the desktop order: variables, body, headers, params, auth, url', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + const position = (name: string) => names.indexOf(name); + expect(position('Region')).toBeLessThan(position('Token')); + expect(position('Token')).toBeLessThan(position('OTP')); + expect(position('OTP')).toBeLessThan(position('Trace')); + expect(position('Trace')).toBeLessThan(position('User id')); + }); +}); + +describe('prompts held in a runtime variable', () => { + it('asks for a prompt stored in a runtime variable by an earlier send', async () => { + const names = await runner.collectPromptVariableNames({ + item: promptedRequest, + collection, + environment, + runtimeVariables: { token: '{{?Secret}}' } + }); + + expect(names).toContain('Secret'); + }); + + it('asks for nothing extra when no runtime variable holds a prompt', async () => { + const names = await runner.collectPromptVariableNames({ + item: promptedRequest, + collection, + environment, + runtimeVariables: { token: 'already-resolved' } + }); + + expect(names).not.toContain('Secret'); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index 52920a85..1b827522 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -15,8 +15,11 @@ import type { Variables, JsonValue } from './utils/variable-interpolator'; import type { VariableValueOrVariants, VariableValueType } from '@opencollection/types/common/variables'; import { getRequestScripts, getRequestAssertions, scriptsArrayToObject, - isHttpRequest, getItemType, getItemName, getHttpMethod, getRequestUrl, type InternalHttpRequest + isHttpRequest, getItemType, getItemName, getHttpMethod, getRequestUrl, + getHttpHeaders, getHttpBody, getRequestAuth, getHttpParams, type InternalHttpRequest } from '@/utils/schemaHelpers'; +import { extractPromptVariables } from '@/utils/promptVariables'; +import { selectBodyVariant } from '@/utils/request'; import { getItemUuid } from '@/utils/itemUtils'; import { cloneDeep, isEqual } from 'lodash-es'; @@ -30,6 +33,7 @@ interface RunContext { environmentVariables: Variables; collectionVariables: Variables; runtimeVariables: Variables; + promptVariables: Variables; processEnvVars: Variables; timeout: number; warnings: string[]; @@ -69,8 +73,10 @@ export interface RunRequestOptions { collection: OpenCollectionCollection; environment?: Environment; runtimeVariables?: Variables; + promptVariables?: Variables; timeout?: number; validateSSL?: boolean; + prepared?: HttpRequest; } export interface TestResultsResponse { @@ -143,7 +149,9 @@ export class RequestRunner { } async runRequest(options: RunRequestOptions): Promise { - const { item, collection, environment, runtimeVariables = {}, timeout = 30000 } = options; + const { + item, collection, environment, runtimeVariables = {}, promptVariables = {}, timeout = 30000, prepared + } = options; const context: RunContext = { collection, environment, @@ -151,13 +159,14 @@ export class RequestRunner { collectionVariables: getCollectionVariables(collection), processEnvVars: (typeof process !== 'undefined' && process.env ? process.env : {}) as Record, runtimeVariables, + promptVariables, timeout, warnings: [] }; const initialEnvVariables = cloneDeep(context.environmentVariables); const initialCollectionVariables = cloneDeep(context.collectionVariables); - const response = await this.runRequestWithContext(item, context, 0, []); + const response = await this.runRequestWithContext(item, context, 0, [], prepared); const declaredEnvNames = new Set( (environment?.variables ?? []) @@ -232,15 +241,17 @@ export class RequestRunner { item: HttpRequest, context: RunContext, depth: number, - chain: string[] + chain: string[], + prepared?: HttpRequest ): Promise { const { - collection, environmentVariables, collectionVariables, runtimeVariables, processEnvVars, timeout, warnings + collection, environmentVariables, collectionVariables, runtimeVariables, promptVariables, + processEnvVars, timeout, warnings } = context; const requestId = this.generateRequestId(); try { - const processedRequest: InternalHttpRequest = await this.preprocessRequest(item, collection); + const processedRequest = (prepared ?? await this.prepareRequest(item, collection)) as InternalHttpRequest; processedRequest.__bruno__executionMode = 'standalone'; processedRequest.__brunoHeadersSetByScript = []; @@ -249,6 +260,7 @@ export class RequestRunner { const allVariables = { environmentVariables, runtimeVariables, + promptVariables, processEnvVars, collectionVariables, folderVariables, @@ -415,7 +427,40 @@ export class RequestRunner { }, vars); } - private async preprocessRequest( + async collectPromptVariableNames( + options: Pick + ): Promise { + const { item, collection, environment, runtimeVariables = {}, prepared } = options; + const processed = prepared ?? await this.prepareRequest(item, collection); + const { folderVariables, requestVariables } = getCollectionFolderRequestVariables(collection, processed); + const body = getHttpBody(processed); + + const enabled = (rows: T[]): T[] => + rows.filter((row) => (row as { disabled?: boolean } | null)?.disabled !== true); + + const { body: selectedBody } = selectBodyVariant(body); + const bodyData = selectedBody && 'data' in selectedBody ? selectedBody.data : selectedBody; + const bodyToScan = Array.isArray(bodyData) ? enabled(bodyData as Array<{ disabled?: boolean }>) : bodyData; + + const effectiveVariables = { + ...getCollectionVariables(collection), + ...this.getEnvironmentVariables(environment), + ...folderVariables, + ...requestVariables, + ...runtimeVariables + }; + + return extractPromptVariables([ + effectiveVariables, + bodyToScan, + enabled(getHttpHeaders(processed)), + enabled(getHttpParams(processed)), + getRequestAuth(processed), + getRequestUrl(processed) + ]); + } + + async prepareRequest( item: HttpRequest, collection: OpenCollectionCollection ): Promise { diff --git a/packages/bruno-api-docs/src/runner/utils/prompt-variables.spec.ts b/packages/bruno-api-docs/src/runner/utils/prompt-variables.spec.ts new file mode 100644 index 00000000..5e9446e3 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/prompt-variables.spec.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from 'vitest'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { interpolateVars } from './variable-interpolator'; + +const req = (http: Record): HttpRequest => ({ http: { method: 'GET', ...http } as any }); + +describe('sending a request with prompt answers', () => { + it('puts the answer into the URL', () => { + const out = interpolateVars(req({ url: 'https://api.com/otp/{{?OTP}}' }), { + promptVariables: { '?OTP': '123456' } + }); + + expect(out.http!.url).toBe('https://api.com/otp/123456'); + }); + + it('puts the answer into a header value and a header name', () => { + const out = interpolateVars( + req({ url: 'https://api.com', headers: [{ name: 'X-{{?Header}}', value: '{{?Token}}' }] }), + { promptVariables: { '?Header': 'Otp', '?Token': 'abc' } } + ); + + expect(out.http!.headers![0]).toMatchObject({ name: 'X-Otp', value: 'abc' }); + }); + + it('puts the answer into a query parameter', () => { + const out = interpolateVars( + req({ url: 'https://api.com', params: [{ name: 'otp', value: '{{?OTP}}', type: 'query' }] }), + { promptVariables: { '?OTP': '123456' } } + ); + + expect(out.http!.params![0].value).toBe('123456'); + }); + + it('puts the answer into a JSON body exactly as the reader typed it', () => { + const out = interpolateVars( + req({ + url: 'https://api.com', + headers: [{ name: 'content-type', value: 'application/json' }], + body: { type: 'json', data: '{"otp":"{{?OTP}}"}' } + }), + { promptVariables: { '?OTP': 'a"b' } } + ); + + expect((out.http!.body as { data: string }).data).toBe('{"otp":"a"b"}'); + }); + + it('puts the answer into a form field even when the collection sets no content type header', () => { + const out = interpolateVars( + req({ url: 'https://api.com', body: { type: 'form-urlencoded', data: [{ name: 'otp', value: '{{?OTP}}', enabled: true }] } }), + { promptVariables: { '?OTP': '123456' } } + ); + + expect((out.http!.body as { data: { value: string }[] }).data[0].value).toBe('123456'); + }); + + it('puts the answer into a multipart field even when the collection sets no content type header', () => { + const out = interpolateVars( + req({ url: 'https://api.com', body: { type: 'multipart-form', data: [{ name: 'otp', value: '{{?OTP}}', enabled: true }] } }), + { promptVariables: { '?OTP': '123456' } } + ); + + expect((out.http!.body as { data: { value: string }[] }).data[0].value).toBe('123456'); + }); + + it('leaves the token in place when the reader was never asked', () => { + const out = interpolateVars(req({ url: 'https://api.com/otp/{{?OTP}}' })); + + expect(out.http!.url).toBe('https://api.com/otp/{{?OTP}}'); + }); + + it('sends an empty string for a prompt the reader left blank', () => { + const out = interpolateVars(req({ url: 'https://api.com/otp/{{?OTP}}' }), { + promptVariables: { '?OTP': '' } + }); + + expect(out.http!.url).toBe('https://api.com/otp/'); + }); + + it('puts the answer into a bearer token', () => { + const out = interpolateVars( + req({ url: 'https://api.com', auth: { type: 'bearer', token: '{{?Token}}' } }), + { promptVariables: { '?Token': 'abc123' } } + ); + + expect(out.http!.auth).toMatchObject({ type: 'bearer', token: 'abc123' }); + }); + + it('puts the answers into a basic username and password', () => { + const out = interpolateVars( + req({ + url: 'https://api.com', + auth: { type: 'basic', username: '{{?User}}', password: '{{?Pass}}' } + }), + { promptVariables: { '?User': 'ada', '?Pass': 'hunter2' } } + ); + + expect(out.http!.auth).toMatchObject({ type: 'basic', username: 'ada', password: 'hunter2' }); + }); + + it('puts the answers into an api key name and value', () => { + const out = interpolateVars( + req({ + url: 'https://api.com', + auth: { type: 'apikey', key: 'X-{{?KeyName}}', value: '{{?KeyValue}}' } + }), + { promptVariables: { '?KeyName': 'Otp', '?KeyValue': '123456' } } + ); + + expect(out.http!.auth).toMatchObject({ type: 'apikey', key: 'X-Otp', value: '123456' }); + }); + + it('leaves an auth token in place when the reader was never asked', () => { + const out = interpolateVars( + req({ url: 'https://api.com', auth: { type: 'bearer', token: '{{?Token}}' } }) + ); + + expect(out.http!.auth).toMatchObject({ type: 'bearer', token: '{{?Token}}' }); + }); +}); + +describe('where prompt answers sit among the other variables', () => { + it('wins over every other scope, matching the desktop app', () => { + const out = interpolateVars(req({ url: 'https://api.com/{{value}}' }), { + collectionVariables: { value: 'collection' }, + environmentVariables: { value: 'environment' }, + folderVariables: { value: 'folder' }, + requestVariables: { value: 'request' }, + runtimeVariables: { value: 'runtime' }, + promptVariables: { value: 'prompt' } + }); + + expect(out.http!.url).toBe('https://api.com/prompt'); + }); + + it('cannot be shadowed by an ordinary variable, because answers are keyed by the token text', () => { + const out = interpolateVars(req({ url: '{{?OTP}}|{{OTP}}' }), { + environmentVariables: { OTP: 'from-env' }, + promptVariables: { '?OTP': 'typed-in' } + }); + + expect(out.http!.url).toBe('typed-in|from-env'); + }); + + it('resolves a prompt used inside another variable value', () => { + const out = interpolateVars(req({ url: '{{host}}/users' }), { + environmentVariables: { host: 'https://{{?Host}}' }, + promptVariables: { '?Host': 'api.example.com' } + }); + + expect(out.http!.url).toBe('https://api.example.com/users'); + }); + + it('leaves other variable values untouched when the reader was never asked', () => { + const out = interpolateVars(req({ url: '{{host}}/users' }), { + environmentVariables: { host: 'https://{{?Host}}' } + }); + + expect(out.http!.url).toBe('https://{{?Host}}/users'); + }); + + it('does not disturb a variable value that holds an ordinary token', () => { + const out = interpolateVars(req({ url: '{{path}}' }), { + environmentVariables: { path: '/v1/{{unset}}' }, + promptVariables: { '?OTP': '1' } + }); + + expect(out.http!.url).toBe('/v1/{{unset}}'); + }); +}); + +describe('prompt names and values the single-pass interpolator used to miss', () => { + it('resolves a prompt whose name contains a dot, instead of reading it as nested access', () => { + const out = interpolateVars(req({ url: 'https://api.com/{{?user.id}}' }), { + promptVariables: { '?user.id': '42' } + }); + + expect(out.http!.url).toBe('https://api.com/42'); + }); + + it('still reads a genuinely nested value such as process.env', () => { + const out = interpolateVars(req({ url: '{{process.env.HOST}}' }), { + processEnvVars: { HOST: 'https://api.com' }, + promptVariables: { '?OTP': '1' } + }); + + expect(out.http!.url).toBe('https://api.com'); + }); + + it('resolves a prompt nested inside an object-valued variable', () => { + const out = interpolateVars(req({ url: '{{config}}' }), { + collectionVariables: { config: { host: '{{?Host}}' } }, + promptVariables: { '?Host': 'api.example.com' } + }); + + expect(out.http!.url).toBe('{"host":"api.example.com"}'); + }); + + it('resolves a prompt nested inside an array-valued variable', () => { + const out = interpolateVars(req({ url: '{{hosts}}' }), { + collectionVariables: { hosts: ['{{?Host}}'] }, + promptVariables: { '?Host': 'api.example.com' } + }); + + expect(out.http!.url).toBe('["api.example.com"]'); + }); + + it('leaves dynamic tokens in a variable value for the main pass rather than expanding them early', () => { + const out = interpolateVars(req({ url: '{{path}}' }), { + collectionVariables: { path: '/{{?Id}}/{{$randomUUID}}' }, + promptVariables: { '?Id': '7' } + }); + + expect(out.http!.url).toMatch(/^\/7\/[0-9a-f-]{36}$/); + }); +}); + +describe('what the prompt pre-pass must not disturb', () => { + it('keeps a dynamic token in a variable value fresh per field, prompts or not', () => { + const out = interpolateVars( + req({ url: '{{path}}', headers: [{ name: 'X-Id', value: '{{path}}' }] }), + { + collectionVariables: { path: '/{{$randomUUID}}' }, + promptVariables: { '?Id': '7' } + } + ); + + expect(out.http!.url).not.toBe(out.http!.headers![0].value); + }); + + it('leaves a non-plain object variable intact instead of flattening it', () => { + const when = new Date('2020-01-02T03:04:05.000Z'); + const out = interpolateVars(req({ url: 'https://api.com/{{when}}/{{?Id}}' }), { + collectionVariables: { when: when as never }, + promptVariables: { '?Id': '7' } + }); + + expect(out.http!.url).toContain('2020-01-02'); + expect(out.http!.url).not.toContain('{}'); + }); + + it('does not change how an ordinary dotted variable name resolves', () => { + const out = interpolateVars(req({ url: '{{a.b}}' }), { + collectionVariables: { 'a': { b: 'nested' }, 'a.b': 'flat' }, + promptVariables: { '?Id': '7' } + }); + + expect(out.http!.url).toBe('nested'); + }); +}); + +describe('resolving through layers of variables, as the desktop app does', () => { + it('resolves a prompt reached through two variables', () => { + const out = interpolateVars(req({ url: '{{outer}}/users' }), { + collectionVariables: { outer: '{{inner}}', inner: 'https://{{?Host}}' }, + promptVariables: { '?Host': 'api.example.com' } + }); + + expect(out.http!.url).toBe('https://api.example.com/users'); + }); + + it('answers the same prompt everywhere it appears, including inside another variable', () => { + const out = interpolateVars(req({ url: '{{?OTP}}/{{path}}' }), { + collectionVariables: { path: 'otp/{{?OTP}}' }, + promptVariables: { '?OTP': '123456' } + }); + + expect(out.http!.url).toBe('123456/otp/123456'); + }); + + it('answers a prompt reached through two variables and again at the top level', () => { + const out = interpolateVars(req({ url: '{{?Host}}/{{outer}}' }), { + collectionVariables: { outer: '{{inner}}', inner: '{{?Host}}/v1' }, + promptVariables: { '?Host': 'api.example.com' } + }); + + expect(out.http!.url).toBe('api.example.com/api.example.com/v1'); + }); + + it('resolves a variable token that the reader typed into the dialog', () => { + const out = interpolateVars(req({ url: '{{?Path}}/users' }), { + collectionVariables: { baseUrl: 'https://api.example.com' }, + promptVariables: { '?Path': '{{baseUrl}}/v1' } + }); + + expect(out.http!.url).toBe('https://api.example.com/v1/users'); + }); + + it('gives up rather than looping forever on variables that point at each other', () => { + const out = interpolateVars(req({ url: '{{a}}' }), { + collectionVariables: { a: '{{b}}', b: '{{a}}' } + }); + + expect(out.http!.url).toMatch(/^\{\{[ab]\}\}$/); + }); + + it('leaves a variable that refers to itself alone', () => { + const out = interpolateVars(req({ url: '{{self}}' }), { + collectionVariables: { self: '{{self}}' } + }); + + expect(out.http!.url).toBe('{{self}}'); + }); + + it('still leaves an unknown token untouched', () => { + const out = interpolateVars(req({ url: '{{known}}/{{unknown}}' }), { + collectionVariables: { known: 'ok' } + }); + + expect(out.http!.url).toBe('ok/{{unknown}}'); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/utils/request-merger.spec.ts b/packages/bruno-api-docs/src/runner/utils/request-merger.spec.ts index 9fe3f6a6..d67a0e5e 100644 --- a/packages/bruno-api-docs/src/runner/utils/request-merger.spec.ts +++ b/packages/bruno-api-docs/src/runner/utils/request-merger.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { mergeAuth } from './request-merger'; -import { getRequestAuth } from '@/utils/schemaHelpers'; +import { mergeAuth, mergeHeaders } from './request-merger'; +import { getRequestAuth, getHttpHeaders } from '@/utils/schemaHelpers'; // A playground request keeps its auth on the http protocol block; folders/collection nest // it under `request.auth`. Auth is either a concrete object, the string 'inherit', or @@ -19,6 +19,63 @@ const resolve = (request: any, coll: any, path: any[] = []): unknown => { return getRequestAuth(request); }; +describe('mergeHeaders (which headers a request ends up sending)', () => { + type Row = { name: string; value: string; disabled?: boolean }; + + const requestWith = (headers: Row[]): any => ({ http: { method: 'GET', url: 'https://x', headers } }); + const collectionWith = (headers: Row[]): any => ({ info: { name: 'C' }, request: { headers } }); + const folderWith = (name: string, headers: Row[]): any => ({ info: { type: 'folder', name }, request: { headers } }); + + const sent = (request: any): Record => + Object.fromEntries(getHttpHeaders(request).map((header: Row) => [header.name.toLowerCase(), header.value])); + + it('sends a header the request inherits from the collection', () => { + const request = requestWith([]); + mergeHeaders(collectionWith([{ name: 'X-Token', value: 'from-collection' }]), request); + + expect(sent(request)['x-token']).toBe('from-collection'); + }); + + it('lets the request override an inherited header of the same name', () => { + const request = requestWith([{ name: 'X-Token', value: 'mine' }]); + mergeHeaders(collectionWith([{ name: 'X-Token', value: 'from-collection' }]), request); + + expect(sent(request)['x-token']).toBe('mine'); + }); + + it('still sends the inherited header when the request has switched its own copy off', () => { + const request = requestWith([{ name: 'X-Token', value: 'mine', disabled: true }]); + mergeHeaders(collectionWith([{ name: 'X-Token', value: 'from-collection' }]), request); + + expect(sent(request)['x-token']).toBe('from-collection'); + }); + + it('does not send a header the collection has switched off', () => { + const request = requestWith([]); + mergeHeaders(collectionWith([{ name: 'X-Off', value: 'no', disabled: true }]), request); + + expect(sent(request)['x-off']).toBeUndefined(); + }); + + it('prefers the closest folder over the collection for the same header', () => { + const request = requestWith([]); + mergeHeaders( + collectionWith([{ name: 'X-Token', value: 'from-collection' }]), + request, + [folderWith('outer', [{ name: 'X-Token', value: 'from-outer' }]), folderWith('inner', [{ name: 'X-Token', value: 'from-inner' }])] + ); + + expect(sent(request)['x-token']).toBe('from-inner'); + }); + + it('ignores a blank row rather than failing the whole merge', () => { + const request = requestWith([]); + mergeHeaders(collectionWith([{ name: '', value: 'nameless' }, { name: 'X-Keep', value: 'kept' }]), request); + + expect(sent(request)['x-keep']).toBe('kept'); + }); +}); + describe('mergeAuth (inherited-auth resolution for the playground send path)', () => { describe('acceptance #1 — a request set to inherit uses its nearest parent', () => { it('resolves from the collection when no folder configures auth', () => { diff --git a/packages/bruno-api-docs/src/runner/utils/request-merger.ts b/packages/bruno-api-docs/src/runner/utils/request-merger.ts index 372aef9c..b85575ee 100644 --- a/packages/bruno-api-docs/src/runner/utils/request-merger.ts +++ b/packages/bruno-api-docs/src/runner/utils/request-merger.ts @@ -21,23 +21,20 @@ import { resolveInheritedAuth } from '@/utils/request'; export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, requestTreePath: Item[] = []): void => { const headers = new Map(); - // Start with collection-level headers - const collectionHeaders = collection.request?.headers || []; - collectionHeaders.forEach((header) => { - if (!header.disabled) { - headers.set(header.name.toLowerCase(), header); - } - }); + const addLevel = (rows: HttpRequestHeader[] | undefined): void => { + (rows || []).forEach((header) => { + if (header.name && !header.disabled) { + headers.set(header.name.toLowerCase(), header); + } + }); + }; + + addLevel(collection.request?.headers); // Apply folder-level headers in order (parent to child) for (const item of requestTreePath) { if (isFolder(item)) { - const folderHeaders = item.request?.headers || []; - folderHeaders.forEach((header) => { - if (!header.disabled) { - headers.set(header.name.toLowerCase(), header); - } - }); + addLevel(item.request?.headers); } } @@ -53,15 +50,14 @@ export const mergeHeaders = (collection: OpenCollection, request: HttpRequest, r request.http.headers = [...currentHeaders]; } - // Merge with existing request headers (request headers take precedence) - const requestHeaderMap = new Map(); - currentHeaders.forEach((header) => { - requestHeaderMap.set(header.name.toLowerCase(), header); - }); + const ownHeaderKeys = new Set( + currentHeaders + .filter((header) => header.name && !header.disabled) + .map((header) => header.name.toLowerCase()) + ); - // Add merged headers that don't exist in request headers.forEach((header, name) => { - if (!requestHeaderMap.has(name)) { + if (!ownHeaderKeys.has(name)) { request.http!.headers!.push({ ...header }); } }); diff --git a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.spec.ts b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.spec.ts index 3896b187..4a3ac246 100644 --- a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.spec.ts +++ b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.spec.ts @@ -108,6 +108,15 @@ describe('interpolateVars — typed variables in a JSON body', () => { }); }); + it('escapes a JSON body even when the collection sets no content type header', () => { + const out = interpolateVars( + req({ method: 'POST', url: 'https://api.com', body: { type: 'json', data: '{"note":"{{note}}"}' } }), + { folderVariables: { note: 'he said "hi"' } } + ); + + expect(JSON.parse((out.http!.body as { data: string }).data)).toEqual({ note: 'he said "hi"' }); + }); + it('still JSON-escapes a string value that contains quotes', () => { const out = interpolateVars(jsonReq('{"note":"{{note}}"}'), { folderVariables: { note: 'he said "hi"' } @@ -115,3 +124,63 @@ describe('interpolateVars — typed variables in a JSON body', () => { expect(JSON.parse((out.http!.body as { data: string }).data)).toEqual({ note: 'he said "hi"' }); }); }); + +describe('interpolateVars — a variable whose value mentions its own name', () => { + it('does not keep growing the text on every pass', () => { + const out = interpolateVars(req({ url: '{{a}}' }), { + collectionVariables: { a: '{{a}}{{a}}{{a}}{{a}}' } + }); + + expect(out.http!.url!.length).toBeLessThan(100); + }); + + it('leaves the unresolvable token in place rather than looping on it', () => { + const out = interpolateVars(req({ url: '{{a}}' }), { collectionVariables: { a: '{{a}}' } }); + + expect(out.http!.url).toBe('{{a}}'); + }); + + it('stops a pair of variables that point at each other', () => { + const out = interpolateVars(req({ url: '{{a}}' }), { + collectionVariables: { a: '{{b}}{{b}}', b: '{{a}}{{a}}' } + }); + + expect(out.http!.url!.length).toBeLessThan(100); + }); + + it('still fills in the same variable everywhere it appears', () => { + const out = interpolateVars(req({ url: 'https://{{host}}/{{host}}' }), { + collectionVariables: { host: 'api.com' } + }); + + expect(out.http!.url).toBe('https://api.com/api.com'); + }); + + it('gives up quietly on an absurdly deep chain of variables rather than failing the send', () => { + const collectionVariables: Record = {}; + for (let index = 0; index < 5000; index += 1) { + collectionVariables[`v${index}`] = `{{v${index + 1}}}`; + } + collectionVariables.v5000 = 'END'; + + const out = interpolateVars(req({ url: '{{v0}}' }), { collectionVariables }); + + expect(out.http!.url).toMatch(/^\{\{v\d+\}\}$/); + }); + + it('fills in a variable reached directly and through another variable in the same string', () => { + const out = interpolateVars(req({ url: '{{a}}-{{b}}' }), { + collectionVariables: { a: '{{b}}', b: 'B' } + }); + + expect(out.http!.url).toBe('B-B'); + }); + + it('still follows a chain of variables that point at one another', () => { + const out = interpolateVars(req({ url: '{{a}}' }), { + collectionVariables: { a: '{{b}}', b: '{{c}}', c: 'https://api.com' } + }); + + expect(out.http!.url).toBe('https://api.com'); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts index 7b9caf58..71adea1d 100644 --- a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts +++ b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts @@ -2,6 +2,7 @@ import type { HttpRequest, HttpRequestHeader, HttpRequestParam } from '@opencoll import { isPlainObject } from 'lodash-es'; import { getRequestUrl, getHttpMethod, getHttpHeaders, getHttpBody, getHttpParams, getRequestAuth } from '@/utils/schemaHelpers'; import { templateVariableGlobalRegex } from '@/utils/common'; +import { isPromptVariableToken } from '@/utils/promptVariables'; import { mockDataFunctions } from './faker-functions'; export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; @@ -11,6 +12,8 @@ export type Variables = Record; const MOCK_PATTERN = /\{\{\$(\w+)\}\}/g; const JSON_SPECIAL_CHARS = /[\\\n\r\t"]/; +const MAX_RESOLUTION_DEPTH = 64; + const escapeJSONString = (str: string): string => { if (!JSON_SPECIAL_CHARS.test(str)) { return str; @@ -70,35 +73,56 @@ export const interpolate = ( const mocked = prepareMock(str, escapeJSONStrings); const preparedVars = isPlainObject(variables) ? prepareMockObj(variables, escapeJSONStrings) : variables; - return mocked.replace(templateVariableGlobalRegex(), (match, variableName) => { - const trimmedName = variableName.trim(); - - // Handle nested object access (e.g., process.env.NODE_ENV) - const value = getNestedValue(preparedVars, trimmedName); + const resolving = new Set(); - if (value === null) { - return match; // Keep original if variable not found + const expandInside = (name: string, value: string): string => { + if (resolving.size >= MAX_RESOLUTION_DEPTH) { + return value; } - // A typed object/array variable is inserted as raw JSON (never quote-escaped — that would - // corrupt a JSON body), and numbers/booleans bare. Only string values get JSON-escaped. - if (typeof value === 'object') { - return JSON.stringify(value); - } + resolving.add(name); + const expanded = substitute(value); + resolving.delete(name); + return expanded; + }; - let result = String(value); - if (escapeJSONStrings && typeof value === 'string') { - result = result.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - } + const substitute = (input: string): string => + input.replace(templateVariableGlobalRegex(), (match, variableName) => { + const trimmedName = variableName.trim(); - return result; - }); + if (resolving.has(trimmedName)) { + return match; + } + + // Handle nested object access (e.g., process.env.NODE_ENV) + const value = getNestedValue(preparedVars, trimmedName); + + if (value === null) { + return match; // Keep original if variable not found + } + + if (typeof value === 'object') { + return expandInside(trimmedName, JSON.stringify(value)); + } + + let result = String(value); + if (escapeJSONStrings && typeof value === 'string' && !isPromptVariableToken(trimmedName)) { + result = result.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + return expandInside(trimmedName, result); + }); + + return substitute(mocked); }; /** * Get nested value from object using dot notation */ const getNestedValue = (obj: JsonValue, path: string): JsonValue => { + if (path.startsWith('?') && obj != null && typeof obj === 'object') { + return (obj as { [k: string]: JsonValue })[path] ?? null; + } return path.split('.').reduce((current, key) => { if (current != null && typeof current === 'object') { return (current as { [k: string]: JsonValue })[key] ?? null; @@ -107,18 +131,6 @@ const getNestedValue = (obj: JsonValue, path: string): JsonValue => { }, obj); }; -/** - * Get content type from headers - */ -const getContentType = (headers: Record = {}): string => { - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === 'content-type') { - return value; - } - } - return ''; -}; - /** * Enhanced variable interpolation that handles all variable types like Bruno */ @@ -208,28 +220,18 @@ export const interpolateVars = ( interpolatedRequest.http.headers = newHeaders; } - // Get content type for body interpolation - const headerMap: Record = {}; - const headersForContentType = getHttpHeaders(interpolatedRequest); - if (headersForContentType) { - headersForContentType.forEach((header: HttpRequestHeader) => { - headerMap[header.name] = header.value; - }); - } - const contentType = getContentType(headerMap); - // Interpolate body based on content type const currentBody = getHttpBody(interpolatedRequest); if (currentBody) { const body = currentBody; if ('type' in body && 'data' in body) { - if (contentType.includes('json') && body.type === 'json') { + if (body.type === 'json') { // Handle JSON body with proper escaping if (typeof body.data === 'string' && body.data.length > 0) { body.data = _interpolate(body.data, { escapeJSONStrings: true }); } - } else if (contentType === 'application/x-www-form-urlencoded' && body.type === 'form-urlencoded') { + } else if (body.type === 'form-urlencoded') { // Handle form-urlencoded body if ('data' in body && Array.isArray(body.data)) { body.data = body.data.map((entry) => ({ @@ -237,7 +239,7 @@ export const interpolateVars = ( value: _interpolate(entry.value) })); } - } else if (contentType === 'multipart/form-data' && body.type === 'multipart-form') { + } else if (body.type === 'multipart-form') { // Handle multipart form body if ('data' in body && Array.isArray(body.data)) { body.data = body.data.map((entry) => ({ diff --git a/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.spec.ts b/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.spec.ts new file mode 100644 index 00000000..1a181ca7 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import AssertRuntime from './assert-runtime'; + +const response = { + status: 200, + statusText: 'OK', + headers: {}, + responseTime: 5, + url: 'https://api.example.com', + body: null +} as never; + +const request = { method: 'GET', url: 'https://api.example.com' } as never; + +const run = (value: string, variables: Record) => + new AssertRuntime().runAssertions( + [{ expression: 'res.status', operator: 'eq', value }] as never, + request, + response, + variables as never + )[0]; + +beforeAll(async () => { + for (let attempt = 0; attempt < 200 && run('200', {}).status !== 'pass'; attempt += 1) { + await new Promise((resolve) => { setTimeout(resolve, 10); }); + } +}); + +describe('the expected value of an assertion', () => { + it('resolves a prompt answer, so the reader is compared against what they typed', () => { + expect(run('{{?Expected}}', { promptVariables: { '?Expected': '200' } }).status).toBe('pass'); + }); + + it('resolves an ordinary variable', () => { + expect(run('{{expected}}', { collectionVariables: { expected: '200' } }).status).toBe('pass'); + }); + + it('resolves a prompt answer reached through a variable', () => { + expect( + run('{{expected}}', { + collectionVariables: { expected: '{{?Expected}}' }, + promptVariables: { '?Expected': '200' } + }).status + ).toBe('pass'); + }); + + it('resolves a folder or request variable, not just the environment ones', () => { + expect(run('{{fromFolder}}', { folderVariables: { fromFolder: '200' } }).status).toBe('pass'); + expect(run('{{fromRequest}}', { requestVariables: { fromRequest: '200' } }).status).toBe('pass'); + }); + + it('leaves a plain value alone', () => { + expect(run('200', {}).status).toBe('pass'); + expect(run('404', {}).status).toBe('fail'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.ts b/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.ts index 46c6e083..b17c749f 100644 --- a/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.ts +++ b/packages/bruno-api-docs/src/scripting/runtime/assert-runtime.ts @@ -110,6 +110,7 @@ export class AssertRuntime { collectionVariables = {}, folderVariables = {}, requestVariables = {}, + promptVariables = {}, globalEnvironmentVariables = {} } = variables; @@ -120,7 +121,12 @@ export class AssertRuntime { variables: { environmentVariables, runtimeVariables, - globalEnvironmentVariables + promptVariables, + globalEnvironmentVariables, + collectionVariables, + folderVariables, + requestVariables, + processEnvVars } }); @@ -162,8 +168,11 @@ export class AssertRuntime { scriptType: 'expression' }); + const resolvedRhsOperand + = typeof rhsOperand === 'string' ? (bru.interpolate(rhsOperand) as string) : rhsOperand; + // Evaluate RHS operand - const rhs = evaluateRhsOperand(rhsOperand, operator, context); + const rhs = evaluateRhsOperand(resolvedRhsOperand, operator, context); // Perform assertion based on operator switch (operator) { diff --git a/packages/bruno-api-docs/src/scripting/utils/bru.spec.ts b/packages/bruno-api-docs/src/scripting/utils/bru.spec.ts index 84d6ff04..937e02fb 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bru.spec.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bru.spec.ts @@ -152,6 +152,15 @@ describe('Bru (bru object)', () => { expect(bru.interpolate('https://{{host}}/{{path}}')).toBe('https://runtime/users'); }); + it('resolves a prompt answer, so a script can read what the reader typed', () => { + const bru = makeBru({ + promptVariables: { '?OTP': '123456' }, + runtimeVariables: { host: 'api.example.com' } + }); + expect(bru.interpolate('{{?OTP}}')).toBe('123456'); + expect(bru.interpolate('https://{{host}}/verify/{{?OTP}}')).toBe('https://api.example.com/verify/123456'); + }); + it('resolves nested {{}} inside a variable value and returns non-strings unchanged', () => { const bru = makeBru({ runtimeVariables: { base: 'api.example.com', url: 'https://{{base}}/v1', n: 42 } }); expect(bru.getVar('url')).toBe('https://api.example.com/v1'); diff --git a/packages/bruno-api-docs/src/scripting/utils/bru.ts b/packages/bruno-api-docs/src/scripting/utils/bru.ts index 14159e33..084e20f8 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bru.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bru.ts @@ -26,6 +26,7 @@ interface BruVariables { collectionVariables?: VariableStore; folderVariables?: VariableStore; requestVariables?: VariableStore; + promptVariables?: VariableStore; processEnvVars?: VariableStore; } @@ -109,6 +110,7 @@ class Bru { collectionVariables: VariableStore; folderVariables: VariableStore; requestVariables: VariableStore; + promptVariables: VariableStore; processEnvVars: VariableStore; collectionPath: string | undefined; collectionName: string | undefined; @@ -136,6 +138,7 @@ class Bru { this.collectionVariables = vars.collectionVariables || {}; this.folderVariables = vars.folderVariables || {}; this.requestVariables = vars.requestVariables || {}; + this.promptVariables = vars.promptVariables || {}; this.processEnvVars = vars.processEnvVars || {}; this.collectionPath = collectionPath; this.collectionName = collectionName; @@ -191,6 +194,7 @@ class Bru { ...this.folderVariables, ...this.requestVariables, ...this.runtimeVariables, + ...this.promptVariables, process: { env: { ...this.processEnvVars } } }; } diff --git a/packages/bruno-api-docs/src/ui/Modal/Modal.tsx b/packages/bruno-api-docs/src/ui/Modal/Modal.tsx index 74b8d2e0..0a09d133 100644 --- a/packages/bruno-api-docs/src/ui/Modal/Modal.tsx +++ b/packages/bruno-api-docs/src/ui/Modal/Modal.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useRef, type ReactNode } from 'react'; +import React, { useCallback, useEffect, useRef, type ReactNode } from 'react'; import { Portal } from '@/ui/Portal/Portal'; import { CloseIcon } from '@/assets/icons'; +import cx from '@/utils/cx'; import { StyledWrapper } from './StyledWrapper'; export type ModalSize = 'md' | 'lg'; @@ -15,10 +16,23 @@ export interface ModalProps { ariaLabel?: string; className?: string; testId?: string; + initialFocusRef?: React.RefObject; } -export const Modal: React.FC = ({ open, onClose, title, children, footer, size = 'lg', ariaLabel, className, testId }) => { - const dialogRef = useRef(null); +export const Modal: React.FC = ({ + open, onClose, title, children, footer, size = 'lg', ariaLabel, className, testId, initialFocusRef +}) => { + const focusedRef = useRef(false); + + const attachDialog = useCallback((node: HTMLDivElement | null) => { + if (!node) { + focusedRef.current = false; + return; + } + if (focusedRef.current) return; + focusedRef.current = true; + (initialFocusRef?.current ?? node).focus(); + }, [initialFocusRef]); useEffect(() => { if (!open) return undefined; @@ -28,7 +42,6 @@ export const Modal: React.FC = ({ open, onClose, title, children, fo document.addEventListener('keydown', onKeyDown); const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; - dialogRef.current?.focus(); return () => { document.removeEventListener('keydown', onKeyDown); document.body.style.overflow = previousOverflow; @@ -40,14 +53,14 @@ export const Modal: React.FC = ({ open, onClose, title, children, fo return ( { if (event.target === event.currentTarget) onClose(); }} >
{ ); }); }); + +describe('a prompt variable in the URL', () => { + it('does not invent a query parameter from the marker inside {{?Name}}', () => { + expect(syncQueryParams([], 'https://api.example.com/otp/{{?OTP}}')).toEqual([]); + }); + + it('still reads a real query string that follows a prompt token', () => { + expect(syncQueryParams([], 'https://api.example.com/{{?User}}?page=2')).toEqual([ + { name: 'page', value: '2', type: 'query' } + ]); + }); + + it('leaves the token intact when the URL is rebuilt', () => { + expect(buildRequestUrl('https://api.example.com/otp/{{?OTP}}', [])).toBe( + 'https://api.example.com/otp/{{?OTP}}' + ); + }); + + it('appends a real parameter after a URL holding a prompt token', () => { + expect( + buildRequestUrl('https://api.example.com/otp/{{?OTP}}', [{ name: 'page', value: '2', type: 'query' }]) + ).toBe('https://api.example.com/otp/{{?OTP}}?page=2'); + }); + + it('is unaffected for an ordinary URL with a query string', () => { + expect(syncQueryParams([], 'https://api.example.com/x?a=1&b=2')).toEqual([ + { name: 'a', value: '1', type: 'query' }, + { name: 'b', value: '2', type: 'query' } + ]); + }); + + it('still finds a path parameter that sits after a prompt token', () => { + expect(parsePathParamNames('https://api.example.com/{{?tenant}}/posts/:postId')).toEqual(['postId']); + }); + + it('still finds a path parameter when a real query string follows the prompt token too', () => { + expect(parsePathParamNames('https://api.example.com/{{?tenant}}/posts/:postId?limit=5')).toEqual(['postId']); + }); + + it('offers the path parameter as a row even though the URL holds a prompt token', () => { + expect(syncPathParams([], 'https://api.example.com/{{?tenant}}/posts/:postId')).toEqual([ + { name: 'postId', value: '', type: 'path' } + ]); + }); + + it('fills in a path parameter that sits after a prompt token', () => { + expect( + applyPathParams('https://api.example.com/{{?tenant}}/posts/:postId', [ + { name: 'postId', value: '42', type: 'path' } + ]) + ).toBe('https://api.example.com/{{?tenant}}/posts/42'); + }); + + it('keeps the real query string while filling in a path parameter after a prompt token', () => { + expect( + applyPathParams('https://api.example.com/{{?tenant}}/posts/:postId?limit=5', [ + { name: 'postId', value: '42', type: 'path' } + ]) + ).toBe('https://api.example.com/{{?tenant}}/posts/42?limit=5'); + }); + + it('keeps the whole URL when a query parameter is edited, rather than cutting it at the prompt token', () => { + expect( + setUrlQueryParams('https://api.example.com/{{?tenant}}/posts', [ + { name: 'page', value: '2', type: 'query' } + ]) + ).toBe('https://api.example.com/{{?tenant}}/posts?page=2'); + }); + + it('replaces only the real query string when a query parameter is edited', () => { + expect( + setUrlQueryParams('https://api.example.com/{{?tenant}}/posts?page=1', [ + { name: 'page', value: '2', type: 'query' } + ]) + ).toBe('https://api.example.com/{{?tenant}}/posts?page=2'); + }); + + it('drops the query string entirely when the last query parameter is removed', () => { + expect(setUrlQueryParams('https://api.example.com/{{?tenant}}/posts?page=1', [])).toBe( + 'https://api.example.com/{{?tenant}}/posts' + ); + }); + + it('treats a fragment after a prompt token as the end of the path', () => { + expect(parsePathParamNames('https://api.example.com/{{?tenant}}/posts/:postId#section')).toEqual(['postId']); + }); + + it('lists every path parameter when the URL holds more than one prompt token', () => { + expect( + parsePathParamNames('https://api.example.com/{{?tenant}}/:orgId/{{?region}}/posts/:postId') + ).toEqual(['orgId', 'postId']); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/pathParams.ts b/packages/bruno-api-docs/src/utils/pathParams.ts index 086342eb..46a73436 100644 --- a/packages/bruno-api-docs/src/utils/pathParams.ts +++ b/packages/bruno-api-docs/src/utils/pathParams.ts @@ -31,7 +31,8 @@ export const parsePathParamNames = (url: string | undefined | null): string[] => if (!url || typeof url !== 'string') return []; // Only the path matters; drop the query string and fragment up-front. - const pathPortion = url.split('?')[0].split('#')[0]; + const pathEnd = pathEndIndex(url); + const pathPortion = pathEnd === -1 ? url : url.slice(0, pathEnd); const names: string[] = []; const seen = new Set(); @@ -108,7 +109,7 @@ export const applyPathParams = ( if (valueByName.size === 0) return url; // Substitute only within the path; preserve the query string / fragment. - const sepIndex = url.search(/[?#]/); + const sepIndex = pathEndIndex(url); const pathPart = sepIndex === -1 ? url : url.slice(0, sepIndex); const rest = sepIndex === -1 ? '' : url.slice(sepIndex); @@ -157,7 +158,7 @@ export const buildRequestUrl = ( const hashIndex = withPath.indexOf('#'); const fragment = hashIndex === -1 ? '' : withPath.slice(hashIndex); const beforeHash = hashIndex === -1 ? withPath : withPath.slice(0, hashIndex); - const qIndex = beforeHash.indexOf('?'); + const qIndex = queryStartIndex(beforeHash); const base = qIndex === -1 ? beforeHash : beforeHash.slice(0, qIndex); const existingQuery = qIndex === -1 ? '' : beforeHash.slice(qIndex + 1); @@ -186,11 +187,33 @@ export const buildRequestUrl = ( return `${base}${queryString ? `?${queryString}` : ''}${fragment}`; }; +const indexOutsideTemplates = (str: string, separators: string): number => { + let depth = 0; + for (let i = 0; i < str.length; i += 1) { + if (str.startsWith('{{', i)) { + depth += 1; + i += 1; + continue; + } + if (depth > 0 && str.startsWith('}}', i)) { + depth -= 1; + i += 1; + continue; + } + if (depth === 0 && separators.includes(str[i])) return i; + } + return -1; +}; + +const queryStartIndex = (str: string): number => indexOutsideTemplates(str, '?'); + +const pathEndIndex = (str: string): number => indexOutsideTemplates(str, '?#'); + const parseUrlQueryParams = (url: string | undefined | null): { name: string; value: string }[] => { if (!url || typeof url !== 'string') return []; const beforeHash = url.split('#')[0]; - const qIndex = beforeHash.indexOf('?'); + const qIndex = queryStartIndex(beforeHash); if (qIndex === -1) return []; const pairs: { name: string; value: string }[] = []; @@ -252,7 +275,7 @@ export const setUrlQueryParams = ( const hashIndex = url.indexOf('#'); const fragment = hashIndex === -1 ? '' : url.slice(hashIndex); const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex); - const qIndex = beforeHash.indexOf('?'); + const qIndex = queryStartIndex(beforeHash); const base = qIndex === -1 ? beforeHash : beforeHash.slice(0, qIndex); const queryString = enabled.map((p) => `${p.name}=${p.value ?? ''}`).join('&'); diff --git a/packages/bruno-api-docs/src/utils/promptVariables.spec.ts b/packages/bruno-api-docs/src/utils/promptVariables.spec.ts new file mode 100644 index 00000000..8a512046 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/promptVariables.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { + buildPromptVariableMap, + extractPromptVariables, + extractPromptVariablesFromString, + isPromptVariableToken, + promptVariableName, + toPromptVariableKey +} from './promptVariables'; + +describe('recognising a prompt token', () => { + it('accepts a plain name', () => { + expect(isPromptVariableToken('?OTP')).toBe(true); + expect(promptVariableName('?OTP')).toBe('OTP'); + }); + + it('accepts a name with spaces inside it', () => { + expect(isPromptVariableToken('?Test user id')).toBe(true); + expect(promptVariableName('?Test user id')).toBe('Test user id'); + }); + + it('accepts a single character name', () => { + expect(isPromptVariableToken('?x')).toBe(true); + }); + + it('rejects a name padded with spaces, so it reads as an ordinary variable instead', () => { + expect(isPromptVariableToken('? OTP')).toBe(false); + expect(isPromptVariableToken('?OTP ')).toBe(false); + expect(promptVariableName('?OTP ')).toBeNull(); + }); + + it('rejects an ordinary variable and an empty token', () => { + expect(isPromptVariableToken('baseUrl')).toBe(false); + expect(isPromptVariableToken('?')).toBe(false); + expect(isPromptVariableToken('')).toBe(false); + }); + + it('rejects a name containing braces', () => { + expect(isPromptVariableToken('?{OTP}')).toBe(false); + }); +}); + +describe('finding prompts in a string', () => { + it('finds every prompt in the order it appears', () => { + expect(extractPromptVariablesFromString('{{?User}}/{{?Repo}}')).toEqual(['User', 'Repo']); + }); + + it('ignores ordinary variables and dynamic tokens', () => { + expect(extractPromptVariablesFromString('{{baseUrl}}/{{$randomUUID}}')).toEqual([]); + }); + + it('finds a prompt sitting next to an ordinary variable', () => { + expect(extractPromptVariablesFromString('{{baseUrl}}/otp/{{?OTP}}')).toEqual(['OTP']); + }); + + it('scans each string independently, so a second call finds the same prompts', () => { + const url = '{{?OTP}}'; + expect(extractPromptVariablesFromString(url)).toEqual(['OTP']); + expect(extractPromptVariablesFromString(url)).toEqual(['OTP']); + }); + + it('survives a value that is not a string', () => { + expect(extractPromptVariablesFromString(undefined as unknown as string)).toEqual([]); + }); +}); + +describe('finding prompts across a request', () => { + it('walks nested objects and arrays', () => { + const request = { + url: '{{?Host}}/users', + headers: [{ name: 'X-Token', value: '{{?Token}}' }], + body: { data: '{"id":"{{?User id}}"}' } + }; + + expect(extractPromptVariables(request)).toEqual(['Host', 'Token', 'User id']); + }); + + it('lists a prompt once however many times it is used', () => { + const request = { url: '{{?OTP}}', headers: [{ name: 'X-OTP', value: '{{?OTP}}' }] }; + + expect(extractPromptVariables(request)).toEqual(['OTP']); + }); + + it('treats names differing only in case as two different prompts', () => { + expect(extractPromptVariables(['{{?otp}}', '{{?OTP}}'])).toEqual(['otp', 'OTP']); + }); + + it('returns nothing for a request with no prompts', () => { + expect(extractPromptVariables({ url: '{{baseUrl}}/users' })).toEqual([]); + }); + + it('copes with null and undefined values inside the request', () => { + expect(extractPromptVariables({ url: null, body: undefined, headers: [] })).toEqual([]); + }); + + it('does not loop forever on a request that refers back to itself', () => { + const request: Record = { url: '{{?Host}}' }; + request.self = request; + + expect(extractPromptVariables(request)).toEqual(['Host']); + }); +}); + +describe('turning answers into interpolation values', () => { + it('keys each answer by its token text so it cannot clash with a real variable', () => { + expect(toPromptVariableKey('OTP')).toBe('?OTP'); + expect(buildPromptVariableMap(['OTP'], { OTP: '123456' })).toEqual({ '?OTP': '123456' }); + }); + + it('includes a prompt the reader left blank, so the token resolves to nothing rather than being sent as text', () => { + expect(buildPromptVariableMap(['OTP'], {})).toEqual({ '?OTP': '' }); + }); + + it('ignores answers for prompts this request does not use', () => { + expect(buildPromptVariableMap(['OTP'], { OTP: '1', Unused: '2' })).toEqual({ '?OTP': '1' }); + }); +}); + +describe('matching the desktop extractor exactly', () => { + it('lists a prompt used twice in one string only once', () => { + expect(extractPromptVariablesFromString('{{?OTP}}/{{?OTP}}')).toEqual(['OTP']); + }); + + it('degrades to what it collected rather than failing the send', () => { + const hostile = { + good: '{{?Wanted}}', + get bad() { + throw new Error('cannot read this'); + } + }; + + expect(extractPromptVariables(hostile)).toEqual(['Wanted']); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/promptVariables.ts b/packages/bruno-api-docs/src/utils/promptVariables.ts new file mode 100644 index 00000000..9b167c52 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/promptVariables.ts @@ -0,0 +1,59 @@ +const PROMPT_VARIABLE_NAME_PATTERN = /[^{}\s](?:[^{}]*[^{}\s])?/; + +const PROMPT_VARIABLE_TEXT_PATTERN = new RegExp(`^\\?(${PROMPT_VARIABLE_NAME_PATTERN.source})$`); + +const promptVariableTemplatePattern = (): RegExp => + new RegExp(`{{\\?(${PROMPT_VARIABLE_NAME_PATTERN.source})}}`, 'g'); + +export const toPromptVariableKey = (name: string): string => `?${name}`; + +export const isPromptVariableToken = (word: string): boolean => PROMPT_VARIABLE_TEXT_PATTERN.test(word); + +export const promptVariableName = (word: string): string | null => PROMPT_VARIABLE_TEXT_PATTERN.exec(word)?.[1] ?? null; + +export const extractPromptVariablesFromString = (str: string): string[] => { + if (typeof str !== 'string' || !str) return []; + return Array.from(new Set(Array.from(str.matchAll(promptVariableTemplatePattern()), (match) => match[1]))); +}; + +export const extractPromptVariables = (value: unknown): string[] => { + const names = new Set(); + const seen = new WeakSet(); + + const walk = (node: unknown): void => { + if (typeof node === 'string') { + for (const name of extractPromptVariablesFromString(node)) names.add(name); + return; + } + if (node === null || typeof node !== 'object') return; + if (seen.has(node)) return; + seen.add(node); + + if (Array.isArray(node)) { + for (const entry of node) walk(entry); + return; + } + for (const key of Object.keys(node)) { + try { + walk((node as Record)[key]); + } catch (error) { + console.error('Error extracting prompt variables:', error); + } + } + }; + + try { + walk(value); + } catch (error) { + console.error('Error extracting prompt variables:', error); + } + return Array.from(names); +}; + +export const buildPromptVariableMap = (names: string[], values: Record): Record => { + const map: Record = {}; + for (const name of names) { + map[toPromptVariableKey(name)] = Object.prototype.hasOwnProperty.call(values, name) ? values[name] : ''; + } + return map; +}; diff --git a/packages/bruno-api-docs/src/utils/variableHighlight.ts b/packages/bruno-api-docs/src/utils/variableHighlight.ts index f137b7da..0bbd2d80 100644 --- a/packages/bruno-api-docs/src/utils/variableHighlight.ts +++ b/packages/bruno-api-docs/src/utils/variableHighlight.ts @@ -1,13 +1,8 @@ import { mockDataFunctions } from '@/runner/utils/faker-functions'; +import { isPromptVariableToken } from '@/utils/promptVariables'; export type VariableTokenClass = 'variable-valid' | 'variable-invalid' | 'variable-prompt'; -/** - * Prompt-variable text pattern, matching Bruno's `PROMPT_VARIABLE_TEXT_PATTERN`: - * `?` followed by a name that has no leading/trailing whitespace and no braces. - */ -const PROMPT_VARIABLE_TEXT_PATTERN = /^\?([^{}\s](?:[^{}]*[^{}\s])?)$/; - /** * Classify the raw inner text of a `{{...}}` token, mirroring Bruno's tokenizer * exactly (`utils/common/codemirror.js`). The word is NOT trimmed — `{{ foo }}` @@ -18,7 +13,7 @@ const PROMPT_VARIABLE_TEXT_PATTERN = /^\?([^{}\s](?:[^{}]*[^{}\s])?)$/; * 4. otherwise → invalid (e.g. `{{randomUUID}}` without the `$`). */ export const classifyVariableToken = (word: string, isFound: (name: string) => boolean): VariableTokenClass => { - if (PROMPT_VARIABLE_TEXT_PATTERN.test(word)) return 'variable-prompt'; + if (isPromptVariableToken(word)) return 'variable-prompt'; const isMock = word.startsWith('$') && Object.prototype.hasOwnProperty.call(mockDataFunctions, word.slice(1)); if (isMock || isFound(word)) return 'variable-valid'; return 'variable-invalid'; diff --git a/packages/bruno-api-docs/src/utils/variableResolution.ts b/packages/bruno-api-docs/src/utils/variableResolution.ts index 54eaaea6..27718ef9 100644 --- a/packages/bruno-api-docs/src/utils/variableResolution.ts +++ b/packages/bruno-api-docs/src/utils/variableResolution.ts @@ -101,6 +101,7 @@ export type VariableScope | 'dynamic' | 'oauth2' | '$secrets' + | 'prompt' | 'undefined'; /** Scopes a variable can actually be declared in, `$secrets` being an environment's external secrets. */