@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.
- package/CHANGELOG.md +25 -0
- package/Dockerfile +2 -0
- package/apps/desktop/src/main/controllers/__tests__/McpInstallCtr.test.ts +286 -0
- package/apps/desktop/src/main/controllers/__tests__/NotificationCtr.test.ts +347 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +645 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerSyncCtr.test.ts +372 -0
- package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +276 -0
- package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +171 -0
- package/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +573 -0
- package/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts +415 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/I18nManager.test.ts +353 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/IoCContainer.test.ts +156 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/ProtocolManager.test.ts +348 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/StaticFileServerManager.test.ts +481 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/StoreManager.test.ts +164 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts +513 -0
- package/changelog/v1.json +9 -0
- package/docs/self-hosting/environment-variables/model-provider.mdx +31 -0
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +30 -0
- package/package.json +1 -1
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +6 -3
- package/src/config/modelProviders/vertexai.ts +1 -1
- package/src/envs/llm.ts +4 -0
- 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
|
+
});
|