diff --git a/packages/bruno-app/src/components/AIAssist/index.js b/packages/bruno-app/src/components/AIAssist/index.js index c6362267a..0238dac98 100644 --- a/packages/bruno-app/src/components/AIAssist/index.js +++ b/packages/bruno-app/src/components/AIAssist/index.js @@ -21,18 +21,29 @@ const SUGGESTIONS = { { label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' }, { label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' }, { label: 'Log response', prompt: 'Log the response status and a short summary of the body' } + ], + 'docs': [ + { label: 'Overview', prompt: 'Write an overview section describing the purpose and key features' }, + { label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' }, + { label: 'Examples', prompt: 'Add request and response examples with sample JSON' }, + { label: 'Errors', prompt: 'Document common error responses and status codes' } ] }; const TITLES = { 'tests': 'Generate Tests', 'pre-request': 'Generate Pre-Request Script', - 'post-response': 'Generate Post-Response Script' + 'post-response': 'Generate Post-Response Script', + 'docs': 'Generate Documentation' +}; + +const PREVIEW_LABELS = { + docs: 'Preview · replaces current documentation' }; const isValidType = (t) => SUGGESTIONS[t] !== undefined; -const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => { +const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onApply }) => { const [isOpen, setIsOpen] = useState(false); const [prompt, setPrompt] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -49,6 +60,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => { const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]); const title = TITLES[scriptType] || 'Generate with AI'; + const previewLabel = PREVIEW_LABELS[scriptType] || 'Preview · replaces current script'; const close = useCallback(() => { setIsOpen(false); @@ -85,7 +97,8 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => { scriptType, prompt: text, currentScript: currentScript || '', - requestContext + requestContext, + docsContext }); if (result?.error) { setError(result.error); @@ -102,7 +115,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => { setIsLoading(false); } }, - [prompt, isLoading, scriptType, currentScript, requestContext] + [prompt, isLoading, scriptType, currentScript, requestContext, docsContext] ); const handleApply = useCallback(() => { @@ -206,7 +219,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => { <>
- Preview · replaces current script + {previewLabel}
{generated}
diff --git a/packages/bruno-app/src/components/AIAssist/index.spec.js b/packages/bruno-app/src/components/AIAssist/index.spec.js new file mode 100644 index 000000000..94af1a072 --- /dev/null +++ b/packages/bruno-app/src/components/AIAssist/index.spec.js @@ -0,0 +1,356 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { ThemeProvider } from 'styled-components'; +import { aiGenerateScript } from 'utils/ai'; +import AIAssist from './index'; + +jest.mock('utils/ai', () => ({ + aiGenerateScript: jest.fn() +})); + +const theme = { + bg: '#1e1e1e', + text: '#ffffff', + border: { radius: { sm: '4px', md: '6px' } }, + colors: { + accent: '#6366f1', + text: { muted: '#9ca3af', danger: '#ef4444' }, + bg: { danger: '#ef4444' } + }, + input: { + border: '#374151', + bg: '#111827', + focusBorder: '#6366f1' + }, + font: { monospace: 'monospace' } +}; + +const createStore = (aiEnabled = true) => configureStore({ + reducer: { + app: (state = { preferences: { ai: { enabled: aiEnabled } } }) => state + } +}); + +const defaultProps = { + scriptType: 'tests', + currentScript: 'test("ok", () => {});', + onApply: jest.fn() +}; + +const renderAIAssist = ({ + props = {}, + aiEnabled = true +} = {}) => { + const mergedProps = { ...defaultProps, ...props }; + return render( + + + + + + ); +}; + +const openPopup = () => { + fireEvent.click(screen.getByRole('button', { name: 'Generate Tests' })); +}; + +describe('AIAssist', () => { + beforeEach(() => { + jest.clearAllMocks(); + aiGenerateScript.mockResolvedValue({ content: 'test("generated", () => {});' }); + }); + + describe('visibility', () => { + it('renders nothing when AI is disabled', () => { + const { container } = renderAIAssist({ aiEnabled: false }); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing for an unsupported script type', () => { + const { container } = renderAIAssist({ props: { scriptType: 'unknown-type' } }); + expect(container.firstChild).toBeNull(); + }); + + it('renders the trigger when AI is enabled and the script type is supported', () => { + renderAIAssist(); + expect(screen.getByRole('button', { name: 'Generate Tests' })).toBeInTheDocument(); + }); + }); + + describe('titles', () => { + it.each([ + ['tests', 'Generate Tests'], + ['pre-request', 'Generate Pre-Request Script'], + ['post-response', 'Generate Post-Response Script'], + ['docs', 'Generate Documentation'] + ])('uses the correct title for %s', (scriptType, title) => { + renderAIAssist({ props: { scriptType } }); + expect(screen.getByRole('button', { name: title })).toBeInTheDocument(); + }); + }); + + describe('popup interactions', () => { + it('opens and closes the popup from the trigger and close button', () => { + renderAIAssist(); + openPopup(); + + expect(screen.getByRole('dialog', { name: 'Generate Tests' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument(); + }); + + it('closes the popup when Escape is pressed', () => { + renderAIAssist(); + openPopup(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument(); + }); + + it('closes the popup when clicking outside', () => { + renderAIAssist(); + openPopup(); + + fireEvent.mouseDown(document.body); + expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument(); + }); + }); + + describe('prompt view', () => { + it('shows suggestion chips when the prompt is empty', () => { + renderAIAssist(); + openPopup(); + + expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'JSON body' })).toBeInTheDocument(); + }); + + it('shows docs suggestions for the docs script type', () => { + renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Overview' } }); + fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' })); + + expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Request' })).toBeInTheDocument(); + }); + + it('hides suggestions once the user starts typing', () => { + renderAIAssist(); + openPopup(); + + fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), { + target: { value: 'Add a status test' } + }); + + expect(screen.queryByRole('button', { name: 'Status 200' })).not.toBeInTheDocument(); + }); + + it('keeps Generate disabled until the prompt has text', () => { + renderAIAssist(); + openPopup(); + + expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled(); + + fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), { + target: { value: 'Add a status test' } + }); + + expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled(); + }); + }); + + describe('generation flow', () => { + it('generates from a suggestion chip', async () => { + renderAIAssist(); + openPopup(); + + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + + await waitFor(() => { + expect(aiGenerateScript).toHaveBeenCalledWith({ + scriptType: 'tests', + prompt: 'Add a test asserting the response status code is 200', + currentScript: 'test("ok", () => {});', + requestContext: undefined + }); + }); + + expect(screen.getByText('test("generated", () => {});')).toBeInTheDocument(); + }); + + it('passes docs context for folder and collection documentation', async () => { + const docsContext = { + scope: 'folder', + name: 'Users', + collectionName: 'Pet Store API', + folders: [{ name: 'Admin', requestCount: 1, subfolderCount: 0 }], + requests: [{ name: 'List Users', method: 'GET', url: '{{base}}/users' }] + }; + + renderAIAssist({ props: { scriptType: 'docs', currentScript: '', docsContext } }); + fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' })); + fireEvent.click(screen.getByRole('button', { name: 'Overview' })); + + await waitFor(() => { + expect(aiGenerateScript).toHaveBeenCalledWith({ + scriptType: 'docs', + prompt: 'Write an overview section describing the purpose and key features', + currentScript: '', + requestContext: undefined, + docsContext + }); + }); + }); + + it('generates from the prompt input and passes request context', async () => { + const requestContext = { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + params: [], + body: null + }; + + renderAIAssist({ props: { requestContext } }); + openPopup(); + + fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), { + target: { value: 'Add auth header test' } + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate' })); + + await waitFor(() => { + expect(aiGenerateScript).toHaveBeenCalledWith({ + scriptType: 'tests', + prompt: 'Add auth header test', + currentScript: 'test("ok", () => {});', + requestContext + }); + }); + }); + + it.each([ + ['metaKey', { metaKey: true }], + ['ctrlKey', { ctrlKey: true }] + ])('generates when pressing modifier+Enter using %s', async (_modifier, keyEvent) => { + renderAIAssist(); + openPopup(); + + const textarea = screen.getByPlaceholderText('Describe what you want to generate...'); + fireEvent.change(textarea, { target: { value: 'Add response time test' } }); + fireEvent.keyDown(textarea, { key: 'Enter', ...keyEvent }); + + await waitFor(() => { + expect(aiGenerateScript).toHaveBeenCalledWith({ + scriptType: 'tests', + prompt: 'Add response time test', + currentScript: 'test("ok", () => {});', + requestContext: undefined + }); + }); + }); + + it('shows a loading state while generation is in progress', async () => { + let resolveGenerate; + aiGenerateScript.mockImplementation(() => new Promise((resolve) => { + resolveGenerate = resolve; + })); + + renderAIAssist(); + openPopup(); + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + + expect(screen.getByText('Generating...')).toBeInTheDocument(); + + resolveGenerate({ content: 'test("done", () => {});' }); + + await waitFor(() => { + expect(screen.getByText('test("done", () => {});')).toBeInTheDocument(); + }); + }); + + it('shows an API error without entering preview mode', async () => { + aiGenerateScript.mockResolvedValue({ error: 'Provider unavailable' }); + + renderAIAssist(); + openPopup(); + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + + await waitFor(() => { + expect(screen.getByText('Provider unavailable')).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + }); + + it('shows a fallback error when no content is returned', async () => { + aiGenerateScript.mockResolvedValue({}); + + renderAIAssist(); + openPopup(); + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + + await waitFor(() => { + expect(screen.getByText('No content was generated. Try rephrasing your prompt.')).toBeInTheDocument(); + }); + }); + }); + + describe('preview and apply', () => { + const showPreview = async () => { + renderAIAssist(); + openPopup(); + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); + }; + + it('uses the script preview label for script types', async () => { + await showPreview(); + expect(screen.getByText('Preview · replaces current script')).toBeInTheDocument(); + }); + + it('uses the documentation preview label for docs', async () => { + aiGenerateScript.mockResolvedValue({ content: '# API Docs' }); + + renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Existing' } }); + fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' })); + fireEvent.click(screen.getByRole('button', { name: 'Overview' })); + + await waitFor(() => { + expect(screen.getByText('Preview · replaces current documentation')).toBeInTheDocument(); + }); + expect(screen.getByText('# API Docs')).toBeInTheDocument(); + }); + + it('applies generated content and closes the popup', async () => { + const onApply = jest.fn(); + renderAIAssist({ props: { onApply } }); + openPopup(); + fireEvent.click(screen.getByRole('button', { name: 'Status 200' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(onApply).toHaveBeenCalledWith('test("generated", () => {});'); + expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument(); + }); + + it('returns to the prompt view when Back is clicked', async () => { + await showPreview(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.getByPlaceholderText('Describe what you want to generate...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 3da73f18f..73ebbb8d4 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -4,11 +4,13 @@ import find from 'lodash/find'; import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections'; import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; +import AIAssist from 'components/AIAssist'; +import { buildDocsContextFromCollection } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import { IconEdit, IconX, IconFileText } from '@tabler/icons'; import Button from 'ui/Button/index'; @@ -25,6 +27,7 @@ const Docs = ({ collection }) => { const isEditing = focusedTab?.docsEditing || false; const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', ''); const preferences = useSelector((state) => state.app.preferences); + const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]); // StyledWrapper has overflow-y: auto — use null selector. // Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll. @@ -85,18 +88,21 @@ const Docs = ({ collection }) => { {isEditing ? ( - +
+ + +
) : (
diff --git a/packages/bruno-app/src/components/Documentation/index.js b/packages/bruno-app/src/components/Documentation/index.js index 555bef3fd..c6f730f39 100644 --- a/packages/bruno-app/src/components/Documentation/index.js +++ b/packages/bruno-app/src/components/Documentation/index.js @@ -4,11 +4,13 @@ import find from 'lodash/find'; import { updateRequestDocs } from 'providers/ReduxStore/slices/collections'; import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; +import AIAssist from 'components/AIAssist'; +import { buildRequestContextFromItem } from 'utils/ai'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; @@ -42,6 +44,7 @@ const Documentation = ({ item, collection }) => { }; const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); if (!item) { return null; @@ -54,18 +57,26 @@ const Documentation = ({ item, collection }) => {
{isEditing ? ( - +
+ + +
) : ( )} diff --git a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js index 8e0b3b6c8..00cc3a295 100644 --- a/packages/bruno-app/src/components/FolderSettings/Documentation/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Documentation/index.js @@ -4,11 +4,13 @@ import find from 'lodash/find'; import { updateFolderDocs } from 'providers/ReduxStore/slices/collections'; import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import Markdown from 'components/MarkDown'; import CodeEditor from 'components/CodeEditor'; +import AIAssist from 'components/AIAssist'; +import { buildDocsContextFromFolder } from 'utils/ai'; import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState'; @@ -43,6 +45,7 @@ const Documentation = ({ collection, folder }) => { }; const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid)); + const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]); if (!folder) { return null; @@ -56,7 +59,7 @@ const Documentation = ({ collection, folder }) => { {isEditing ? (
-
+
{ initialScroll={scroll} onScroll={setScroll} /> +