feat(ai-assist): add docs generation with folder and collection context (#8246)

This commit is contained in:
Utkarsh
2026-06-22 11:38:28 +05:30
committed by GitHub
parent ccf62641dd
commit fd30eaeccf
9 changed files with 679 additions and 38 deletions

View File

@@ -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 }) => {
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">Preview · replaces current script</span>
<span className="preview-label">{previewLabel}</span>
<pre className="preview-code">{generated}</pre>
</div>
</div>

View File

@@ -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(
<Provider store={createStore(aiEnabled)}>
<ThemeProvider theme={theme}>
<AIAssist {...mergedProps} />
</ThemeProvider>
</Provider>
);
};
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();
});
});
});

View File

@@ -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 }) => {
</div>
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
</div>
) : (
<div className="pl-1">
<div className="h-[1px] min-h-[500px]">

View File

@@ -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 }) => {
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist
scriptType="docs"
currentScript={docs || ''}
requestContext={requestContext}
onApply={onEdit}
/>
</div>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}

View File

@@ -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 ? (
<div className="flex flex-col flex-1 min-h-0">
<div className="mt-2 flex-1 overflow-auto min-h-0">
<div className="mt-2 flex-1 overflow-auto min-h-0 relative">
<CodeEditor
collection={collection}
theme={displayedTheme}
@@ -69,6 +72,7 @@ const Documentation = ({ collection, folder }) => {
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
</div>
<div className="mt-6 flex-shrink-0">
<Button type="submit" size="sm" onClick={onSave}>