@cosmocoder/mcp-web-docs 2.0.14 → 2.0.16

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.
@@ -5,6 +5,7 @@ import { join } from 'path';
5
5
  import { mkdtemp, rm } from 'node:fs/promises';
6
6
  import { DocumentStore } from './storage.js';
7
7
  import { createMockEmbeddings } from '../__mocks__/embeddings.js';
8
+ import { logger } from '../util/logger.js';
8
9
  import { setImmediate as nextTurn } from 'node:timers/promises';
9
10
  function deferred() {
10
11
  let resolve;
@@ -1005,14 +1006,13 @@ describe('DocumentStore', () => {
1005
1006
  it('should clear search cache for deleted document', async () => {
1006
1007
  const url = 'https://example.com/cache-test';
1007
1008
  await store.addDocument(createTestDocument(url, 'Cache Test'));
1008
- // Search to populate cache
1009
- await store.searchByText('cache test');
1009
+ const cachedResults = await store.searchByText('cache test');
1010
+ expect(cachedResults.some((result) => result.url === url)).toBe(true);
1010
1011
  // Delete document
1011
1012
  await store.deleteDocument(url);
1012
1013
  // Search again - should not find the document
1013
- const results = await store.searchByText('cache test');
1014
- const hasDeletedDoc = results.some((r) => r.url === url);
1015
- expect(hasDeletedDoc).toBe(false);
1014
+ const refreshedResults = await store.searchByText('cache test');
1015
+ expect(refreshedResults.some((result) => result.url === url)).toBe(false);
1016
1016
  });
1017
1017
  });
1018
1018
  describe('searchDocuments (vector search)', () => {
@@ -1067,6 +1067,25 @@ describe('DocumentStore', () => {
1067
1067
  expect(result.metadata.type).toBe('api');
1068
1068
  });
1069
1069
  });
1070
+ it('should prefilter pure vector search to exact URLs before applying the limit', async () => {
1071
+ const queryVector = await mockEmbeddings.embed('exact scope query');
1072
+ const globalUrl = 'https://example.com/global-best';
1073
+ const scopedUrl = 'https://example.com/scoped-lower';
1074
+ const globalDoc = createTestDocument(globalUrl, 'Global Best');
1075
+ const scopedDoc = createTestDocument(scopedUrl, 'Scoped Lower');
1076
+ globalDoc.chunks[0].vector = queryVector;
1077
+ scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
1078
+ await store.addDocument(globalDoc);
1079
+ await store.addDocument(scopedDoc);
1080
+ const globalResults = await store.searchDocuments(queryVector, { limit: 1 });
1081
+ const scopedResults = await store.searchDocuments(queryVector, { limit: 1, filterUrls: [scopedUrl] });
1082
+ expect(globalResults[0].url).toBe(globalUrl);
1083
+ expect(scopedResults.map((result) => result.url)).toEqual([scopedUrl]);
1084
+ });
1085
+ it('should return no vector results for an explicit empty URL scope', async () => {
1086
+ const queryVector = await mockEmbeddings.embed('guide');
1087
+ await expect(store.searchDocuments(queryVector, { filterUrls: [] })).resolves.toEqual([]);
1088
+ });
1070
1089
  it('should return empty array for empty query vector without text query', async () => {
1071
1090
  const results = await store.searchDocuments([], { limit: 5 });
1072
1091
  expect(results).toEqual([]);
@@ -1084,10 +1103,12 @@ describe('DocumentStore', () => {
1084
1103
  });
1085
1104
  it('should use caching for repeated queries', async () => {
1086
1105
  const query = 'unique cache test query';
1106
+ const embedSpy = vi.spyOn(mockEmbeddings, 'embed');
1087
1107
  const results1 = await store.searchByText(query);
1088
1108
  const results2 = await store.searchByText(query);
1089
1109
  // Results should be identical (from cache)
1090
1110
  expect(results1).toEqual(results2);
1111
+ expect(embedSpy).toHaveBeenCalledTimes(1);
1091
1112
  });
1092
1113
  it('should respect limit option', async () => {
1093
1114
  const results = await store.searchByText('guide', { limit: 1 });
@@ -1101,9 +1122,60 @@ describe('DocumentStore', () => {
1101
1122
  expect(result.url.startsWith('https://example.com/react')).toBe(true);
1102
1123
  });
1103
1124
  });
1104
- it('should handle quoted phrases', async () => {
1105
- const results = await store.searchByText('"React Hooks"');
1106
- expect(Array.isArray(results)).toBe(true);
1125
+ it('should scope both hybrid search legs before ranking', async () => {
1126
+ const query = 'adversarial hybrid scope';
1127
+ const queryVector = await mockEmbeddings.embed(query);
1128
+ const globalUrl = 'https://example.com/hybrid-global-best';
1129
+ const scopedUrl = 'https://example.com/hybrid-scoped-lower';
1130
+ const globalDoc = createDocumentWithContent(globalUrl, 'Global Hybrid', query);
1131
+ const scopedDoc = createDocumentWithContent(scopedUrl, 'Scoped Hybrid', query);
1132
+ globalDoc.chunks[0].vector = queryVector;
1133
+ scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
1134
+ await store.addDocument(globalDoc);
1135
+ await store.addDocument(scopedDoc);
1136
+ expect(store.ftsIndexCreated).toBe(true);
1137
+ const results = await store.searchByText(query, { limit: 2, filterUrls: [scopedUrl] });
1138
+ expect(results.map((result) => result.url)).toEqual([scopedUrl]);
1139
+ expect(results[0].score).toBeGreaterThan(0);
1140
+ });
1141
+ it('should preserve exact URL scope in the pure-vector text fallback', async () => {
1142
+ const query = 'fallback scope query';
1143
+ const queryVector = await mockEmbeddings.embed(query);
1144
+ const globalUrl = 'https://example.com/fallback-global-best';
1145
+ const scopedUrl = 'https://example.com/fallback-scoped-lower';
1146
+ const globalDoc = createTestDocument(globalUrl, 'Fallback Global Best');
1147
+ const scopedDoc = createTestDocument(scopedUrl, 'Fallback Scoped Lower');
1148
+ globalDoc.chunks[0].vector = queryVector;
1149
+ scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
1150
+ await store.addDocument(globalDoc);
1151
+ await store.addDocument(scopedDoc);
1152
+ store.ftsIndexCreated = false;
1153
+ const globalResults = await store.searchByText(query, { limit: 1 });
1154
+ const scopedResults = await store.searchByText(query, { limit: 1, filterUrls: [scopedUrl] });
1155
+ expect(globalResults[0].url).toBe(globalUrl);
1156
+ expect(scopedResults.map((result) => result.url)).toEqual([scopedUrl]);
1157
+ });
1158
+ it('should return no text results for an explicit empty URL scope', async () => {
1159
+ await expect(store.searchByText('guide', { filterUrls: [] })).resolves.toEqual([]);
1160
+ });
1161
+ it('should scope both phrase search legs before ranking', async () => {
1162
+ const phrase = 'adversarial phrase scope';
1163
+ const query = `"${phrase}"`;
1164
+ const queryVector = await mockEmbeddings.embed(query);
1165
+ const globalUrl = 'https://example.com/phrase-global-best';
1166
+ const scopedUrl = 'https://example.com/phrase-scoped-lower';
1167
+ const globalDoc = createDocumentWithContent(globalUrl, 'Global Phrase', phrase);
1168
+ const scopedDoc = createDocumentWithContent(scopedUrl, 'Scoped Phrase', phrase);
1169
+ globalDoc.chunks[0].vector = queryVector;
1170
+ scopedDoc.chunks[0].vector = queryVector.map((value) => -value);
1171
+ await store.addDocument(globalDoc);
1172
+ await store.addDocument(scopedDoc);
1173
+ const internals = replacementInternals();
1174
+ internals.ftsIndexCreated = false;
1175
+ await internals.createFTSIndex();
1176
+ const results = await store.searchByText(query, { limit: 2, filterUrls: [scopedUrl] });
1177
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/Phrase-based FTS returned [1-9]/));
1178
+ expect(results.map((result) => result.url)).toEqual([scopedUrl]);
1107
1179
  });
1108
1180
  it('should handle empty query gracefully', async () => {
1109
1181
  // Generate embedding for empty query
@@ -1336,6 +1408,13 @@ describe('DocumentStore', () => {
1336
1408
  // Should not throw and should return empty (no match)
1337
1409
  expect(Array.isArray(results)).toBe(true);
1338
1410
  });
1411
+ it('should safely filter by exact URLs with special characters', async () => {
1412
+ const url = "https://example.com/team's-guide";
1413
+ await store.addDocument(createTestDocument(url, 'Special URL Guide'));
1414
+ const results = await store.searchByText('guide', { filterUrls: [url] });
1415
+ expect(results.length).toBeGreaterThan(0);
1416
+ expect(results.every((result) => result.url === url)).toBe(true);
1417
+ });
1339
1418
  });
1340
1419
  describe('document tags', () => {
1341
1420
  it('should set and retrieve tags for a document', async () => {
@@ -1499,6 +1578,15 @@ describe('DocumentStore', () => {
1499
1578
  expect(result.url.startsWith('https://example.com/react')).toBe(true);
1500
1579
  });
1501
1580
  });
1581
+ it('should intersect exact URL, prefix URL, and tag filters', async () => {
1582
+ const results = await store.searchByText('guide', {
1583
+ filterByTags: ['frontend'],
1584
+ filterUrl: 'https://example.com/react',
1585
+ filterUrls: ['https://example.com/react-hooks', 'https://example.com/express-api'],
1586
+ });
1587
+ expect(results.length).toBeGreaterThan(0);
1588
+ expect(results.every((result) => result.url === 'https://example.com/react-hooks')).toBe(true);
1589
+ });
1502
1590
  });
1503
1591
  describe('document version', () => {
1504
1592
  it('should store and retrieve version for a document', async () => {
@@ -1605,15 +1693,23 @@ describe('DocumentStore', () => {
1605
1693
  expect(typeof result.compacted).toBe('boolean');
1606
1694
  expect(typeof result.cleanedUp).toBe('boolean');
1607
1695
  });
1608
- it('should clear search cache after optimization', async () => {
1609
- // Add document and search to populate cache
1610
- await store.addDocument(createTestDocument('https://example.com/cache', 'Cache Test'));
1611
- await store.searchByText('cache test');
1612
- // Run optimization (which should clear cache)
1696
+ it('should not cache a search that started before optimization', async () => {
1697
+ const query = 'in-flight optimization cache';
1698
+ const embed = mockEmbeddings.embed.bind(mockEmbeddings);
1699
+ const searchStarted = Promise.withResolvers();
1700
+ const releaseSearch = Promise.withResolvers();
1701
+ const embedSpy = vi.spyOn(mockEmbeddings, 'embed').mockImplementationOnce(async (text) => {
1702
+ searchStarted.resolve();
1703
+ await releaseSearch.promise;
1704
+ return embed(text);
1705
+ });
1706
+ const inFlightSearch = store.searchByText(query);
1707
+ await searchStarted.promise;
1613
1708
  await store.optimize();
1614
- // Search should still work (but cache was cleared)
1615
- const results = await store.searchByText('cache test');
1616
- expect(Array.isArray(results)).toBe(true);
1709
+ releaseSearch.resolve();
1710
+ await inFlightSearch;
1711
+ await store.searchByText(query);
1712
+ expect(embedSpy).toHaveBeenCalledTimes(2);
1617
1713
  });
1618
1714
  it('should preserve data after optimization', async () => {
1619
1715
  // Add documents