@lobehub/chat 1.124.0 → 1.124.2
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/.env.example +5 -0
- package/.github/scripts/pr-comment.js +11 -2
- package/.github/workflows/desktop-pr-build.yml +86 -12
- package/.github/workflows/release-desktop-beta.yml +91 -20
- package/CHANGELOG.md +58 -0
- package/Dockerfile +2 -0
- package/Dockerfile.database +2 -0
- package/Dockerfile.pglite +2 -0
- package/apps/desktop/electron-builder.js +8 -4
- package/changelog/v1.json +21 -0
- package/docs/self-hosting/environment-variables/model-provider.mdx +18 -0
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +20 -0
- package/locales/ar/chat.json +2 -0
- package/locales/bg-BG/chat.json +2 -0
- package/locales/de-DE/chat.json +2 -0
- package/locales/en-US/chat.json +2 -0
- package/locales/es-ES/chat.json +2 -0
- package/locales/fa-IR/chat.json +2 -0
- package/locales/fr-FR/chat.json +2 -0
- package/locales/it-IT/chat.json +2 -0
- package/locales/ja-JP/chat.json +2 -0
- package/locales/ko-KR/chat.json +2 -0
- package/locales/nl-NL/chat.json +2 -0
- package/locales/pl-PL/chat.json +2 -0
- package/locales/pt-BR/chat.json +2 -0
- package/locales/ru-RU/chat.json +2 -0
- package/locales/tr-TR/chat.json +2 -0
- package/locales/vi-VN/chat.json +2 -0
- package/locales/zh-CN/chat.json +2 -0
- package/locales/zh-CN/modelProvider.json +1 -1
- package/locales/zh-TW/chat.json +2 -0
- package/package.json +1 -1
- package/packages/const/src/hotkeys.ts +1 -1
- package/packages/const/src/index.ts +1 -0
- package/packages/const/src/settings/hotkey.ts +3 -2
- package/packages/const/src/trace.ts +1 -1
- package/packages/const/src/user.ts +1 -2
- package/packages/database/src/client/db.test.ts +19 -13
- package/packages/electron-server-ipc/src/ipcClient.test.ts +783 -1
- package/packages/file-loaders/src/loadFile.test.ts +61 -0
- package/packages/file-loaders/src/utils/isTextReadableFile.test.ts +43 -0
- package/packages/file-loaders/src/utils/parser-utils.test.ts +155 -0
- package/packages/model-bank/src/aiModels/aihubmix.ts +38 -4
- package/packages/model-bank/src/aiModels/groq.ts +26 -8
- package/packages/model-bank/src/aiModels/hunyuan.ts +3 -3
- package/packages/model-bank/src/aiModels/modelscope.ts +13 -2
- package/packages/model-bank/src/aiModels/moonshot.ts +25 -5
- package/packages/model-bank/src/aiModels/novita.ts +40 -9
- package/packages/model-bank/src/aiModels/openrouter.ts +0 -13
- package/packages/model-bank/src/aiModels/qwen.ts +62 -1
- package/packages/model-bank/src/aiModels/siliconcloud.ts +20 -0
- package/packages/model-bank/src/aiModels/volcengine.ts +141 -15
- package/packages/model-runtime/package.json +2 -1
- package/packages/model-runtime/src/ai21/index.test.ts +2 -2
- package/packages/model-runtime/src/ai360/index.test.ts +2 -2
- package/packages/model-runtime/src/akashchat/index.test.ts +19 -0
- package/packages/model-runtime/src/anthropic/index.test.ts +1 -2
- package/packages/model-runtime/src/baichuan/index.test.ts +1 -2
- package/packages/model-runtime/src/bedrock/index.test.ts +1 -2
- package/packages/model-runtime/src/bfl/createImage.test.ts +1 -2
- package/packages/model-runtime/src/bfl/index.test.ts +1 -2
- package/packages/model-runtime/src/cloudflare/index.test.ts +1 -2
- package/packages/model-runtime/src/cohere/index.test.ts +19 -0
- package/packages/model-runtime/src/deepseek/index.test.ts +2 -2
- package/packages/model-runtime/src/fireworksai/index.test.ts +2 -2
- package/packages/model-runtime/src/giteeai/index.test.ts +2 -2
- package/packages/model-runtime/src/github/index.test.ts +2 -2
- package/packages/model-runtime/src/google/createImage.test.ts +1 -2
- package/packages/model-runtime/src/google/index.test.ts +1 -1
- package/packages/model-runtime/src/groq/index.test.ts +2 -3
- package/packages/model-runtime/src/huggingface/index.test.ts +40 -0
- package/packages/model-runtime/src/hunyuan/index.test.ts +2 -3
- package/packages/model-runtime/src/internlm/index.test.ts +2 -2
- package/packages/model-runtime/src/jina/index.test.ts +19 -0
- package/packages/model-runtime/src/lmstudio/index.test.ts +2 -2
- package/packages/model-runtime/src/minimax/index.test.ts +19 -0
- package/packages/model-runtime/src/mistral/index.test.ts +2 -3
- package/packages/model-runtime/src/modelscope/index.test.ts +19 -0
- package/packages/model-runtime/src/moonshot/index.test.ts +1 -2
- package/packages/model-runtime/src/nebius/index.test.ts +19 -0
- package/packages/model-runtime/src/newapi/index.test.ts +49 -42
- package/packages/model-runtime/src/newapi/index.ts +124 -143
- package/packages/model-runtime/src/novita/index.test.ts +3 -4
- package/packages/model-runtime/src/nvidia/index.test.ts +19 -0
- package/packages/model-runtime/src/openrouter/index.test.ts +2 -3
- package/packages/model-runtime/src/perplexity/index.test.ts +2 -3
- package/packages/model-runtime/src/ppio/index.test.ts +3 -4
- package/packages/model-runtime/src/qwen/index.test.ts +2 -2
- package/packages/model-runtime/src/sambanova/index.test.ts +19 -0
- package/packages/model-runtime/src/search1api/index.test.ts +19 -0
- package/packages/model-runtime/src/sensenova/index.test.ts +2 -2
- package/packages/model-runtime/src/spark/index.test.ts +2 -2
- package/packages/model-runtime/src/stepfun/index.test.ts +2 -2
- package/packages/model-runtime/src/taichu/index.test.ts +4 -5
- package/packages/model-runtime/src/tencentcloud/index.test.ts +1 -1
- package/packages/model-runtime/src/togetherai/index.test.ts +1 -2
- package/packages/model-runtime/src/upstage/index.test.ts +1 -2
- package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.test.ts +9 -7
- package/packages/model-runtime/src/utils/streams/anthropic.ts +2 -2
- package/packages/model-runtime/src/utils/streams/openai/openai.ts +20 -13
- package/packages/model-runtime/src/utils/streams/openai/responsesStream.test.ts +1 -2
- package/packages/model-runtime/src/utils/streams/openai/responsesStream.ts +2 -2
- package/packages/model-runtime/src/utils/streams/protocol.ts +2 -2
- package/packages/model-runtime/src/wenxin/index.test.ts +2 -3
- package/packages/model-runtime/src/xai/index.test.ts +2 -2
- package/packages/model-runtime/src/zeroone/index.test.ts +1 -2
- package/packages/model-runtime/src/zhipu/index.test.ts +2 -3
- package/packages/model-runtime/vitest.config.mts +0 -7
- package/packages/types/src/index.ts +2 -0
- package/packages/types/src/message/base.ts +1 -1
- package/packages/types/src/openai/chat.ts +2 -3
- package/packages/utils/package.json +2 -1
- package/packages/utils/src/_deprecated/parseModels.test.ts +1 -1
- package/packages/utils/src/_deprecated/parseModels.ts +1 -1
- package/packages/utils/src/client/topic.test.ts +1 -2
- package/packages/utils/src/client/topic.ts +1 -2
- package/packages/utils/src/electron/desktopRemoteRPCFetch.ts +1 -1
- package/packages/utils/src/fetch/fetchSSE.ts +7 -8
- package/packages/utils/src/fetch/parseError.ts +1 -3
- package/packages/utils/src/format.test.ts +1 -2
- package/packages/utils/src/index.ts +1 -0
- package/packages/utils/src/toolManifest.ts +1 -2
- package/packages/utils/src/trace.ts +1 -1
- package/packages/utils/vitest.config.mts +1 -1
- package/packages/web-crawler/src/__tests__/urlRules.test.ts +275 -0
- package/packages/web-crawler/src/crawImpl/__tests__/exa.test.ts +269 -0
- package/packages/web-crawler/src/crawImpl/__tests__/firecrawl.test.ts +284 -0
- package/packages/web-crawler/src/crawImpl/__tests__/naive.test.ts +234 -0
- package/packages/web-crawler/src/crawImpl/__tests__/tavily.test.ts +359 -0
- package/packages/web-crawler/src/utils/__tests__/errorType.test.ts +217 -0
- package/packages/web-crawler/vitest.config.mts +3 -0
- package/scripts/electronWorkflow/mergeMacReleaseFiles.ts +207 -0
- package/src/app/[variants]/(main)/settings/provider/(detail)/newapi/page.tsx +1 -1
- package/src/components/Thinking/index.tsx +2 -3
- package/src/config/llm.ts +8 -0
- package/src/features/ChatInput/Desktop/index.tsx +16 -4
- package/src/features/ChatInput/StoreUpdater.tsx +2 -0
- package/src/libs/traces/index.ts +1 -1
- package/src/locales/default/chat.ts +1 -0
- package/src/locales/default/modelProvider.ts +1 -1
- package/src/server/modules/ModelRuntime/trace.ts +1 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/cancel-functionality.test.ts +107 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +352 -7
- package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +2 -1
- package/packages/model-runtime/src/openrouter/__snapshots__/index.test.ts.snap +0 -113
@@ -0,0 +1,284 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { NetworkConnectionError, PageNotFoundError, TimeoutError } from '../../utils/errorType';
|
4
|
+
import { firecrawl } from '../firecrawl';
|
5
|
+
|
6
|
+
// Mock dependencies
|
7
|
+
vi.mock('../../utils/withTimeout', () => ({
|
8
|
+
DEFAULT_TIMEOUT: 30000,
|
9
|
+
withTimeout: vi.fn(),
|
10
|
+
}));
|
11
|
+
|
12
|
+
describe('firecrawl crawler', () => {
|
13
|
+
beforeEach(() => {
|
14
|
+
vi.clearAllMocks();
|
15
|
+
delete process.env.FIRECRAWL_API_KEY;
|
16
|
+
delete process.env.FIRECRAWL_URL;
|
17
|
+
});
|
18
|
+
|
19
|
+
it('should successfully crawl content with API key', async () => {
|
20
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
21
|
+
|
22
|
+
const mockResponse = {
|
23
|
+
ok: true,
|
24
|
+
json: vi.fn().mockResolvedValue({
|
25
|
+
success: true,
|
26
|
+
data: {
|
27
|
+
markdown:
|
28
|
+
'This is a test markdown content with enough length to pass validation. '.repeat(3),
|
29
|
+
metadata: {
|
30
|
+
title: 'Test Article',
|
31
|
+
description: 'Test description',
|
32
|
+
sourceURL: 'https://example.com',
|
33
|
+
statusCode: 200,
|
34
|
+
language: 'en',
|
35
|
+
keywords: 'test',
|
36
|
+
robots: 'index',
|
37
|
+
},
|
38
|
+
},
|
39
|
+
}),
|
40
|
+
};
|
41
|
+
|
42
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
43
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
44
|
+
|
45
|
+
const result = await firecrawl('https://example.com', { filterOptions: {} });
|
46
|
+
|
47
|
+
expect(result).toEqual({
|
48
|
+
content: 'This is a test markdown content with enough length to pass validation. '.repeat(3),
|
49
|
+
contentType: 'text',
|
50
|
+
description: 'Test description',
|
51
|
+
length: 'This is a test markdown content with enough length to pass validation. '.repeat(3)
|
52
|
+
.length,
|
53
|
+
siteName: 'example.com',
|
54
|
+
title: 'Test Article',
|
55
|
+
url: 'https://example.com',
|
56
|
+
});
|
57
|
+
|
58
|
+
expect(withTimeout).toHaveBeenCalledWith(expect.any(Promise), 30000);
|
59
|
+
});
|
60
|
+
|
61
|
+
it('should handle missing API key', async () => {
|
62
|
+
const mockResponse = {
|
63
|
+
ok: true,
|
64
|
+
json: vi.fn().mockResolvedValue({
|
65
|
+
success: true,
|
66
|
+
data: {
|
67
|
+
markdown: 'Test content with sufficient length. '.repeat(5),
|
68
|
+
metadata: {
|
69
|
+
title: 'Test',
|
70
|
+
description: 'Test',
|
71
|
+
sourceURL: 'https://example.com',
|
72
|
+
statusCode: 200,
|
73
|
+
language: 'en',
|
74
|
+
keywords: 'test',
|
75
|
+
robots: 'index',
|
76
|
+
},
|
77
|
+
},
|
78
|
+
}),
|
79
|
+
};
|
80
|
+
|
81
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
82
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
83
|
+
|
84
|
+
await firecrawl('https://example.com', { filterOptions: {} });
|
85
|
+
|
86
|
+
expect(withTimeout).toHaveBeenCalledWith(expect.any(Promise), 30000);
|
87
|
+
});
|
88
|
+
|
89
|
+
it('should return undefined for short content', async () => {
|
90
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
91
|
+
|
92
|
+
const mockResponse = {
|
93
|
+
ok: true,
|
94
|
+
json: vi.fn().mockResolvedValue({
|
95
|
+
success: true,
|
96
|
+
data: {
|
97
|
+
markdown: 'Short', // Content too short
|
98
|
+
metadata: {
|
99
|
+
title: 'Test',
|
100
|
+
description: 'Test',
|
101
|
+
sourceURL: 'https://example.com',
|
102
|
+
statusCode: 200,
|
103
|
+
language: 'en',
|
104
|
+
keywords: 'test',
|
105
|
+
robots: 'index',
|
106
|
+
},
|
107
|
+
},
|
108
|
+
}),
|
109
|
+
};
|
110
|
+
|
111
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
112
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
113
|
+
|
114
|
+
const result = await firecrawl('https://example.com', { filterOptions: {} });
|
115
|
+
|
116
|
+
expect(result).toBeUndefined();
|
117
|
+
});
|
118
|
+
|
119
|
+
it('should return undefined when markdown is missing', async () => {
|
120
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
121
|
+
|
122
|
+
const mockResponse = {
|
123
|
+
ok: true,
|
124
|
+
json: vi.fn().mockResolvedValue({
|
125
|
+
success: true,
|
126
|
+
data: {
|
127
|
+
// markdown is missing
|
128
|
+
metadata: {
|
129
|
+
title: 'Test',
|
130
|
+
description: 'Test',
|
131
|
+
sourceURL: 'https://example.com',
|
132
|
+
statusCode: 200,
|
133
|
+
language: 'en',
|
134
|
+
keywords: 'test',
|
135
|
+
robots: 'index',
|
136
|
+
},
|
137
|
+
},
|
138
|
+
}),
|
139
|
+
};
|
140
|
+
|
141
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
142
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
143
|
+
|
144
|
+
const result = await firecrawl('https://example.com', { filterOptions: {} });
|
145
|
+
|
146
|
+
expect(result).toBeUndefined();
|
147
|
+
});
|
148
|
+
|
149
|
+
it('should throw PageNotFoundError for 404 status', async () => {
|
150
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
151
|
+
|
152
|
+
const mockResponse = {
|
153
|
+
ok: false,
|
154
|
+
status: 404,
|
155
|
+
statusText: 'Not Found',
|
156
|
+
};
|
157
|
+
|
158
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
159
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
160
|
+
|
161
|
+
await expect(firecrawl('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
162
|
+
PageNotFoundError,
|
163
|
+
);
|
164
|
+
});
|
165
|
+
|
166
|
+
it('should throw error for other HTTP errors', async () => {
|
167
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
168
|
+
|
169
|
+
const mockResponse = {
|
170
|
+
ok: false,
|
171
|
+
status: 500,
|
172
|
+
statusText: 'Internal Server Error',
|
173
|
+
};
|
174
|
+
|
175
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
176
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
177
|
+
|
178
|
+
await expect(firecrawl('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
179
|
+
'Firecrawl request failed with status 500: Internal Server Error',
|
180
|
+
);
|
181
|
+
});
|
182
|
+
|
183
|
+
it('should throw NetworkConnectionError for fetch failures', async () => {
|
184
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
185
|
+
|
186
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
187
|
+
vi.mocked(withTimeout).mockRejectedValue(new Error('fetch failed'));
|
188
|
+
|
189
|
+
await expect(firecrawl('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
190
|
+
NetworkConnectionError,
|
191
|
+
);
|
192
|
+
});
|
193
|
+
|
194
|
+
it('should throw TimeoutError when request times out', async () => {
|
195
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
196
|
+
|
197
|
+
const timeoutError = new TimeoutError('Request timeout');
|
198
|
+
|
199
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
200
|
+
vi.mocked(withTimeout).mockRejectedValue(timeoutError);
|
201
|
+
|
202
|
+
await expect(firecrawl('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
203
|
+
TimeoutError,
|
204
|
+
);
|
205
|
+
});
|
206
|
+
|
207
|
+
it('should rethrow unknown errors', async () => {
|
208
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
209
|
+
|
210
|
+
const unknownError = new Error('Unknown error');
|
211
|
+
|
212
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
213
|
+
vi.mocked(withTimeout).mockRejectedValue(unknownError);
|
214
|
+
|
215
|
+
await expect(firecrawl('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
216
|
+
'Unknown error',
|
217
|
+
);
|
218
|
+
});
|
219
|
+
|
220
|
+
it('should return undefined when JSON parsing fails', async () => {
|
221
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
222
|
+
|
223
|
+
const mockResponse = {
|
224
|
+
ok: true,
|
225
|
+
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
226
|
+
};
|
227
|
+
|
228
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
229
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
230
|
+
|
231
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
232
|
+
|
233
|
+
const result = await firecrawl('https://example.com', { filterOptions: {} });
|
234
|
+
|
235
|
+
expect(result).toBeUndefined();
|
236
|
+
expect(consoleSpy).toHaveBeenCalled();
|
237
|
+
|
238
|
+
consoleSpy.mockRestore();
|
239
|
+
});
|
240
|
+
|
241
|
+
it('should handle metadata with all optional fields', async () => {
|
242
|
+
process.env.FIRECRAWL_API_KEY = 'test-api-key';
|
243
|
+
|
244
|
+
const mockResponse = {
|
245
|
+
ok: true,
|
246
|
+
json: vi.fn().mockResolvedValue({
|
247
|
+
success: true,
|
248
|
+
data: {
|
249
|
+
markdown: 'Complete test content with all metadata fields provided. '.repeat(3),
|
250
|
+
metadata: {
|
251
|
+
title: 'Complete Test Article',
|
252
|
+
description: 'Complete test description',
|
253
|
+
keywords: 'test,complete,article',
|
254
|
+
language: 'en',
|
255
|
+
ogDescription: 'OG description',
|
256
|
+
ogImage: 'https://example.com/image.jpg',
|
257
|
+
ogLocaleAlternate: ['en-US', 'fr-FR'],
|
258
|
+
ogSiteName: 'Example Site',
|
259
|
+
ogTitle: 'OG Title',
|
260
|
+
ogUrl: 'https://example.com/og',
|
261
|
+
robots: 'index,follow',
|
262
|
+
statusCode: 200,
|
263
|
+
sourceURL: 'https://example.com',
|
264
|
+
},
|
265
|
+
},
|
266
|
+
}),
|
267
|
+
};
|
268
|
+
|
269
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
270
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
271
|
+
|
272
|
+
const result = await firecrawl('https://example.com', { filterOptions: {} });
|
273
|
+
|
274
|
+
expect(result).toEqual({
|
275
|
+
content: 'Complete test content with all metadata fields provided. '.repeat(3),
|
276
|
+
contentType: 'text',
|
277
|
+
description: 'Complete test description',
|
278
|
+
length: 'Complete test content with all metadata fields provided. '.repeat(3).length,
|
279
|
+
siteName: 'example.com',
|
280
|
+
title: 'Complete Test Article',
|
281
|
+
url: 'https://example.com',
|
282
|
+
});
|
283
|
+
});
|
284
|
+
});
|
@@ -0,0 +1,234 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { NetworkConnectionError, PageNotFoundError, TimeoutError } from '../../utils/errorType';
|
4
|
+
import { naive } from '../naive';
|
5
|
+
|
6
|
+
// Mock dependencies
|
7
|
+
vi.mock('../../utils/htmlToMarkdown', () => ({
|
8
|
+
htmlToMarkdown: vi.fn(),
|
9
|
+
}));
|
10
|
+
|
11
|
+
vi.mock('../../utils/withTimeout', () => ({
|
12
|
+
DEFAULT_TIMEOUT: 30000,
|
13
|
+
withTimeout: vi.fn(),
|
14
|
+
}));
|
15
|
+
|
16
|
+
// Mock fetch globally
|
17
|
+
const mockFetch = vi.fn();
|
18
|
+
global.fetch = mockFetch;
|
19
|
+
|
20
|
+
describe('naive crawler', () => {
|
21
|
+
beforeEach(() => {
|
22
|
+
vi.clearAllMocks();
|
23
|
+
});
|
24
|
+
|
25
|
+
it('should return undefined for normal pages (due to cloudflare logic)', async () => {
|
26
|
+
const mockResponse = {
|
27
|
+
status: 200,
|
28
|
+
headers: new Map([['content-type', 'text/html']]),
|
29
|
+
text: vi.fn().mockResolvedValue('<html><body>Test content</body></html>'),
|
30
|
+
};
|
31
|
+
|
32
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
33
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
34
|
+
|
35
|
+
const { htmlToMarkdown } = await import('../../utils/htmlToMarkdown');
|
36
|
+
vi.mocked(htmlToMarkdown).mockReturnValue({
|
37
|
+
content: 'Test content'.padEnd(101, ' '), // Ensure length > 100
|
38
|
+
title: 'Normal Page Title', // Not "Just a moment..." so it returns undefined
|
39
|
+
description: 'Test description',
|
40
|
+
siteName: 'Test Site',
|
41
|
+
length: 101,
|
42
|
+
});
|
43
|
+
|
44
|
+
const result = await naive('https://example.com', { filterOptions: {} });
|
45
|
+
|
46
|
+
expect(result).toBeUndefined();
|
47
|
+
});
|
48
|
+
|
49
|
+
it('should successfully crawl JSON content', async () => {
|
50
|
+
const mockJsonData = { message: 'Hello world', data: [1, 2, 3] };
|
51
|
+
const mockResponse = {
|
52
|
+
status: 200,
|
53
|
+
headers: new Map([['content-type', 'application/json']]),
|
54
|
+
clone: () => ({
|
55
|
+
json: vi.fn().mockResolvedValue(mockJsonData),
|
56
|
+
}),
|
57
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(mockJsonData)),
|
58
|
+
};
|
59
|
+
|
60
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
61
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
62
|
+
|
63
|
+
const result = await naive('https://api.example.com/data', { filterOptions: {} });
|
64
|
+
|
65
|
+
expect(result).toEqual({
|
66
|
+
content: JSON.stringify(mockJsonData, null, 2),
|
67
|
+
contentType: 'json',
|
68
|
+
length: JSON.stringify(mockJsonData, null, 2).length,
|
69
|
+
url: 'https://api.example.com/data',
|
70
|
+
});
|
71
|
+
});
|
72
|
+
|
73
|
+
it('should handle malformed JSON by falling back to text', async () => {
|
74
|
+
const mockText = '{"invalid": json}';
|
75
|
+
const mockResponse = {
|
76
|
+
status: 200,
|
77
|
+
headers: new Map([['content-type', 'application/json']]),
|
78
|
+
clone: () => ({
|
79
|
+
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
80
|
+
}),
|
81
|
+
text: vi.fn().mockResolvedValue(mockText),
|
82
|
+
};
|
83
|
+
|
84
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
85
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
86
|
+
|
87
|
+
const result = await naive('https://api.example.com/data', { filterOptions: {} });
|
88
|
+
|
89
|
+
expect(result).toEqual({
|
90
|
+
content: mockText,
|
91
|
+
contentType: 'json',
|
92
|
+
length: mockText.length,
|
93
|
+
url: 'https://api.example.com/data',
|
94
|
+
});
|
95
|
+
});
|
96
|
+
|
97
|
+
it('should return undefined for short content', async () => {
|
98
|
+
const mockResponse = {
|
99
|
+
status: 200,
|
100
|
+
headers: new Map([['content-type', 'text/html']]),
|
101
|
+
text: vi.fn().mockResolvedValue('<html><body>Short</body></html>'),
|
102
|
+
};
|
103
|
+
|
104
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
105
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
106
|
+
|
107
|
+
const { htmlToMarkdown } = await import('../../utils/htmlToMarkdown');
|
108
|
+
vi.mocked(htmlToMarkdown).mockReturnValue({
|
109
|
+
content: 'Short', // Length < 100
|
110
|
+
title: 'Test Page',
|
111
|
+
length: 5,
|
112
|
+
});
|
113
|
+
|
114
|
+
const result = await naive('https://example.com', { filterOptions: {} });
|
115
|
+
|
116
|
+
expect(result).toBeUndefined();
|
117
|
+
});
|
118
|
+
|
119
|
+
it('should return content when NOT blocked by Cloudflare', async () => {
|
120
|
+
const mockResponse = {
|
121
|
+
status: 200,
|
122
|
+
headers: new Map([['content-type', 'text/html']]),
|
123
|
+
text: vi.fn().mockResolvedValue('<html><body>Normal content</body></html>'),
|
124
|
+
};
|
125
|
+
|
126
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
127
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
128
|
+
|
129
|
+
const { htmlToMarkdown } = await import('../../utils/htmlToMarkdown');
|
130
|
+
vi.mocked(htmlToMarkdown).mockReturnValue({
|
131
|
+
content: 'Test content'.padEnd(101, ' '),
|
132
|
+
title: 'Just a moment...', // Cloudflare blocking page - this will cause return
|
133
|
+
description: 'Test description',
|
134
|
+
siteName: 'Test Site',
|
135
|
+
length: 101,
|
136
|
+
});
|
137
|
+
|
138
|
+
const result = await naive('https://example.com', { filterOptions: {} });
|
139
|
+
|
140
|
+
expect(result).toEqual({
|
141
|
+
content: 'Test content'.padEnd(101, ' '),
|
142
|
+
contentType: 'text',
|
143
|
+
description: 'Test description',
|
144
|
+
length: 101,
|
145
|
+
siteName: 'Test Site',
|
146
|
+
title: 'Just a moment...',
|
147
|
+
url: 'https://example.com',
|
148
|
+
});
|
149
|
+
});
|
150
|
+
|
151
|
+
it('should throw PageNotFoundError for 404 status', async () => {
|
152
|
+
const mockResponse = {
|
153
|
+
status: 404,
|
154
|
+
statusText: 'Not Found',
|
155
|
+
};
|
156
|
+
|
157
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
158
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
159
|
+
|
160
|
+
await expect(naive('https://example.com/notfound', { filterOptions: {} })).rejects.toThrow(
|
161
|
+
PageNotFoundError,
|
162
|
+
);
|
163
|
+
});
|
164
|
+
|
165
|
+
it('should throw NetworkConnectionError for fetch failures', async () => {
|
166
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
167
|
+
vi.mocked(withTimeout).mockRejectedValue(new Error('fetch failed'));
|
168
|
+
|
169
|
+
await expect(naive('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
170
|
+
NetworkConnectionError,
|
171
|
+
);
|
172
|
+
});
|
173
|
+
|
174
|
+
it('should throw TimeoutError when request times out', async () => {
|
175
|
+
const timeoutError = new TimeoutError('Request timeout');
|
176
|
+
|
177
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
178
|
+
vi.mocked(withTimeout).mockRejectedValue(timeoutError);
|
179
|
+
|
180
|
+
await expect(naive('https://example.com', { filterOptions: {} })).rejects.toThrow(TimeoutError);
|
181
|
+
});
|
182
|
+
|
183
|
+
it('should rethrow unknown errors', async () => {
|
184
|
+
const unknownError = new Error('Unknown error');
|
185
|
+
|
186
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
187
|
+
vi.mocked(withTimeout).mockRejectedValue(unknownError);
|
188
|
+
|
189
|
+
await expect(naive('https://example.com', { filterOptions: {} })).rejects.toThrow(
|
190
|
+
'Unknown error',
|
191
|
+
);
|
192
|
+
});
|
193
|
+
|
194
|
+
it('should return undefined when HTML processing fails', async () => {
|
195
|
+
const mockResponse = {
|
196
|
+
status: 200,
|
197
|
+
headers: new Map([['content-type', 'text/html']]),
|
198
|
+
text: vi.fn().mockRejectedValue(new Error('Failed to read text')),
|
199
|
+
};
|
200
|
+
|
201
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
202
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
203
|
+
|
204
|
+
const result = await naive('https://example.com', { filterOptions: {} });
|
205
|
+
|
206
|
+
expect(result).toBeUndefined();
|
207
|
+
});
|
208
|
+
|
209
|
+
it('should pass filter options to htmlToMarkdown', async () => {
|
210
|
+
const mockResponse = {
|
211
|
+
status: 200,
|
212
|
+
headers: new Map([['content-type', 'text/html']]),
|
213
|
+
text: vi.fn().mockResolvedValue('<html><body>Test content</body></html>'),
|
214
|
+
};
|
215
|
+
|
216
|
+
const { withTimeout } = await import('../../utils/withTimeout');
|
217
|
+
vi.mocked(withTimeout).mockResolvedValue(mockResponse as any);
|
218
|
+
|
219
|
+
const { htmlToMarkdown } = await import('../../utils/htmlToMarkdown');
|
220
|
+
vi.mocked(htmlToMarkdown).mockReturnValue({
|
221
|
+
content: 'Test content'.padEnd(101, ' '),
|
222
|
+
title: 'Test Page',
|
223
|
+
length: 101,
|
224
|
+
});
|
225
|
+
|
226
|
+
const filterOptions = { enableReadability: true, pureText: false };
|
227
|
+
await naive('https://example.com', { filterOptions });
|
228
|
+
|
229
|
+
expect(htmlToMarkdown).toHaveBeenCalledWith('<html><body>Test content</body></html>', {
|
230
|
+
filterOptions,
|
231
|
+
url: 'https://example.com',
|
232
|
+
});
|
233
|
+
});
|
234
|
+
});
|