Fix extension headers (#5000)
This commit is contained in:
@@ -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(
|
||||||
|
<ExtensionModal
|
||||||
|
title="Add custom extension"
|
||||||
|
initialData={initialData}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onSubmit={mockOnSubmit}
|
||||||
|
submitLabel="Add Extension"
|
||||||
|
modalType="add"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '../../../ui/button';
|
import { Button } from '../../../ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -43,6 +43,7 @@ export default function ExtensionModal({
|
|||||||
const [showCloseConfirmation, setShowCloseConfirmation] = useState(false);
|
const [showCloseConfirmation, setShowCloseConfirmation] = useState(false);
|
||||||
const [hasPendingEnvVars, setHasPendingEnvVars] = useState(false);
|
const [hasPendingEnvVars, setHasPendingEnvVars] = useState(false);
|
||||||
const [hasPendingHeaders, setHasPendingHeaders] = 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
|
// Function to check if form has been modified
|
||||||
const hasFormChanges = (): boolean => {
|
const hasFormChanges = (): boolean => {
|
||||||
@@ -72,7 +73,7 @@ export default function ExtensionModal({
|
|||||||
envVar.value !== '••••••••'
|
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;
|
const hasPendingInput = hasPendingEnvVars || hasPendingHeaders;
|
||||||
|
|
||||||
return (
|
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
|
// Function to store a secret value
|
||||||
const storeSecret = async (key: string, value: string) => {
|
const storeSecret = async (key: string, value: string) => {
|
||||||
try {
|
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 = () => {
|
const isHeadersValid = () => {
|
||||||
return formData.headers.every(
|
return getFinalHeaders().every(
|
||||||
({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '')
|
({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -249,8 +266,13 @@ export default function ExtensionModal({
|
|||||||
setSubmitAttempted(true);
|
setSubmitAttempted(true);
|
||||||
|
|
||||||
if (isFormValid()) {
|
if (isFormValid()) {
|
||||||
|
const finalFormData = {
|
||||||
|
...formData,
|
||||||
|
headers: getFinalHeaders(),
|
||||||
|
};
|
||||||
|
|
||||||
// Only store env vars that have been edited (which includes new)
|
// Only store env vars that have been edited (which includes new)
|
||||||
const secretPromises = formData.envVars
|
const secretPromises = finalFormData.envVars
|
||||||
.filter((envVar) => envVar.isEdited)
|
.filter((envVar) => envVar.isEdited)
|
||||||
.map(({ key, value }) => storeSecret(key, value));
|
.map(({ key, value }) => storeSecret(key, value));
|
||||||
|
|
||||||
@@ -261,9 +283,11 @@ export default function ExtensionModal({
|
|||||||
if (results.every((success) => success)) {
|
if (results.every((success) => success)) {
|
||||||
// Convert timeout to number if needed
|
// Convert timeout to number if needed
|
||||||
const dataToSubmit = {
|
const dataToSubmit = {
|
||||||
...formData,
|
...finalFormData,
|
||||||
timeout:
|
timeout:
|
||||||
typeof formData.timeout === 'string' ? Number(formData.timeout) : formData.timeout,
|
typeof finalFormData.timeout === 'string'
|
||||||
|
? Number(finalFormData.timeout)
|
||||||
|
: finalFormData.timeout,
|
||||||
};
|
};
|
||||||
onSubmit(dataToSubmit);
|
onSubmit(dataToSubmit);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -366,7 +390,7 @@ export default function ExtensionModal({
|
|||||||
onRemove={handleRemoveHeader}
|
onRemove={handleRemoveHeader}
|
||||||
onChange={handleHeaderChange}
|
onChange={handleHeaderChange}
|
||||||
submitAttempted={submitAttempted}
|
submitAttempted={submitAttempted}
|
||||||
onPendingInputChange={setHasPendingHeaders}
|
onPendingInputChange={handlePendingHeaderChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ interface HeadersSectionProps {
|
|||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
onChange: (index: number, field: 'key' | 'value', value: string) => void;
|
||||||
submitAttempted: boolean;
|
submitAttempted: boolean;
|
||||||
onPendingInputChange?: (hasPending: boolean) => void;
|
onPendingInputChange: (
|
||||||
|
hasPendingInput: boolean,
|
||||||
|
pendingHeader: { key: string; value: string } | null
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeadersSection({
|
export default function HeadersSection({
|
||||||
@@ -29,10 +32,12 @@ export default function HeadersSection({
|
|||||||
value: false,
|
value: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track pending input changes
|
// Notify parent when pending input changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hasPendingInput = newKey.trim() !== '' || newValue.trim() !== '';
|
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]);
|
}, [newKey, newValue, onPendingInputChange]);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user