import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { ThemeProvider } from '@mui/material/styles';
import { lightTheme } from '@services/theme/defaultTheme';
import { AIProviderConfig, ModelFeature, ModelInfo } from '@services/externalAPI/interface';
import { ExternalAPI } from '../index';
// Mock data
const mockLanguageModel: ModelInfo = {
name: 'gpt-4',
caption: 'GPT-4 Language Model',
features: ['language' as ModelFeature],
};
const mockEmbeddingModel: ModelInfo = {
name: 'text-embedding-3-small',
caption: 'OpenAI Embedding Model',
features: ['embedding' as ModelFeature],
};
const mockProvider: AIProviderConfig = {
provider: 'openai',
apiKey: 'sk-test',
baseURL: 'https://api.openai.com/v1',
models: [mockLanguageModel, mockEmbeddingModel],
providerClass: 'openai',
isPreset: false,
enabled: true,
};
const mockAIConfig = {
api: {
provider: 'openai',
model: 'gpt-4',
embeddingModel: 'text-embedding-3-small',
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
};
// Test wrapper component
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
{children}
);
describe('ExternalAPI Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock ExternalAPI service methods
Object.defineProperty(window.service.externalAPI, 'getAIProviders', {
value: vi.fn().mockResolvedValue([mockProvider]),
writable: true,
});
Object.defineProperty(window.service.externalAPI, 'getAIConfig', {
value: vi.fn().mockResolvedValue(mockAIConfig),
writable: true,
});
Object.defineProperty(window.service.externalAPI, 'updateDefaultAIConfig', {
value: vi.fn().mockResolvedValue(undefined),
writable: true,
});
Object.defineProperty(window.service.externalAPI, 'updateProvider', {
value: vi.fn().mockResolvedValue(undefined),
writable: true,
});
Object.defineProperty(window.service.externalAPI, 'deleteProvider', {
value: vi.fn().mockResolvedValue(undefined),
writable: true,
});
// Mock the new delete field API
Object.defineProperty(window.service.externalAPI, 'deleteFieldFromDefaultAIConfig', {
value: vi.fn().mockResolvedValue(undefined),
writable: true,
});
});
// Helper function to render ExternalAPI with theme wrapper and wait for loading to complete
const renderExternalAPI = async () => {
const result = render(
,
);
// Wait for initial loading to complete to avoid act warnings
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
return result;
};
it('should render loading state initially', async () => {
// Don't await here to test the loading state
render(
,
);
expect(screen.getByText('Loading')).toBeInTheDocument();
// Wait for loading to complete to avoid act warnings for subsequent async updates
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
});
it('should render AI model selectors after loading', async () => {
await renderExternalAPI();
// Should show all five model selectors using translation keys
expect(screen.getByText('Preference.DefaultAIModelSelection')).toBeInTheDocument();
expect(screen.getByText('Preference.DefaultEmbeddingModelSelection')).toBeInTheDocument();
expect(screen.getByText('Preference.DefaultSpeechModelSelection')).toBeInTheDocument();
expect(screen.getByText('Preference.DefaultImageGenerationModelSelection')).toBeInTheDocument();
expect(screen.getByText('Preference.DefaultTranscriptionsModelSelection')).toBeInTheDocument();
});
it('should show model selectors with autocomplete inputs', async () => {
await renderExternalAPI();
// Should have five autocomplete inputs for models (language, embedding, speech, image generation, transcriptions)
const inputs = screen.getAllByRole('combobox');
expect(inputs).toHaveLength(6);
});
it('should call delete API when default model is cleared and no embedding model exists', async () => {
const user = userEvent.setup();
// Mock config with no embedding model
Object.defineProperty(window.service.externalAPI, 'getAIConfig', {
value: vi.fn().mockResolvedValue({
api: {
provider: 'openai',
model: 'gpt-4',
// No embeddingModel
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
}),
writable: true,
});
await renderExternalAPI();
// Find default model autocomplete (first one)
const modelSelector = screen.getAllByRole('combobox')[0];
// Look for clear button (usually an X button in MUI Autocomplete)
const clearButton = modelSelector.parentElement?.querySelector('button[title*="Clear"], button[aria-label*="clear"], svg[data-testid="ClearIcon"]');
if (clearButton) {
await user.click(clearButton as HTMLElement);
// Verify both model and provider fields are deleted when no embedding model exists
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model');
});
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider');
});
// Also verify that handleConfigChange was called to update local state
await waitFor(() => {
expect(window.service.externalAPI.updateDefaultAIConfig).toHaveBeenCalled();
});
} else {
// If we can't find clear button, test the onClear callback directly
const autocompleteInput = modelSelector.querySelector('input');
if (autocompleteInput) {
// Focus and clear the input to trigger onChange with null value
await user.click(autocompleteInput);
await user.clear(autocompleteInput);
await user.keyboard('{Escape}'); // Close dropdown
// Verify the delete API was called
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model');
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.provider');
});
}
}
});
it('should only clear model field when embedding model exists', async () => {
const user = userEvent.setup();
// Mock config with embedding model - this should preserve the provider
Object.defineProperty(window.service.externalAPI, 'getAIConfig', {
value: vi.fn().mockResolvedValue({
api: {
provider: 'openai',
model: 'gpt-4',
embeddingModel: 'text-embedding-3-small', // Has embedding model
},
modelParameters: {
temperature: 0.7,
systemPrompt: 'You are a helpful assistant.',
topP: 0.95,
},
}),
writable: true,
});
await renderExternalAPI();
// Find default model autocomplete (first one)
const modelSelector = screen.getAllByRole('combobox')[0];
// Look for clear button
const clearButton = modelSelector.parentElement?.querySelector('button[title*="Clear"], button[aria-label*="clear"], svg[data-testid="ClearIcon"]');
if (clearButton) {
await user.click(clearButton as HTMLElement);
// Should only delete model, NOT provider (because embedding model uses the provider)
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model');
});
// Should NOT delete provider when embedding model exists
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider');
// Verify that handleConfigChange was called
await waitFor(() => {
expect(window.service.externalAPI.updateDefaultAIConfig).toHaveBeenCalled();
});
} else {
// Fallback test
const autocompleteInput = modelSelector.querySelector('input');
if (autocompleteInput) {
await user.click(autocompleteInput);
await user.clear(autocompleteInput);
await user.keyboard('{Escape}');
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.model');
});
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).not.toHaveBeenCalledWith('api.provider');
}
}
});
it('should call delete API when embedding model is cleared via autocomplete', async () => {
const user = userEvent.setup();
await renderExternalAPI();
// Find embedding model autocomplete (second one)
const embeddingSelector = screen.getAllByRole('combobox')[1];
// Look for clear button (usually an X button in MUI Autocomplete)
const clearButton = embeddingSelector.parentElement?.querySelector('button[title*="Clear"], button[aria-label*="clear"], svg[data-testid="ClearIcon"]');
if (clearButton) {
await user.click(clearButton as HTMLElement);
// Verify the delete API was called
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel');
});
// Also verify that handleConfigChange was called to update local state
await waitFor(() => {
expect(window.service.externalAPI.updateDefaultAIConfig).toHaveBeenCalled();
});
} else {
// If we can't find clear button, test the onClear callback directly by simulating autocomplete change to null
const autocompleteInput = embeddingSelector.querySelector('input');
if (autocompleteInput) {
// Focus and clear the input to trigger onChange with null value
await user.click(autocompleteInput);
await user.clear(autocompleteInput);
await user.keyboard('{Escape}'); // Close dropdown
// Verify the delete API was called
await waitFor(() => {
expect(window.service.externalAPI.deleteFieldFromDefaultAIConfig).toHaveBeenCalledWith('api.embeddingModel');
});
}
}
});
it('should handle embedding model clear functionality directly', async () => {
// Test the clear functionality by directly calling the ModelSelector with onClear
const mockOnClear = vi.fn();
// Create a simple test for ModelSelector clear functionality
const { ModelSelector } = await import('../components/ModelSelector');
const testConfig = {
api: {
provider: 'openai',
model: 'text-embedding-3-small',
embeddingModel: 'text-embedding-3-small',
},
modelParameters: {},
};
render(
,
);
// Find and click clear button
const input = screen.getByRole('combobox');
const clearButton = input.parentElement?.querySelector('button[title*="Clear"], [data-testid="ClearIcon"]');
if (clearButton) {
await userEvent.click(clearButton);
expect(mockOnClear).toHaveBeenCalled();
}
});
it('should render provider configuration section', async () => {
await renderExternalAPI();
// Should show add new provider button
const addProviderButton = screen.getByTestId('add-new-provider-button');
expect(addProviderButton).toBeInTheDocument();
});
it('should render model parameters configuration button', async () => {
await renderExternalAPI();
// Should show configure model parameters button
expect(screen.getByText('Preference.ConfigureModelParameters')).toBeInTheDocument();
});
it('should render provider delete buttons for non-preset providers', async () => {
await renderExternalAPI();
// Should show delete provider buttons (since mockProvider is not preset)
const deleteButtons = screen.getAllByTestId('delete-provider-button');
expect(deleteButtons.length).toBeGreaterThan(0);
});
it('should call deleteProvider API when provider delete button is clicked', async () => {
const user = userEvent.setup();
// Mock window.confirm to return true (user confirms deletion)
const originalConfirm = window.confirm;
window.confirm = vi.fn().mockReturnValue(true);
await renderExternalAPI();
// Find and click the delete provider button
const deleteButton = screen.getByTestId('delete-provider-button');
await user.click(deleteButton);
// Verify the delete API was called
await waitFor(() => {
expect(window.service.externalAPI.deleteProvider).toHaveBeenCalledWith('openai');
});
// Restore original confirm
window.confirm = originalConfirm;
});
});