@lobehub/chat 1.137.7 → 1.137.8
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/.cursor/rules/testing-guide/zustand-store-action-test.mdc +579 -0
- package/.github/workflows/e2e.yml +1 -1
- package/CHANGELOG.md +33 -0
- package/changelog/v1.json +12 -0
- package/locales/en-US/modelProvider.json +5 -0
- package/locales/zh-CN/modelProvider.json +5 -0
- package/package.json +1 -1
- package/packages/context-engine/src/tools/ToolsEngine.ts +3 -3
- package/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts +3 -0
- package/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts +79 -0
- package/packages/types/src/auth.ts +2 -0
- package/packages/types/src/user/settings/keyVaults.ts +6 -1
- package/src/app/[variants]/(main)/settings/provider/detail/vertexai/index.tsx +64 -1
- package/src/features/ChatInput/ActionBar/index.tsx +9 -1
- package/src/features/ChatInput/InputEditor/index.tsx +0 -2
- package/src/features/ChatInput/TypoBar/index.tsx +1 -9
- package/src/libs/trpc/lambda/context.ts +3 -1
- package/src/locales/default/modelProvider.ts +5 -0
- package/src/server/modules/ModelRuntime/index.ts +1 -1
- package/src/services/_auth.ts +8 -2
- package/src/store/aiInfra/slices/aiModel/action.test.ts +595 -0
- package/src/store/chat/slices/thread/action.test.ts +1099 -0
- package/src/store/discover/slices/assistant/action.test.ts +228 -0
- package/src/store/discover/slices/mcp/action.test.ts +130 -0
- package/src/store/discover/slices/model/action.test.ts +253 -0
- package/src/store/discover/slices/plugin/action.test.ts +149 -0
- package/src/store/discover/slices/provider/action.test.ts +279 -0
- package/src/store/file/slices/chat/action.test.ts +1 -8
- package/src/store/file/slices/chunk/action.test.ts +478 -0
- package/src/store/file/slices/fileManager/action.test.ts +687 -0
- package/src/store/file/slices/tts/action.test.ts +5 -17
- package/src/store/file/slices/upload/action.test.ts +706 -0
- package/src/store/knowledgeBase/slices/content/action.test.ts +292 -0
- package/src/store/knowledgeBase/slices/crud/action.test.ts +466 -0
- package/src/store/serverConfig/action.test.ts +166 -0
- package/src/store/serverConfig/selectors.test.ts +0 -2
- package/src/store/test-coverage.md +593 -0
- package/src/store/tool/slices/mcpStore/action.test.ts +1146 -0
- package/src/store/tool/slices/oldStore/action.test.ts +13 -46
- package/src/store/tool/slices/oldStore/action.ts +0 -2
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Best practices for testing Zustand store actions
|
|
3
|
+
globs: "src/store/**/*.test.ts"
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Zustand Store Action Testing Guide
|
|
8
|
+
|
|
9
|
+
This guide provides best practices for testing Zustand store actions, based on our proven testing patterns.
|
|
10
|
+
|
|
11
|
+
## Basic Test Structure
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { act, renderHook } from '@testing-library/react';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { messageService } from '@/services/message';
|
|
18
|
+
import { useChatStore } from '../../store';
|
|
19
|
+
|
|
20
|
+
// Keep zustand mock as it's needed globally
|
|
21
|
+
vi.mock('zustand/traditional');
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Reset store state
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
useChatStore.setState(
|
|
27
|
+
{
|
|
28
|
+
activeId: 'test-session-id',
|
|
29
|
+
messagesMap: {},
|
|
30
|
+
loadingIds: [],
|
|
31
|
+
},
|
|
32
|
+
false,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// ✅ Setup only spies that MOST tests need
|
|
36
|
+
vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id');
|
|
37
|
+
// ❌ Don't setup spies that only few tests need - spy only when needed
|
|
38
|
+
|
|
39
|
+
// Setup common mock methods
|
|
40
|
+
act(() => {
|
|
41
|
+
useChatStore.setState({
|
|
42
|
+
refreshMessages: vi.fn(),
|
|
43
|
+
internal_coreProcessMessage: vi.fn(),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('action name', () => {
|
|
53
|
+
describe('validation', () => {
|
|
54
|
+
// Validation tests
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('normal flow', () => {
|
|
58
|
+
// Happy path tests
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('error handling', () => {
|
|
62
|
+
// Error case tests
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Testing Best Practices
|
|
68
|
+
|
|
69
|
+
### 1. Test Layering - Spy Direct Dependencies Only
|
|
70
|
+
|
|
71
|
+
✅ **Good**: Spy on the direct dependency
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
// When testing internal_coreProcessMessage, spy its direct dependency
|
|
75
|
+
const fetchAIChatSpy = vi
|
|
76
|
+
.spyOn(result.current, 'internal_fetchAIChatMessage')
|
|
77
|
+
.mockResolvedValue({ isFunctionCall: false, content: 'AI response' });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
❌ **Bad**: Spy on lower-level implementation details
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Don't spy on services that internal_fetchAIChatMessage uses
|
|
84
|
+
const streamSpy = vi
|
|
85
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
86
|
+
.mockImplementation(...);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Why**: Each test should only mock its direct dependencies, not the entire call chain. This makes tests more maintainable and less brittle.
|
|
90
|
+
|
|
91
|
+
### 2. Mock Management - Minimize Global Spies
|
|
92
|
+
|
|
93
|
+
✅ **Good**: Spy only when needed
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
// ✅ Only spy services that most tests need
|
|
98
|
+
vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id');
|
|
99
|
+
// ✅ Don't spy chatService globally
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should process message', async () => {
|
|
103
|
+
// ✅ Spy chatService only in tests that need it
|
|
104
|
+
const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream')
|
|
105
|
+
.mockImplementation(...);
|
|
106
|
+
|
|
107
|
+
// test logic
|
|
108
|
+
|
|
109
|
+
streamSpy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
❌ **Bad**: Setup all spies globally
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
vi.spyOn(messageService, 'createMessage').mockResolvedValue('id');
|
|
118
|
+
vi.spyOn(chatService, 'createAssistantMessageStream').mockResolvedValue({}); // ❌ Not all tests need this
|
|
119
|
+
vi.spyOn(fileService, 'uploadFile').mockResolvedValue({}); // ❌ Creates implicit coupling
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 3. Service Mocking - Mock the Correct Layer
|
|
124
|
+
|
|
125
|
+
✅ **Good**: Mock the service method
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
it('should fetch AI chat response', async () => {
|
|
129
|
+
const streamSpy = vi
|
|
130
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
131
|
+
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
|
132
|
+
await onMessageHandle?.({ type: 'text', text: 'Hello' } as any);
|
|
133
|
+
await onFinish?.('Hello', {});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// test logic
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
❌ **Bad**: Mock global fetch
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
it('should fetch AI chat response', async () => {
|
|
144
|
+
global.fetch = vi.fn().mockResolvedValue(...); // ❌ Too low level
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 4. Test Organization - Use Descriptive Nesting
|
|
149
|
+
|
|
150
|
+
✅ **Good**: Clear nested structure
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
describe('sendMessage', () => {
|
|
154
|
+
describe('validation', () => {
|
|
155
|
+
it('should not send when session is inactive', async () => {});
|
|
156
|
+
it('should not send when message is empty', async () => {});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('message creation', () => {
|
|
160
|
+
it('should create user message and trigger AI processing', async () => {});
|
|
161
|
+
it('should send message with files attached', async () => {});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('error handling', () => {
|
|
165
|
+
it('should handle message creation errors gracefully', async () => {});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
❌ **Bad**: Flat structure
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
describe('sendMessage', () => {
|
|
174
|
+
it('test 1', async () => {});
|
|
175
|
+
it('test 2', async () => {});
|
|
176
|
+
it('test 3', async () => {});
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 5. Testing Async Actions
|
|
181
|
+
|
|
182
|
+
Always wrap async operations in `act()`:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
it('should send message', async () => {
|
|
186
|
+
const { result } = renderHook(() => useChatStore());
|
|
187
|
+
|
|
188
|
+
await act(async () => {
|
|
189
|
+
await result.current.sendMessage({ message: 'Hello' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(messageService.createMessage).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 6. State Setup - Use act() for setState
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
it('should handle disabled state', async () => {
|
|
200
|
+
act(() => {
|
|
201
|
+
useChatStore.setState({ activeId: undefined });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const { result } = renderHook(() => useChatStore());
|
|
205
|
+
// test logic
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 7. Testing Complex Flows
|
|
210
|
+
|
|
211
|
+
For complex flows with multiple steps, use clear spy setup:
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
it('should handle topic creation flow', async () => {
|
|
215
|
+
// Setup store state
|
|
216
|
+
act(() => {
|
|
217
|
+
useChatStore.setState({
|
|
218
|
+
activeTopicId: undefined,
|
|
219
|
+
messagesMap: {
|
|
220
|
+
'test-session-id': [
|
|
221
|
+
{ id: 'msg-1', role: 'user', content: 'Message 1' },
|
|
222
|
+
{ id: 'msg-2', role: 'assistant', content: 'Response 1' },
|
|
223
|
+
{ id: 'msg-3', role: 'user', content: 'Message 2' },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const { result } = renderHook(() => useChatStore());
|
|
230
|
+
|
|
231
|
+
// Spy on action dependencies
|
|
232
|
+
const createTopicSpy = vi.spyOn(result.current, 'createTopic')
|
|
233
|
+
.mockResolvedValue('new-topic-id');
|
|
234
|
+
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading');
|
|
235
|
+
|
|
236
|
+
// Execute
|
|
237
|
+
await act(async () => {
|
|
238
|
+
await result.current.sendMessage({ message: 'Test message' });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
242
|
+
expect(createTopicSpy).toHaveBeenCalled();
|
|
243
|
+
expect(toggleLoadingSpy).toHaveBeenCalledWith(true, expect.any(String));
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### 8. Streaming Response Mocking
|
|
248
|
+
|
|
249
|
+
When testing streaming responses, simulate the flow properly:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
it('should handle streaming chunks', async () => {
|
|
253
|
+
const { result } = renderHook(() => useChatStore());
|
|
254
|
+
const messages = [
|
|
255
|
+
{ id: 'msg-1', role: 'user', content: 'Hello', sessionId: 'test-session' },
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const streamSpy = vi
|
|
259
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
260
|
+
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
|
261
|
+
// Simulate streaming chunks
|
|
262
|
+
await onMessageHandle?.({ type: 'text', text: 'Hello' } as any);
|
|
263
|
+
await onMessageHandle?.({ type: 'text', text: ' World' } as any);
|
|
264
|
+
await onFinish?.('Hello World', {});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
await result.current.internal_fetchAIChatMessage({
|
|
269
|
+
messages,
|
|
270
|
+
messageId: 'test-message-id',
|
|
271
|
+
model: 'gpt-4o-mini',
|
|
272
|
+
provider: 'openai',
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result.current.internal_dispatchMessage).toHaveBeenCalled();
|
|
277
|
+
|
|
278
|
+
streamSpy.mockRestore();
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### 9. Error Handling Tests
|
|
283
|
+
|
|
284
|
+
Always test error scenarios:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
it('should handle errors gracefully', async () => {
|
|
288
|
+
const { result } = renderHook(() => useChatStore());
|
|
289
|
+
|
|
290
|
+
vi.spyOn(messageService, 'createMessage').mockRejectedValue(
|
|
291
|
+
new Error('create message error'),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await act(async () => {
|
|
295
|
+
try {
|
|
296
|
+
await result.current.sendMessage({ message: 'Test message' });
|
|
297
|
+
} catch {
|
|
298
|
+
// Expected to throw
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### 10. Cleanup After Tests
|
|
307
|
+
|
|
308
|
+
Always restore mocks after each test:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
afterEach(() => {
|
|
312
|
+
vi.restoreAllMocks();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// For individual test cleanup:
|
|
316
|
+
it('should test something', async () => {
|
|
317
|
+
const spy = vi.spyOn(service, 'method').mockImplementation(...);
|
|
318
|
+
|
|
319
|
+
// test logic
|
|
320
|
+
|
|
321
|
+
spy.mockRestore(); // Optional: cleanup immediately after test
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Common Patterns
|
|
326
|
+
|
|
327
|
+
### Testing Store Methods That Call Other Store Methods
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
it('should call internal methods', async () => {
|
|
331
|
+
const { result } = renderHook(() => useChatStore());
|
|
332
|
+
|
|
333
|
+
const internalMethodSpy = vi.spyOn(result.current, 'internal_method')
|
|
334
|
+
.mockResolvedValue();
|
|
335
|
+
|
|
336
|
+
await act(async () => {
|
|
337
|
+
await result.current.publicMethod();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(internalMethodSpy).toHaveBeenCalledWith(
|
|
341
|
+
expect.any(String),
|
|
342
|
+
expect.objectContaining({ key: 'value' }),
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Testing Conditional Logic
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
describe('conditional behavior', () => {
|
|
351
|
+
it('should execute when condition is true', async () => {
|
|
352
|
+
const { result } = renderHook(() => useChatStore());
|
|
353
|
+
vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true);
|
|
354
|
+
|
|
355
|
+
await act(async () => {
|
|
356
|
+
await result.current.sendMessage({ message: 'test' });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(result.current.internal_retrieveChunks).toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should not execute when condition is false', async () => {
|
|
363
|
+
const { result } = renderHook(() => useChatStore());
|
|
364
|
+
vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false);
|
|
365
|
+
|
|
366
|
+
await act(async () => {
|
|
367
|
+
await result.current.sendMessage({ message: 'test' });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(result.current.internal_retrieveChunks).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Testing AbortController
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
it('should abort generation and clear loading state', () => {
|
|
379
|
+
const abortController = new AbortController();
|
|
380
|
+
|
|
381
|
+
act(() => {
|
|
382
|
+
useChatStore.setState({ chatLoadingIdsAbortController: abortController });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const { result } = renderHook(() => useChatStore());
|
|
386
|
+
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
|
|
387
|
+
|
|
388
|
+
act(() => {
|
|
389
|
+
result.current.stopGenerateMessage();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(abortController.signal.aborted).toBe(true);
|
|
393
|
+
expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
## Anti-Patterns to Avoid
|
|
398
|
+
|
|
399
|
+
❌ **Don't**: Mock the entire store
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
vi.mock('../../store', () => ({
|
|
403
|
+
useChatStore: vi.fn(() => ({
|
|
404
|
+
sendMessage: vi.fn(),
|
|
405
|
+
})),
|
|
406
|
+
}));
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
❌ **Don't**: Test implementation details
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
// Bad: testing internal state structure
|
|
413
|
+
expect(result.current.messagesMap).toHaveProperty('test-session');
|
|
414
|
+
|
|
415
|
+
// Good: testing behavior
|
|
416
|
+
expect(result.current.refreshMessages).toHaveBeenCalled();
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
❌ **Don't**: Create tight coupling between tests
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
// Bad: Tests depend on order
|
|
423
|
+
let messageId: string;
|
|
424
|
+
|
|
425
|
+
it('test 1', () => {
|
|
426
|
+
messageId = 'some-id'; // Side effect
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('test 2', () => {
|
|
430
|
+
expect(messageId).toBeDefined(); // Depends on test 1
|
|
431
|
+
});
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
❌ **Don't**: Over-mock services
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// Bad: Mocking everything
|
|
438
|
+
beforeEach(() => {
|
|
439
|
+
vi.mock('@/services/chat');
|
|
440
|
+
vi.mock('@/services/message');
|
|
441
|
+
vi.mock('@/services/file');
|
|
442
|
+
vi.mock('@/services/agent');
|
|
443
|
+
// ... too many global mocks
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
## Testing SWR Hooks in Zustand Stores
|
|
448
|
+
|
|
449
|
+
Some Zustand store slices use SWR hooks for data fetching. These require a different testing approach.
|
|
450
|
+
|
|
451
|
+
### Basic SWR Hook Test Structure
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
455
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
456
|
+
|
|
457
|
+
import { discoverService } from '@/services/discover';
|
|
458
|
+
import { globalHelpers } from '@/store/global/helpers';
|
|
459
|
+
import { useDiscoverStore as useStore } from '../../store';
|
|
460
|
+
|
|
461
|
+
vi.mock('zustand/traditional');
|
|
462
|
+
|
|
463
|
+
beforeEach(() => {
|
|
464
|
+
vi.clearAllMocks();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe('SWR Hook Actions', () => {
|
|
468
|
+
it('should fetch data and return correct response', async () => {
|
|
469
|
+
const mockData = [{ id: '1', name: 'Item 1' }];
|
|
470
|
+
|
|
471
|
+
// Mock the service call (the fetcher)
|
|
472
|
+
vi.spyOn(discoverService, 'getPluginCategories').mockResolvedValue(mockData as any);
|
|
473
|
+
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US');
|
|
474
|
+
|
|
475
|
+
const params = {} as any;
|
|
476
|
+
const { result } = renderHook(() => useStore.getState().usePluginCategories(params));
|
|
477
|
+
|
|
478
|
+
// Use waitFor to wait for async data loading
|
|
479
|
+
await waitFor(() => {
|
|
480
|
+
expect(result.current.data).toEqual(mockData);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(discoverService.getPluginCategories).toHaveBeenCalledWith(params);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Key points**:
|
|
489
|
+
- **DO NOT mock useSWR** - let it use the real implementation
|
|
490
|
+
- Only mock the **service methods** (fetchers)
|
|
491
|
+
- Use `waitFor` from `@testing-library/react` to wait for async operations
|
|
492
|
+
- Check `result.current.data` directly after waitFor completes
|
|
493
|
+
|
|
494
|
+
### Testing SWR Key Generation
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
it('should generate correct SWR key with locale and params', () => {
|
|
498
|
+
vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('zh-CN');
|
|
499
|
+
|
|
500
|
+
const useSWRMock = vi.mocked(useSWR);
|
|
501
|
+
let capturedKey: string | null = null;
|
|
502
|
+
useSWRMock.mockImplementation(((key: string) => {
|
|
503
|
+
capturedKey = key;
|
|
504
|
+
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
|
505
|
+
}) as any);
|
|
506
|
+
|
|
507
|
+
const params = { page: 2, category: 'tools' } as any;
|
|
508
|
+
renderHook(() => useStore.getState().usePluginList(params));
|
|
509
|
+
|
|
510
|
+
expect(capturedKey).toBe('plugin-list-zh-CN-2-tools');
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Testing SWR Configuration
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
it('should have correct SWR configuration', () => {
|
|
518
|
+
const useSWRMock = vi.mocked(useSWR);
|
|
519
|
+
let capturedOptions: any = null;
|
|
520
|
+
useSWRMock.mockImplementation(((key: string, fetcher: any, options: any) => {
|
|
521
|
+
capturedOptions = options;
|
|
522
|
+
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
|
523
|
+
}) as any);
|
|
524
|
+
|
|
525
|
+
renderHook(() => useStore.getState().usePluginIdentifiers());
|
|
526
|
+
|
|
527
|
+
expect(capturedOptions).toMatchObject({ revalidateOnFocus: false });
|
|
528
|
+
});
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### Testing Conditional Fetching
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
it('should not fetch when required parameter is missing', () => {
|
|
535
|
+
const useSWRMock = vi.mocked(useSWR);
|
|
536
|
+
let capturedKey: string | null = null;
|
|
537
|
+
useSWRMock.mockImplementation(((key: string | null) => {
|
|
538
|
+
capturedKey = key;
|
|
539
|
+
return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() };
|
|
540
|
+
}) as any);
|
|
541
|
+
|
|
542
|
+
// When identifier is undefined, SWR key should be null
|
|
543
|
+
renderHook(() => useStore.getState().usePluginDetail({ identifier: undefined }));
|
|
544
|
+
|
|
545
|
+
expect(capturedKey).toBeNull();
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Key Differences from Regular Action Tests
|
|
550
|
+
|
|
551
|
+
1. **Mock useSWR globally**: Use `vi.mock('swr')` at the top level
|
|
552
|
+
2. **Mock the fetcher, not the result**:
|
|
553
|
+
- ✅ **Correct**: `const data = fetcher?.()` - call fetcher and return its Promise
|
|
554
|
+
- ❌ **Wrong**: `return { data: mockData }` - hardcode the result
|
|
555
|
+
3. **Await Promise results**: The `data` field is a Promise, use `await result.current.data`
|
|
556
|
+
4. **No act() wrapper needed**: SWR hooks don't trigger React state updates in these tests
|
|
557
|
+
5. **Test SWR key generation**: Verify keys include locale and parameters
|
|
558
|
+
6. **Test configuration**: Verify revalidation and other SWR options
|
|
559
|
+
7. **Type assertions**: Use `as any` for test mock data where type definitions are strict
|
|
560
|
+
|
|
561
|
+
**Why this matters**:
|
|
562
|
+
- The fetcher (service method) is what we're testing - it must be called
|
|
563
|
+
- Hardcoding the return value bypasses the actual fetcher logic
|
|
564
|
+
- SWR returns Promises in real usage, tests should mirror this behavior
|
|
565
|
+
|
|
566
|
+
## Benefits of This Approach
|
|
567
|
+
|
|
568
|
+
✅ **Clear test layers** - Each test only spies on direct dependencies
|
|
569
|
+
✅ **Correct mocks** - Mocks match actual implementation
|
|
570
|
+
✅ **Better maintainability** - Changes to implementation require fewer test updates
|
|
571
|
+
✅ **Improved coverage** - Structured approach ensures all branches are tested
|
|
572
|
+
✅ **Reduced coupling** - Tests are independent and can run in any order
|
|
573
|
+
|
|
574
|
+
## Reference
|
|
575
|
+
|
|
576
|
+
See example implementation in:
|
|
577
|
+
- `src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts` (Regular actions)
|
|
578
|
+
- `src/store/discover/slices/plugin/action.test.ts` (SWR hooks)
|
|
579
|
+
- `src/store/discover/slices/mcp/action.test.ts` (SWR hooks)
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
### [Version 1.137.8](https://github.com/lobehub/lobe-chat/compare/v1.137.7...v1.137.8)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2025-10-15**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **misc**: Fix duplicate tools id issue and fix link dialog issue.
|
|
12
|
+
|
|
13
|
+
#### 💄 Styles
|
|
14
|
+
|
|
15
|
+
- **misc**: Add region support for Vertex AI provider.
|
|
16
|
+
|
|
17
|
+
<br/>
|
|
18
|
+
|
|
19
|
+
<details>
|
|
20
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
21
|
+
|
|
22
|
+
#### What's fixed
|
|
23
|
+
|
|
24
|
+
- **misc**: Fix duplicate tools id issue and fix link dialog issue, closes [#9731](https://github.com/lobehub/lobe-chat/issues/9731) ([0a8c80d](https://github.com/lobehub/lobe-chat/commit/0a8c80d))
|
|
25
|
+
|
|
26
|
+
#### Styles
|
|
27
|
+
|
|
28
|
+
- **misc**: Add region support for Vertex AI provider, closes [#9720](https://github.com/lobehub/lobe-chat/issues/9720) ([d17b50c](https://github.com/lobehub/lobe-chat/commit/d17b50c))
|
|
29
|
+
|
|
30
|
+
</details>
|
|
31
|
+
|
|
32
|
+
<div align="right">
|
|
33
|
+
|
|
34
|
+
[](#readme-top)
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
5
38
|
### [Version 1.137.7](https://github.com/lobehub/lobe-chat/compare/v1.137.6...v1.137.7)
|
|
6
39
|
|
|
7
40
|
<sup>Released on **2025-10-15**</sup>
|
package/changelog/v1.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"children": {
|
|
4
|
+
"fixes": [
|
|
5
|
+
"Fix duplicate tools id issue and fix link dialog issue."
|
|
6
|
+
],
|
|
7
|
+
"improvements": [
|
|
8
|
+
"Add region support for Vertex AI provider."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"date": "2025-10-15",
|
|
12
|
+
"version": "1.137.8"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"children": {
|
|
4
16
|
"improvements": [
|
|
@@ -399,6 +399,11 @@
|
|
|
399
399
|
"desc": "Enter your Vertex AI Keys",
|
|
400
400
|
"placeholder": "{ \"type\": \"service_account\", \"project_id\": \"xxx\", \"private_key_id\": ... }",
|
|
401
401
|
"title": "Vertex AI Keys"
|
|
402
|
+
},
|
|
403
|
+
"region": {
|
|
404
|
+
"desc": "Select the region for Vertex AI service. Some models like Gemini 2.5 are only available in specific regions (e.g., global)",
|
|
405
|
+
"placeholder": "Select region",
|
|
406
|
+
"title": "Vertex AI Region"
|
|
402
407
|
}
|
|
403
408
|
},
|
|
404
409
|
"zeroone": {
|
|
@@ -399,6 +399,11 @@
|
|
|
399
399
|
"desc": "填入你的 Vertex Ai Keys",
|
|
400
400
|
"placeholder": "{ \"type\": \"service_account\", \"project_id\": \"xxx\", \"private_key_id\": ... }",
|
|
401
401
|
"title": "Vertex AI Keys"
|
|
402
|
+
},
|
|
403
|
+
"region": {
|
|
404
|
+
"desc": "选择 Vertex AI 服务的区域。某些模型如 Gemini 2.5 仅在特定区域可用(如 global)",
|
|
405
|
+
"placeholder": "选择区域",
|
|
406
|
+
"title": "Vertex AI 区域"
|
|
402
407
|
}
|
|
403
408
|
},
|
|
404
409
|
"zeroone": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "1.137.
|
|
3
|
+
"version": "1.137.8",
|
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -56,7 +56,7 @@ export class ToolsEngine {
|
|
|
56
56
|
const { toolIds = [], model, provider, context } = params;
|
|
57
57
|
|
|
58
58
|
// Merge user-provided tool IDs with default tool IDs
|
|
59
|
-
const allToolIds = [...toolIds, ...this.defaultToolIds];
|
|
59
|
+
const allToolIds = [...new Set([...toolIds, ...this.defaultToolIds])];
|
|
60
60
|
|
|
61
61
|
log(
|
|
62
62
|
'Generating tools for model=%s, provider=%s, pluginIds=%o (includes %d default tools)',
|
|
@@ -96,8 +96,8 @@ export class ToolsEngine {
|
|
|
96
96
|
generateToolsDetailed(params: GenerateToolsParams): ToolsGenerationResult {
|
|
97
97
|
const { toolIds = [], model, provider, context } = params;
|
|
98
98
|
|
|
99
|
-
// Merge user-provided tool IDs with default tool IDs
|
|
100
|
-
const allToolIds = [...toolIds, ...this.defaultToolIds];
|
|
99
|
+
// Merge user-provided tool IDs with default tool IDs and deduplicate
|
|
100
|
+
const allToolIds = [...new Set([...toolIds, ...this.defaultToolIds])];
|
|
101
101
|
|
|
102
102
|
log(
|
|
103
103
|
'Generating detailed tools for model=%s, provider=%s, pluginIds=%o (includes %d default tools)',
|