@cosmocoder/mcp-web-docs 2.0.11 → 2.0.13
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/build/crawler/base.d.ts +2 -0
- package/build/crawler/base.js +6 -2
- package/build/crawler/base.js.map +1 -1
- package/build/crawler/docs-crawler.d.ts +1 -0
- package/build/crawler/docs-crawler.js +14 -0
- package/build/crawler/docs-crawler.js.map +1 -1
- package/build/crawler/docs-crawler.test.js +29 -16
- package/build/crawler/docs-crawler.test.js.map +1 -1
- package/build/crawler/github.js +2 -2
- package/build/crawler/github.js.map +1 -1
- package/build/crawler/github.test.js +15 -0
- package/build/crawler/github.test.js.map +1 -1
- package/build/index.js +84 -61
- package/build/index.js.map +1 -1
- package/build/index.test.js +470 -76
- package/build/index.test.js.map +1 -1
- package/build/indexing/queue-manager.d.ts +14 -33
- package/build/indexing/queue-manager.js +77 -70
- package/build/indexing/queue-manager.js.map +1 -1
- package/build/indexing/queue-manager.test.js +155 -245
- package/build/indexing/queue-manager.test.js.map +1 -1
- package/build/storage/storage.js +18 -6
- package/build/storage/storage.js.map +1 -1
- package/build/storage/storage.test.js +39 -2
- package/build/storage/storage.test.js.map +1 -1
- package/build/types.d.ts +2 -0
- package/package.json +1 -1
package/build/index.test.js
CHANGED
|
@@ -1,65 +1,105 @@
|
|
|
1
|
+
import { setImmediate as nextTurn } from 'node:timers/promises';
|
|
1
2
|
import { isValidPublicUrl } from './config.js';
|
|
2
|
-
import { validateToolArgs, AddDocumentationArgsSchema, SearchDocumentationArgsSchema, SetTagsArgsSchema, detectPromptInjection, wrapExternalContent, addInjectionWarnings, sanitizeErrorMessage, } from './util/security.js';
|
|
3
|
+
import { validateToolArgs, AddDocumentationArgsSchema, SearchDocumentationArgsSchema, SetTagsArgsSchema, detectPromptInjection, wrapExternalContent, addInjectionWarnings, sanitizeErrorMessage, SessionExpiredError, } from './util/security.js';
|
|
3
4
|
import { generateDocId } from './util/docs.js';
|
|
4
5
|
import { IndexingStatusTracker } from './indexing/status.js';
|
|
5
|
-
|
|
6
|
+
const { mockCrawlerAbort, mockCrawlerCrawl, mockCrawlerSetPathPrefix, mockClearSession, mockFetchFavicon, mockNotification, mockProcessorProcess, mockRunLatest, mockStoreAddDocument, mockStoreGetDocument, requestHandlers, } = vi.hoisted(() => ({
|
|
7
|
+
mockCrawlerAbort: vi.fn(),
|
|
8
|
+
mockCrawlerCrawl: vi.fn().mockImplementation(async function* () {
|
|
9
|
+
yield { url: 'https://example.com', path: '/', content: '<h1>Test</h1>', title: 'Test' };
|
|
10
|
+
}),
|
|
11
|
+
mockCrawlerSetPathPrefix: vi.fn(),
|
|
12
|
+
mockClearSession: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
mockFetchFavicon: vi.fn().mockResolvedValue('https://example.com/favicon.ico'),
|
|
14
|
+
mockNotification: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
mockProcessorProcess: vi.fn().mockResolvedValue({
|
|
16
|
+
metadata: { url: 'https://example.com', title: 'Test', lastIndexed: new Date() },
|
|
17
|
+
chunks: [],
|
|
18
|
+
}),
|
|
19
|
+
mockRunLatest: vi.fn(),
|
|
20
|
+
mockStoreAddDocument: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
mockStoreGetDocument: vi.fn().mockResolvedValue(null),
|
|
22
|
+
requestHandlers: [],
|
|
23
|
+
}));
|
|
24
|
+
mockRunLatest.mockImplementation(async (_url, operation) => {
|
|
25
|
+
const completion = Promise.resolve().then(() => operation(new AbortController().signal));
|
|
26
|
+
return { completion, replacedExisting: false };
|
|
27
|
+
});
|
|
6
28
|
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
|
7
|
-
McpServer: vi.fn().mockImplementation(()
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
McpServer: vi.fn().mockImplementation(function () {
|
|
30
|
+
return {
|
|
31
|
+
server: {
|
|
32
|
+
setRequestHandler: vi.fn((_schema, handler) => {
|
|
33
|
+
requestHandlers.push(handler);
|
|
34
|
+
}),
|
|
35
|
+
notification: mockNotification,
|
|
36
|
+
onerror: null,
|
|
37
|
+
},
|
|
38
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
15
41
|
}));
|
|
16
42
|
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
17
43
|
StdioServerTransport: vi.fn(),
|
|
18
44
|
}));
|
|
19
45
|
vi.mock('./storage/storage.js', () => ({
|
|
20
|
-
DocumentStore: vi.fn().mockImplementation(()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
DocumentStore: vi.fn().mockImplementation(function () {
|
|
47
|
+
return {
|
|
48
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
listDocuments: vi.fn().mockResolvedValue([]),
|
|
50
|
+
getDocument: mockStoreGetDocument,
|
|
51
|
+
searchByText: vi.fn().mockResolvedValue([]),
|
|
52
|
+
addDocument: mockStoreAddDocument,
|
|
53
|
+
deleteDocument: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
setTags: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
listAllTags: vi.fn().mockResolvedValue([]),
|
|
56
|
+
optimize: vi.fn().mockResolvedValue({ compacted: false, cleanedUp: false }),
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
}));
|
|
60
|
+
vi.mock('./indexing/queue-manager.js', () => ({
|
|
61
|
+
IndexingQueueManager: function () {
|
|
62
|
+
return {
|
|
63
|
+
runLatest: mockRunLatest,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
30
66
|
}));
|
|
31
67
|
vi.mock('./embeddings/fastembed.js', () => ({
|
|
32
|
-
FastEmbeddings: vi.fn().mockImplementation(()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
FastEmbeddings: vi.fn().mockImplementation(function () {
|
|
69
|
+
return {
|
|
70
|
+
dimensions: 384,
|
|
71
|
+
embed: vi.fn().mockResolvedValue(new Array(384).fill(0)),
|
|
72
|
+
};
|
|
73
|
+
}),
|
|
36
74
|
}));
|
|
37
75
|
vi.mock('./processor/processor.js', () => ({
|
|
38
|
-
WebDocumentProcessor: vi.fn().mockImplementation(()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})),
|
|
76
|
+
WebDocumentProcessor: vi.fn().mockImplementation(function () {
|
|
77
|
+
return {
|
|
78
|
+
process: mockProcessorProcess,
|
|
79
|
+
};
|
|
80
|
+
}),
|
|
44
81
|
}));
|
|
45
82
|
vi.mock('./crawler/docs-crawler.js', () => ({
|
|
46
|
-
DocsCrawler: vi.fn().mockImplementation(()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
83
|
+
DocsCrawler: vi.fn().mockImplementation(function () {
|
|
84
|
+
return {
|
|
85
|
+
crawl: mockCrawlerCrawl,
|
|
86
|
+
abort: mockCrawlerAbort,
|
|
87
|
+
setPathPrefix: mockCrawlerSetPathPrefix,
|
|
88
|
+
setStorageState: vi.fn(),
|
|
89
|
+
};
|
|
90
|
+
}),
|
|
53
91
|
}));
|
|
54
92
|
vi.mock('./crawler/auth.js', () => ({
|
|
55
|
-
AuthManager: vi.fn().mockImplementation(()
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
AuthManager: vi.fn().mockImplementation(function () {
|
|
94
|
+
return {
|
|
95
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
hasSession: vi.fn().mockResolvedValue(false),
|
|
97
|
+
loadSession: vi.fn().mockResolvedValue(null),
|
|
98
|
+
clearSession: mockClearSession,
|
|
99
|
+
performInteractiveLogin: vi.fn().mockResolvedValue(undefined),
|
|
100
|
+
validateSession: vi.fn().mockResolvedValue({ isValid: true }),
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
63
103
|
}));
|
|
64
104
|
vi.mock('./config.js', () => ({
|
|
65
105
|
loadConfig: vi.fn().mockResolvedValue({
|
|
@@ -75,7 +115,7 @@ vi.mock('./config.js', () => ({
|
|
|
75
115
|
normalizeUrl: vi.fn().mockImplementation((url) => url.replace(/\/$/, '')),
|
|
76
116
|
}));
|
|
77
117
|
vi.mock('./util/favicon.js', () => ({
|
|
78
|
-
fetchFavicon:
|
|
118
|
+
fetchFavicon: mockFetchFavicon,
|
|
79
119
|
}));
|
|
80
120
|
vi.mock('./util/docs.js', () => ({
|
|
81
121
|
generateDocId: vi.fn().mockImplementation((url) => {
|
|
@@ -88,10 +128,394 @@ vi.mock('crawlee', () => ({
|
|
|
88
128
|
Configuration: { getGlobalConfig: vi.fn().mockReturnValue({ set: vi.fn() }) },
|
|
89
129
|
Dataset: { open: vi.fn().mockResolvedValue({ drop: vi.fn() }) },
|
|
90
130
|
}));
|
|
131
|
+
const processedPageWithChunk = {
|
|
132
|
+
metadata: { url: 'https://example.com', title: 'Test', lastIndexed: new Date() },
|
|
133
|
+
chunks: [
|
|
134
|
+
{
|
|
135
|
+
content: 'content',
|
|
136
|
+
url: 'https://example.com',
|
|
137
|
+
title: 'Test',
|
|
138
|
+
path: '/',
|
|
139
|
+
startLine: 1,
|
|
140
|
+
endLine: 1,
|
|
141
|
+
vector: [0],
|
|
142
|
+
metadata: { type: 'overview' },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
91
146
|
describe('WebDocsServer', () => {
|
|
92
147
|
beforeEach(() => {
|
|
93
148
|
vi.clearAllMocks();
|
|
94
149
|
});
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
vi.restoreAllMocks();
|
|
152
|
+
});
|
|
153
|
+
describe('operation lifecycle integration', () => {
|
|
154
|
+
let toolHandler;
|
|
155
|
+
beforeAll(async () => {
|
|
156
|
+
requestHandlers.length = 0;
|
|
157
|
+
await import('./index.js');
|
|
158
|
+
await nextTurn();
|
|
159
|
+
toolHandler = requestHandlers.at(-1);
|
|
160
|
+
});
|
|
161
|
+
it('does not start or notify a rejected operation before admitting its tokenless successor', async () => {
|
|
162
|
+
const startIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'startIndexing');
|
|
163
|
+
let successorCompletion;
|
|
164
|
+
mockRunLatest
|
|
165
|
+
.mockRejectedValueOnce(new Error('replacement cancellation timed out'))
|
|
166
|
+
.mockImplementationOnce(async (_url, operation) => {
|
|
167
|
+
successorCompletion = Promise.resolve().then(() => operation(new AbortController().signal));
|
|
168
|
+
return { completion: successorCompletion, replacedExisting: false };
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
await expect(toolHandler({
|
|
172
|
+
params: {
|
|
173
|
+
name: 'add_documentation',
|
|
174
|
+
arguments: { url: 'https://example.com', _meta: { progressToken: 'rejected-token' } },
|
|
175
|
+
},
|
|
176
|
+
})).rejects.toThrow('replacement cancellation timed out');
|
|
177
|
+
expect(startIndexing).not.toHaveBeenCalled();
|
|
178
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
179
|
+
await successorCompletion;
|
|
180
|
+
expect(startIndexing).toHaveBeenCalledOnce();
|
|
181
|
+
expect(mockNotification.mock.calls.some(([notification]) => notification.params?.progressToken === 'rejected-token')).toBe(false);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await Promise.allSettled(successorCompletion ? [successorCompletion] : []);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
it.each([
|
|
188
|
+
{
|
|
189
|
+
name: 'keeps successor progress state when an old terminal notification finishes late',
|
|
190
|
+
firstToken: 'same-token',
|
|
191
|
+
secondToken: 'same-token',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'does not reuse an old progress token when its tokenless successor starts',
|
|
195
|
+
firstToken: 'old-token',
|
|
196
|
+
secondToken: undefined,
|
|
197
|
+
},
|
|
198
|
+
])('$name', async ({ firstToken, secondToken }) => {
|
|
199
|
+
const startIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'startIndexing');
|
|
200
|
+
const firstStoreLookup = Promise.withResolvers();
|
|
201
|
+
const secondStoreLookup = Promise.withResolvers();
|
|
202
|
+
const oldTerminalNotification = Promise.withResolvers();
|
|
203
|
+
const oldTerminalStarted = Promise.withResolvers();
|
|
204
|
+
const successorProgressed = Promise.withResolvers();
|
|
205
|
+
const releaseSuccessorCrawl = Promise.withResolvers();
|
|
206
|
+
mockStoreGetDocument.mockReturnValueOnce(firstStoreLookup.promise).mockReturnValueOnce(secondStoreLookup.promise);
|
|
207
|
+
mockCrawlerCrawl.mockImplementationOnce(async function* () {
|
|
208
|
+
yield { url: 'https://example.com', path: '/', content: '<h1>Test</h1>', title: 'Test' };
|
|
209
|
+
successorProgressed.resolve();
|
|
210
|
+
await releaseSuccessorCrawl.promise;
|
|
211
|
+
});
|
|
212
|
+
mockNotification.mockImplementation(async (notification) => {
|
|
213
|
+
if (notification.params?.message?.startsWith('Cancelled -')) {
|
|
214
|
+
oldTerminalStarted.resolve();
|
|
215
|
+
await oldTerminalNotification.promise;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
let active;
|
|
219
|
+
const completions = [];
|
|
220
|
+
const runLatest = async (_url, operation) => {
|
|
221
|
+
const previous = active;
|
|
222
|
+
if (previous) {
|
|
223
|
+
previous.controller.abort();
|
|
224
|
+
await previous.completion;
|
|
225
|
+
}
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const completion = Promise.resolve().then(() => operation(controller.signal));
|
|
228
|
+
completions.push(completion);
|
|
229
|
+
active = { controller, completion };
|
|
230
|
+
return { completion, replacedExisting: previous !== undefined };
|
|
231
|
+
};
|
|
232
|
+
mockRunLatest.mockImplementationOnce(runLatest).mockImplementationOnce(runLatest);
|
|
233
|
+
try {
|
|
234
|
+
await toolHandler({
|
|
235
|
+
params: {
|
|
236
|
+
name: 'add_documentation',
|
|
237
|
+
arguments: { url: 'https://example.com', _meta: { progressToken: firstToken } },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
await nextTurn();
|
|
241
|
+
expect(startIndexing).toHaveBeenCalledOnce();
|
|
242
|
+
const replacementResponse = toolHandler({
|
|
243
|
+
params: {
|
|
244
|
+
name: 'add_documentation',
|
|
245
|
+
arguments: secondToken ? { url: 'https://example.com', _meta: { progressToken: secondToken } } : { url: 'https://example.com' },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
await nextTurn();
|
|
249
|
+
firstStoreLookup.resolve();
|
|
250
|
+
await oldTerminalStarted.promise;
|
|
251
|
+
await replacementResponse;
|
|
252
|
+
await nextTurn();
|
|
253
|
+
expect(startIndexing).toHaveBeenCalledTimes(2);
|
|
254
|
+
const notificationsAtSuccessorBoundary = mockNotification.mock.calls.length;
|
|
255
|
+
oldTerminalNotification.resolve();
|
|
256
|
+
await nextTurn();
|
|
257
|
+
secondStoreLookup.resolve();
|
|
258
|
+
await successorProgressed.promise;
|
|
259
|
+
releaseSuccessorCrawl.resolve();
|
|
260
|
+
await active.completion;
|
|
261
|
+
if (secondToken) {
|
|
262
|
+
const successorNotifications = mockNotification.mock.calls
|
|
263
|
+
.slice(notificationsAtSuccessorBoundary)
|
|
264
|
+
.map(([notification]) => notification.params);
|
|
265
|
+
expect(successorNotifications).toEqual(expect.arrayContaining([
|
|
266
|
+
expect.objectContaining({ progressToken: secondToken, message: expect.stringContaining('Finding subpages') }),
|
|
267
|
+
expect.objectContaining({ progressToken: secondToken, message: expect.stringContaining('No content was extracted') }),
|
|
268
|
+
]));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
expect(mockNotification.mock.calls
|
|
272
|
+
.slice(notificationsAtSuccessorBoundary)
|
|
273
|
+
.some(([notification]) => notification.params?.progressToken === firstToken)).toBe(false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
firstStoreLookup.resolve();
|
|
278
|
+
secondStoreLookup.resolve();
|
|
279
|
+
oldTerminalNotification.resolve();
|
|
280
|
+
releaseSuccessorCrawl.resolve();
|
|
281
|
+
await Promise.allSettled(completions);
|
|
282
|
+
mockNotification.mockResolvedValue(undefined);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
it('reports cancellation when an aborted crawler rejects with an ordinary error', async () => {
|
|
286
|
+
const enteredCrawl = Promise.withResolvers();
|
|
287
|
+
const releaseCrawl = Promise.withResolvers();
|
|
288
|
+
mockCrawlerCrawl.mockImplementationOnce(async function* () {
|
|
289
|
+
yield* [];
|
|
290
|
+
enteredCrawl.resolve();
|
|
291
|
+
await releaseCrawl.promise;
|
|
292
|
+
throw new Error('crawler stopped');
|
|
293
|
+
});
|
|
294
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
295
|
+
const failIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'failIndexing');
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
let completion;
|
|
298
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
299
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
300
|
+
return { completion, replacedExisting: false };
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
304
|
+
await enteredCrawl.promise;
|
|
305
|
+
controller.abort();
|
|
306
|
+
releaseCrawl.resolve();
|
|
307
|
+
await completion;
|
|
308
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
309
|
+
expect(failIndexing).not.toHaveBeenCalled();
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
releaseCrawl.resolve();
|
|
313
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
it('cancels instead of failing when abort arrives while an expired session is being cleared', async () => {
|
|
317
|
+
const clearSession = Promise.withResolvers();
|
|
318
|
+
const clearSessionStarted = Promise.withResolvers();
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
321
|
+
const failIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'failIndexing');
|
|
322
|
+
let completion;
|
|
323
|
+
mockCrawlerCrawl.mockImplementationOnce(async function* () {
|
|
324
|
+
yield* [];
|
|
325
|
+
throw new SessionExpiredError('session expired', 'https://example.com', 'https://example.com/login', {
|
|
326
|
+
isLoginPage: true,
|
|
327
|
+
confidence: 1,
|
|
328
|
+
reasons: ['login page'],
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
mockClearSession.mockImplementationOnce(() => {
|
|
332
|
+
clearSessionStarted.resolve();
|
|
333
|
+
return clearSession.promise;
|
|
334
|
+
});
|
|
335
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
336
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
337
|
+
return { completion, replacedExisting: false };
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
await toolHandler({
|
|
341
|
+
params: {
|
|
342
|
+
name: 'add_documentation',
|
|
343
|
+
arguments: { url: 'https://example.com', _meta: { progressToken: 'expired-session-token' } },
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
await clearSessionStarted.promise;
|
|
347
|
+
controller.abort();
|
|
348
|
+
clearSession.resolve();
|
|
349
|
+
await Promise.allSettled([completion]);
|
|
350
|
+
await nextTurn();
|
|
351
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
352
|
+
expect(failIndexing).not.toHaveBeenCalled();
|
|
353
|
+
expect(mockNotification.mock.calls.some(([notification]) => notification.params?.progressToken === 'expired-session-token' && notification.params?.message?.startsWith('Cancelled -'))).toBe(true);
|
|
354
|
+
expect(mockNotification.mock.calls.some(([notification]) => notification.params?.message?.includes('Authentication session has expired'))).toBe(false);
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
controller.abort();
|
|
358
|
+
clearSession.resolve();
|
|
359
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
it('cancels instead of completing an existing add after its document lookup resolves', async () => {
|
|
363
|
+
const lookup = Promise.withResolvers();
|
|
364
|
+
const lookupStarted = Promise.withResolvers();
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
367
|
+
const completeIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'completeIndexing');
|
|
368
|
+
const failIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'failIndexing');
|
|
369
|
+
let completion;
|
|
370
|
+
mockStoreGetDocument.mockImplementationOnce(() => {
|
|
371
|
+
lookupStarted.resolve();
|
|
372
|
+
return lookup.promise;
|
|
373
|
+
});
|
|
374
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
375
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
376
|
+
return { completion, replacedExisting: false };
|
|
377
|
+
});
|
|
378
|
+
try {
|
|
379
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
380
|
+
await lookupStarted.promise;
|
|
381
|
+
controller.abort();
|
|
382
|
+
lookup.resolve({ url: 'https://example.com', title: 'Existing', lastIndexed: new Date() });
|
|
383
|
+
await completion;
|
|
384
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
385
|
+
expect(completeIndexing).not.toHaveBeenCalled();
|
|
386
|
+
expect(failIndexing).not.toHaveBeenCalled();
|
|
387
|
+
expect(mockCrawlerCrawl).not.toHaveBeenCalled();
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
lookup.resolve({ url: 'https://example.com', title: 'Existing', lastIndexed: new Date() });
|
|
391
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
it('does not store a document when cancellation arrives during favicon lookup', async () => {
|
|
395
|
+
const favicon = Promise.withResolvers();
|
|
396
|
+
const faviconStarted = Promise.withResolvers();
|
|
397
|
+
const controller = new AbortController();
|
|
398
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
399
|
+
const completeIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'completeIndexing');
|
|
400
|
+
const failIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'failIndexing');
|
|
401
|
+
let completion;
|
|
402
|
+
mockProcessorProcess.mockResolvedValueOnce(processedPageWithChunk);
|
|
403
|
+
mockFetchFavicon.mockImplementationOnce(() => {
|
|
404
|
+
faviconStarted.resolve();
|
|
405
|
+
return favicon.promise;
|
|
406
|
+
});
|
|
407
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
408
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
409
|
+
return { completion, replacedExisting: false };
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
413
|
+
await faviconStarted.promise;
|
|
414
|
+
controller.abort();
|
|
415
|
+
favicon.resolve(null);
|
|
416
|
+
await completion;
|
|
417
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
418
|
+
expect(mockStoreAddDocument).not.toHaveBeenCalled();
|
|
419
|
+
expect(completeIndexing).not.toHaveBeenCalled();
|
|
420
|
+
expect(failIndexing).not.toHaveBeenCalled();
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
favicon.resolve(null);
|
|
424
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
it('does not complete when cancellation arrives during document storage', async () => {
|
|
428
|
+
const add = Promise.withResolvers();
|
|
429
|
+
const addStarted = Promise.withResolvers();
|
|
430
|
+
const controller = new AbortController();
|
|
431
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
432
|
+
const completeIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'completeIndexing');
|
|
433
|
+
const failIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'failIndexing');
|
|
434
|
+
let completion;
|
|
435
|
+
mockProcessorProcess.mockResolvedValueOnce(processedPageWithChunk);
|
|
436
|
+
mockStoreAddDocument.mockImplementationOnce(() => {
|
|
437
|
+
addStarted.resolve();
|
|
438
|
+
return add.promise;
|
|
439
|
+
});
|
|
440
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
441
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
442
|
+
return { completion, replacedExisting: false };
|
|
443
|
+
});
|
|
444
|
+
try {
|
|
445
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
446
|
+
await addStarted.promise;
|
|
447
|
+
controller.abort();
|
|
448
|
+
add.resolve();
|
|
449
|
+
await completion;
|
|
450
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
451
|
+
expect(mockStoreAddDocument).toHaveBeenCalledWith(expect.any(Object), { signal: controller.signal, tags: [] });
|
|
452
|
+
expect(completeIndexing).not.toHaveBeenCalled();
|
|
453
|
+
expect(failIndexing).not.toHaveBeenCalled();
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
add.resolve();
|
|
457
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
it('stops retrying a conflicted write when cancellation arrives during backoff', async () => {
|
|
461
|
+
const firstAttempt = Promise.withResolvers();
|
|
462
|
+
const controller = new AbortController();
|
|
463
|
+
const cancelIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'cancelIndexing');
|
|
464
|
+
const completeIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'completeIndexing');
|
|
465
|
+
let completion;
|
|
466
|
+
mockProcessorProcess.mockResolvedValueOnce(processedPageWithChunk);
|
|
467
|
+
mockStoreAddDocument.mockImplementationOnce(async () => {
|
|
468
|
+
firstAttempt.resolve();
|
|
469
|
+
throw new Error('Commit conflict');
|
|
470
|
+
});
|
|
471
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
472
|
+
completion = Promise.resolve().then(() => operation(controller.signal));
|
|
473
|
+
return { completion, replacedExisting: false };
|
|
474
|
+
});
|
|
475
|
+
try {
|
|
476
|
+
await toolHandler({ params: { name: 'add_documentation', arguments: { url: 'https://example.com' } } });
|
|
477
|
+
await firstAttempt.promise;
|
|
478
|
+
await nextTurn();
|
|
479
|
+
controller.abort();
|
|
480
|
+
await completion;
|
|
481
|
+
expect(cancelIndexing).toHaveBeenCalledOnce();
|
|
482
|
+
expect(mockStoreAddDocument).toHaveBeenCalledOnce();
|
|
483
|
+
expect(completeIndexing).not.toHaveBeenCalled();
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
controller.abort();
|
|
487
|
+
await Promise.allSettled(completion ? [completion] : []);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
it('starts reindex status inside runLatest and preserves the replacement message', async () => {
|
|
491
|
+
const url = 'https://example.com';
|
|
492
|
+
mockStoreGetDocument.mockResolvedValueOnce({
|
|
493
|
+
url,
|
|
494
|
+
title: 'Example Docs',
|
|
495
|
+
lastIndexed: new Date(),
|
|
496
|
+
requiresAuth: false,
|
|
497
|
+
tags: ['docs'],
|
|
498
|
+
pathPrefix: '/api/v2',
|
|
499
|
+
});
|
|
500
|
+
mockProcessorProcess.mockResolvedValueOnce(processedPageWithChunk);
|
|
501
|
+
const startIndexing = vi.spyOn(IndexingStatusTracker.prototype, 'startIndexing');
|
|
502
|
+
let completion;
|
|
503
|
+
mockRunLatest.mockImplementationOnce(async (_url, operation) => {
|
|
504
|
+
expect(startIndexing).not.toHaveBeenCalled();
|
|
505
|
+
completion = Promise.resolve().then(() => operation(new AbortController().signal));
|
|
506
|
+
await nextTurn();
|
|
507
|
+
expect(startIndexing).toHaveBeenCalledOnce();
|
|
508
|
+
return { completion, replacedExisting: true };
|
|
509
|
+
});
|
|
510
|
+
const response = (await toolHandler({ params: { name: 'reindex_documentation', arguments: { url } } }));
|
|
511
|
+
const payload = JSON.parse(response.content[0].text);
|
|
512
|
+
expect(payload.message).toContain('Previous operation was cancelled');
|
|
513
|
+
expect(mockRunLatest).toHaveBeenCalledOnce();
|
|
514
|
+
await completion;
|
|
515
|
+
expect(mockCrawlerSetPathPrefix).toHaveBeenCalledWith('/api/v2');
|
|
516
|
+
expect(mockStoreAddDocument).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ pathPrefix: '/api/v2' }) }), expect.objectContaining({ tags: ['docs'] }));
|
|
517
|
+
});
|
|
518
|
+
});
|
|
95
519
|
describe('URL Validation', () => {
|
|
96
520
|
it('should validate public URLs', () => {
|
|
97
521
|
const mockIsValidPublicUrl = isValidPublicUrl;
|
|
@@ -298,36 +722,6 @@ describe('WebDocsServer', () => {
|
|
|
298
722
|
tracker.stop();
|
|
299
723
|
});
|
|
300
724
|
});
|
|
301
|
-
describe('IndexingQueueManager', () => {
|
|
302
|
-
it('should manage indexing operations', async () => {
|
|
303
|
-
const queue = new IndexingQueueManager();
|
|
304
|
-
expect(queue.isIndexing('https://example.com')).toBe(false);
|
|
305
|
-
const controller = await queue.startOperation('https://example.com');
|
|
306
|
-
expect(controller).toBeDefined();
|
|
307
|
-
// Need to register the operation for isIndexing to return true
|
|
308
|
-
const mockPromise = new Promise((resolve) => setTimeout(resolve, 100));
|
|
309
|
-
queue.registerOperation('https://example.com', controller, mockPromise);
|
|
310
|
-
expect(queue.isIndexing('https://example.com')).toBe(true);
|
|
311
|
-
queue.completeOperation('https://example.com');
|
|
312
|
-
expect(queue.isIndexing('https://example.com')).toBe(false);
|
|
313
|
-
});
|
|
314
|
-
it('should cancel existing operation when starting new one for same URL', async () => {
|
|
315
|
-
vi.useFakeTimers();
|
|
316
|
-
const queue = new IndexingQueueManager();
|
|
317
|
-
const controller1 = await queue.startOperation('https://example.com');
|
|
318
|
-
const mockPromise = new Promise((resolve) => setTimeout(resolve, 1000));
|
|
319
|
-
queue.registerOperation('https://example.com', controller1, mockPromise);
|
|
320
|
-
// Starting a new operation should cancel the previous one
|
|
321
|
-
const startPromise = queue.startOperation('https://example.com');
|
|
322
|
-
// Advance timers to resolve the mock promise
|
|
323
|
-
await vi.advanceTimersByTimeAsync(1100);
|
|
324
|
-
const controller2 = await startPromise;
|
|
325
|
-
expect(controller1.signal.aborted).toBe(true);
|
|
326
|
-
expect(controller2.signal.aborted).toBe(false);
|
|
327
|
-
queue.completeOperation('https://example.com');
|
|
328
|
-
vi.useRealTimers();
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
725
|
describe('Error Handling', () => {
|
|
332
726
|
it('should sanitize error messages', () => {
|
|
333
727
|
const errorWithPassword = new Error('Connection failed: password=secret123');
|