import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { SettingsPanel } from './SettingsPanel' import type { Settings } from '../types' import type { ThemeManager } from '../hooks/useThemeManager' // Mock the tauri/mock-tauri calls used by GitHubSection const mockInvokeFn = vi.fn() const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined) vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: unknown[]) => mockInvokeFn(...args), })) vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: (...args: unknown[]) => mockInvokeFn(...args), })) vi.mock('../utils/url', () => ({ openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args), })) const emptySettings: Settings = { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, } const populatedSettings: Settings = { anthropic_key: 'sk-ant-api03-test123', openai_key: 'sk-openai-test456', google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: 5, } const mockThemeManager: ThemeManager = { themes: [], activeThemeId: null, activeTheme: null, isDark: false, switchTheme: vi.fn(), createTheme: vi.fn().mockResolvedValue('untitled'), reloadThemes: vi.fn(), } describe('SettingsPanel', () => { const onSave = vi.fn() const onClose = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders nothing when not open', () => { const { container } = render( ) expect(container.innerHTML).toBe('') }) it('renders modal when open', () => { render( ) expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('AI Provider Keys')).toBeInTheDocument() expect(screen.getByText(/stored locally/)).toBeInTheDocument() }) it('shows two key fields with labels', () => { render( ) expect(screen.getByText('OpenAI')).toBeInTheDocument() expect(screen.getByText('Google AI')).toBeInTheDocument() }) it('populates fields from settings', () => { render( ) const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement expect(openaiInput.value).toBe('sk-openai-test456') expect(googleInput.value).toBe('') }) it('calls onSave with trimmed keys on save', () => { render( ) const openaiInput = screen.getByTestId('settings-key-openai') fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } }) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith({ anthropic_key: null, openai_key: 'sk-openai-test', google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: 5, }) expect(onClose).toHaveBeenCalled() }) it('converts empty/whitespace keys to null', () => { render( ) // Clear the openai key field const openaiInput = screen.getByTestId('settings-key-openai') fireEvent.change(openaiInput, { target: { value: ' ' } }) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith({ anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: 5, }) }) it('calls onClose when Cancel is clicked', () => { render( ) fireEvent.click(screen.getByText('Cancel')) expect(onClose).toHaveBeenCalled() }) it('calls onClose when close button is clicked', () => { render( ) fireEvent.click(screen.getByTitle('Close settings')) expect(onClose).toHaveBeenCalled() }) it('calls onClose on Escape key', () => { render( ) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' }) expect(onClose).toHaveBeenCalled() }) it('saves on Cmd+Enter', () => { render( ) const openaiInput = screen.getByTestId('settings-key-openai') fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } }) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true }) expect(onSave).toHaveBeenCalledWith({ anthropic_key: null, openai_key: 'sk-openai-test', google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: 5, }) }) it('calls onClose when clicking backdrop', () => { render( ) fireEvent.click(screen.getByTestId('settings-panel')) expect(onClose).toHaveBeenCalled() }) it('clears a key field when X button is clicked', () => { render( ) const clearBtn = screen.getByTestId('clear-openai') fireEvent.click(clearBtn) const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement expect(openaiInput.value).toBe('') }) it('shows keyboard shortcut hint in footer', () => { render( ) expect(screen.getByText(/to open settings/)).toBeInTheDocument() }) it('resets fields when reopened with different settings', () => { const { rerender } = render( ) // Verify initial state const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement expect(openaiInput.value).toBe('sk-openai-test456') // Close and reopen with different settings rerender( ) const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' } rerender( ) const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement expect(updatedInput.value).toBe('new-key') }) describe('GitHub OAuth section', () => { it('shows Login with GitHub button when not connected', () => { render( ) expect(screen.getByTestId('github-login')).toBeInTheDocument() expect(screen.getByText('Login with GitHub')).toBeInTheDocument() }) it('does not show GitHub token input field', () => { render( ) expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument() }) it('shows connected state with username when GitHub is connected', () => { const connectedSettings: Settings = { ...emptySettings, github_token: 'gho_test_token', github_username: 'lucaong', } render( ) expect(screen.getByTestId('github-connected')).toBeInTheDocument() expect(screen.getByText('lucaong')).toBeInTheDocument() expect(screen.getByText('Connected')).toBeInTheDocument() expect(screen.getByTestId('github-disconnect')).toBeInTheDocument() }) it('clears GitHub connection on disconnect', () => { const connectedSettings: Settings = { ...emptySettings, github_token: 'gho_test_token', github_username: 'lucaong', } render( ) fireEvent.click(screen.getByTestId('github-disconnect')) // onSave should be called with cleared GitHub fields expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ github_token: null, github_username: null, })) }) it('shows waiting state with user code during OAuth flow', async () => { mockInvokeFn.mockImplementation(async (cmd: string) => { if (cmd === 'github_device_flow_start') { return { device_code: 'test_device_code', user_code: 'TEST-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5, } } if (cmd === 'github_device_flow_poll') { return { status: 'pending', access_token: null, error: 'authorization_pending' } } return null }) render( ) fireEvent.click(screen.getByTestId('github-login')) await waitFor(() => { expect(screen.getByTestId('github-waiting')).toBeInTheDocument() expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234') }) expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device') }) it('shows verification URL as clickable link in waiting state', async () => { mockInvokeFn.mockImplementation(async (cmd: string) => { if (cmd === 'github_device_flow_start') { return { device_code: 'test_device_code', user_code: 'TEST-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5, } } if (cmd === 'github_device_flow_poll') { return { status: 'pending', access_token: null, error: 'authorization_pending' } } return null }) render( ) fireEvent.click(screen.getByTestId('github-login')) await waitFor(() => { const urlButton = screen.getByTestId('github-open-url') expect(urlButton).toBeInTheDocument() expect(urlButton).toHaveTextContent('https://github.com/login/device') }) }) it('shows retry button when OAuth flow errors', async () => { mockInvokeFn.mockImplementation(async (cmd: string) => { if (cmd === 'github_device_flow_start') { throw 'Network error' } return null }) render( ) fireEvent.click(screen.getByTestId('github-login')) await waitFor(() => { expect(screen.getByTestId('github-error')).toHaveTextContent('Network error') expect(screen.getByTestId('github-retry')).toBeInTheDocument() }) }) it('shows GitHub section description about connecting', () => { render( ) expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument() }) it('displays the actual backend error string when device flow start fails', async () => { mockInvokeFn.mockImplementation(async (cmd: string) => { if (cmd === 'github_device_flow_start') { // Tauri invoke rejects with a plain string, not an Error instance throw 'GitHub device flow not available. Ensure a GitHub App is registered.' } return null }) render( ) fireEvent.click(screen.getByTestId('github-login')) await waitFor(() => { expect(screen.getByTestId('github-error')).toHaveTextContent( 'GitHub device flow not available. Ensure a GitHub App is registered.' ) }) }) it('prevents double-click by disabling button during login flow', async () => { let resolveStart: ((v: unknown) => void) | null = null mockInvokeFn.mockImplementation(async (cmd: string) => { if (cmd === 'github_device_flow_start') { return new Promise(r => { resolveStart = r }) } return null }) render( ) const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement fireEvent.click(loginBtn) // Button should be disabled while waiting await waitFor(() => { expect(loginBtn.disabled).toBe(true) }) // Clean up resolveStart?.({ device_code: 'dc', user_code: 'UC-1234', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5, }) }) }) })