@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.
@@ -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
- import { IndexingQueueManager } from './indexing/queue-manager.js';
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
- server: {
9
- setRequestHandler: vi.fn(),
10
- notification: vi.fn().mockResolvedValue(undefined),
11
- onerror: null,
12
- },
13
- connect: vi.fn().mockResolvedValue(undefined),
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
- 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
- })),
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
- dimensions: 384,
34
- embed: vi.fn().mockResolvedValue(new Array(384).fill(0)),
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
- process: vi.fn().mockResolvedValue({
40
- metadata: { url: 'https://example.com', title: 'Test', lastIndexed: new Date() },
41
- chunks: [],
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
- 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
- })),
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
- 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
- })),
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: vi.fn().mockResolvedValue('https://example.com/favicon.ico'),
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');