@lobehub/lobehub 2.0.0-next.141 → 2.0.0-next.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/Dockerfile +2 -0
  3. package/apps/desktop/src/main/controllers/__tests__/McpInstallCtr.test.ts +286 -0
  4. package/apps/desktop/src/main/controllers/__tests__/NotificationCtr.test.ts +347 -0
  5. package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +645 -0
  6. package/apps/desktop/src/main/controllers/__tests__/RemoteServerSyncCtr.test.ts +372 -0
  7. package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +276 -0
  8. package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +171 -0
  9. package/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +573 -0
  10. package/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts +415 -0
  11. package/apps/desktop/src/main/core/infrastructure/__tests__/I18nManager.test.ts +353 -0
  12. package/apps/desktop/src/main/core/infrastructure/__tests__/IoCContainer.test.ts +156 -0
  13. package/apps/desktop/src/main/core/infrastructure/__tests__/ProtocolManager.test.ts +348 -0
  14. package/apps/desktop/src/main/core/infrastructure/__tests__/StaticFileServerManager.test.ts +481 -0
  15. package/apps/desktop/src/main/core/infrastructure/__tests__/StoreManager.test.ts +164 -0
  16. package/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts +513 -0
  17. package/changelog/v1.json +9 -0
  18. package/docs/self-hosting/environment-variables/model-provider.mdx +31 -0
  19. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +30 -0
  20. package/package.json +1 -1
  21. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +6 -3
  22. package/src/config/modelProviders/vertexai.ts +1 -1
  23. package/src/envs/llm.ts +4 -0
  24. package/src/server/modules/ModelRuntime/index.ts +4 -4
