Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test doesn't seem to test the breakage, it just seems to test that you can fill in the form. I think as is, it is only going to break if we change our form or texts.

Copy link
Collaborator Author

@amed-xyz amed-xyz Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it guards against a regression on the underlying bug where submittedData.headers was always empty

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { Button } from '../../../ui/button';
import {
Dialog,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 !== '')
);
};
Expand Down Expand Up @@ -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));

Expand All @@ -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();
Expand Down Expand Up @@ -366,7 +390,7 @@ export default function ExtensionModal({
onRemove={handleRemoveHeader}
onChange={handleHeaderChange}
submitAttempted={submitAttempted}
onPendingInputChange={setHasPendingHeaders}
onPendingInputChange={handlePendingHeaderChange}
/>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 = () => {
Expand Down
Loading