| 1 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | |
| 3 | const settingsHandlersState = vi.hoisted(() => { |
| 4 | const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>() |
| 5 | |
| 6 | return { |
| 7 | appMock: { |
| 8 | getVersion: vi.fn(() => '1.0.0') |
| 9 | }, |
| 10 | applyProxyMock: vi.fn(), |
| 11 | dialogMock: { |
| 12 | showOpenDialog: vi.fn() |
| 13 | }, |
| 14 | handlers, |
| 15 | ipcMainMock: { |
| 16 | handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => { |
| 17 | handlers.set(channel, handler) |
| 18 | }) |
| 19 | }, |
| 20 | localeMock: { |
| 21 | readAppLocale: vi.fn(async () => 'zh'), |
| 22 | uiText: vi.fn((locale: string, zh: string, en: string) => (locale === 'en' ? en : zh)) |
| 23 | }, |
| 24 | logMock: { |
| 25 | error: vi.fn(), |
| 26 | info: vi.fn() |
| 27 | }, |
| 28 | resolveModelMock: vi.fn() |
| 29 | } |
| 30 | }) |
| 31 | |
| 32 | vi.mock('electron', () => ({ |
| 33 | BrowserWindow: { |
| 34 | fromWebContents: vi.fn(), |
| 35 | getFocusedWindow: vi.fn() |
| 36 | }, |
| 37 | app: settingsHandlersState.appMock, |
| 38 | dialog: settingsHandlersState.dialogMock, |
| 39 | ipcMain: settingsHandlersState.ipcMainMock |
| 40 | })) |
| 41 | |
| 42 | vi.mock('electron-log/main.js', () => ({ |
| 43 | default: settingsHandlersState.logMock |
| 44 | })) |
| 45 | |
| 46 | vi.mock('../../../src/main/agent-runtime/model', () => ({ |
| 47 | resolveModel: settingsHandlersState.resolveModelMock, |
| 48 | runWithModelTemperatureControl: <T>(_config: unknown, task: () => T): T => task(), |
| 49 | OPENAI_RESPONSES_FORMAT_ERROR_EN: 'Invalid OpenAI Responses API payload.', |
| 50 | OPENAI_RESPONSES_FORMAT_ERROR_ZH: '当前 provider 返回的不是 OpenAI Responses API 格式。', |
| 51 | isOpenAIResponsesFormatError: (error: unknown) => |
| 52 | /Cannot read propert(?:y|ies).*undefined.*map|Cannot read propert(?:y|ies).*map.*undefined/i.test( |
| 53 | error instanceof Error ? error.message : '' |
| 54 | ) |
| 55 | })) |
| 56 | |
| 57 | vi.mock('../../../src/main/utils/proxy', () => ({ |
| 58 | applyProxy: settingsHandlersState.applyProxyMock |
| 59 | })) |
| 60 | |
| 61 | vi.mock('../../../src/main/config/locale-utils', () => ({ |
| 62 | readAppLocale: settingsHandlersState.localeMock.readAppLocale, |
| 63 | uiText: settingsHandlersState.localeMock.uiText |
| 64 | })) |
| 65 | |
| 66 | vi.mock('@shared/model-timeout', () => ({ |
| 67 | CONFIGURABLE_MODEL_TIMEOUT_PROFILES: ['planning', 'design', 'agent', 'document'], |
| 68 | resolveModelTimeoutMs: vi.fn((value: unknown, profile: string) => { |
| 69 | if (typeof value === 'number' && Number.isFinite(value)) return value |
| 70 | const defaults: Record<string, number> = { |
| 71 | planning: 300000, |
| 72 | design: 300000, |
| 73 | agent: 600000, |
| 74 | document: 600000 |
| 75 | } |
| 76 | return defaults[profile] ?? 300000 |
| 77 | }) |
| 78 | })) |
| 79 | |
| 80 | async function registerWithDb(overrides: Partial<Record<string, unknown>> = {}) { |
| 81 | vi.resetModules() |
| 82 | settingsHandlersState.handlers.clear() |
| 83 | |
| 84 | const { registerSettingsHandlers } = await import('../../../src/main/config/settings-handlers') |
| 85 | |
| 86 | const db = { |
| 87 | getAllSettings: vi.fn(async () => ({})), |
| 88 | listModelConfigs: vi.fn(async () => []), |
| 89 | setSetting: vi.fn(async () => undefined), |
| 90 | setStoragePath: vi.fn(async () => undefined), |
| 91 | ...overrides |
| 92 | } |
| 93 | |
| 94 | const ctx = { |
| 95 | mainWindow: {} as never, |
| 96 | db, |
| 97 | encryptApiKey: vi.fn((value: string) => value), |
| 98 | decryptApiKey: vi.fn((value: unknown) => String(value ?? '')) |
| 99 | } |
| 100 | |
| 101 | registerSettingsHandlers(ctx as never) |
| 102 | |
| 103 | return { |
| 104 | db, |
| 105 | getHandler: (channel: string) => settingsHandlersState.handlers.get(channel) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | describe('registerSettingsHandlers proxy settings', () => { |
| 110 | beforeEach(() => { |
| 111 | settingsHandlersState.applyProxyMock.mockReset() |
| 112 | settingsHandlersState.appMock.getVersion.mockClear() |
| 113 | settingsHandlersState.dialogMock.showOpenDialog.mockReset() |
| 114 | settingsHandlersState.handlers.clear() |
| 115 | settingsHandlersState.ipcMainMock.handle.mockClear() |
| 116 | settingsHandlersState.localeMock.readAppLocale.mockReset() |
| 117 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 118 | settingsHandlersState.localeMock.uiText.mockClear() |
| 119 | settingsHandlersState.logMock.error.mockClear() |
| 120 | settingsHandlersState.logMock.info.mockClear() |
| 121 | settingsHandlersState.resolveModelMock.mockReset() |
| 122 | }) |
| 123 | |
| 124 | it('returns trimmed proxyUrl from settings:get', async () => { |
| 125 | const { getHandler } = await registerWithDb({ |
| 126 | getAllSettings: vi.fn(async () => ({ |
| 127 | locale: 'en', |
| 128 | proxy_url: ' http://127.0.0.1:7890 ', |
| 129 | storage_path: ' /tmp/workspace ' |
| 130 | })) |
| 131 | }) |
| 132 | |
| 133 | const getSettings = getHandler('settings:get') |
| 134 | const result = await getSettings?.() |
| 135 | |
| 136 | expect(result).toMatchObject({ |
| 137 | locale: 'en', |
| 138 | proxyUrl: 'http://127.0.0.1:7890', |
| 139 | storagePath: '/tmp/workspace' |
| 140 | }) |
| 141 | }) |
| 142 | |
| 143 | it('applies proxy before persisting proxy_url in settings:save', async () => { |
| 144 | const callOrder: string[] = [] |
| 145 | settingsHandlersState.applyProxyMock.mockImplementation(() => { |
| 146 | callOrder.push('apply') |
| 147 | }) |
| 148 | const db = { |
| 149 | setSetting: vi.fn(async (key: string) => { |
| 150 | if (key === 'proxy_url') { |
| 151 | callOrder.push('persist') |
| 152 | } |
| 153 | }) |
| 154 | } |
| 155 | const { getHandler } = await registerWithDb(db) |
| 156 | |
| 157 | const saveSettings = getHandler('settings:save') |
| 158 | const result = await saveSettings?.(undefined, { |
| 159 | proxyUrl: ' http://127.0.0.1:7890 ' |
| 160 | }) |
| 161 | |
| 162 | expect(result).toEqual({ success: true }) |
| 163 | expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith('http://127.0.0.1:7890') |
| 164 | expect(db.setSetting).toHaveBeenCalledWith('proxy_url', 'http://127.0.0.1:7890') |
| 165 | expect(callOrder).toEqual(['apply', 'persist']) |
| 166 | }) |
| 167 | |
| 168 | it('does not persist proxy_url when applyProxy fails', async () => { |
| 169 | settingsHandlersState.applyProxyMock.mockImplementation(() => { |
| 170 | throw new Error('bad proxy') |
| 171 | }) |
| 172 | const db = { |
| 173 | setSetting: vi.fn(async () => undefined) |
| 174 | } |
| 175 | const { getHandler } = await registerWithDb(db) |
| 176 | |
| 177 | const saveSettings = getHandler('settings:save') |
| 178 | |
| 179 | await expect( |
| 180 | saveSettings?.(undefined, { |
| 181 | proxyUrl: 'http://broken-proxy' |
| 182 | }) |
| 183 | ).rejects.toThrow('代理设置无效:bad proxy') |
| 184 | |
| 185 | expect(settingsHandlersState.localeMock.readAppLocale).toHaveBeenCalled() |
| 186 | expect(db.setSetting).not.toHaveBeenCalledWith('proxy_url', expect.anything()) |
| 187 | }) |
| 188 | |
| 189 | it('clears persisted proxy when saving an empty proxyUrl', async () => { |
| 190 | const db = { |
| 191 | setSetting: vi.fn(async () => undefined) |
| 192 | } |
| 193 | const { getHandler } = await registerWithDb(db) |
| 194 | |
| 195 | const saveSettings = getHandler('settings:save') |
| 196 | await saveSettings?.(undefined, { proxyUrl: ' ' }) |
| 197 | |
| 198 | expect(settingsHandlersState.applyProxyMock).toHaveBeenCalledWith(undefined) |
| 199 | expect(db.setSetting).toHaveBeenCalledWith('proxy_url', '') |
| 200 | }) |
| 201 | }) |
| 202 | |
| 203 | describe('registerSettingsHandlers model temperature settings', () => { |
| 204 | beforeEach(() => { |
| 205 | settingsHandlersState.handlers.clear() |
| 206 | settingsHandlersState.ipcMainMock.handle.mockClear() |
| 207 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 208 | settingsHandlersState.resolveModelMock.mockReset() |
| 209 | }) |
| 210 | |
| 211 | it('returns disableTemperature in the model config list', async () => { |
| 212 | const { getHandler } = await registerWithDb({ |
| 213 | listModelConfigs: vi.fn(async () => [ |
| 214 | { |
| 215 | id: 'model-1', |
| 216 | name: 'Reasoning model', |
| 217 | provider: 'openai', |
| 218 | model: 'reasoner', |
| 219 | apiKey: 'secret', |
| 220 | baseUrl: '', |
| 221 | maxTokens: 4096, |
| 222 | disableTemperature: 1, |
| 223 | thinkingParameterMode: 'omit', |
| 224 | active: 1, |
| 225 | createdAt: 1, |
| 226 | updatedAt: 2 |
| 227 | } |
| 228 | ]) |
| 229 | }) |
| 230 | |
| 231 | const listModelConfigs = getHandler('settings:listModelConfigs') |
| 232 | await expect(listModelConfigs?.()).resolves.toEqual([ |
| 233 | expect.objectContaining({ |
| 234 | id: 'model-1', |
| 235 | disableTemperature: true, |
| 236 | thinkingParameterMode: 'omit' |
| 237 | }) |
| 238 | ]) |
| 239 | }) |
| 240 | |
| 241 | it('persists parameter controls when saving a model config', async () => { |
| 242 | const upsertModelConfig = vi.fn(async () => 'model-1') |
| 243 | const { getHandler } = await registerWithDb({ upsertModelConfig }) |
| 244 | |
| 245 | const saveModelConfig = getHandler('settings:upsertModelConfig') |
| 246 | await saveModelConfig?.(undefined, { |
| 247 | name: 'Reasoning model', |
| 248 | provider: 'openai', |
| 249 | model: 'reasoner', |
| 250 | apiKey: 'secret', |
| 251 | baseUrl: '', |
| 252 | maxTokens: 4096, |
| 253 | disableTemperature: true, |
| 254 | thinkingParameterMode: 'omit' |
| 255 | }) |
| 256 | |
| 257 | expect(upsertModelConfig).toHaveBeenCalledWith( |
| 258 | expect.objectContaining({ |
| 259 | disableTemperature: true, |
| 260 | thinkingParameterMode: 'omit' |
| 261 | }) |
| 262 | ) |
| 263 | }) |
| 264 | |
| 265 | it('accepts the OpenAI Responses provider when saving a model config', async () => { |
| 266 | const upsertModelConfig = vi.fn(async () => 'model-1') |
| 267 | const { getHandler } = await registerWithDb({ upsertModelConfig }) |
| 268 | |
| 269 | const saveModelConfig = getHandler('settings:upsertModelConfig') |
| 270 | await saveModelConfig?.(undefined, { |
| 271 | name: 'Responses model', |
| 272 | provider: 'openai-responses', |
| 273 | model: 'gpt-5.1', |
| 274 | apiKey: 'secret', |
| 275 | baseUrl: 'https://api.openai.com/v1', |
| 276 | maxTokens: 4096, |
| 277 | disableTemperature: false, |
| 278 | thinkingParameterMode: 'not-valid' |
| 279 | }) |
| 280 | |
| 281 | expect(upsertModelConfig).toHaveBeenCalledWith( |
| 282 | expect.objectContaining({ |
| 283 | provider: 'openai-responses', |
| 284 | thinkingParameterMode: 'auto' |
| 285 | }) |
| 286 | ) |
| 287 | }) |
| 288 | |
| 289 | it('explains invalid Responses API payloads during model verification', async () => { |
| 290 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 291 | settingsHandlersState.resolveModelMock.mockReturnValue({ |
| 292 | invoke: vi.fn(async () => { |
| 293 | throw new TypeError("Cannot read properties of undefined (reading 'map')") |
| 294 | }) |
| 295 | }) |
| 296 | const { getHandler } = await registerWithDb() |
| 297 | |
| 298 | const verifyApiKey = getHandler('settings:verifyApiKey') |
| 299 | const result = await verifyApiKey?.(undefined, { |
| 300 | provider: 'openai-responses', |
| 301 | model: 'gpt-5.5', |
| 302 | apiKey: 'secret', |
| 303 | baseUrl: 'https://www.toumingren.xyz/v1', |
| 304 | maxTokens: 4096, |
| 305 | timeoutMs: 60000 |
| 306 | }) |
| 307 | |
| 308 | expect(result).toEqual({ |
| 309 | valid: false, |
| 310 | message: expect.stringContaining('不是 OpenAI Responses API 格式') |
| 311 | }) |
| 312 | }) |
| 313 | |
| 314 | it('explains unsupported thinking parameter errors during model verification', async () => { |
| 315 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 316 | settingsHandlersState.resolveModelMock.mockReturnValue({ |
| 317 | invoke: vi.fn(async () => { |
| 318 | throw new Error('Unsupported parameter: thinking') |
| 319 | }) |
| 320 | }) |
| 321 | const { getHandler } = await registerWithDb() |
| 322 | |
| 323 | const verifyApiKey = getHandler('settings:verifyApiKey') |
| 324 | const result = await verifyApiKey?.(undefined, { |
| 325 | provider: 'openai', |
| 326 | model: 'compatible-model', |
| 327 | apiKey: 'secret', |
| 328 | baseUrl: 'https://api.example-compatible.com/v1', |
| 329 | maxTokens: 4096, |
| 330 | thinkingParameterMode: 'auto', |
| 331 | timeoutMs: 60000 |
| 332 | }) |
| 333 | |
| 334 | expect(result).toEqual({ |
| 335 | valid: false, |
| 336 | message: expect.stringContaining('不发送 thinking 参数') |
| 337 | }) |
| 338 | }) |
| 339 | |
| 340 | it('explains unrecognized thinking argument errors during model verification', async () => { |
| 341 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 342 | settingsHandlersState.resolveModelMock.mockReturnValue({ |
| 343 | invoke: vi.fn(async () => { |
| 344 | throw new Error('Unrecognized request argument supplied: thinking') |
| 345 | }) |
| 346 | }) |
| 347 | const { getHandler } = await registerWithDb() |
| 348 | |
| 349 | const verifyApiKey = getHandler('settings:verifyApiKey') |
| 350 | const result = await verifyApiKey?.(undefined, { |
| 351 | provider: 'openai', |
| 352 | model: 'compatible-model', |
| 353 | apiKey: 'secret', |
| 354 | baseUrl: 'https://api.example-compatible.com/v1', |
| 355 | maxTokens: 4096, |
| 356 | thinkingParameterMode: 'auto', |
| 357 | timeoutMs: 60000 |
| 358 | }) |
| 359 | |
| 360 | expect(result).toEqual({ |
| 361 | valid: false, |
| 362 | message: expect.stringContaining('不发送 thinking 参数') |
| 363 | }) |
| 364 | }) |
| 365 | |
| 366 | it('explains older undefined map errors during Responses API verification', async () => { |
| 367 | settingsHandlersState.localeMock.readAppLocale.mockResolvedValue('zh') |
| 368 | settingsHandlersState.resolveModelMock.mockReturnValue({ |
| 369 | invoke: vi.fn(async () => { |
| 370 | throw new TypeError("Cannot read property 'map' of undefined") |
| 371 | }) |
| 372 | }) |
| 373 | const { getHandler } = await registerWithDb() |
| 374 | |
| 375 | const verifyApiKey = getHandler('settings:verifyApiKey') |
| 376 | const result = await verifyApiKey?.(undefined, { |
| 377 | provider: 'openai-responses', |
| 378 | model: 'gpt-5.5', |
| 379 | apiKey: 'secret', |
| 380 | baseUrl: 'https://www.toumingren.xyz/v1', |
| 381 | maxTokens: 4096, |
| 382 | timeoutMs: 60000 |
| 383 | }) |
| 384 | |
| 385 | expect(result).toEqual({ |
| 386 | valid: false, |
| 387 | message: expect.stringContaining('OpenAI Responses API') |
| 388 | }) |
| 389 | }) |
| 390 | }) |
| 391 | |
| 392 | describe('registerSettingsHandlers model usage', () => { |
| 393 | beforeEach(() => { |
| 394 | settingsHandlersState.handlers.clear() |
| 395 | settingsHandlersState.ipcMainMock.handle.mockClear() |
| 396 | }) |
| 397 | |
| 398 | it('delegates the selected usage period to the database', async () => { |
| 399 | const stats = { |
| 400 | period: '7d', |
| 401 | startedAt: 1, |
| 402 | totals: { |
| 403 | callCount: 2, |
| 404 | exactCallCount: 1, |
| 405 | estimatedCallCount: 1, |
| 406 | inputTokens: 100, |
| 407 | outputTokens: 20, |
| 408 | totalTokens: 120 |
| 409 | }, |
| 410 | byModel: [], |
| 411 | byDay: [] |
| 412 | } |
| 413 | const getModelUsageStats = vi.fn(async () => stats) |
| 414 | const { getHandler } = await registerWithDb({ getModelUsageStats }) |
| 415 | |
| 416 | await expect(getHandler('settings:getModelUsage')?.(undefined, '7d')).resolves.toBe(stats) |
| 417 | expect(getModelUsageStats).toHaveBeenCalledWith('7d') |
| 418 | }) |
| 419 | |
| 420 | it('falls back to 30 days for an invalid usage period', async () => { |
| 421 | const getModelUsageStats = vi.fn(async () => ({ period: '30d' })) |
| 422 | const { getHandler } = await registerWithDb({ getModelUsageStats }) |
| 423 | |
| 424 | await getHandler('settings:getModelUsage')?.(undefined, 'invalid') |
| 425 | expect(getModelUsageStats).toHaveBeenCalledWith('30d') |
| 426 | }) |
| 427 | }) |
| 428 |