import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, within } from '@testing-library/react' import { SettingsPanel } from './SettingsPanel' import type { Settings } from '../types' import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode' import type { AiAgentsStatus } from '../lib/aiAgents' const { trackEventMock } = vi.hoisted(() => ({ trackEventMock: vi.fn(), })) vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock, })) const emptySettings: Settings = { auto_pull_interval_minutes: null, autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, auto_advance_inbox_after_organize: null, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null, theme_mode: null, ui_language: null, default_ai_agent: null, hide_gitignored_files: null, all_notes_show_pdfs: null, all_notes_show_images: null, all_notes_show_unsupported: null, } function installPointerCapturePolyfill() { if (!HTMLElement.prototype.hasPointerCapture) { HTMLElement.prototype.hasPointerCapture = () => false } if (!HTMLElement.prototype.setPointerCapture) { HTMLElement.prototype.setPointerCapture = () => {} } if (!HTMLElement.prototype.releasePointerCapture) { HTMLElement.prototype.releasePointerCapture = () => {} } } function createStorageMock(): Storage { const values = new Map() return { get length() { return values.size }, clear: vi.fn(() => { values.clear() }), getItem: vi.fn((key: string) => values.get(key) ?? null), key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), removeItem: vi.fn((key: string) => { values.delete(key) }), setItem: vi.fn((key: string, value: string) => { values.set(key, value) }), } } function installMatchMedia(matches = false) { Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn((query: string) => ({ matches, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(() => true), })), }) } describe('SettingsPanel', () => { const onSave = vi.fn() const onClose = vi.fn() const localStorageMock = createStorageMock() function renderOpenSettings(settings: Settings = emptySettings) { return render( ) } function saveSettingsPanel() { fireEvent.click(screen.getByTestId('settings-save')) } function expectSettingsSaved(partial: Partial) { expect(onSave).toHaveBeenCalledWith(expect.objectContaining(partial)) } function selectThemeMode(label: string) { fireEvent.click(screen.getByRole('radio', { name: label })) } beforeEach(() => { vi.clearAllMocks() trackEventMock.mockClear() Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) installMatchMedia(false) window.localStorage.clear() document.documentElement.removeAttribute('data-theme') document.documentElement.classList.remove('dark') installPointerCapturePolyfill() }) 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.getAllByText('Sync & Updates').length).toBeGreaterThan(0) }) it('separates coding agents, local models, and API models in AI settings', async () => { const aiAgentsStatus: AiAgentsStatus = { claude_code: { status: 'installed', version: '2.1.18' }, codex: { status: 'missing', version: null }, opencode: { status: 'missing', version: null }, pi: { status: 'missing', version: null }, gemini: { status: 'missing', version: null }, } render( ) expect(screen.getByText('Recognized coding agents')).toBeInTheDocument() expect(screen.getByText('Claude Code')).toBeInTheDocument() expect(screen.getByText('2.1.18')).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Local model' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'API model' })).toBeInTheDocument() fireEvent.mouseDown(screen.getByRole('tab', { name: 'Local model' }), { button: 0, ctrlKey: false }) fireEvent.change(screen.getByLabelText('Model ID'), { target: { value: 'llama3.2' } }) fireEvent.click(screen.getByRole('button', { name: 'Test model' })) expect(await screen.findByText('Connection works. The model replied successfully.')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add local model' })).toBeInTheDocument() expect(screen.queryByText('Recognized coding agents')).not.toBeInTheDocument() fireEvent.mouseDown(screen.getByRole('tab', { name: 'API model' }), { button: 0, ctrlKey: false }) expect(screen.getByRole('button', { name: 'Add API model' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Add local model' })).not.toBeInTheDocument() fireEvent.pointerDown(screen.getByText('OpenAI').closest('button')!, { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: 'Gemini' })) expect(screen.getByDisplayValue('Gemini')).toBeInTheDocument() expect(screen.getByDisplayValue('https://generativelanguage.googleapis.com/v1beta/openai')).toBeInTheDocument() expect(screen.getByPlaceholderText('gemini-2.5-flash')).toBeInTheDocument() }) it('updates the draft language when stored settings finish loading', () => { const { rerender } = render( ) rerender( ) expect(screen.getByText('设置')).toBeInTheDocument() expect(screen.queryByText('Settings')).not.toBeInTheDocument() }, 10_000) it('calls onSave with stable defaults on save', () => { render( ) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ auto_pull_interval_minutes: 5, autogit_enabled: false, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, release_channel: null, theme_mode: 'light', note_width_mode: 'normal', sidebar_type_pluralization_enabled: true, hide_gitignored_files: true, all_notes_show_pdfs: false, all_notes_show_images: false, all_notes_show_unsupported: false, })) expect(onClose).toHaveBeenCalled() }) it('saves Gitignored content visibility immediately for keyboard close', () => { render( ) fireEvent.click(screen.getByTestId('settings-hide-gitignored-files')) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' }) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ hide_gitignored_files: false, })) expect(onClose).toHaveBeenCalled() }) it('renders All Notes file visibility switches off by default', () => { render( ) expect(screen.getByText('Show PDFs')).toBeInTheDocument() expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'false') expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'false') expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false') }) it('preserves saved All Notes file visibility switches', () => { render( ) expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'true') expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'true') expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false') }) it('saves All Notes file visibility immediately before Escape close', () => { render( ) const pdfSwitch = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch') fireEvent.click(pdfSwitch) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' }) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ all_notes_show_pdfs: true, all_notes_show_images: false, all_notes_show_unsupported: false, })) expect(onClose).toHaveBeenCalled() }) it('tracks All Notes visibility toggles with categorical metadata only', () => { render( ) fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')) expect(trackEventMock).toHaveBeenCalledWith('all_notes_visibility_changed', { category: 'images', enabled: 1, }) expect(trackEventMock).not.toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ path: expect.any(String) }), ) }) it('defaults the color mode control to light', () => { render( ) expect(screen.getByTestId('settings-theme-mode')).toBeInTheDocument() expect(screen.getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true') expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false') expect(screen.getByRole('radio', { name: 'System' })).toHaveAttribute('aria-checked', 'false') }) it('defaults the language selector to system language', () => { render( ) expect(screen.getByTestId('settings-ui-language')).toHaveAttribute('data-value', 'system') expect(screen.getByText('系统(简体中文)')).toBeInTheDocument() }) it('defaults note width to normal and sidebar type pluralization to enabled', () => { render( ) expect(screen.getByTestId('settings-default-note-width')).toHaveAttribute('data-value', 'normal') expect( within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch') ).toHaveAttribute('aria-checked', 'true') }) it('preserves saved default note width and sidebar type pluralization preferences', () => { render( ) expect(screen.getByTestId('settings-default-note-width')).toHaveAttribute('data-value', 'wide') expect( within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch') ).toHaveAttribute('aria-checked', 'false') }) it('saves default note width and sidebar type pluralization preferences', () => { render( ) fireEvent.pointerDown(screen.getByTestId('settings-default-note-width'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: 'Wide' })) fireEvent.click(within(screen.getByTestId('settings-sidebar-type-pluralization')).getByRole('switch')) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ note_width_mode: 'wide', sidebar_type_pluralization_enabled: false, })) }) it('keeps the language selector keyboard accessible', () => { render( ) const trigger = screen.getByTestId('settings-ui-language') trigger.focus() fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' }) expect(screen.getByRole('option', { name: 'Simplified Chinese' })).toBeInTheDocument() }) it('saves the selected UI language and updates visible settings text', () => { render( ) fireEvent.pointerDown(screen.getByTestId('settings-ui-language'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: 'Simplified Chinese' })) expect(screen.getByText('设置')).toBeInTheDocument() fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ ui_language: 'zh-CN', })) }) it('uses the stored color mode mirror when settings have no saved mode', () => { window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark') render( ) expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true') }) it('saves the selected dark color mode', () => { renderOpenSettings() selectThemeMode('Dark') saveSettingsPanel() expectSettingsSaved({ theme_mode: 'dark', }) }) it('applies the selected dark color mode immediately while settings stays open', () => { renderOpenSettings() selectThemeMode('Dark') expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(document.documentElement).toHaveClass('dark') expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark') expectSettingsSaved({ theme_mode: 'dark', }) }) it('saves system color mode while applying the current OS appearance immediately', () => { installMatchMedia(true) renderOpenSettings() selectThemeMode('System') saveSettingsPanel() expect(document.documentElement).toHaveAttribute('data-theme', 'dark') expect(document.documentElement).toHaveClass('dark') expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('system') expectSettingsSaved({ theme_mode: 'system', }) }) it('preserves a saved dark color mode until changed', () => { render( ) expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true') fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ theme_mode: 'dark', })) }) it('defaults the release channel trigger to stable', () => { render( ) expect(screen.getByTestId('settings-release-channel')).toHaveAttribute('data-value', 'stable') expect(screen.queryByText(/Beta\/Stable/i)).not.toBeInTheDocument() }) it('anchors the default agent dropdown with the popper strategy', () => { render( ) fireEvent.pointerDown(screen.getByTestId('settings-default-ai-agent'), { button: 0, pointerType: 'mouse' }) expect(document.querySelector('[data-anchor-strategy="popper"]')).toBeInTheDocument() }) it('keeps keyboard opening enabled for the default agent dropdown', () => { render( ) const trigger = screen.getByTestId('settings-default-ai-agent') trigger.focus() fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' }) expect(document.querySelector('[data-anchor-strategy="popper"]')).toBeInTheDocument() expect(screen.getByRole('option', { name: /Codex/i })).toBeInTheDocument() }) it('treats a legacy beta release channel as stable', () => { render( ) expect(screen.getByTestId('settings-release-channel')).toHaveAttribute('data-value', 'stable') expect(screen.queryByText('Beta')).not.toBeInTheDocument() }) it('preserves alpha when alpha is already selected', () => { const alphaSettings: Settings = { ...emptySettings, release_channel: 'alpha', } render( ) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ release_channel: 'alpha', })) }) it('defaults the organization workflow switch to on', () => { render( ) expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true') }) it('defaults auto-advance to the next inbox item to off', () => { render( ) expect(screen.getByRole('switch', { name: 'Auto-advance to next Inbox item' })).toHaveAttribute('aria-checked', 'false') }) it('defaults the initial H1 auto-rename switch to on', () => { render( ) expect(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })).toHaveAttribute('aria-checked', 'true') }) it('defaults AutoGit to off with recommended thresholds', () => { render( ) expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toHaveAttribute('aria-checked', 'false') expect(screen.getByTestId('settings-autogit-idle-threshold')).toHaveValue(90) expect(screen.getByTestId('settings-autogit-inactive-threshold')).toHaveValue(30) }) it('saves AutoGit preferences when toggled and edited', () => { render( ) fireEvent.click(screen.getByRole('switch', { name: 'Enable AutoGit' })) fireEvent.change(screen.getByTestId('settings-autogit-idle-threshold'), { target: { value: '120' } }) fireEvent.change(screen.getByTestId('settings-autogit-inactive-threshold'), { target: { value: '45' } }) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ autogit_enabled: true, autogit_idle_threshold_seconds: 120, autogit_inactive_threshold_seconds: 45, })) }) it('disables AutoGit controls when the current vault is not git-enabled', () => { render( ) expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toBeDisabled() expect(screen.getByTestId('settings-autogit-idle-threshold')).toBeDisabled() expect(screen.getByTestId('settings-autogit-inactive-threshold')).toBeDisabled() }) it('saves the initial H1 auto-rename preference when toggled off', () => { render( ) fireEvent.click(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ initial_h1_auto_rename_enabled: false, })) }) it('saves the organization workflow preference when toggled off', () => { const onSaveExplicitOrganization = vi.fn() render( ) fireEvent.click(screen.getByRole('switch', { name: 'Organize notes explicitly' })) fireEvent.click(screen.getByTestId('settings-save')) expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false) }) it('saves the auto-advance inbox preference when toggled on', () => { render( ) fireEvent.click(screen.getByRole('switch', { name: 'Auto-advance to next Inbox item' })) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ auto_advance_inbox_after_organize: true, })) }) 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( ) fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true }) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ auto_pull_interval_minutes: 5, })) }) it('calls onClose when clicking backdrop', () => { render( ) fireEvent.click(screen.getByTestId('settings-panel')) expect(onClose).toHaveBeenCalled() }) it('shows keyboard shortcut hint in footer', () => { render( ) expect(screen.getByText(/to open settings/)).toBeInTheDocument() }) it('keeps Tab focus inside the settings panel', () => { render( <> ) const backgroundAction = screen.getByTestId('background-action') const closeButton = screen.getByTitle('Close settings') const saveButton = screen.getByTestId('settings-save') backgroundAction.focus() fireEvent.keyDown(document, { key: 'Tab' }) expect(closeButton).toHaveFocus() fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }) expect(saveButton).toHaveFocus() fireEvent.keyDown(document, { key: 'Tab' }) expect(closeButton).toHaveFocus() }) it('copies the MCP config from the AI Agents section', () => { const onCopyMcpConfig = vi.fn() render( ) fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' })) expect(onCopyMcpConfig).toHaveBeenCalledOnce() }) describe('Privacy & Telemetry section', () => { it('renders crash reporting and analytics toggles', () => { render( ) expect(screen.getByTestId('settings-crash-reporting')).toBeInTheDocument() expect(screen.getByTestId('settings-analytics')).toBeInTheDocument() }) it('toggles reflect initial settings state', () => { const withTelemetry: Settings = { ...emptySettings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: false, anonymous_id: 'test-uuid', } render( ) const crashCheckbox = within(screen.getByTestId('settings-crash-reporting')).getByRole('checkbox') const analyticsCheckbox = within(screen.getByTestId('settings-analytics')).getByRole('checkbox') expect(crashCheckbox).toHaveAttribute('aria-checked', 'true') expect(analyticsCheckbox).toHaveAttribute('aria-checked', 'false') }) it('saves telemetry settings when toggled and saved', () => { render( ) fireEvent.click(within(screen.getByTestId('settings-crash-reporting')).getByRole('checkbox')) fireEvent.click(screen.getByTestId('settings-save')) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ crash_reporting_enabled: true, analytics_enabled: false, })) }) }) })