diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx new file mode 100644 index 000000000000..377bc56b7d18 --- /dev/null +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ExtensionModal from './ExtensionModal'; +import { ExtensionFormData } from '../utils'; + +describe('ExtensionModal', () => { + it('creates a http_streamable extension', async () => { + const user = userEvent.setup(); + const mockOnSubmit = vi.fn(); + const mockOnClose = vi.fn(); + + const initialData: ExtensionFormData = { + name: '', + description: '', + type: 'stdio', // Default type + cmd: '', + endpoint: '', + enabled: true, + timeout: 300, + envVars: [], + headers: [], + }; + + render( + + ); + + const nameInput = screen.getByPlaceholderText('Enter extension name...'); + const submitButton = screen.getByTestId('extension-submit-btn'); + + await user.type(nameInput, 'Test MCP'); + + const typeSelect = screen.getByRole('combobox'); + await user.click(typeSelect); + + const httpOption = screen.getByText('Streamable HTTP'); + await user.click(httpOption); + + await waitFor(() => { + expect(screen.getByText('Request Headers')).toBeInTheDocument(); + }); + + const endpointInput = screen.getByPlaceholderText('Enter endpoint URL...'); + await user.type(endpointInput, 'https://foo.bar.com/mcp/'); + + const descriptionInput = screen.getByPlaceholderText('Optional description...'); + await user.type(descriptionInput, 'Test MCP extension'); + + const headerNameInput = screen.getByPlaceholderText('Header name'); + const headerValueInput = screen + .getAllByPlaceholderText('Value') + .find( + (input) => + input.closest('div')?.textContent?.includes('Request Headers') || + input.parentElement?.parentElement?.textContent?.includes('Request Headers') + ); + + await user.type(headerNameInput, 'Authorization'); + if (headerValueInput) { + await user.type(headerValueInput, 'Bearer abc123'); + } + + await user.click(submitButton); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + const submittedData = mockOnSubmit.mock.calls[0][0]; + + expect(submittedData.name).toBe('Test MCP'); + expect(submittedData.type).toBe('streamable_http'); + expect(submittedData.endpoint).toBe('https://foo.bar.com/mcp/'); + expect(submittedData.description).toBe('Test MCP extension'); + expect(submittedData.timeout).toBe(300); + expect(submittedData.headers).toHaveLength(1); + expect(submittedData.headers).toEqual([ + { key: 'Authorization', value: 'Bearer abc123', isEdited: true }, + ]); + }); +}); diff --git a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx index 3a7c84f658f4..788cd08d3ae4 100644 --- a/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Button } from '../../../ui/button'; import { Dialog, @@ -43,6 +43,7 @@ export default function ExtensionModal({ const [showCloseConfirmation, setShowCloseConfirmation] = useState(false); const [hasPendingEnvVars, setHasPendingEnvVars] = useState(false); const [hasPendingHeaders, setHasPendingHeaders] = useState(false); + const [pendingHeader, setPendingHeader] = useState<{ key: string; value: string } | null>(null); // Function to check if form has been modified const hasFormChanges = (): boolean => { @@ -72,7 +73,7 @@ export default function ExtensionModal({ envVar.value !== '••••••••' ); - // Check if there are pending environment variables being typed + // Check if there are pending environment variables or headers being typed const hasPendingInput = hasPendingEnvVars || hasPendingHeaders; return ( @@ -168,6 +169,14 @@ export default function ExtensionModal({ }); }; + const handlePendingHeaderChange = useCallback( + (hasPending: boolean, header: { key: string; value: string } | null) => { + setHasPendingHeaders(hasPending); + setPendingHeader(header); + }, + [] + ); + // Function to store a secret value const storeSecret = async (key: string, value: string) => { try { @@ -217,8 +226,16 @@ export default function ExtensionModal({ ); }; + const getFinalHeaders = () => { + const finalHeaders = [...formData.headers]; + if (pendingHeader && pendingHeader.key.trim() !== '' && pendingHeader.value.trim() !== '') { + finalHeaders.push({ ...pendingHeader, isEdited: true }); + } + return finalHeaders; + }; + const isHeadersValid = () => { - return formData.headers.every( + return getFinalHeaders().every( ({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '') ); }; @@ -249,8 +266,13 @@ export default function ExtensionModal({ setSubmitAttempted(true); if (isFormValid()) { + const finalFormData = { + ...formData, + headers: getFinalHeaders(), + }; + // Only store env vars that have been edited (which includes new) - const secretPromises = formData.envVars + const secretPromises = finalFormData.envVars .filter((envVar) => envVar.isEdited) .map(({ key, value }) => storeSecret(key, value)); @@ -261,9 +283,11 @@ export default function ExtensionModal({ if (results.every((success) => success)) { // Convert timeout to number if needed const dataToSubmit = { - ...formData, + ...finalFormData, timeout: - typeof formData.timeout === 'string' ? Number(formData.timeout) : formData.timeout, + typeof finalFormData.timeout === 'string' + ? Number(finalFormData.timeout) + : finalFormData.timeout, }; onSubmit(dataToSubmit); onClose(); @@ -366,7 +390,7 @@ export default function ExtensionModal({ onRemove={handleRemoveHeader} onChange={handleHeaderChange} submitAttempted={submitAttempted} - onPendingInputChange={setHasPendingHeaders} + onPendingInputChange={handlePendingHeaderChange} /> diff --git a/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx b/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx index 5fe28dc1c181..f7ea5037b5b8 100644 --- a/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx +++ b/ui/desktop/src/components/settings/extensions/modal/HeadersSection.tsx @@ -10,7 +10,10 @@ interface HeadersSectionProps { onRemove: (index: number) => void; onChange: (index: number, field: 'key' | 'value', value: string) => void; submitAttempted: boolean; - onPendingInputChange?: (hasPending: boolean) => void; + onPendingInputChange: ( + hasPendingInput: boolean, + pendingHeader: { key: string; value: string } | null + ) => void; } export default function HeadersSection({ @@ -29,10 +32,12 @@ export default function HeadersSection({ value: false, }); - // Track pending input changes + // Notify parent when pending input changes React.useEffect(() => { const hasPendingInput = newKey.trim() !== '' || newValue.trim() !== ''; - onPendingInputChange?.(hasPendingInput); + const pendingHeader = + newKey.trim() && newValue.trim() ? { key: newKey, value: newValue } : null; + onPendingInputChange(hasPendingInput, pendingHeader); }, [newKey, newValue, onPendingInputChange]); const handleAdd = () => {