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; }); });