From af03f800804a417d886aa69c46742877e8e83627 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Mon, 21 Sep 2026 18:30:59 +0530 Subject: [PATCH 1/6] feat: prompt variable changes for the bruno api docs --- .../playground-prompt-variables.spec.ts | 143 +++++++++++ .../Views/PlaygroundView/PlaygroundView.tsx | 34 ++- .../usePromptVariables.spec.tsx | 88 +++++++ .../PlaygroundView/usePromptVariables.ts | 49 ++++ .../PromptVariablesModal.spec.tsx | 96 +++++++ .../PromptVariablesModal.tsx | 83 +++++++ .../PromptVariablesModal/StyledWrapper.ts | 108 ++++++++ .../VariableInfoCard/VariableInfoCard.tsx | 3 +- .../bruno-api-docs/src/constants/variable.ts | 1 + .../hooks/useVariableResolver.prompt.spec.tsx | 117 +++++++++ .../src/hooks/useVariableResolver.tsx | 5 + .../runner/collect-prompt-variables.spec.ts | 197 +++++++++++++++ packages/bruno-api-docs/src/runner/index.ts | 53 +++- .../src/runner/utils/prompt-variables.spec.ts | 234 ++++++++++++++++++ .../src/runner/utils/request-merger.spec.ts | 61 ++++- .../src/runner/utils/request-merger.ts | 36 ++- .../utils/variable-interpolator.spec.ts | 4 +- .../src/runner/utils/variable-interpolator.ts | 48 ++-- .../src/scripting/runtime/assert-runtime.ts | 2 + .../bruno-api-docs/src/scripting/utils/bru.ts | 4 + .../bruno-api-docs/src/ui/Modal/Modal.tsx | 24 +- .../src/utils/pathParams.spec.ts | 31 +++ .../bruno-api-docs/src/utils/pathParams.ts | 22 +- .../src/utils/promptVariables.spec.ts | 141 +++++++++++ .../src/utils/promptVariables.ts | 69 ++++++ .../src/utils/variableHighlight.ts | 9 +- .../src/utils/variableResolution.ts | 1 + 27 files changed, 1596 insertions(+), 67 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/playground/playground-prompt-variables.spec.ts create mode 100644 packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/usePromptVariables.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/usePromptVariables.ts create mode 100644 packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.tsx create mode 100644 packages/bruno-api-docs/src/components/PromptVariablesModal/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/hooks/useVariableResolver.prompt.spec.tsx create mode 100644 packages/bruno-api-docs/src/runner/collect-prompt-variables.spec.ts create mode 100644 packages/bruno-api-docs/src/runner/utils/prompt-variables.spec.ts create mode 100644 packages/bruno-api-docs/src/utils/promptVariables.spec.ts create mode 100644 packages/bruno-api-docs/src/utils/promptVariables.ts 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..e624c8ef 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,7 @@ 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(); // 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 +74,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 +108,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 +119,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 promptVariablesController.collect(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 +153,10 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec } })); } finally { + sendInFlightRef.current = false; setIsLoading(false); } - }, [collection, editableItem, selectedEnvironment, itemUuid, dispatch]); + }, [collection, editableItem, selectedEnvironment, itemUuid, dispatch, promptVariablesController]); 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..cb8e1bbb --- /dev/null +++ b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.spec.tsx @@ -0,0 +1,96 @@ +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 fields = (root: ReturnType) => + root.querySelectorAll('input[data-testid^="prompt-variable-input-"]'); + +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(query(root, 'label[for="prompt-0"]').text).toBe('OTP'); + expect(query(root, 'label[for="prompt-1"]').text).toBe('User id'); + }); + + it('points each label at the field it names', () => { + const root = useRenderToDom(dialogTree({ names: ['OTP'] })); + + expect(query(root, 'label').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(fields(root).map((field) => field.getAttribute('value'))).toEqual(['', '']); + }); + + it('matches the wording for the title and the two buttons', () => { + const root = useRenderToDom(dialogTree()); + + expect(query(root, '.modal-title').text).toContain('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..0efae475 --- /dev/null +++ b/packages/bruno-api-docs/src/components/PromptVariablesModal/PromptVariablesModal.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { StyledFooter, StyledModal, 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..508479be --- /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 StyledModal = styled(Modal)` + .modal-dialog { + width: min(34rem, 100%); + } + + .prompt-variables-title { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-primary); + } +`; + +export const StyledWrapper = styled.div` + 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; + } + } +`; + +export const StyledFooter = styled.div` + 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.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 e88b2224..fbdfdf94 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..349529a4 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/collect-prompt-variables.spec.ts @@ -0,0 +1,197 @@ +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" +`; + +const collection = parseYaml(collectionYaml) as any; +const folder = collection.items[0]; +const promptedRequest = folder.items[0]; +const plainRequest = folder.items[1]; + +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('still asks for a value in a switched-off param, matching the desktop app', async () => { + const names = await runner.collectPromptVariableNames({ item: promptedRequest, collection, environment }); + + expect(names).toContain('Disabled Param'); + }); + + 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('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')); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index d4024981..ab8bb1ce 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -14,8 +14,10 @@ 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 { getItemUuid } from '@/utils/itemUtils'; import { cloneDeep, isEqual } from 'lodash-es'; @@ -29,6 +31,7 @@ interface RunContext { environmentVariables: Variables; collectionVariables: Variables; runtimeVariables: Variables; + promptVariables: Variables; processEnvVars: Variables; timeout: number; warnings: string[]; @@ -68,8 +71,10 @@ export interface RunRequestOptions { collection: OpenCollectionCollection; environment?: Environment; runtimeVariables?: Variables; + promptVariables?: Variables; timeout?: number; validateSSL?: boolean; + prepared?: HttpRequest; } export interface TestResultsResponse { @@ -142,7 +147,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, @@ -150,13 +157,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 ?? []) @@ -231,15 +239,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'; const { folderVariables, requestVariables } = getCollectionFolderRequestVariables(collection, processedRequest); @@ -247,6 +257,7 @@ export class RequestRunner { const allVariables = { environmentVariables, runtimeVariables, + promptVariables, processEnvVars, collectionVariables, folderVariables, @@ -408,7 +419,35 @@ export class RequestRunner { }, vars); } - private async preprocessRequest( + async collectPromptVariableNames( + options: Pick + ): Promise { + const { item, collection, environment, prepared } = options; + const processed = prepared ?? await this.prepareRequest(item, collection); + const { folderVariables, requestVariables } = getCollectionFolderRequestVariables(collection, processed); + const body = getHttpBody(processed); + + const enabledHeaders = (rows: unknown): unknown[] => + (Array.isArray(rows) ? rows : []).filter((row) => (row as { disabled?: boolean })?.disabled !== true); + + const effectiveVariables = { + ...getCollectionVariables(collection), + ...this.getEnvironmentVariables(environment), + ...folderVariables, + ...requestVariables + }; + + return extractPromptVariables([ + effectiveVariables, + body && 'data' in body ? body.data : body, + enabledHeaders(getHttpHeaders(processed)), + 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..03bc8dc7 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/prompt-variables.spec.ts @@ -0,0 +1,234 @@ +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('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/'); + }); +}); + +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('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..4228d374 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,10 +108,10 @@ describe('interpolateVars — typed variables in a JSON body', () => { }); }); - it('still JSON-escapes a string value that contains quotes', () => { + it('puts a quote in a variable value straight into the body, the way the desktop app does', () => { const out = interpolateVars(jsonReq('{"note":"{{note}}"}'), { folderVariables: { note: 'he said "hi"' } }); - expect(JSON.parse((out.http!.body as { data: string }).data)).toEqual({ note: 'he said "hi"' }); + expect((out.http!.body as { data: string }).data).toBe('{"note":"he said "hi""}'); }); }); 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..de88602d 100644 --- a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts +++ b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts @@ -11,6 +11,8 @@ export type Variables = Record; const MOCK_PATTERN = /\{\{\$(\w+)\}\}/g; const JSON_SPECIAL_CHARS = /[\\\n\r\t"]/; +const MAX_INTERPOLATION_PASSES = 10; + const escapeJSONString = (str: string): string => { if (!JSON_SPECIAL_CHARS.test(str)) { return str; @@ -70,35 +72,45 @@ 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(); + const substitute = (input: string): { output: string; changed: boolean } => { + let changed = false; + const output = input.replace(templateVariableGlobalRegex(), (match, variableName) => { + const trimmedName = variableName.trim(); - // Handle nested object access (e.g., process.env.NODE_ENV) - const value = getNestedValue(preparedVars, trimmedName); + // 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 (value === null) { + return match; // Keep original if variable not found + } - // 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); - } + changed = true; - let result = String(value); - if (escapeJSONStrings && typeof value === 'string') { - result = result.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - } + if (typeof value === 'object') { + return JSON.stringify(value); + } - return result; - }); + return String(value); + }); + return { output, changed }; + }; + + let resolved = mocked; + for (let pass = 0; pass < MAX_INTERPOLATION_PASSES; pass += 1) { + const { output, changed } = substitute(resolved); + if (!changed || output === resolved) return output; + resolved = output; + } + return resolved; }; /** * 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; 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..e28bcdbe 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,6 +121,7 @@ export class AssertRuntime { variables: { environmentVariables, runtimeVariables, + promptVariables, globalEnvironmentVariables } }); 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 044badda..384569c4 100644 --- a/packages/bruno-api-docs/src/ui/Modal/Modal.tsx +++ b/packages/bruno-api-docs/src/ui/Modal/Modal.tsx @@ -1,19 +1,34 @@ -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 { StyledWrapper } from './StyledWrapper'; -interface ModalProps { +export interface ModalProps { open: boolean; onClose: () => void; title?: ReactNode; children: ReactNode; ariaLabel?: string; className?: string; + initialFocusRef?: React.RefObject; } -export const Modal: React.FC = ({ open, onClose, title, children, ariaLabel, className }) => { +export const Modal: React.FC = ({ + open, onClose, title, children, ariaLabel, className, initialFocusRef +}) => { const dialogRef = useRef(null); + const focusedRef = useRef(false); + + const attachDialog = useCallback((node: HTMLDivElement | null) => { + dialogRef.current = node; + if (!node) { + focusedRef.current = false; + return; + } + if (focusedRef.current) return; + focusedRef.current = true; + (initialFocusRef?.current ?? node).focus(); + }, [initialFocusRef]); useEffect(() => { if (!open) return undefined; @@ -23,7 +38,6 @@ export const Modal: React.FC = ({ open, onClose, title, children, ar 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,7 +54,7 @@ export const Modal: React.FC = ({ open, onClose, title, children, ar if (event.target === event.currentTarget) onClose(); }} > -
+
{title !== undefined &&
{title}
}