mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Add native system notifications as a desktop-wide attention channel for permission prompts and scheduled task completion. The implementation keeps notification presentation owned by the OS, adds a user-facing enable switch with permission handling, and lets scheduled tasks choose desktop notifications without routing that channel through IM adapters. Constraint: Notifications must use OS-native APIs without custom sound playback. Constraint: Desktop channel is local-only and must not be sent through IM adapter delivery. Rejected: Browser Notification API | not reliable inside the packaged Tauri desktop runtime. Rejected: Treat desktop as an IM channel | would leak a local-only channel into server-side adapter sending. Confidence: high Scope-risk: moderate Directive: Keep notification styling at the OS layer; business code should only provide title, body, dedupe, and routing decisions. Tested: bun run check:desktop Tested: bun run quality:pr Tested: Computer Use macOS debug app verification for settings toggle, permission prompt, scheduled task desktop channel, and task-run polling dedupe Not-tested: Windows and Linux native runtime smoke tests on physical hosts
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { Settings } from '../pages/Settings'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
import { useUIStore } from '../stores/uiStore'
|
|
import { useUpdateStore } from '../stores/updateStore'
|
|
import type { SavedProvider } from '../types/provider'
|
|
import type { ProviderPreset } from '../types/providerPreset'
|
|
|
|
const MOCK_DELETE_PROVIDER = vi.fn()
|
|
const MOCK_GET_SETTINGS = vi.fn()
|
|
const MOCK_UPDATE_SETTINGS = vi.fn()
|
|
const desktopNotificationsMock = vi.hoisted(() => ({
|
|
getDesktopNotificationPermission: vi.fn(),
|
|
requestDesktopNotificationPermission: vi.fn(),
|
|
openDesktopNotificationSettings: vi.fn(),
|
|
}))
|
|
const providerStoreState = {
|
|
providers: [] as SavedProvider[],
|
|
activeId: null as string | null,
|
|
hasLoadedProviders: true,
|
|
presets: [] as ProviderPreset[],
|
|
isLoading: false,
|
|
isPresetsLoading: false,
|
|
fetchProviders: vi.fn(),
|
|
fetchPresets: vi.fn(),
|
|
deleteProvider: MOCK_DELETE_PROVIDER,
|
|
activateProvider: vi.fn(),
|
|
activateOfficial: vi.fn(),
|
|
testProvider: vi.fn(),
|
|
createProvider: vi.fn(),
|
|
updateProvider: vi.fn(),
|
|
testConfig: vi.fn(),
|
|
}
|
|
|
|
vi.mock('../api/agents', () => ({
|
|
agentsApi: {
|
|
list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../stores/providerStore', () => ({
|
|
useProviderStore: () => providerStoreState,
|
|
}))
|
|
|
|
vi.mock('../api/providers', () => ({
|
|
providersApi: {
|
|
getSettings: MOCK_GET_SETTINGS,
|
|
updateSettings: MOCK_UPDATE_SETTINGS,
|
|
},
|
|
}))
|
|
|
|
vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock)
|
|
|
|
vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
|
ClaudeOfficialLogin: () => <div data-testid="claude-official-login" />,
|
|
}))
|
|
|
|
vi.mock('../pages/AdapterSettings', () => ({
|
|
AdapterSettings: () => <div>Adapter Settings Mock</div>,
|
|
}))
|
|
|
|
vi.mock('../stores/agentStore', () => ({
|
|
useAgentStore: () => ({
|
|
activeAgents: [],
|
|
allAgents: [],
|
|
isLoading: false,
|
|
error: null,
|
|
selectedAgent: null,
|
|
fetchAgents: vi.fn(),
|
|
selectAgent: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('../stores/skillStore', () => ({
|
|
useSkillStore: () => ({
|
|
skills: [],
|
|
selectedSkill: null,
|
|
isLoading: false,
|
|
isDetailLoading: false,
|
|
error: null,
|
|
fetchSkills: vi.fn(),
|
|
fetchSkillDetail: vi.fn(),
|
|
clearSelection: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('../components/chat/CodeViewer', () => ({
|
|
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
|
|
}))
|
|
|
|
describe('Settings > General tab', () => {
|
|
beforeEach(() => {
|
|
MOCK_DELETE_PROVIDER.mockReset()
|
|
desktopNotificationsMock.getDesktopNotificationPermission.mockReset()
|
|
desktopNotificationsMock.requestDesktopNotificationPermission.mockReset()
|
|
desktopNotificationsMock.openDesktopNotificationSettings.mockReset()
|
|
desktopNotificationsMock.getDesktopNotificationPermission.mockResolvedValue('default')
|
|
desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('granted')
|
|
desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true)
|
|
MOCK_GET_SETTINGS.mockResolvedValue({})
|
|
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
|
providerStoreState.providers = []
|
|
providerStoreState.activeId = null
|
|
providerStoreState.hasLoadedProviders = true
|
|
providerStoreState.presets = []
|
|
providerStoreState.isLoading = false
|
|
providerStoreState.isPresetsLoading = false
|
|
providerStoreState.fetchProviders = vi.fn()
|
|
providerStoreState.fetchPresets = vi.fn()
|
|
providerStoreState.activateProvider = vi.fn()
|
|
providerStoreState.activateOfficial = vi.fn()
|
|
providerStoreState.testProvider = vi.fn()
|
|
providerStoreState.createProvider = vi.fn()
|
|
providerStoreState.updateProvider = vi.fn()
|
|
providerStoreState.testConfig = vi.fn()
|
|
|
|
useSettingsStore.setState({
|
|
locale: 'en',
|
|
thinkingEnabled: true,
|
|
skipWebFetchPreflight: true,
|
|
desktopNotificationsEnabled: true,
|
|
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
|
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
|
useSettingsStore.setState({ thinkingEnabled: enabled })
|
|
}),
|
|
setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => {
|
|
useSettingsStore.setState({ skipWebFetchPreflight: enabled })
|
|
}),
|
|
setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
|
useSettingsStore.setState({ desktopNotificationsEnabled: enabled })
|
|
}),
|
|
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
|
useSettingsStore.setState({ webSearch })
|
|
}),
|
|
})
|
|
|
|
useUIStore.setState({ pendingSettingsTab: null })
|
|
useUpdateStore.setState({
|
|
status: 'idle',
|
|
availableVersion: null,
|
|
releaseNotes: null,
|
|
progressPercent: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
error: null,
|
|
checkedAt: null,
|
|
shouldPrompt: false,
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
checkForUpdates: vi.fn().mockResolvedValue(null),
|
|
installUpdate: vi.fn().mockResolvedValue(undefined),
|
|
dismissPrompt: vi.fn(),
|
|
})
|
|
})
|
|
|
|
it('shows WebFetch preflight toggle enabled by default', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
const toggle = screen.getByLabelText('Skip WebFetch domain preflight')
|
|
expect(toggle).toBeChecked()
|
|
})
|
|
|
|
it('lets the user disable WebFetch preflight skipping', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
const toggle = screen.getByLabelText('Skip WebFetch domain preflight')
|
|
fireEvent.click(toggle)
|
|
|
|
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('lets the user disable thinking mode for new sessions', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
const toggle = screen.getByLabelText('Enable thinking mode')
|
|
expect(toggle).toBeChecked()
|
|
fireEvent.click(toggle)
|
|
|
|
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
|
|
})
|
|
|
|
it('lets the user disable desktop system notifications', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
const toggle = screen.getByLabelText('Enable system notifications')
|
|
expect(toggle).toBeChecked()
|
|
fireEvent.click(toggle)
|
|
|
|
expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(false)
|
|
expect(desktopNotificationsMock.requestDesktopNotificationPermission).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('requests native notification permission when desktop notifications are enabled', async () => {
|
|
useSettingsStore.setState({ desktopNotificationsEnabled: false })
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByLabelText('Enable system notifications'))
|
|
})
|
|
|
|
expect(useSettingsStore.getState().setDesktopNotificationsEnabled).toHaveBeenCalledWith(true)
|
|
await vi.waitFor(() => {
|
|
expect(desktopNotificationsMock.requestDesktopNotificationPermission).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
it('opens system settings when enabling notifications finds system denial', async () => {
|
|
useSettingsStore.setState({ desktopNotificationsEnabled: false })
|
|
desktopNotificationsMock.requestDesktopNotificationPermission.mockResolvedValue('denied')
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByLabelText('Enable system notifications'))
|
|
})
|
|
|
|
await vi.waitFor(() => {
|
|
expect(desktopNotificationsMock.openDesktopNotificationSettings).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
it('saves WebSearch fallback provider settings', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Tavily' }))
|
|
fireEvent.change(screen.getByLabelText('Tavily API key'), {
|
|
target: { value: 'tvly-test-key' },
|
|
})
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
|
|
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
|
|
mode: 'tavily',
|
|
tavilyApiKey: 'tvly-test-key',
|
|
braveApiKey: '',
|
|
})
|
|
})
|
|
|
|
it('links to WebSearch provider API key dashboards', () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByText('General'))
|
|
|
|
expect(screen.getByRole('link', { name: 'Get Tavily API key' })).toHaveAttribute(
|
|
'href',
|
|
'https://app.tavily.com/home',
|
|
)
|
|
expect(screen.getByRole('link', { name: 'Get Brave Search API key' })).toHaveAttribute(
|
|
'href',
|
|
'https://api-dashboard.search.brave.com/app/keys',
|
|
)
|
|
})
|
|
|
|
it('keeps extension tabs available alongside the terminal tab', () => {
|
|
render(<Settings />)
|
|
|
|
expect(screen.queryByText('Install')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Terminal')).toBeInTheDocument()
|
|
expect(screen.getByText('MCP')).toBeInTheDocument()
|
|
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Settings > Providers tab', () => {
|
|
beforeEach(() => {
|
|
MOCK_DELETE_PROVIDER.mockReset()
|
|
MOCK_GET_SETTINGS.mockResolvedValue({})
|
|
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
|
providerStoreState.providers = [
|
|
{
|
|
id: 'provider-1',
|
|
name: 'MiniMax-M2.7-highspeed(openai)',
|
|
presetId: 'custom',
|
|
apiKey: '***',
|
|
baseUrl: 'https://api.minimaxi.com',
|
|
apiFormat: 'openai_chat',
|
|
models: {
|
|
main: 'MiniMax-M2.7-highspeed',
|
|
haiku: '',
|
|
sonnet: '',
|
|
opus: '',
|
|
},
|
|
notes: '',
|
|
},
|
|
]
|
|
providerStoreState.activeId = null
|
|
providerStoreState.hasLoadedProviders = true
|
|
})
|
|
|
|
it('does not query official OAuth status before providers finish loading', () => {
|
|
providerStoreState.providers = []
|
|
providerStoreState.activeId = null
|
|
providerStoreState.hasLoadedProviders = false
|
|
|
|
render(<Settings />)
|
|
|
|
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows official OAuth status only after official provider is confirmed active', () => {
|
|
providerStoreState.providers = []
|
|
providerStoreState.activeId = null
|
|
providerStoreState.hasLoadedProviders = true
|
|
|
|
render(<Settings />)
|
|
|
|
expect(screen.getByTestId('claude-official-login')).toBeInTheDocument()
|
|
})
|
|
|
|
it('requires confirmation before deleting a provider', async () => {
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getAllByText('Delete')[0]!)
|
|
|
|
expect(MOCK_DELETE_PROVIDER).not.toHaveBeenCalled()
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
expect(screen.getByText('Delete provider "MiniMax-M2.7-highspeed(openai)"? This cannot be undone.')).toBeInTheDocument()
|
|
|
|
const dialog = screen.getByRole('dialog')
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }))
|
|
|
|
expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1')
|
|
})
|
|
|
|
it('uses the shared dropdown for API format in the provider form', () => {
|
|
providerStoreState.presets = [
|
|
{
|
|
id: 'custom',
|
|
name: 'Custom',
|
|
baseUrl: 'https://api.example.com/anthropic',
|
|
apiFormat: 'anthropic',
|
|
defaultModels: {
|
|
main: 'custom-main',
|
|
haiku: '',
|
|
sonnet: '',
|
|
opus: '',
|
|
},
|
|
needsApiKey: true,
|
|
websiteUrl: '',
|
|
},
|
|
]
|
|
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
|
|
|
|
const dialog = screen.getByRole('dialog')
|
|
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /Anthropic Messages \(native\)/i }))
|
|
fireEvent.click(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i }))
|
|
|
|
expect(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })).toBeInTheDocument()
|
|
expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument()
|
|
})
|
|
|
|
it('hides the API key by default and reveals it from the eye button', () => {
|
|
providerStoreState.presets = [
|
|
{
|
|
id: 'custom',
|
|
name: 'Custom',
|
|
baseUrl: 'https://api.example.com/anthropic',
|
|
apiFormat: 'anthropic',
|
|
defaultModels: {
|
|
main: 'custom-main',
|
|
haiku: '',
|
|
sonnet: '',
|
|
opus: '',
|
|
},
|
|
needsApiKey: true,
|
|
websiteUrl: '',
|
|
},
|
|
]
|
|
|
|
render(<Settings />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add Provider/i }))
|
|
|
|
const dialog = screen.getByRole('dialog')
|
|
const apiKeyInput = within(dialog).getByPlaceholderText('sk-...')
|
|
|
|
expect(apiKeyInput).toHaveAttribute('type', 'password')
|
|
|
|
fireEvent.click(within(dialog).getByRole('button', { name: 'Show API Key' }))
|
|
|
|
expect(apiKeyInput).toHaveAttribute('type', 'text')
|
|
expect(within(dialog).getByRole('button', { name: 'Hide API Key' })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Settings > About tab', () => {
|
|
beforeEach(() => {
|
|
useUIStore.setState({ pendingSettingsTab: 'about' })
|
|
useUpdateStore.setState({
|
|
status: 'available',
|
|
availableVersion: '0.1.5',
|
|
releaseNotes: '# Claude Code Haha v0.1.5\n\n- Fixed updater rendering\n- Added markdown support',
|
|
progressPercent: 0,
|
|
downloadedBytes: 0,
|
|
totalBytes: null,
|
|
error: null,
|
|
checkedAt: null,
|
|
shouldPrompt: true,
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
checkForUpdates: vi.fn().mockResolvedValue(null),
|
|
installUpdate: vi.fn().mockResolvedValue(undefined),
|
|
dismissPrompt: vi.fn(),
|
|
})
|
|
})
|
|
|
|
it('renders release notes with markdown formatting', async () => {
|
|
render(<Settings />)
|
|
|
|
expect(await screen.findByRole('heading', { name: 'Claude Code Haha v0.1.5' })).toBeInTheDocument()
|
|
expect(screen.getByText('Fixed updater rendering')).toBeInTheDocument()
|
|
expect(screen.getByText('Added markdown support')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows downloaded bytes instead of a fake zero percent when total size is unknown', async () => {
|
|
useUpdateStore.setState({
|
|
status: 'downloading',
|
|
availableVersion: '0.1.5',
|
|
releaseNotes: '# Claude Code Haha v0.1.5',
|
|
progressPercent: 0,
|
|
downloadedBytes: 1536,
|
|
totalBytes: null,
|
|
error: null,
|
|
checkedAt: null,
|
|
shouldPrompt: true,
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
checkForUpdates: vi.fn().mockResolvedValue(null),
|
|
installUpdate: vi.fn().mockResolvedValue(undefined),
|
|
dismissPrompt: vi.fn(),
|
|
})
|
|
|
|
render(<Settings />)
|
|
|
|
expect(await screen.findByText('Downloading update... 1.5 KB downloaded')).toBeInTheDocument()
|
|
expect(screen.queryByText('Downloading update... 0%')).not.toBeInTheDocument()
|
|
})
|
|
})
|