mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Introduce the Electron desktop shell alongside the existing React renderer and local Bun server boundary. The migration keeps the DesktopHost contract explicit across Tauri, Electron, and browser runtimes while adding Electron main/preload services for dialogs, shell, notifications, updates, tray/window lifecycle, terminal, preview WebContentsView, app mode, and release/package validation.
The commit also carries the latest local main desktop command updates, including agent slash entries and hidden-by-default markdown thinking details, so the packaged Electron build matches the current main UX surface.
Constraint: React renderer, local Bun server, REST/WebSocket, and sidecar boundaries must remain reusable during the migration
Constraint: macOS dev packages are ad-hoc signed and cannot prove Developer ID notarization or Gatekeeper release launch
Rejected: Browser-only smoke validation | it cannot exercise native dialogs, keychain prompts, notification behavior, or packaged app startup
Confidence: medium
Scope-risk: broad
Directive: Do not remove Tauri host support until signed Electron release artifacts pass native OS smoke on macOS, Windows, and Linux
Tested: bun run check:desktop
Tested: cd desktop && bun run check:electron
Tested: CSC_IDENTITY_AUTO_DISCOVERY=false bun run electron📦dir
Tested: bun run test:package-smoke --platform macos --package-kind dir --artifacts-dir desktop/build-artifacts/electron
Tested: Computer Use read packaged Electron app window at desktop/build-artifacts/electron/mac-arm64/Claude Code Haha.app
Not-tested: Developer ID signed/notarized Gatekeeper launch
Not-tested: Real OS notification click-to-session action
Not-tested: Windows and Linux packaged app smoke on real hosts
1177 lines
34 KiB
TypeScript
1177 lines
34 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ApiError } from '../api/client'
|
|
import { browserHost } from '../lib/desktopHost/browserHost'
|
|
|
|
describe('settingsStore locale defaults', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('defaults to Chinese when no locale is stored', async () => {
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
expect(useSettingsStore.getState().locale).toBe('zh')
|
|
})
|
|
|
|
it('keeps a stored locale override', async () => {
|
|
window.localStorage.setItem('cc-haha-locale', 'en')
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
expect(useSettingsStore.getState().locale).toBe('en')
|
|
})
|
|
})
|
|
|
|
describe('settingsStore UI zoom', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
window.localStorage.clear()
|
|
document.documentElement.removeAttribute('data-app-zoom-percent')
|
|
document.documentElement.style.removeProperty('--app-zoom')
|
|
document.body.style.removeProperty('zoom')
|
|
})
|
|
|
|
it('hydrates from the app zoom storage key', async () => {
|
|
window.localStorage.setItem('cc-haha-app-zoom', '1.25')
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
|
|
})
|
|
|
|
it('applies and persists UI zoom changes through the shared app zoom controller', async () => {
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
useSettingsStore.getState().setUiZoom(1.25)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.25')
|
|
})
|
|
expect(useSettingsStore.getState().uiZoom).toBe(1.25)
|
|
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('125')
|
|
})
|
|
|
|
it('clamps UI zoom changes to the supported range', async () => {
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
useSettingsStore.getState().setUiZoom(9)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('2')
|
|
})
|
|
expect(useSettingsStore.getState().uiZoom).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('settingsStore update proxy persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('defaults old user settings to automatic system proxy mode', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().updateProxy).toEqual({
|
|
mode: 'system',
|
|
url: '',
|
|
})
|
|
})
|
|
|
|
it('persists manual update proxy settings trimmed', async () => {
|
|
const updateUser = vi.fn().mockResolvedValue({})
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser,
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn(),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().setUpdateProxy({
|
|
mode: 'manual',
|
|
url: ' http://127.0.0.1:7890 ',
|
|
})
|
|
|
|
expect(useSettingsStore.getState().updateProxy).toEqual({
|
|
mode: 'manual',
|
|
url: 'http://127.0.0.1:7890',
|
|
})
|
|
expect(updateUser).toHaveBeenCalledWith({
|
|
updateProxy: {
|
|
mode: 'manual',
|
|
url: 'http://127.0.0.1:7890',
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('settingsStore network persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('defaults old user settings to 120s system network settings', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().network).toEqual({
|
|
aiRequestTimeoutMs: 120_000,
|
|
proxy: {
|
|
mode: 'system',
|
|
url: '',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('persists trimmed manual network proxy and clamps timeout', async () => {
|
|
const updateUser = vi.fn().mockResolvedValue({})
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser,
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn(),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().setNetwork({
|
|
aiRequestTimeoutMs: 999_999,
|
|
proxy: {
|
|
mode: 'manual',
|
|
url: ' http://127.0.0.1:7890 ',
|
|
},
|
|
})
|
|
|
|
expect(useSettingsStore.getState().network).toEqual({
|
|
aiRequestTimeoutMs: 600_000,
|
|
proxy: {
|
|
mode: 'manual',
|
|
url: 'http://127.0.0.1:7890',
|
|
},
|
|
})
|
|
expect(updateUser).toHaveBeenCalledWith({
|
|
network: {
|
|
aiRequestTimeoutMs: 600_000,
|
|
proxy: {
|
|
mode: 'manual',
|
|
url: 'http://127.0.0.1:7890',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
it('persists the chat send behavior preference and normalizes invalid values', async () => {
|
|
const updateUser = vi.fn().mockResolvedValue({})
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({
|
|
chatSendBehavior: 'unexpected',
|
|
}),
|
|
updateUser,
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
expect(useSettingsStore.getState().chatSendBehavior).toBe('enter')
|
|
|
|
await useSettingsStore.getState().setChatSendBehavior('modifierEnter')
|
|
|
|
expect(useSettingsStore.getState().chatSendBehavior).toBe('modifierEnter')
|
|
expect(updateUser).toHaveBeenCalledWith({ chatSendBehavior: 'modifierEnter' })
|
|
})
|
|
})
|
|
|
|
describe('settingsStore app mode', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__
|
|
Reflect.deleteProperty(window, 'desktopHost')
|
|
Reflect.deleteProperty(window, '__TAURI__')
|
|
})
|
|
|
|
it('hydrates app mode from the native desktop command', async () => {
|
|
const invoke = vi.fn().mockResolvedValue({
|
|
mode: 'portable',
|
|
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
})
|
|
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
|
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
|
tauriWindow.__TAURI_INTERNALS__ = {}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAppMode()
|
|
|
|
expect(invoke).toHaveBeenCalledWith('get_app_mode')
|
|
expect(useSettingsStore.getState().appMode).toEqual({
|
|
mode: 'portable',
|
|
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
})
|
|
})
|
|
|
|
it('hydrates app mode from an injected desktop host', async () => {
|
|
const getAppMode = vi.fn().mockResolvedValue({
|
|
mode: 'portable',
|
|
portableDir: 'D:\\cc-haha\\data',
|
|
defaultPortableDir: 'D:\\cc-haha\\data',
|
|
})
|
|
window.desktopHost = {
|
|
...browserHost,
|
|
kind: 'electron',
|
|
isDesktop: true,
|
|
appMode: {
|
|
...browserHost.appMode,
|
|
get: getAppMode,
|
|
},
|
|
}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAppMode()
|
|
|
|
expect(getAppMode).toHaveBeenCalledTimes(1)
|
|
expect(useSettingsStore.getState().appMode).toEqual({
|
|
mode: 'portable',
|
|
portableDir: 'D:\\cc-haha\\data',
|
|
defaultPortableDir: 'D:\\cc-haha\\data',
|
|
})
|
|
})
|
|
|
|
it('persists app mode through the native desktop command and marks restart required', async () => {
|
|
const invoke = vi.fn().mockResolvedValue(undefined)
|
|
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
|
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
|
tauriWindow.__TAURI_INTERNALS__ = {}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
appMode: {
|
|
mode: 'default',
|
|
portableDir: null,
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
},
|
|
appModeRequiresRestart: false,
|
|
})
|
|
|
|
await useSettingsStore.getState().setAppMode('portable')
|
|
|
|
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
|
|
mode: 'portable',
|
|
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
})
|
|
expect(useSettingsStore.getState().appMode).toEqual({
|
|
mode: 'portable',
|
|
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
activeConfigDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
configDirSource: 'portable',
|
|
})
|
|
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
|
|
})
|
|
|
|
it('persists app mode through an injected desktop host', async () => {
|
|
const setAppMode = vi.fn().mockResolvedValue(undefined)
|
|
window.desktopHost = {
|
|
...browserHost,
|
|
kind: 'electron',
|
|
isDesktop: true,
|
|
appMode: {
|
|
...browserHost.appMode,
|
|
set: setAppMode,
|
|
},
|
|
}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
appMode: {
|
|
mode: 'default',
|
|
portableDir: null,
|
|
defaultPortableDir: 'D:\\cc-haha\\data',
|
|
},
|
|
appModeRequiresRestart: false,
|
|
})
|
|
|
|
await useSettingsStore.getState().setAppMode('portable')
|
|
|
|
expect(setAppMode).toHaveBeenCalledWith({
|
|
mode: 'portable',
|
|
portableDir: 'D:\\cc-haha\\data',
|
|
})
|
|
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
|
|
})
|
|
|
|
it('persists a user-selected portable directory', async () => {
|
|
const invoke = vi.fn().mockResolvedValue(undefined)
|
|
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
|
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
|
tauriWindow.__TAURI_INTERNALS__ = {}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
appMode: {
|
|
mode: 'default',
|
|
portableDir: null,
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
},
|
|
appModeRequiresRestart: false,
|
|
})
|
|
|
|
await useSettingsStore.getState().setAppMode('portable', 'D:\\portable-data')
|
|
|
|
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
|
|
mode: 'portable',
|
|
portableDir: 'D:\\portable-data',
|
|
})
|
|
expect(useSettingsStore.getState().appMode).toMatchObject({
|
|
mode: 'portable',
|
|
portableDir: 'D:\\portable-data',
|
|
activeConfigDir: 'D:\\portable-data',
|
|
configDirSource: 'portable',
|
|
})
|
|
})
|
|
|
|
it('switches app mode back to the system data source', async () => {
|
|
const invoke = vi.fn().mockResolvedValue(undefined)
|
|
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
|
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
|
tauriWindow.__TAURI_INTERNALS__ = {}
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
appMode: {
|
|
mode: 'portable',
|
|
portableDir: 'D:\\portable-data',
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
activeConfigDir: 'D:\\portable-data',
|
|
configDirSource: 'portable',
|
|
},
|
|
appModeRequiresRestart: false,
|
|
})
|
|
|
|
await useSettingsStore.getState().setAppMode('default', null)
|
|
|
|
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
|
|
mode: 'default',
|
|
portableDir: null,
|
|
})
|
|
expect(useSettingsStore.getState().appMode).toEqual({
|
|
mode: 'default',
|
|
portableDir: null,
|
|
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
|
activeConfigDir: null,
|
|
configDirSource: 'system',
|
|
})
|
|
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('settingsStore desktop notification persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('defaults desktop notifications to explicit opt-in', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(false)
|
|
})
|
|
|
|
it('keeps desktop notifications disabled when user settings do not opt in', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(false)
|
|
})
|
|
|
|
it('persists the latest desktop notification toggle when saves overlap', async () => {
|
|
const pendingSaves: Array<() => void> = []
|
|
const updateUser = vi.fn(
|
|
() =>
|
|
new Promise<{ ok: true }>((resolve) => {
|
|
pendingSaves.push(() => resolve({ ok: true }))
|
|
}),
|
|
)
|
|
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser,
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
const firstSave = useSettingsStore.getState().setDesktopNotificationsEnabled(false)
|
|
await vi.waitFor(() => {
|
|
expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: false })
|
|
})
|
|
|
|
const secondSave = useSettingsStore.getState().setDesktopNotificationsEnabled(true)
|
|
expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true)
|
|
|
|
pendingSaves.shift()?.()
|
|
await vi.waitFor(() => {
|
|
expect(updateUser).toHaveBeenCalledWith({ desktopNotificationsEnabled: true })
|
|
})
|
|
pendingSaves.shift()?.()
|
|
await Promise.all([firstSave, secondSave])
|
|
|
|
expect(updateUser).toHaveBeenLastCalledWith({ desktopNotificationsEnabled: true })
|
|
expect(useSettingsStore.getState().desktopNotificationsEnabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('settingsStore thinking persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('persists both enabled and disabled thinking states explicitly', async () => {
|
|
const updateUser = vi.fn().mockResolvedValue({})
|
|
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser,
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn(),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().setThinkingEnabled(false)
|
|
await useSettingsStore.getState().setThinkingEnabled(true)
|
|
|
|
expect(updateUser).toHaveBeenNthCalledWith(1, { alwaysThinkingEnabled: false })
|
|
expect(updateUser).toHaveBeenNthCalledWith(2, { alwaysThinkingEnabled: true })
|
|
expect(useSettingsStore.getState().thinkingEnabled).toBe(true)
|
|
})
|
|
|
|
it('rolls back the thinking toggle when persistence fails', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser: vi.fn().mockRejectedValue(new Error('save failed')),
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn(),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().setThinkingEnabled(false)
|
|
|
|
expect(useSettingsStore.getState().thinkingEnabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('settingsStore desktop terminal shell persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it('hydrates desktop terminal settings from user settings and falls back to system defaults', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({
|
|
desktopTerminal: {
|
|
startupShell: 'pwsh',
|
|
customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
},
|
|
}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
|
startupShell: 'system',
|
|
customShellPath: '',
|
|
})
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
|
startupShell: 'pwsh',
|
|
customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
})
|
|
})
|
|
|
|
it('persists desktop terminal settings explicitly', async () => {
|
|
const updateUser = vi.fn().mockResolvedValue({ ok: true })
|
|
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser,
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await useSettingsStore.getState().setDesktopTerminal({
|
|
startupShell: 'custom',
|
|
customShellPath: 'C:\\tools\\pwsh.exe',
|
|
})
|
|
|
|
expect(updateUser).toHaveBeenCalledWith({
|
|
desktopTerminal: {
|
|
startupShell: 'custom',
|
|
customShellPath: 'C:\\tools\\pwsh.exe',
|
|
},
|
|
})
|
|
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
|
startupShell: 'custom',
|
|
customShellPath: 'C:\\tools\\pwsh.exe',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('settingsStore theme persistence', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
document.documentElement.removeAttribute('data-theme')
|
|
document.documentElement.style.colorScheme = ''
|
|
})
|
|
|
|
it('falls back to the pure white theme when user settings have no theme', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
const { useUIStore } = await import('./uiStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().theme).toBe('white')
|
|
expect(useUIStore.getState().theme).toBe('white')
|
|
expect(document.documentElement.getAttribute('data-theme')).toBe('white')
|
|
expect(document.documentElement.style.colorScheme).toBe('light')
|
|
})
|
|
|
|
it('hydrates the pure white theme from user settings', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({ theme: 'white' }),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
const { useUIStore } = await import('./uiStore')
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().theme).toBe('white')
|
|
expect(useUIStore.getState().theme).toBe('white')
|
|
expect(document.documentElement.getAttribute('data-theme')).toBe('white')
|
|
expect(document.documentElement.style.colorScheme).toBe('light')
|
|
})
|
|
})
|
|
|
|
describe('settingsStore H5 access behavior', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
it.each([404, 405])('falls back to disabled defaults only for legacy H5 endpoint status %s', async (status) => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockRejectedValue(new ApiError(status, { message: 'legacy' })),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
h5Access: {
|
|
enabled: true,
|
|
tokenPreview: 'h5_prev',
|
|
allowedOrigins: ['https://prev.example'],
|
|
publicBaseUrl: 'https://prev.example/app',
|
|
},
|
|
})
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().h5Access).toEqual({
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
})
|
|
expect(useSettingsStore.getState().h5AccessError).toBeNull()
|
|
})
|
|
|
|
it('preserves the last known H5 state and surfaces an H5 error on non-legacy load failures', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn().mockResolvedValue({}),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn().mockResolvedValue({ models: [] }),
|
|
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn().mockRejectedValue(new ApiError(500, { message: 'H5 unavailable' })),
|
|
enable: vi.fn(),
|
|
disable: vi.fn(),
|
|
regenerate: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
useSettingsStore.setState({
|
|
h5Access: {
|
|
enabled: true,
|
|
tokenPreview: 'h5_prev',
|
|
allowedOrigins: ['https://prev.example'],
|
|
publicBaseUrl: 'https://prev.example/app',
|
|
},
|
|
})
|
|
|
|
await useSettingsStore.getState().fetchAll()
|
|
|
|
expect(useSettingsStore.getState().h5Access).toEqual({
|
|
enabled: true,
|
|
tokenPreview: 'h5_prev',
|
|
allowedOrigins: ['https://prev.example'],
|
|
publicBaseUrl: 'https://prev.example/app',
|
|
})
|
|
expect(useSettingsStore.getState().h5AccessError).toBe('H5 unavailable')
|
|
})
|
|
|
|
it('handles H5 enable, regenerate, and disable transitions without persisting a raw token in store state', async () => {
|
|
vi.doMock('../api/settings', () => ({
|
|
settingsApi: {
|
|
getUser: vi.fn(),
|
|
updateUser: vi.fn(),
|
|
getPermissionMode: vi.fn(),
|
|
setPermissionMode: vi.fn(),
|
|
getCliLauncherStatus: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/models', () => ({
|
|
modelsApi: {
|
|
list: vi.fn(),
|
|
getCurrent: vi.fn(),
|
|
setCurrent: vi.fn(),
|
|
getEffort: vi.fn(),
|
|
setEffort: vi.fn(),
|
|
},
|
|
}))
|
|
vi.doMock('../api/h5Access', () => ({
|
|
h5AccessApi: {
|
|
get: vi.fn(),
|
|
enable: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: true,
|
|
tokenPreview: 'h5_first',
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
token: 'raw-enable-token',
|
|
}),
|
|
disable: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
},
|
|
}),
|
|
regenerate: vi.fn().mockResolvedValue({
|
|
settings: {
|
|
enabled: true,
|
|
tokenPreview: 'h5_second',
|
|
allowedOrigins: ['https://phone.example'],
|
|
publicBaseUrl: 'https://phone.example/app',
|
|
},
|
|
token: 'raw-regenerated-token',
|
|
}),
|
|
update: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
const { useSettingsStore } = await import('./settingsStore')
|
|
|
|
await expect(useSettingsStore.getState().enableH5Access()).resolves.toBe('raw-enable-token')
|
|
expect(useSettingsStore.getState().h5Access).toEqual({
|
|
enabled: true,
|
|
tokenPreview: 'h5_first',
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
})
|
|
|
|
await expect(useSettingsStore.getState().regenerateH5AccessToken()).resolves.toBe('raw-regenerated-token')
|
|
expect(useSettingsStore.getState().h5Access).toEqual({
|
|
enabled: true,
|
|
tokenPreview: 'h5_second',
|
|
allowedOrigins: ['https://phone.example'],
|
|
publicBaseUrl: 'https://phone.example/app',
|
|
})
|
|
|
|
await expect(useSettingsStore.getState().disableH5Access()).resolves.toBeUndefined()
|
|
expect(useSettingsStore.getState().h5Access).toEqual({
|
|
enabled: false,
|
|
tokenPreview: null,
|
|
allowedOrigins: [],
|
|
publicBaseUrl: null,
|
|
})
|
|
expect(useSettingsStore.getState().h5AccessError).toBeNull()
|
|
expect('h5AccessGeneratedToken' in useSettingsStore.getState()).toBe(false)
|
|
})
|
|
})
|