@@ -0,0 +1,372 @@
1
+ import { ProxyTRPCRequestParams } from '@lobechat/electron-client-ipc';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { App } from '@/core/App';
5
+
6
+ import RemoteServerSyncCtr from '../RemoteServerSyncCtr';
7
+
8
+ // Mock logger
9
+ vi.mock('@/utils/logger', () => ({
10
+ createLogger: () => ({
11
+ debug: vi.fn(),
12
+ error: vi.fn(),
13
+ info: vi.fn(),
14
+ warn: vi.fn(),
15
+ }),
16
+ }));
17
+
18
+ // Mock electron
19
+ vi.mock('electron', () => ({
20
+ app: {
21
+ getAppPath: vi.fn(() => '/mock/app/path'),
22
+ getPath: vi.fn(() => '/mock/user/data'),
23
+ },
24
+ ipcMain: {
25
+ on: vi.fn(),
26
+ },
27
+ }));
28
+
29
+ // Mock electron-is
30
+ vi.mock('electron-is', () => ({
31
+ dev: vi.fn(() => false),
32
+ linux: vi.fn(() => false),
33
+ macOS: vi.fn(() => false),
34
+ windows: vi.fn(() => false),
35
+ }));
36
+
37
+ // Mock http and https modules
38
+ vi.mock('node:http', () => ({
39
+ default: {
40
+ request: vi.fn(),
41
+ },
42
+ }));
43
+
44
+ vi.mock('node:https', () => ({
45
+ default: {
46
+ request: vi.fn(),
47
+ },
48
+ }));
49
+
50
+ // Mock proxy agents
51
+ vi.mock('http-proxy-agent', () => ({
52
+ HttpProxyAgent: vi.fn().mockImplementation(() => ({})),
53
+ }));
54
+
55
+ vi.mock('https-proxy-agent', () => ({
56
+ HttpsProxyAgent: vi.fn().mockImplementation(() => ({})),
57
+ }));
58
+
59
+ // Mock RemoteServerConfigCtr
60
+ const mockRemoteServerConfigCtr = {
61
+ getRemoteServerConfig: vi.fn(),
62
+ getRemoteServerUrl: vi.fn(),
63
+ getAccessToken: vi.fn(),
64
+ refreshAccessToken: vi.fn(),
65
+ };
66
+
67
+ const mockStoreManager = {
68
+ get: vi.fn().mockReturnValue({
69
+ enableProxy: false,
70
+ proxyServer: '',
71
+ proxyPort: '',
72
+ proxyType: 'http',
73
+ }),
74
+ };
75
+
76
+ const mockApp = {
77
+ getController: vi.fn(() => mockRemoteServerConfigCtr),
78
+ storeManager: mockStoreManager,
79
+ } as unknown as App;
80
+
81
+ describe('RemoteServerSyncCtr', () => {
82
+ let controller: RemoteServerSyncCtr;
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+ controller = new RemoteServerSyncCtr(mockApp);
87
+ });
88
+
89
+ describe('proxyTRPCRequest', () => {
90
+ const baseParams: ProxyTRPCRequestParams = {
91
+ urlPath: '/trpc/test.query',
92
+ method: 'GET',
93
+ headers: { 'content-type': 'application/json' },
94
+ };
95
+
96
+ it('should return 503 when remote server sync is not active', async () => {
97
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
98
+ active: false,
99
+ storageMode: 'cloud',
100
+ });
101
+
102
+ const result = await controller.proxyTRPCRequest(baseParams);
103
+
104
+ expect(result.status).toBe(503);
105
+ expect(result.statusText).toBe('Remote server sync not active or configured');
106
+ });
107
+
108
+ it('should return 503 when selfHost mode without remoteServerUrl', async () => {
109
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
110
+ active: true,
111
+ storageMode: 'selfHost',
112
+ remoteServerUrl: '',
113
+ });
114
+
115
+ const result = await controller.proxyTRPCRequest(baseParams);
116
+
117
+ expect(result.status).toBe(503);
118
+ expect(result.statusText).toBe('Remote server sync not active or configured');
119
+ });
120
+
121
+ it('should return 401 when no access token is available', async () => {
122
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
123
+ active: true,
124
+ storageMode: 'cloud',
125
+ });
126
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
127
+ mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue(null);
128
+
129
+ // Mock https.request to simulate the forwardRequest behavior
130
+ const https = await import('node:https');
131
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
132
+ // Simulate response
133
+ const mockResponse = {
134
+ statusCode: 401,
135
+ statusMessage: 'Authentication required, missing token',
136
+ headers: {},
137
+ on: vi.fn((event, handler) => {
138
+ if (event === 'data') {
139
+ handler(Buffer.from(''));
140
+ }
141
+ if (event === 'end') {
142
+ handler();
143
+ }
144
+ }),
145
+ };
146
+ callback(mockResponse);
147
+ return {
148
+ on: vi.fn(),
149
+ write: vi.fn(),
150
+ end: vi.fn(),
151
+ };
152
+ });
153
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
154
+
155
+ const result = await controller.proxyTRPCRequest(baseParams);
156
+
157
+ expect(result.status).toBe(401);
158
+ });
159
+
160
+ it('should forward request successfully when configured properly', async () => {
161
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
162
+ active: true,
163
+ storageMode: 'cloud',
164
+ });
165
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
166
+ mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
167
+
168
+ const https = await import('node:https');
169
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
170
+ const mockResponse = {
171
+ statusCode: 200,
172
+ statusMessage: 'OK',
173
+ headers: { 'content-type': 'application/json' },
174
+ on: vi.fn((event, handler) => {
175
+ if (event === 'data') {
176
+ handler(Buffer.from('{"success":true}'));
177
+ }
178
+ if (event === 'end') {
179
+ handler();
180
+ }
181
+ }),
182
+ };
183
+ callback(mockResponse);
184
+ return {
185
+ on: vi.fn(),
186
+ write: vi.fn(),
187
+ end: vi.fn(),
188
+ };
189
+ });
190
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
191
+
192
+ const result = await controller.proxyTRPCRequest(baseParams);
193
+
194
+ expect(result.status).toBe(200);
195
+ expect(result.statusText).toBe('OK');
196
+ });
197
+
198
+ it('should retry request after token refresh on 401', async () => {
199
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
200
+ active: true,
201
+ storageMode: 'cloud',
202
+ });
203
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
204
+ mockRemoteServerConfigCtr.getAccessToken
205
+ .mockResolvedValueOnce('expired-token')
206
+ .mockResolvedValueOnce('new-valid-token');
207
+ mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({ success: true });
208
+
209
+ const https = await import('node:https');
210
+ let callCount = 0;
211
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
212
+ callCount++;
213
+ const mockResponse = {
214
+ statusCode: callCount === 1 ? 401 : 200,
215
+ statusMessage: callCount === 1 ? 'Unauthorized' : 'OK',
216
+ headers: { 'content-type': 'application/json' },
217
+ on: vi.fn((event, handler) => {
218
+ if (event === 'data') {
219
+ handler(Buffer.from(callCount === 1 ? '' : '{"success":true}'));
220
+ }
221
+ if (event === 'end') {
222
+ handler();
223
+ }
224
+ }),
225
+ };
226
+ callback(mockResponse);
227
+ return {
228
+ on: vi.fn(),
229
+ write: vi.fn(),
230
+ end: vi.fn(),
231
+ };
232
+ });
233
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
234
+
235
+ const result = await controller.proxyTRPCRequest(baseParams);
236
+
237
+ expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
238
+ expect(result.status).toBe(200);
239
+ });
240
+
241
+ it('should keep 401 response when token refresh fails', async () => {
242
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
243
+ active: true,
244
+ storageMode: 'cloud',
245
+ });
246
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
247
+ mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('expired-token');
248
+ mockRemoteServerConfigCtr.refreshAccessToken.mockResolvedValue({
249
+ success: false,
250
+ error: 'Refresh failed',
251
+ });
252
+
253
+ const https = await import('node:https');
254
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
255
+ const mockResponse = {
256
+ statusCode: 401,
257
+ statusMessage: 'Unauthorized',
258
+ headers: {},
259
+ on: vi.fn((event, handler) => {
260
+ if (event === 'data') {
261
+ handler(Buffer.from(''));
262
+ }
263
+ if (event === 'end') {
264
+ handler();
265
+ }
266
+ }),
267
+ };
268
+ callback(mockResponse);
269
+ return {
270
+ on: vi.fn(),
271
+ write: vi.fn(),
272
+ end: vi.fn(),
273
+ };
274
+ });
275
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
276
+
277
+ const result = await controller.proxyTRPCRequest(baseParams);
278
+
279
+ expect(mockRemoteServerConfigCtr.refreshAccessToken).toHaveBeenCalled();
280
+ expect(result.status).toBe(401);
281
+ });
282
+
283
+ it('should handle request error gracefully', async () => {
284
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
285
+ active: true,
286
+ storageMode: 'cloud',
287
+ });
288
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
289
+ mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
290
+
291
+ const https = await import('node:https');
292
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
293
+ return {
294
+ on: vi.fn((event, handler) => {
295
+ if (event === 'error') {
296
+ handler(new Error('Network error'));
297
+ }
298
+ }),
299
+ write: vi.fn(),
300
+ end: vi.fn(),
301
+ };
302
+ });
303
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
304
+
305
+ const result = await controller.proxyTRPCRequest(baseParams);
306
+
307
+ expect(result.status).toBe(502);
308
+ expect(result.statusText).toBe('Error forwarding request');
309
+ });
310
+
311
+ it('should include request body when provided', async () => {
312
+ mockRemoteServerConfigCtr.getRemoteServerConfig.mockResolvedValue({
313
+ active: true,
314
+ storageMode: 'cloud',
315
+ });
316
+ mockRemoteServerConfigCtr.getRemoteServerUrl.mockResolvedValue('https://api.example.com');
317
+ mockRemoteServerConfigCtr.getAccessToken.mockResolvedValue('valid-token');
318
+
319
+ const https = await import('node:https');
320
+ const mockWrite = vi.fn();
321
+ const mockRequest = vi.fn().mockImplementation((options, callback) => {
322
+ const mockResponse = {
323
+ statusCode: 200,
324
+ statusMessage: 'OK',
325
+ headers: {},
326
+ on: vi.fn((event, handler) => {
327
+ if (event === 'data') {
328
+ handler(Buffer.from('{"success":true}'));
329
+ }
330
+ if (event === 'end') {
331
+ handler();
332
+ }
333
+ }),
334
+ };
335
+ callback(mockResponse);
336
+ return {
337
+ on: vi.fn(),
338
+ write: mockWrite,
339
+ end: vi.fn(),
340
+ };
341
+ });
342
+ vi.mocked(https.default.request).mockImplementation(mockRequest);
343
+
344
+ const paramsWithBody: ProxyTRPCRequestParams = {
345
+ ...baseParams,
346
+ method: 'POST',
347
+ body: '{"data":"test"}',
348
+ };
349
+
350
+ await controller.proxyTRPCRequest(paramsWithBody);
351
+
352
+ expect(mockWrite).toHaveBeenCalledWith('{"data":"test"}', 'utf8');
353
+ });
354
+ });
355
+
356
+ describe('afterAppReady', () => {
357
+ it('should register stream:start IPC handler', async () => {
358
+ const { ipcMain } = await import('electron');
359
+
360
+ controller.afterAppReady();
361
+
362
+ expect(ipcMain.on).toHaveBeenCalledWith('stream:start', expect.any(Function));
363
+ });
364
+ });
365
+
366
+ describe('destroy', () => {
367
+ it('should clean up resources', () => {
368
+ // destroy method doesn't throw
369
+ expect(() => controller.destroy()).not.toThrow();
370
+ });
371
+ });
372
+ });
@@ -0,0 +1,276 @@
1
+ import { ThemeMode } from '@lobechat/electron-client-ipc';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { App } from '@/core/App';
5
+
6
+ import SystemController from '../SystemCtr';
7
+
8
+ // Mock logger
9
+ vi.mock('@/utils/logger', () => ({
10
+ createLogger: () => ({
11
+ debug: vi.fn(),
12
+ error: vi.fn(),
13
+ info: vi.fn(),
14
+ warn: vi.fn(),
15
+ }),
16
+ }));
17
+
18
+ // Mock electron
19
+ vi.mock('electron', () => ({
20
+ app: {
21
+ getLocale: vi.fn(() => 'en-US'),
22
+ getPath: vi.fn((name: string) => `/mock/path/${name}`),
23
+ },
24
+ nativeTheme: {
25
+ on: vi.fn(),
26
+ shouldUseDarkColors: false,
27
+ },
28
+ shell: {
29
+ openExternal: vi.fn().mockResolvedValue(undefined),
30
+ },
31
+ systemPreferences: {
32
+ isTrustedAccessibilityClient: vi.fn(() => true),
33
+ },
34
+ }));
35
+
36
+ // Mock electron-is
37
+ vi.mock('electron-is', () => ({
38
+ macOS: vi.fn(() => true),
39
+ }));
40
+
41
+ // Mock node:fs
42
+ vi.mock('node:fs', () => ({
43
+ readFileSync: vi.fn(),
44
+ writeFileSync: vi.fn(),
45
+ }));
46
+
47
+ // Mock @/const/dir
48
+ vi.mock('@/const/dir', () => ({
49
+ DB_SCHEMA_HASH_FILENAME: 'db-schema-hash.txt',
50
+ LOCAL_DATABASE_DIR: 'database',
51
+ userDataDir: '/mock/user/data',
52
+ }));
53
+
54
+ // Mock browserManager
55
+ const mockBrowserManager = {
56
+ broadcastToAllWindows: vi.fn(),
57
+ handleAppThemeChange: vi.fn(),
58
+ };
59
+
60
+ // Mock storeManager
61
+ const mockStoreManager = {
62
+ get: vi.fn(),
63
+ set: vi.fn(),
64
+ };
65
+
66
+ // Mock i18n
67
+ const mockI18n = {
68
+ changeLanguage: vi.fn().mockResolvedValue(undefined),
69
+ };
70
+
71
+ const mockApp = {
72
+ appStoragePath: '/mock/storage',
73
+ browserManager: mockBrowserManager,
74
+ i18n: mockI18n,
75
+ storeManager: mockStoreManager,
76
+ } as unknown as App;
77
+
78
+ describe('SystemController', () => {
79
+ let controller: SystemController;
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ controller = new SystemController(mockApp);
84
+ });
85
+
86
+ describe('getAppState', () => {
87
+ it('should return app state with system info', async () => {
88
+ const result = await controller.getAppState();
89
+
90
+ expect(result).toMatchObject({
91
+ arch: expect.any(String),
92
+ platform: expect.any(String),
93
+ systemAppearance: 'light',
94
+ userPath: {
95
+ desktop: '/mock/path/desktop',
96
+ documents: '/mock/path/documents',
97
+ downloads: '/mock/path/downloads',
98
+ home: '/mock/path/home',
99
+ music: '/mock/path/music',
100
+ pictures: '/mock/path/pictures',
101
+ userData: '/mock/path/userData',
102
+ videos: '/mock/path/videos',
103
+ },
104
+ });
105
+ });
106
+
107
+ it('should return dark appearance when nativeTheme is dark', async () => {
108
+ const { nativeTheme } = await import('electron');
109
+ Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
110
+
111
+ const result = await controller.getAppState();
112
+
113
+ expect(result.systemAppearance).toBe('dark');
114
+
115
+ // Reset
116
+ Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: false });
117
+ });
118
+ });
119
+
120
+ describe('checkAccessibilityForMacOS', () => {
121
+ it('should check accessibility on macOS', async () => {
122
+ const { systemPreferences } = await import('electron');
123
+
124
+ controller.checkAccessibilityForMacOS();
125
+
126
+ expect(systemPreferences.isTrustedAccessibilityClient).toHaveBeenCalledWith(true);
127
+ });
128
+
129
+ it('should return undefined on non-macOS', async () => {
130
+ const { macOS } = await import('electron-is');
131
+ vi.mocked(macOS).mockReturnValue(false);
132
+
133
+ const result = controller.checkAccessibilityForMacOS();
134
+
135
+ expect(result).toBeUndefined();
136
+
137
+ // Reset
138
+ vi.mocked(macOS).mockReturnValue(true);
139
+ });
140
+ });
141
+
142
+ describe('openExternalLink', () => {
143
+ it('should open external link', async () => {
144
+ const { shell } = await import('electron');
145
+
146
+ await controller.openExternalLink('https://example.com');
147
+
148
+ expect(shell.openExternal).toHaveBeenCalledWith('https://example.com');
149
+ });
150
+ });
151
+
152
+ describe('updateLocale', () => {
153
+ it('should update locale and broadcast change', async () => {
154
+ const result = await controller.updateLocale('zh-CN');
155
+
156
+ expect(mockStoreManager.set).toHaveBeenCalledWith('locale', 'zh-CN');
157
+ expect(mockI18n.changeLanguage).toHaveBeenCalledWith('zh-CN');
158
+ expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('localeChanged', {
159
+ locale: 'zh-CN',
160
+ });
161
+ expect(result).toEqual({ success: true });
162
+ });
163
+
164
+ it('should use system locale when set to auto', async () => {
165
+ await controller.updateLocale('auto');
166
+
167
+ expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en-US');
168
+ });
169
+ });
170
+
171
+ describe('updateThemeModeHandler', () => {
172
+ it('should update theme mode and broadcast change', async () => {
173
+ const themeMode: ThemeMode = 'dark';
174
+
175
+ await controller.updateThemeModeHandler(themeMode);
176
+
177
+ expect(mockStoreManager.set).toHaveBeenCalledWith('themeMode', 'dark');
178
+ expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('themeChanged', {
179
+ themeMode: 'dark',
180
+ });
181
+ expect(mockBrowserManager.handleAppThemeChange).toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ describe('getDatabasePath', () => {
186
+ it('should return database path', async () => {
187
+ const result = await controller.getDatabasePath();
188
+
189
+ expect(result).toBe('/mock/storage/database');
190
+ });
191
+ });
192
+
193
+ describe('getDatabaseSchemaHash', () => {
194
+ it('should return schema hash when file exists', async () => {
195
+ const { readFileSync } = await import('node:fs');
196
+ vi.mocked(readFileSync).mockReturnValue('abc123');
197
+
198
+ const result = await controller.getDatabaseSchemaHash();
199
+
200
+ expect(result).toBe('abc123');
201
+ });
202
+
203
+ it('should return undefined when file does not exist', async () => {
204
+ const { readFileSync } = await import('node:fs');
205
+ vi.mocked(readFileSync).mockImplementation(() => {
206
+ throw new Error('File not found');
207
+ });
208
+
209
+ const result = await controller.getDatabaseSchemaHash();
210
+
211
+ expect(result).toBeUndefined();
212
+ });
213
+ });
214
+
215
+ describe('getUserDataPath', () => {
216
+ it('should return user data path', async () => {
217
+ const result = await controller.getUserDataPath();
218
+
219
+ expect(result).toBe('/mock/user/data');
220
+ });
221
+ });
222
+
223
+ describe('setDatabaseSchemaHash', () => {
224
+ it('should write schema hash to file', async () => {
225
+ const { writeFileSync } = await import('node:fs');
226
+
227
+ await controller.setDatabaseSchemaHash('newhash123');
228
+
229
+ expect(writeFileSync).toHaveBeenCalledWith(
230
+ '/mock/storage/db-schema-hash.txt',
231
+ 'newhash123',
232
+ 'utf8',
233
+ );
234
+ });
235
+ });
236
+
237
+ describe('afterAppReady', () => {
238
+ it('should initialize system theme listener', async () => {
239
+ const { nativeTheme } = await import('electron');
240
+
241
+ controller.afterAppReady();
242
+
243
+ expect(nativeTheme.on).toHaveBeenCalledWith('updated', expect.any(Function));
244
+ });
245
+
246
+ it('should not initialize listener twice', async () => {
247
+ const { nativeTheme } = await import('electron');
248
+
249
+ controller.afterAppReady();
250
+ controller.afterAppReady();
251
+
252
+ // Should only be called once
253
+ expect(nativeTheme.on).toHaveBeenCalledTimes(1);
254
+ });
255
+
256
+ it('should broadcast system theme change when theme updates', async () => {
257
+ const { nativeTheme } = await import('electron');
258
+
259
+ controller.afterAppReady();
260
+
261
+ // Get the callback that was registered
262
+ const callback = vi.mocked(nativeTheme.on).mock.calls[0][1] as () => void;
263
+
264
+ // Simulate theme change to dark
265
+ Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: true });
266
+ callback();
267
+
268
+ expect(mockBrowserManager.broadcastToAllWindows).toHaveBeenCalledWith('systemThemeChanged', {
269
+ themeMode: 'dark',
270
+ });
271
+
272
+ // Reset
273
+ Object.defineProperty(nativeTheme, 'shouldUseDarkColors', { value: false });
274
+ });
275
+ });
276
+ });