@softerist/heuristic-mcp 2.1.47 → 3.0.0
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/.agent/workflows/code-review.md +60 -0
- package/.prettierrc +7 -0
- package/ARCHITECTURE.md +105 -170
- package/CONTRIBUTING.md +32 -113
- package/GEMINI.md +73 -0
- package/LICENSE +21 -21
- package/README.md +161 -54
- package/config.json +876 -75
- package/debug-pids.js +27 -0
- package/eslint.config.js +36 -0
- package/features/ann-config.js +37 -26
- package/features/clear-cache.js +28 -19
- package/features/find-similar-code.js +142 -66
- package/features/hybrid-search.js +253 -93
- package/features/index-codebase.js +1455 -394
- package/features/lifecycle.js +813 -180
- package/features/register.js +58 -52
- package/index.js +450 -306
- package/lib/cache-ops.js +22 -0
- package/lib/cache-utils.js +68 -0
- package/lib/cache.js +1392 -587
- package/lib/call-graph.js +165 -50
- package/lib/cli.js +154 -0
- package/lib/config.js +462 -121
- package/lib/embedding-process.js +77 -0
- package/lib/embedding-worker.js +545 -30
- package/lib/ignore-patterns.js +61 -59
- package/lib/json-worker.js +14 -0
- package/lib/json-writer.js +344 -0
- package/lib/logging.js +88 -0
- package/lib/memory-logger.js +13 -0
- package/lib/project-detector.js +13 -17
- package/lib/server-lifecycle.js +38 -0
- package/lib/settings-editor.js +645 -0
- package/lib/tokenizer.js +207 -104
- package/lib/utils.js +273 -198
- package/lib/vector-store-binary.js +592 -0
- package/mcp_config.example.json +13 -0
- package/package.json +13 -2
- package/scripts/clear-cache.js +6 -17
- package/scripts/download-model.js +14 -9
- package/scripts/postinstall.js +5 -5
- package/search-configs.js +36 -0
- package/test/ann-config.test.js +179 -0
- package/test/ann-fallback.test.js +6 -6
- package/test/binary-store.test.js +69 -0
- package/test/cache-branches.test.js +120 -0
- package/test/cache-errors.test.js +264 -0
- package/test/cache-extra.test.js +300 -0
- package/test/cache-helpers.test.js +205 -0
- package/test/cache-hnsw-failure.test.js +40 -0
- package/test/cache-json-worker.test.js +190 -0
- package/test/cache-worker.test.js +102 -0
- package/test/cache.test.js +443 -0
- package/test/call-graph.test.js +103 -4
- package/test/clear-cache.test.js +69 -68
- package/test/code-review-workflow.test.js +50 -0
- package/test/config.test.js +418 -0
- package/test/coverage-gap.test.js +497 -0
- package/test/coverage-maximizer.test.js +236 -0
- package/test/debug-analysis.js +107 -0
- package/test/embedding-model.test.js +173 -103
- package/test/embedding-worker-extra.test.js +272 -0
- package/test/embedding-worker.test.js +158 -0
- package/test/features.test.js +139 -0
- package/test/final-boost.test.js +271 -0
- package/test/final-polish.test.js +183 -0
- package/test/final.test.js +95 -0
- package/test/find-similar-code.test.js +191 -0
- package/test/helpers.js +92 -11
- package/test/helpers.test.js +46 -0
- package/test/hybrid-search-basic.test.js +62 -0
- package/test/hybrid-search-branch.test.js +202 -0
- package/test/hybrid-search-callgraph.test.js +229 -0
- package/test/hybrid-search-extra.test.js +81 -0
- package/test/hybrid-search.test.js +484 -71
- package/test/index-cli.test.js +520 -0
- package/test/index-codebase-batch.test.js +119 -0
- package/test/index-codebase-branches.test.js +585 -0
- package/test/index-codebase-core.test.js +1032 -0
- package/test/index-codebase-edge-cases.test.js +254 -0
- package/test/index-codebase-errors.test.js +132 -0
- package/test/index-codebase-gap.test.js +239 -0
- package/test/index-codebase-lines.test.js +151 -0
- package/test/index-codebase-watcher.test.js +259 -0
- package/test/index-codebase-zone.test.js +259 -0
- package/test/index-codebase.test.js +371 -69
- package/test/index-memory.test.js +220 -0
- package/test/indexer-detailed.test.js +176 -0
- package/test/integration.test.js +148 -92
- package/test/json-worker.test.js +50 -0
- package/test/lifecycle.test.js +541 -0
- package/test/master.test.js +198 -0
- package/test/perfection.test.js +349 -0
- package/test/project-detector.test.js +65 -0
- package/test/register.test.js +262 -0
- package/test/tokenizer.test.js +55 -93
- package/test/ultra-maximizer.test.js +116 -0
- package/test/utils-branches.test.js +161 -0
- package/test/utils-extra.test.js +116 -0
- package/test/utils.test.js +131 -0
- package/test/verify_fixes.js +76 -0
- package/test/worker-errors.test.js +96 -0
- package/test/worker-init.test.js +102 -0
- package/test/worker_throttling.test.js +93 -0
- package/tools/scripts/benchmark-search.js +95 -0
- package/tools/scripts/cache-stats.js +71 -0
- package/tools/scripts/manual-search.js +34 -0
- package/vitest.config.js +19 -9
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { EmbeddingsCache } from '../lib/cache.js';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
|
|
5
|
+
vi.mock('fs/promises');
|
|
6
|
+
|
|
7
|
+
// Basic mock index for stats
|
|
8
|
+
const mockIndex = {
|
|
9
|
+
setEf: vi.fn(),
|
|
10
|
+
efConstruction: 200,
|
|
11
|
+
m: 48,
|
|
12
|
+
getK: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('EmbeddingsCache Helper Methods', () => {
|
|
16
|
+
let cache;
|
|
17
|
+
let config;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
config = {
|
|
22
|
+
enableCache: true,
|
|
23
|
+
cacheDirectory: '/mock/cache',
|
|
24
|
+
annEnabled: true,
|
|
25
|
+
annEfSearch: 10,
|
|
26
|
+
|
|
27
|
+
callGraphEnabled: true,
|
|
28
|
+
callGraphMaxHops: 2,
|
|
29
|
+
verbose: true,
|
|
30
|
+
};
|
|
31
|
+
cache = new EmbeddingsCache(config);
|
|
32
|
+
|
|
33
|
+
// Spy on console
|
|
34
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('EF Search Configuration', () => {
|
|
38
|
+
it('should validate efSearch input', () => {
|
|
39
|
+
expect(cache.setEfSearch('invalid').success).toBe(false);
|
|
40
|
+
expect(cache.setEfSearch(0).success).toBe(false);
|
|
41
|
+
expect(cache.setEfSearch(1001).success).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should update config when index not loaded', () => {
|
|
45
|
+
const result = cache.setEfSearch(100);
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
expect(result.applied).toBe(false);
|
|
48
|
+
expect(cache.config.annEfSearch).toBe(100);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should update active index if loaded', () => {
|
|
52
|
+
cache.annIndex = mockIndex;
|
|
53
|
+
const result = cache.setEfSearch(50);
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
expect(result.applied).toBe(true);
|
|
56
|
+
expect(mockIndex.setEf).toHaveBeenCalledWith(50);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('ANN Stats', () => {
|
|
61
|
+
it('should return stats with no index', () => {
|
|
62
|
+
const stats = cache.getAnnStats();
|
|
63
|
+
expect(stats.indexLoaded).toBe(false);
|
|
64
|
+
expect(stats.enabled).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return stats with index meta', () => {
|
|
68
|
+
cache.annMeta = {
|
|
69
|
+
metric: 'cosine',
|
|
70
|
+
dim: 128,
|
|
71
|
+
count: 10,
|
|
72
|
+
m: 16,
|
|
73
|
+
efConstruction: 100,
|
|
74
|
+
};
|
|
75
|
+
cache.annIndex = {};
|
|
76
|
+
const stats = cache.getAnnStats();
|
|
77
|
+
expect(stats.indexLoaded).toBe(true);
|
|
78
|
+
expect(stats.config.metric).toBe('cosine');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('Vector Store Helpers', () => {
|
|
83
|
+
it('normalizes vectors when setting the store', () => {
|
|
84
|
+
const store = [{ file: 'a.js', vector: [1, 2, 3] }];
|
|
85
|
+
cache.setVectorStore(store);
|
|
86
|
+
|
|
87
|
+
const [chunk] = cache.getVectorStore();
|
|
88
|
+
expect(chunk.vector).toBeInstanceOf(Float32Array);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('Call Graph Helper Methods', () => {
|
|
93
|
+
it('should manage file call data', () => {
|
|
94
|
+
const file = 'test.js';
|
|
95
|
+
const data = { valid: true };
|
|
96
|
+
|
|
97
|
+
cache.setFileCallData(file, data);
|
|
98
|
+
expect(cache.getFileCallData(file)).toBe(data);
|
|
99
|
+
expect(cache.callGraph).toBeNull(); // invalidation
|
|
100
|
+
|
|
101
|
+
cache.removeFileCallData(file);
|
|
102
|
+
expect(cache.getFileCallData(file)).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should clear call graph data and file', async () => {
|
|
106
|
+
cache.setFileCallData('a.js', {});
|
|
107
|
+
await cache.clearCallGraphData({ removeFile: true });
|
|
108
|
+
|
|
109
|
+
expect(cache.getFileCallDataCount()).toBe(0);
|
|
110
|
+
expect(fs.rm).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining('call-graph.json'),
|
|
112
|
+
expect.any(Object)
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle pruning', () => {
|
|
117
|
+
cache.setFileCallData('a.js', {});
|
|
118
|
+
cache.setFileCallData('b.js', {});
|
|
119
|
+
|
|
120
|
+
const validFiles = new Set(['a.js']);
|
|
121
|
+
const pruned = cache.pruneCallGraphData(validFiles);
|
|
122
|
+
|
|
123
|
+
expect(pruned).toBe(1);
|
|
124
|
+
expect(cache.getFileCallData('b.js')).toBeUndefined();
|
|
125
|
+
expect(cache.getFileCallData('a.js')).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should handle pruning with no valid files set (guard)', () => {
|
|
129
|
+
expect(cache.pruneCallGraphData(null)).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('Call Graph Lazy Loading', () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
vi.resetModules();
|
|
136
|
+
// We need to re-import CodebaseIndexer or Cache if we were testing its internal dynamic imports,
|
|
137
|
+
// but we are testing cache.js which does dynamic imports of call-graph.js.
|
|
138
|
+
// We mock call-graph.js
|
|
139
|
+
const fakeGraph = { defines: new Map(), calledBy: new Map() };
|
|
140
|
+
vi.doMock('../lib/call-graph.js', () => ({
|
|
141
|
+
buildCallGraph: vi.fn(() => fakeGraph),
|
|
142
|
+
getRelatedFiles: vi.fn(() => new Map([['related.js', 1]])),
|
|
143
|
+
}));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rebuildCallGraph should handle import and build', async () => {
|
|
147
|
+
// Re-instantiate to ensure clean state
|
|
148
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
149
|
+
const cache = new EmbeddingsCache({ ...config, verbose: true });
|
|
150
|
+
|
|
151
|
+
cache.setFileCallData('f.js', {});
|
|
152
|
+
|
|
153
|
+
// Spy on console to verify success
|
|
154
|
+
const logSpy = vi.spyOn(console, 'info');
|
|
155
|
+
|
|
156
|
+
// Trigger rebuild
|
|
157
|
+
await cache.rebuildCallGraph();
|
|
158
|
+
|
|
159
|
+
// Wait for microtask resolution of dynamic import
|
|
160
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
161
|
+
|
|
162
|
+
expect(cache.callGraph).toBeDefined();
|
|
163
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Built graph'));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rebuildCallGraph should handle errors', async () => {
|
|
167
|
+
vi.doMock('../lib/call-graph.js', () => ({
|
|
168
|
+
buildCallGraph: vi.fn(() => {
|
|
169
|
+
throw new Error('Build failed');
|
|
170
|
+
}),
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
174
|
+
const cache = new EmbeddingsCache({ ...config, verbose: true });
|
|
175
|
+
|
|
176
|
+
const logSpy = vi.spyOn(console, 'error');
|
|
177
|
+
cache.rebuildCallGraph();
|
|
178
|
+
|
|
179
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
180
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to build'));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('getRelatedFiles should rebuild graph if missing', async () => {
|
|
184
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
185
|
+
const cache = new EmbeddingsCache(config);
|
|
186
|
+
cache.setFileCallData('f.js', {});
|
|
187
|
+
|
|
188
|
+
const result = await cache.getRelatedFiles(['sym']);
|
|
189
|
+
|
|
190
|
+
expect(result.size).toBe(1); // Mock returns 1 item
|
|
191
|
+
expect(cache.callGraph).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('getRelatedFiles should return empty if disabled or empty', async () => {
|
|
195
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
196
|
+
// Disabled
|
|
197
|
+
let c = new EmbeddingsCache({ ...config, callGraphEnabled: false });
|
|
198
|
+
expect((await c.getRelatedFiles(['s'])).size).toBe(0);
|
|
199
|
+
|
|
200
|
+
// Empty symbols
|
|
201
|
+
c = new EmbeddingsCache(config);
|
|
202
|
+
expect((await c.getRelatedFiles([])).size).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock fs to avoid actual file I/O
|
|
4
|
+
vi.mock('fs/promises');
|
|
5
|
+
|
|
6
|
+
// Mock hnswlib-node to fail loading
|
|
7
|
+
vi.mock('hnswlib-node', () => {
|
|
8
|
+
throw new Error('Module not found or load error');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('EmbeddingsCache HNSW Failures', () => {
|
|
12
|
+
it('should handle hnswlib import failure gracefully', async () => {
|
|
13
|
+
// Need to dynamic import cache to trigger the hnswlib import attempt (if it wasn't already cached by other tests, but Vitest isolates files)
|
|
14
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
15
|
+
|
|
16
|
+
const config = {
|
|
17
|
+
enableCache: true,
|
|
18
|
+
annEnabled: true,
|
|
19
|
+
annMinChunks: 1,
|
|
20
|
+
cacheDirectory: '/tmp/test-hnsw-fail',
|
|
21
|
+
fileExtensions: ['js'],
|
|
22
|
+
embeddingModel: 'test',
|
|
23
|
+
};
|
|
24
|
+
const cache = new EmbeddingsCache(config);
|
|
25
|
+
|
|
26
|
+
// Add chunks to trigger ANN condition
|
|
27
|
+
cache.vectorStore = [
|
|
28
|
+
{ file: 'a.js', vector: [1, 0] },
|
|
29
|
+
{ file: 'b.js', vector: [0, 1] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Attempt to ensure index
|
|
33
|
+
const index = await cache.ensureAnnIndex();
|
|
34
|
+
|
|
35
|
+
// Should be null because hnswlib failed to load
|
|
36
|
+
expect(index).toBeNull();
|
|
37
|
+
|
|
38
|
+
// Should also check console.error behavior if desired
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
enableCache: true,
|
|
6
|
+
cacheDirectory: '/cache',
|
|
7
|
+
embeddingModel: 'test-model',
|
|
8
|
+
fileExtensions: ['js'],
|
|
9
|
+
excludePatterns: [],
|
|
10
|
+
annEnabled: false,
|
|
11
|
+
callGraphEnabled: false,
|
|
12
|
+
verbose: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('EmbeddingsCache JSON worker parsing', () => {
|
|
21
|
+
it('uses a worker to parse large JSON files', async () => {
|
|
22
|
+
const Worker = vi.fn(function (_url, options) {
|
|
23
|
+
const worker = new EventEmitter();
|
|
24
|
+
const filePath = options.workerData.filePath;
|
|
25
|
+
const data = filePath.endsWith('embeddings.json') ? [] : {};
|
|
26
|
+
setImmediate(() => {
|
|
27
|
+
worker.emit('message', { ok: true, data });
|
|
28
|
+
});
|
|
29
|
+
return worker;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const fsMock = {
|
|
33
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
stat: vi.fn().mockResolvedValue({ size: 6 * 1024 * 1024 }),
|
|
35
|
+
readFile: vi.fn((filePath) => {
|
|
36
|
+
if (filePath.endsWith('meta.json')) {
|
|
37
|
+
return Promise.resolve(
|
|
38
|
+
JSON.stringify({ version: 1, embeddingModel: baseConfig.embeddingModel })
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return Promise.reject(new Error('missing'));
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
vi.doMock('worker_threads', () => ({ Worker }));
|
|
46
|
+
vi.doMock('fs/promises', () => ({
|
|
47
|
+
default: fsMock,
|
|
48
|
+
...fsMock,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
52
|
+
const cache = new EmbeddingsCache(baseConfig);
|
|
53
|
+
|
|
54
|
+
await cache.load();
|
|
55
|
+
|
|
56
|
+
expect(Worker).toHaveBeenCalledTimes(2);
|
|
57
|
+
expect(cache.getVectorStore()).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('logs when the JSON worker reports a parse error', async () => {
|
|
61
|
+
const Worker = vi.fn(function () {
|
|
62
|
+
const worker = new EventEmitter();
|
|
63
|
+
setImmediate(() => {
|
|
64
|
+
worker.emit('message', { ok: false, error: 'bad json' });
|
|
65
|
+
});
|
|
66
|
+
return worker;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const fsMock = {
|
|
70
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
stat: vi.fn().mockResolvedValue({ size: 6 * 1024 * 1024 }),
|
|
72
|
+
readFile: vi.fn((filePath) => {
|
|
73
|
+
if (filePath.endsWith('meta.json')) {
|
|
74
|
+
return Promise.resolve(
|
|
75
|
+
JSON.stringify({ version: 1, embeddingModel: baseConfig.embeddingModel })
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return Promise.reject(new Error('missing'));
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
vi.doMock('worker_threads', () => ({ Worker }));
|
|
83
|
+
vi.doMock('fs/promises', () => ({
|
|
84
|
+
default: fsMock,
|
|
85
|
+
...fsMock,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
89
|
+
const cache = new EmbeddingsCache(baseConfig);
|
|
90
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
91
|
+
|
|
92
|
+
await cache.load();
|
|
93
|
+
|
|
94
|
+
const hasParseError = consoleSpy.mock.calls.some(
|
|
95
|
+
(call) => typeof call[0] === 'string' && call[0].includes('Failed to parse embeddings.json')
|
|
96
|
+
);
|
|
97
|
+
expect(hasParseError).toBe(true);
|
|
98
|
+
|
|
99
|
+
consoleSpy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('logs when the JSON worker exits with a failure code', async () => {
|
|
103
|
+
const Worker = vi.fn(function () {
|
|
104
|
+
const worker = new EventEmitter();
|
|
105
|
+
setImmediate(() => {
|
|
106
|
+
worker.emit('exit', 1);
|
|
107
|
+
});
|
|
108
|
+
return worker;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fsMock = {
|
|
112
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
stat: vi.fn().mockResolvedValue({ size: 6 * 1024 * 1024 }),
|
|
114
|
+
readFile: vi.fn((filePath) => {
|
|
115
|
+
if (filePath.endsWith('meta.json')) {
|
|
116
|
+
return Promise.resolve(
|
|
117
|
+
JSON.stringify({ version: 1, embeddingModel: baseConfig.embeddingModel })
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return Promise.reject(new Error('missing'));
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
vi.doMock('worker_threads', () => ({ Worker }));
|
|
125
|
+
vi.doMock('fs/promises', () => ({
|
|
126
|
+
default: fsMock,
|
|
127
|
+
...fsMock,
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
131
|
+
const cache = new EmbeddingsCache(baseConfig);
|
|
132
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
133
|
+
|
|
134
|
+
await cache.load();
|
|
135
|
+
|
|
136
|
+
const hasExitError = consoleSpy.mock.calls.some(
|
|
137
|
+
(call) => typeof call[0] === 'string' && call[0].includes('JSON worker exited with code')
|
|
138
|
+
);
|
|
139
|
+
expect(hasExitError).toBe(true);
|
|
140
|
+
|
|
141
|
+
consoleSpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('ignores subsequent events after settlement (covers settled guard)', async () => {
|
|
145
|
+
// This targets the "if (settled) return;" line in the finish function.
|
|
146
|
+
// We manually invoke the message handler twice to force a second finish call.
|
|
147
|
+
const Worker = vi.fn(function () {
|
|
148
|
+
const worker = {
|
|
149
|
+
once(event, handler) {
|
|
150
|
+
if (event === 'message') {
|
|
151
|
+
setImmediate(() => {
|
|
152
|
+
handler({ ok: true, data: [] });
|
|
153
|
+
handler({ ok: false, error: 'late' });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return worker;
|
|
157
|
+
},
|
|
158
|
+
removeAllListeners: vi.fn(),
|
|
159
|
+
terminate: vi.fn(() => Promise.resolve()),
|
|
160
|
+
};
|
|
161
|
+
return worker;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const fsMock = {
|
|
165
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
166
|
+
stat: vi.fn().mockResolvedValue({ size: 6 * 1024 * 1024 }),
|
|
167
|
+
readFile: vi.fn((filePath) => {
|
|
168
|
+
if (filePath.endsWith('meta.json')) {
|
|
169
|
+
return Promise.resolve(
|
|
170
|
+
JSON.stringify({ version: 1, embeddingModel: baseConfig.embeddingModel })
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return Promise.reject(new Error('missing'));
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
vi.doMock('worker_threads', () => ({ Worker }));
|
|
178
|
+
vi.doMock('fs/promises', () => ({
|
|
179
|
+
default: fsMock,
|
|
180
|
+
...fsMock,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
const { EmbeddingsCache } = await import('../lib/cache.js');
|
|
184
|
+
const cache = new EmbeddingsCache(baseConfig);
|
|
185
|
+
// Should not throw or log an error about double-resolution
|
|
186
|
+
await cache.load();
|
|
187
|
+
|
|
188
|
+
expect(Worker).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { EmbeddingsCache } from '../lib/cache.js';
|
|
4
|
+
|
|
5
|
+
// Hoist the mock worker
|
|
6
|
+
const { mockWorker } = vi.hoisted(() => {
|
|
7
|
+
const worker = {
|
|
8
|
+
on: vi.fn(),
|
|
9
|
+
once: vi.fn(),
|
|
10
|
+
removeAllListeners: vi.fn(),
|
|
11
|
+
terminate: vi.fn(),
|
|
12
|
+
postMessage: vi.fn(),
|
|
13
|
+
unref: vi.fn()
|
|
14
|
+
};
|
|
15
|
+
return { mockWorker: worker };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Use a shared variable to track execution
|
|
19
|
+
let terminateCatchCalled = false;
|
|
20
|
+
|
|
21
|
+
// Mock fs
|
|
22
|
+
vi.mock('fs/promises', async () => {
|
|
23
|
+
return {
|
|
24
|
+
default: {
|
|
25
|
+
stat: vi.fn().mockResolvedValue({
|
|
26
|
+
size: 6 * 1024 * 1024,
|
|
27
|
+
isDirectory: () => false
|
|
28
|
+
}),
|
|
29
|
+
readFile: vi.fn().mockResolvedValue('[]'),
|
|
30
|
+
mkdir: vi.fn().mockResolvedValue(),
|
|
31
|
+
writeFile: vi.fn().mockResolvedValue(),
|
|
32
|
+
rm: vi.fn().mockResolvedValue(),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Mock worker_threads
|
|
38
|
+
vi.mock('worker_threads', () => {
|
|
39
|
+
return {
|
|
40
|
+
Worker: class {
|
|
41
|
+
constructor() {
|
|
42
|
+
return mockWorker;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('Cache Worker Termination', () => {
|
|
49
|
+
let cache;
|
|
50
|
+
const config = {
|
|
51
|
+
enableCache: true,
|
|
52
|
+
cacheDirectory: '/test/cache',
|
|
53
|
+
fileExtensions: ['js'],
|
|
54
|
+
embeddingModel: 'test-model'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
terminateCatchCalled = false;
|
|
60
|
+
|
|
61
|
+
// Setup worker to simulate successful message
|
|
62
|
+
mockWorker.once.mockImplementation((event, handler) => {
|
|
63
|
+
if (event === 'message') {
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
// cache.js expects ok: true
|
|
66
|
+
handler({ ok: true, data: [] });
|
|
67
|
+
}, 10);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
mockWorker.on.mockImplementation((event, handler) => {
|
|
72
|
+
if (event === 'message') {
|
|
73
|
+
setTimeout(() => handler({ ok: true, data: [] }), 10);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Default terminate behavior
|
|
78
|
+
mockWorker.terminate.mockResolvedValue(undefined);
|
|
79
|
+
|
|
80
|
+
cache = new EmbeddingsCache(config);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should handle worker termination errors (line 29 coverage)', async () => {
|
|
84
|
+
// Setup terminate to return an object with catch() that sets the flag
|
|
85
|
+
// We use mockReturnValue to ensure it returns exactly this object
|
|
86
|
+
const fakePromise = {
|
|
87
|
+
catch: (cb) => {
|
|
88
|
+
terminateCatchCalled = true;
|
|
89
|
+
if (cb) cb();
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
},
|
|
92
|
+
then: (cb) => { if (cb) cb(); return Promise.resolve(); }
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
mockWorker.terminate.mockReturnValue(fakePromise);
|
|
96
|
+
|
|
97
|
+
await cache.load();
|
|
98
|
+
|
|
99
|
+
expect(mockWorker.terminate).toHaveBeenCalled();
|
|
100
|
+
expect(terminateCatchCalled).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|