@cosmocoder/mcp-web-docs 2.0.11 → 2.0.12

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