Files
tolaria/src/components/SettingsPanel.test.tsx

942 lines
34 KiB
TypeScript
Raw Normal View History

import { beforeEach, describe, expect, it, vi } from 'vitest'
2026-05-17 19:15:55 +02:00
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
2026-04-24 22:26:07 +02:00
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
2026-05-03 16:15:03 +02:00
import type { AiAgentsStatus } from '../lib/aiAgents'
2026-05-11 16:37:06 +02:00
import type { VaultOption } from './StatusBar'
const { trackEventMock } = vi.hoisted(() => ({
trackEventMock: vi.fn(),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
const emptySettings: Settings = {
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79) * feat: add auto-pull vault sync with conflict handling - Add git_pull, has_remote, get_conflict_files to Rust backend - Add GitPullResult type and auto_pull_interval_minutes to Settings - Create useAutoSync hook (pull on launch, focus, periodic interval) - Update StatusBar with real sync status indicator - Add pull interval setting to SettingsPanel - Add mock handler for git_pull - Update existing tests for new Settings field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for git pull, settings, and useAutoSync hook - Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated), get_conflict_files, parse_updated_files, GitPullResult serialization - Add frontend tests: useAutoSync (mount pull, focus pull, conflict, error, manual trigger, concurrent prevention, no_remote) - Fix existing settings tests for new auto_pull_interval_minutes field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add auto-pull-vault wireframes Frames showing: sync idle, syncing, conflict indicator, settings sync section, and conflict toast notification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add auto_pull_interval_minutes to all Settings literals Fix build error and test fixtures missing the new field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: cargo fmt * ci: re-trigger CI after flaky test * ci: retrigger after disk space cleanup --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
auto_pull_interval_minutes: null,
2026-05-16 12:13:20 +02:00
git_enabled: null,
autogit_enabled: null,
autogit_idle_threshold_seconds: null,
autogit_inactive_threshold_seconds: null,
2026-04-24 15:04:08 +09:00
auto_advance_inbox_after_organize: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
2026-04-24 22:26:07 +02:00
theme_mode: null,
2026-04-26 08:18:47 +02:00
ui_language: null,
2026-05-11 13:46:18 +02:00
date_display_format: null,
2026-04-28 08:55:40 +02:00
default_ai_agent: null,
hide_gitignored_files: null,
all_notes_show_pdfs: null,
all_notes_show_images: null,
all_notes_show_unsupported: null,
}
2026-05-11 16:37:06 +02:00
const workspaceVaults: VaultOption[] = [
{ label: 'Personal Notes', path: '/personal', alias: 'personal', color: 'purple', available: true, mounted: true },
{ label: 'Team Vault', path: '/team', alias: 'team', available: true, mounted: false },
]
function installPointerCapturePolyfill() {
if (!HTMLElement.prototype.hasPointerCapture) {
HTMLElement.prototype.hasPointerCapture = () => false
}
if (!HTMLElement.prototype.setPointerCapture) {
HTMLElement.prototype.setPointerCapture = () => {}
}
if (!HTMLElement.prototype.releasePointerCapture) {
HTMLElement.prototype.releasePointerCapture = () => {}
}
}
2026-04-24 22:26:07 +02:00
function createStorageMock(): Storage {
const values = new Map<string, string>()
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) }),
}
}
2026-05-05 15:00:24 +02:00
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()
2026-04-24 22:26:07 +02:00
const localStorageMock = createStorageMock()
2026-05-05 15:00:24 +02:00
function renderOpenSettings(settings: Settings = emptySettings) {
return render(
<SettingsPanel open={true} settings={settings} onSave={onSave} onClose={onClose} />
)
}
function saveSettingsPanel() {
fireEvent.click(screen.getByTestId('settings-save'))
}
function expectSettingsSaved(partial: Partial<Settings>) {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining(partial))
}
function selectThemeMode(label: string) {
fireEvent.click(screen.getByRole('radio', { name: label }))
}
beforeEach(() => {
vi.clearAllMocks()
trackEventMock.mockClear()
2026-04-24 22:26:07 +02:00
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
2026-05-05 15:00:24 +02:00
installMatchMedia(false)
2026-04-24 22:26:07 +02:00
window.localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
installPointerCapturePolyfill()
})
it('renders nothing when not open', () => {
const { container } = render(
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(container.innerHTML).toBe('')
})
it('renders modal when open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText('Settings')).toBeInTheDocument()
2026-05-03 16:15:03 +02:00
expect(screen.getAllByText('Sync & Updates').length).toBeGreaterThan(0)
})
it('separates local agents, local models, and API models in AI settings', async () => {
2026-05-03 16:15:03 +02:00
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 },
2026-05-29 00:46:59 +02:00
kiro: { status: 'missing', version: null },
2026-05-03 16:15:03 +02:00
}
render(
<SettingsPanel
open={true}
settings={emptySettings}
aiAgentsStatus={aiAgentsStatus}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByText('Recognized local agents')).toBeInTheDocument()
2026-05-03 16:15:03 +02:00
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 local agents')).not.toBeInTheDocument()
2026-05-03 16:15:03 +02:00
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()
})
2026-05-12 22:30:24 +02:00
it('lets users disable AI surfaces without showing missing-agent setup', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, ai_features_enabled: false }}
onSave={onSave}
onClose={onClose}
/>
)
expect(within(screen.getByTestId('settings-ai-features-enabled')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
expect(screen.queryByText('Recognized local agents')).not.toBeInTheDocument()
2026-05-12 22:30:24 +02:00
fireEvent.click(within(screen.getByTestId('settings-ai-features-enabled')).getByRole('switch'))
saveSettingsPanel()
expectSettingsSaved({ ai_features_enabled: true })
})
2026-04-26 09:46:11 +02:00
it('updates the draft language when stored settings finish loading', () => {
const { rerender } = render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
rerender(
<SettingsPanel
open={true}
settings={{ ...emptySettings, ui_language: 'zh-CN' }}
2026-04-26 09:46:11 +02:00
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByText('设置')).toBeInTheDocument()
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
2026-04-30 21:01:09 +02:00
}, 10_000)
2026-04-26 09:46:11 +02:00
it('calls onSave with stable defaults on save', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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,
2026-04-24 22:26:07 +02:00
theme_mode: 'light',
2026-05-11 13:46:18 +02:00
date_display_format: 'friendly',
note_width_mode: 'normal',
sidebar_type_pluralization_enabled: true,
2026-04-28 08:55:40 +02:00
hide_gitignored_files: true,
all_notes_show_pdfs: false,
all_notes_show_images: false,
all_notes_show_unsupported: false,
2026-05-11 16:37:06 +02:00
multi_workspace_enabled: false,
2026-04-28 08:55:40 +02:00
}))
expect(onClose).toHaveBeenCalled()
})
2026-05-11 16:37:06 +02:00
it('keeps vault identity management hidden until multiple vaults are enabled', () => {
const onUpdateWorkspaceIdentity = vi.fn()
2026-05-14 17:17:54 +02:00
const onReorderVaults = vi.fn()
2026-05-11 16:37:06 +02:00
render(
<SettingsPanel
open={true}
settings={emptySettings}
vaults={workspaceVaults}
defaultWorkspacePath="/personal"
onSave={onSave}
2026-05-14 17:17:54 +02:00
onReorderVaults={onReorderVaults}
2026-05-11 16:37:06 +02:00
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onClose={onClose}
/>,
)
expect(screen.getAllByText('Vaults').length).toBeGreaterThan(0)
expect(screen.queryByTestId('settings-workspace-row-personal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('switch', { name: 'Use multiple vaults at the same time' }))
expect(screen.getByTestId('settings-workspace-row-personal')).toBeInTheDocument()
expect(screen.getByLabelText('Vault name for Personal Notes')).toBeInTheDocument()
const labelInput = screen.getByLabelText('Vault label for Personal Notes') as HTMLInputElement
expect(labelInput).toHaveValue('PN')
const slugInput = screen.getByLabelText('Vault slug for Personal Notes') as HTMLInputElement
expect(slugInput).toBeInTheDocument()
expect(slugInput.readOnly).toBe(true)
expect(screen.getAllByLabelText('The display name shown in menus, settings, and vault selectors.').length).toBeGreaterThan(0)
expect(screen.getAllByLabelText('The short initials shown on notes, search results, breadcrumbs, and vault badges.').length).toBeGreaterThan(0)
expect(screen.getAllByLabelText('The stable prefix used in cross-vault links and relationships. It is read-only for now to avoid breaking existing references.').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'Make Default' })).toHaveAttribute('data-variant', 'secondary')
expect(within(screen.getByTestId('settings-workspace-row-personal')).getByRole('button', { name: 'Purple' }).getAttribute('style')).toContain('2px solid var(--foreground)')
2026-05-14 17:17:54 +02:00
expect(screen.getByRole('button', { name: 'Move vault Personal Notes up' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Move vault Personal Notes down' }))
expect(onReorderVaults).toHaveBeenCalledWith(['/team', '/personal'])
2026-05-11 16:37:06 +02:00
const nameInput = screen.getByLabelText('Vault name for Personal Notes')
fireEvent.change(nameInput, {
target: { value: 'Personal Main' },
})
expect(nameInput).toHaveValue('Personal Main')
expect(onUpdateWorkspaceIdentity).not.toHaveBeenCalled()
fireEvent.blur(nameInput)
expect(onUpdateWorkspaceIdentity).toHaveBeenCalledWith('/personal', { label: 'Personal Main' })
onUpdateWorkspaceIdentity.mockClear()
fireEvent.change(labelInput, {
target: { value: 'pm' },
})
expect(labelInput).toHaveValue('PM')
expect(onUpdateWorkspaceIdentity).not.toHaveBeenCalled()
fireEvent.blur(labelInput)
expect(onUpdateWorkspaceIdentity).toHaveBeenCalledWith('/personal', { shortLabel: 'PM' })
fireEvent.change(slugInput, {
target: { value: 'personal-main' },
})
expect(onUpdateWorkspaceIdentity).not.toHaveBeenCalledWith('/personal', { alias: 'personal-main' })
saveSettingsPanel()
expectSettingsSaved({ multi_workspace_enabled: true })
})
it('confirms before removing a non-default vault from settings', () => {
const onRemoveVault = vi.fn()
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, multi_workspace_enabled: true }}
vaults={workspaceVaults}
defaultWorkspacePath="/personal"
onSave={onSave}
onRemoveVault={onRemoveVault}
onUpdateWorkspaceIdentity={vi.fn()}
onClose={onClose}
/>,
)
expect(screen.getByRole('button', { name: 'Remove vault Personal Notes' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Remove vault Team Vault' }))
expect(screen.getByTestId('confirm-delete-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Remove vault' }))
expect(onRemoveVault).toHaveBeenCalledWith('/team')
})
2026-04-28 08:55:40 +02:00
it('saves Gitignored content visibility immediately for keyboard close', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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()
})
2026-05-03 16:15:03 +02:00
it('renders All Notes file visibility switches off by default', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-03 16:15:03 +02:00
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')
})
2026-05-03 16:15:03 +02:00
it('preserves saved All Notes file visibility switches', () => {
render(
<SettingsPanel
open={true}
settings={{
...emptySettings,
all_notes_show_pdfs: true,
all_notes_show_images: true,
all_notes_show_unsupported: false,
}}
onSave={onSave}
onClose={onClose}
/>
)
2026-05-03 16:15:03 +02:00
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')
})
2026-05-03 16:15:03 +02:00
it('saves All Notes file visibility immediately before Escape close', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-03 16:15:03 +02:00
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-03 16:15:03 +02:00
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) }),
)
})
2026-04-24 22:26:07 +02:00
it('defaults the color mode control to light', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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')
2026-05-05 15:00:24 +02:00
expect(screen.getByRole('radio', { name: 'System' })).toHaveAttribute('aria-checked', 'false')
2026-04-24 22:26:07 +02:00
})
2026-04-26 08:18:47 +02:00
it('defaults the language selector to system language', () => {
render(
<SettingsPanel
open={true}
settings={emptySettings}
locale="en"
systemLocale="zh-CN"
2026-04-26 08:18:47 +02:00
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByTestId('settings-ui-language')).toHaveAttribute('data-value', 'system')
expect(screen.getByText('系统(简体中文)')).toBeInTheDocument()
})
2026-05-11 13:46:18 +02:00
it('defaults date display to friendly, note width to normal, and sidebar type pluralization to enabled', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-11 13:46:18 +02:00
expect(screen.getByTestId('settings-date-display-format')).toHaveAttribute('data-value', 'friendly')
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')
})
2026-05-11 13:46:18 +02:00
it('preserves saved date display, default note width, and sidebar type pluralization preferences', () => {
render(
<SettingsPanel
open={true}
settings={{
...emptySettings,
2026-05-11 13:46:18 +02:00
date_display_format: 'iso',
note_width_mode: 'wide',
sidebar_type_pluralization_enabled: false,
}}
onSave={onSave}
onClose={onClose}
/>
)
2026-05-11 13:46:18 +02:00
expect(screen.getByTestId('settings-date-display-format')).toHaveAttribute('data-value', 'iso')
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')
})
2026-05-11 13:46:18 +02:00
it('saves date display, default note width, and sidebar type pluralization preferences', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-11 13:46:18 +02:00
fireEvent.pointerDown(screen.getByTestId('settings-date-display-format'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: 'ISO (2026-05-11)' }))
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({
2026-05-11 13:46:18 +02:00
date_display_format: 'iso',
note_width_mode: 'wide',
sidebar_type_pluralization_enabled: false,
}))
2026-05-11 13:46:18 +02:00
expect(trackEventMock).toHaveBeenCalledWith('date_display_format_changed', { format: 'iso' })
})
2026-04-26 08:18:47 +02:00
it('keeps the language selector keyboard accessible', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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',
2026-04-26 08:18:47 +02:00
}))
})
2026-04-24 22:26:07 +02:00
it('uses the stored color mode mirror when settings have no saved mode', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true')
})
it('saves the selected dark color mode', () => {
2026-05-05 15:00:24 +02:00
renderOpenSettings()
2026-04-24 22:26:07 +02:00
2026-05-05 15:00:24 +02:00
selectThemeMode('Dark')
saveSettingsPanel()
2026-04-24 22:26:07 +02:00
2026-05-05 15:00:24 +02:00
expectSettingsSaved({
theme_mode: 'dark',
2026-05-05 15:00:24 +02:00
})
})
it('applies the selected dark color mode immediately while settings stays open', () => {
2026-05-05 15:00:24 +02:00
renderOpenSettings()
2026-05-05 15:00:24 +02:00
selectThemeMode('Dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
2026-05-05 15:00:24 +02:00
expectSettingsSaved({
2026-04-24 22:26:07 +02:00
theme_mode: 'dark',
2026-05-05 15:00:24 +02:00
})
})
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',
})
2026-04-24 22:26:07 +02:00
})
it('preserves a saved dark color mode until changed', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, theme_mode: 'dark' }}
onSave={onSave}
onClose={onClose}
/>
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel
open={true}
settings={{ ...emptySettings, release_channel: 'beta' }}
onSave={onSave}
onClose={onClose}
/>
)
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(
<SettingsPanel open={true} settings={alphaSettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
release_channel: 'alpha',
}))
})
it('defaults the organization workflow switch to on', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true')
})
2026-04-24 15:04:08 +09:00
it('defaults auto-advance to the next inbox item to off', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('switch', { name: 'Auto-advance to next Inbox item' })).toHaveAttribute('aria-checked', 'false')
})
2026-04-16 12:18:11 +02:00
it('defaults the initial H1 auto-rename switch to on', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
2026-05-16 12:13:20 +02:00
expect(screen.getAllByText('Git')).not.toHaveLength(0)
expect(screen.getByRole('switch', { name: 'Enable Git features' })).toHaveAttribute('aria-checked', 'true')
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)
})
2026-05-16 12:13:20 +02:00
it('saves the global Git feature preference when toggled off', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('switch', { name: 'Enable Git features' }))
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
git_enabled: false,
}))
})
it('disables AutoGit controls when Git features are disabled globally', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, git_enabled: false, autogit_enabled: true }}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByRole('switch', { name: 'Enable Git features' })).toHaveAttribute('aria-checked', 'false')
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 AutoGit preferences when toggled and edited', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel
open={true}
settings={emptySettings}
isGitVault={false}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toBeDisabled()
expect(screen.getByTestId('settings-autogit-idle-threshold')).toBeDisabled()
expect(screen.getByTestId('settings-autogit-inactive-threshold')).toBeDisabled()
})
2026-04-16 12:18:11 +02:00
it('saves the initial H1 auto-rename preference when toggled off', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel
open={true}
settings={emptySettings}
onSave={onSave}
explicitOrganizationEnabled={true}
onSaveExplicitOrganization={onSaveExplicitOrganization}
onClose={onClose}
/>
)
fireEvent.click(screen.getByRole('switch', { name: 'Organize notes explicitly' }))
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false)
})
2026-04-24 15:04:08 +09:00
it('saves the auto-advance inbox preference when toggled on', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalled()
})
it('calls onClose when close button is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTitle('Close settings'))
expect(onClose).toHaveBeenCalled()
})
it('calls onClose on Escape key', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('saves on Cmd+Enter', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79) * feat: add auto-pull vault sync with conflict handling - Add git_pull, has_remote, get_conflict_files to Rust backend - Add GitPullResult type and auto_pull_interval_minutes to Settings - Create useAutoSync hook (pull on launch, focus, periodic interval) - Update StatusBar with real sync status indicator - Add pull interval setting to SettingsPanel - Add mock handler for git_pull - Update existing tests for new Settings field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for git pull, settings, and useAutoSync hook - Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated), get_conflict_files, parse_updated_files, GitPullResult serialization - Add frontend tests: useAutoSync (mount pull, focus pull, conflict, error, manual trigger, concurrent prevention, no_remote) - Fix existing settings tests for new auto_pull_interval_minutes field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add auto-pull-vault wireframes Frames showing: sync idle, syncing, conflict indicator, settings sync section, and conflict toast notification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add auto_pull_interval_minutes to all Settings literals Fix build error and test fixtures missing the new field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: cargo fmt * ci: re-trigger CI after flaky test * ci: retrigger after disk space cleanup --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
auto_pull_interval_minutes: 5,
}))
})
it('calls onClose when clicking backdrop', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-panel'))
expect(onClose).toHaveBeenCalled()
})
it('shows keyboard shortcut hint in footer', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
})
it('keeps Tab focus inside the settings panel', () => {
render(
<>
<button type="button" data-testid="background-action">Background</button>
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
</>
)
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()
})
2026-05-17 19:15:55 +02:00
it('does not trap focus away from a portaled settings dropdown', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
act(() => {
fireEvent.pointerDown(screen.getByTestId('settings-default-ai-agent'), { button: 0, pointerType: 'mouse' })
})
const option = screen.getByRole('option', { name: /Codex/i })
act(() => {
option.focus()
})
const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
act(() => {
document.dispatchEvent(event)
})
expect(event.defaultPrevented).toBe(false)
expect(screen.getByTitle('Close settings')).not.toHaveFocus()
expect(screen.getByTestId('settings-save')).not.toHaveFocus()
})
2026-04-29 13:39:05 +02:00
it('copies the MCP config from the AI Agents section', () => {
const onCopyMcpConfig = vi.fn()
render(
<SettingsPanel
open={true}
settings={emptySettings}
onSave={onSave}
onCopyMcpConfig={onCopyMcpConfig}
onClose={onClose}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
describe('Privacy & Telemetry section', () => {
it('renders crash reporting and analytics toggles', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={withTelemetry} onSave={onSave} onClose={onClose} />
)
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(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
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,
}))
})
})
})