nano-brain 2026.1.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/AGENTS_SNIPPET.md +36 -0
- package/CHANGELOG.md +68 -0
- package/README.md +281 -0
- package/SKILL.md +153 -0
- package/bin/cli.js +18 -0
- package/index.html +929 -0
- package/nano-brain +4 -0
- package/opencode-mcp.json +9 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
- package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
- package/openspec/changes/codebase-indexing/design.md +169 -0
- package/openspec/changes/codebase-indexing/proposal.md +30 -0
- package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
- package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
- package/openspec/changes/codebase-indexing/tasks.md +56 -0
- package/openspec/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/specs/mcp-server/spec.md +75 -0
- package/openspec/specs/search-pipeline/spec.md +29 -0
- package/openspec/specs/storage-limits/spec.md +94 -0
- package/openspec/specs/workspace-scoping/spec.md +70 -0
- package/package.json +34 -0
- package/site/build.js +66 -0
- package/site/partials/_api.html +83 -0
- package/site/partials/_compare.html +100 -0
- package/site/partials/_config.html +23 -0
- package/site/partials/_features.html +43 -0
- package/site/partials/_footer.html +6 -0
- package/site/partials/_hero.html +9 -0
- package/site/partials/_how-it-works.html +26 -0
- package/site/partials/_models.html +18 -0
- package/site/partials/_quick-start.html +15 -0
- package/site/partials/_stats.html +1 -0
- package/site/partials/_tech-stack.html +13 -0
- package/site/script.js +12 -0
- package/site/shell.html +44 -0
- package/site/styles.css +548 -0
- package/src/chunker.ts +427 -0
- package/src/codebase.ts +331 -0
- package/src/collections.ts +192 -0
- package/src/embeddings.ts +293 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +503 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +664 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +623 -0
- package/src/types.ts +202 -0
- package/src/watcher.ts +384 -0
- package/test/chunker.test.ts +479 -0
- package/test/cli.test.ts +309 -0
- package/test/codebase-chunker.test.ts +446 -0
- package/test/codebase.test.ts +678 -0
- package/test/collections.test.ts +571 -0
- package/test/harvester.test.ts +636 -0
- package/test/integration.test.ts +150 -0
- package/test/llm.test.ts +322 -0
- package/test/search.test.ts +572 -0
- package/test/server.test.ts +541 -0
- package/test/storage.test.ts +302 -0
- package/test/store.test.ts +465 -0
- package/test/watcher.test.ts +656 -0
- package/test/workspace.test.ts +239 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { startWatcher } from '../src/watcher.js';
|
|
3
|
+
import type { Store, Collection } from '../src/types.js';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as os from 'os';
|
|
7
|
+
|
|
8
|
+
describe('Watcher', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let collectionPath: string;
|
|
11
|
+
let mockStore: Store;
|
|
12
|
+
let collections: Collection[];
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-test-'));
|
|
16
|
+
collectionPath = path.join(tmpDir, 'docs');
|
|
17
|
+
fs.mkdirSync(collectionPath, { recursive: true });
|
|
18
|
+
|
|
19
|
+
mockStore = {
|
|
20
|
+
findDocument: vi.fn().mockReturnValue(null),
|
|
21
|
+
insertContent: vi.fn(),
|
|
22
|
+
insertDocument: vi.fn().mockReturnValue(1),
|
|
23
|
+
deactivateDocument: vi.fn(),
|
|
24
|
+
bulkDeactivateExcept: vi.fn().mockReturnValue(0),
|
|
25
|
+
getIndexHealth: vi.fn().mockReturnValue({
|
|
26
|
+
documentCount: 0,
|
|
27
|
+
chunkCount: 0,
|
|
28
|
+
pendingEmbeddings: 0,
|
|
29
|
+
collections: [],
|
|
30
|
+
databaseSize: 0,
|
|
31
|
+
modelStatus: {
|
|
32
|
+
embedding: 'missing',
|
|
33
|
+
reranker: 'missing',
|
|
34
|
+
expander: 'missing',
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
close: vi.fn(),
|
|
38
|
+
getDocumentBody: vi.fn(),
|
|
39
|
+
insertEmbedding: vi.fn(),
|
|
40
|
+
ensureVecTable: vi.fn(),
|
|
41
|
+
searchFTS: vi.fn().mockReturnValue([]),
|
|
42
|
+
searchVec: vi.fn().mockReturnValue([]),
|
|
43
|
+
getCachedResult: vi.fn().mockReturnValue(null),
|
|
44
|
+
setCachedResult: vi.fn(),
|
|
45
|
+
getHashesNeedingEmbedding: vi.fn().mockReturnValue([]),
|
|
46
|
+
} as unknown as Store;
|
|
47
|
+
|
|
48
|
+
collections = [
|
|
49
|
+
{
|
|
50
|
+
name: 'test-collection',
|
|
51
|
+
path: collectionPath,
|
|
52
|
+
pattern: '**/*.md',
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
if (fs.existsSync(tmpDir)) {
|
|
59
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('initialization', () => {
|
|
65
|
+
it('should create watcher with default options', () => {
|
|
66
|
+
const watcher = startWatcher({
|
|
67
|
+
store: mockStore,
|
|
68
|
+
collections,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(watcher).toBeDefined();
|
|
72
|
+
expect(watcher.stop).toBeDefined();
|
|
73
|
+
expect(watcher.isDirty).toBeDefined();
|
|
74
|
+
expect(watcher.triggerReindex).toBeDefined();
|
|
75
|
+
expect(watcher.getStats).toBeDefined();
|
|
76
|
+
|
|
77
|
+
watcher.stop();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should start with clean state', () => {
|
|
81
|
+
const watcher = startWatcher({
|
|
82
|
+
store: mockStore,
|
|
83
|
+
collections,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(watcher.isDirty()).toBe(false);
|
|
87
|
+
|
|
88
|
+
const stats = watcher.getStats();
|
|
89
|
+
expect(stats.pendingChanges).toBe(0);
|
|
90
|
+
expect(stats.isReindexing).toBe(false);
|
|
91
|
+
expect(stats.lastReindexAt).toBeNull();
|
|
92
|
+
|
|
93
|
+
watcher.stop();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('dirty flag', () => {
|
|
98
|
+
it('should set dirty flag on file change', async () => {
|
|
99
|
+
const testFile = path.join(collectionPath, 'test.md');
|
|
100
|
+
fs.writeFileSync(testFile, '# Initial\n\nContent');
|
|
101
|
+
|
|
102
|
+
const watcher = startWatcher({
|
|
103
|
+
store: mockStore,
|
|
104
|
+
collections,
|
|
105
|
+
debounceMs: 100,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
109
|
+
|
|
110
|
+
expect(watcher.isDirty()).toBe(false);
|
|
111
|
+
|
|
112
|
+
fs.writeFileSync(testFile, '# Modified\n\nContent');
|
|
113
|
+
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
115
|
+
|
|
116
|
+
expect(watcher.isDirty()).toBe(true);
|
|
117
|
+
|
|
118
|
+
watcher.stop();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should clear dirty flag after reindex', async () => {
|
|
122
|
+
const testFile = path.join(collectionPath, 'test.md');
|
|
123
|
+
fs.writeFileSync(testFile, '# Test\n\nContent');
|
|
124
|
+
|
|
125
|
+
const watcher = startWatcher({
|
|
126
|
+
store: mockStore,
|
|
127
|
+
collections,
|
|
128
|
+
debounceMs: 100,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(testFile, '# Modified\n\nContent');
|
|
134
|
+
|
|
135
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
136
|
+
expect(watcher.isDirty()).toBe(true);
|
|
137
|
+
|
|
138
|
+
await watcher.triggerReindex();
|
|
139
|
+
|
|
140
|
+
expect(watcher.isDirty()).toBe(false);
|
|
141
|
+
|
|
142
|
+
watcher.stop();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('debounce', () => {
|
|
147
|
+
it('should debounce multiple rapid changes', async () => {
|
|
148
|
+
const onUpdate = vi.fn();
|
|
149
|
+
const watcher = startWatcher({
|
|
150
|
+
store: mockStore,
|
|
151
|
+
collections,
|
|
152
|
+
onUpdate,
|
|
153
|
+
debounceMs: 200,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const testFile = path.join(collectionPath, 'test.md');
|
|
157
|
+
|
|
158
|
+
fs.writeFileSync(testFile, '# Test 1');
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
160
|
+
|
|
161
|
+
fs.writeFileSync(testFile, '# Test 2');
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
163
|
+
|
|
164
|
+
fs.writeFileSync(testFile, '# Test 3');
|
|
165
|
+
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
167
|
+
|
|
168
|
+
expect(watcher.isDirty()).toBe(true);
|
|
169
|
+
|
|
170
|
+
watcher.stop();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('file operations', () => {
|
|
175
|
+
it('should detect new .md file', async () => {
|
|
176
|
+
const existingFile = path.join(collectionPath, 'existing.md');
|
|
177
|
+
fs.writeFileSync(existingFile, '# Existing');
|
|
178
|
+
|
|
179
|
+
const watcher = startWatcher({
|
|
180
|
+
store: mockStore,
|
|
181
|
+
collections,
|
|
182
|
+
debounceMs: 100,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
186
|
+
|
|
187
|
+
const testFile = path.join(collectionPath, 'new-file.md');
|
|
188
|
+
fs.writeFileSync(testFile, '# New File\n\nContent');
|
|
189
|
+
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
191
|
+
|
|
192
|
+
expect(watcher.isDirty()).toBe(true);
|
|
193
|
+
const stats = watcher.getStats();
|
|
194
|
+
expect(stats.pendingChanges).toBeGreaterThan(0);
|
|
195
|
+
|
|
196
|
+
watcher.stop();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should detect modified .md file', async () => {
|
|
200
|
+
const testFile = path.join(collectionPath, 'existing.md');
|
|
201
|
+
fs.writeFileSync(testFile, '# Original');
|
|
202
|
+
|
|
203
|
+
const watcher = startWatcher({
|
|
204
|
+
store: mockStore,
|
|
205
|
+
collections,
|
|
206
|
+
debounceMs: 100,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
210
|
+
|
|
211
|
+
fs.writeFileSync(testFile, '# Modified');
|
|
212
|
+
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
214
|
+
|
|
215
|
+
expect(watcher.isDirty()).toBe(true);
|
|
216
|
+
|
|
217
|
+
watcher.stop();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should detect deleted .md file', async () => {
|
|
221
|
+
const testFile = path.join(collectionPath, 'to-delete.md');
|
|
222
|
+
fs.writeFileSync(testFile, '# To Delete');
|
|
223
|
+
|
|
224
|
+
const watcher = startWatcher({
|
|
225
|
+
store: mockStore,
|
|
226
|
+
collections,
|
|
227
|
+
debounceMs: 100,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
231
|
+
|
|
232
|
+
fs.unlinkSync(testFile);
|
|
233
|
+
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
235
|
+
|
|
236
|
+
expect(watcher.isDirty()).toBe(true);
|
|
237
|
+
|
|
238
|
+
watcher.stop();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should ignore non-.md files', async () => {
|
|
242
|
+
const mdFile = path.join(collectionPath, 'existing.md');
|
|
243
|
+
fs.writeFileSync(mdFile, '# Existing');
|
|
244
|
+
|
|
245
|
+
const watcher = startWatcher({
|
|
246
|
+
store: mockStore,
|
|
247
|
+
collections,
|
|
248
|
+
debounceMs: 100,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
252
|
+
|
|
253
|
+
const testFile = path.join(collectionPath, 'test.txt');
|
|
254
|
+
fs.writeFileSync(testFile, 'Not markdown');
|
|
255
|
+
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
257
|
+
|
|
258
|
+
expect(watcher.isDirty()).toBe(false);
|
|
259
|
+
|
|
260
|
+
watcher.stop();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('triggerReindex', () => {
|
|
265
|
+
it('should process pending changes', async () => {
|
|
266
|
+
const testFile = path.join(collectionPath, 'test.md');
|
|
267
|
+
fs.writeFileSync(testFile, '# Test Document\n\nContent here');
|
|
268
|
+
|
|
269
|
+
const watcher = startWatcher({
|
|
270
|
+
store: mockStore,
|
|
271
|
+
collections,
|
|
272
|
+
debounceMs: 100,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await watcher.triggerReindex();
|
|
276
|
+
|
|
277
|
+
expect(mockStore.insertContent).toHaveBeenCalled();
|
|
278
|
+
expect(mockStore.insertDocument).toHaveBeenCalled();
|
|
279
|
+
expect(mockStore.bulkDeactivateExcept).toHaveBeenCalled();
|
|
280
|
+
|
|
281
|
+
watcher.stop();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should update lastReindexAt timestamp', async () => {
|
|
285
|
+
const watcher = startWatcher({
|
|
286
|
+
store: mockStore,
|
|
287
|
+
collections,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const statsBefore = watcher.getStats();
|
|
291
|
+
expect(statsBefore.lastReindexAt).toBeNull();
|
|
292
|
+
|
|
293
|
+
await watcher.triggerReindex();
|
|
294
|
+
|
|
295
|
+
const statsAfter = watcher.getStats();
|
|
296
|
+
expect(statsAfter.lastReindexAt).not.toBeNull();
|
|
297
|
+
expect(statsAfter.lastReindexAt).toBeGreaterThan(0);
|
|
298
|
+
|
|
299
|
+
watcher.stop();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should not reindex if already reindexing', async () => {
|
|
303
|
+
const watcher = startWatcher({
|
|
304
|
+
store: mockStore,
|
|
305
|
+
collections,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const reindex1 = watcher.triggerReindex();
|
|
309
|
+
const reindex2 = watcher.triggerReindex();
|
|
310
|
+
|
|
311
|
+
await Promise.all([reindex1, reindex2]);
|
|
312
|
+
|
|
313
|
+
watcher.stop();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should handle missing files gracefully', async () => {
|
|
317
|
+
vi.mocked(mockStore.findDocument).mockReturnValue({
|
|
318
|
+
id: 1,
|
|
319
|
+
collection: 'test-collection',
|
|
320
|
+
path: path.join(collectionPath, 'missing.md'),
|
|
321
|
+
title: 'Missing',
|
|
322
|
+
hash: 'abc123',
|
|
323
|
+
createdAt: new Date().toISOString(),
|
|
324
|
+
modifiedAt: new Date().toISOString(),
|
|
325
|
+
active: true,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const watcher = startWatcher({
|
|
329
|
+
store: mockStore,
|
|
330
|
+
collections,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await expect(watcher.triggerReindex()).resolves.not.toThrow();
|
|
334
|
+
|
|
335
|
+
watcher.stop();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('stop', () => {
|
|
340
|
+
it('should clean up watcher and intervals', async () => {
|
|
341
|
+
const watcher = startWatcher({
|
|
342
|
+
store: mockStore,
|
|
343
|
+
collections,
|
|
344
|
+
debounceMs: 100,
|
|
345
|
+
pollIntervalMs: 500,
|
|
346
|
+
sessionPollMs: 500,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
350
|
+
|
|
351
|
+
watcher.stop();
|
|
352
|
+
|
|
353
|
+
const testFile = path.join(collectionPath, 'after-stop.md');
|
|
354
|
+
fs.writeFileSync(testFile, '# After Stop');
|
|
355
|
+
|
|
356
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
357
|
+
|
|
358
|
+
expect(watcher.isDirty()).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should prevent reindex after stop', async () => {
|
|
362
|
+
const watcher = startWatcher({
|
|
363
|
+
store: mockStore,
|
|
364
|
+
collections,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
watcher.stop();
|
|
368
|
+
|
|
369
|
+
await watcher.triggerReindex();
|
|
370
|
+
|
|
371
|
+
expect(mockStore.insertContent).not.toHaveBeenCalled();
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('getStats', () => {
|
|
376
|
+
it('should return correct statistics', () => {
|
|
377
|
+
const watcher = startWatcher({
|
|
378
|
+
store: mockStore,
|
|
379
|
+
collections,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const stats = watcher.getStats();
|
|
383
|
+
|
|
384
|
+
expect(stats).toHaveProperty('filesWatched');
|
|
385
|
+
expect(stats).toHaveProperty('lastReindexAt');
|
|
386
|
+
expect(stats).toHaveProperty('pendingChanges');
|
|
387
|
+
expect(stats).toHaveProperty('isReindexing');
|
|
388
|
+
|
|
389
|
+
expect(typeof stats.filesWatched).toBe('number');
|
|
390
|
+
expect(stats.pendingChanges).toBe(0);
|
|
391
|
+
expect(stats.isReindexing).toBe(false);
|
|
392
|
+
|
|
393
|
+
watcher.stop();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should track pending changes count', async () => {
|
|
397
|
+
const testFile1 = path.join(collectionPath, 'test1.md');
|
|
398
|
+
const testFile2 = path.join(collectionPath, 'test2.md');
|
|
399
|
+
|
|
400
|
+
fs.writeFileSync(testFile1, '# Test 1');
|
|
401
|
+
fs.writeFileSync(testFile2, '# Test 2');
|
|
402
|
+
|
|
403
|
+
const watcher = startWatcher({
|
|
404
|
+
store: mockStore,
|
|
405
|
+
collections,
|
|
406
|
+
debounceMs: 100,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
410
|
+
|
|
411
|
+
fs.writeFileSync(testFile1, '# Test 1 Modified');
|
|
412
|
+
fs.writeFileSync(testFile2, '# Test 2 Modified');
|
|
413
|
+
|
|
414
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
415
|
+
|
|
416
|
+
const stats = watcher.getStats();
|
|
417
|
+
expect(stats.pendingChanges).toBeGreaterThan(0);
|
|
418
|
+
|
|
419
|
+
watcher.stop();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('onUpdate callback', () => {
|
|
424
|
+
it('should call onUpdate for changed files', async () => {
|
|
425
|
+
const testFile = path.join(collectionPath, 'callback-test.md');
|
|
426
|
+
fs.writeFileSync(testFile, '# Callback Test');
|
|
427
|
+
|
|
428
|
+
const onUpdate = vi.fn();
|
|
429
|
+
const watcher = startWatcher({
|
|
430
|
+
store: mockStore,
|
|
431
|
+
collections,
|
|
432
|
+
onUpdate,
|
|
433
|
+
debounceMs: 100,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
437
|
+
|
|
438
|
+
fs.writeFileSync(testFile, '# Callback Test Modified');
|
|
439
|
+
|
|
440
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
441
|
+
|
|
442
|
+
expect(onUpdate).toHaveBeenCalled();
|
|
443
|
+
|
|
444
|
+
watcher.stop();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('integrity check', () => {
|
|
449
|
+
it('should detect hash mismatches on startup', async () => {
|
|
450
|
+
const testFile = path.join(collectionPath, 'existing.md');
|
|
451
|
+
fs.writeFileSync(testFile, '# Modified Content');
|
|
452
|
+
|
|
453
|
+
vi.mocked(mockStore.getIndexHealth).mockReturnValue({
|
|
454
|
+
documentCount: 1,
|
|
455
|
+
chunkCount: 1,
|
|
456
|
+
pendingEmbeddings: 0,
|
|
457
|
+
collections: [
|
|
458
|
+
{
|
|
459
|
+
name: 'test-collection',
|
|
460
|
+
documentCount: 1,
|
|
461
|
+
path: collectionPath,
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
databaseSize: 1024,
|
|
465
|
+
modelStatus: {
|
|
466
|
+
embedding: 'missing',
|
|
467
|
+
reranker: 'missing',
|
|
468
|
+
expander: 'missing',
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
vi.mocked(mockStore.findDocument).mockReturnValue({
|
|
473
|
+
id: 1,
|
|
474
|
+
collection: 'test-collection',
|
|
475
|
+
path: testFile,
|
|
476
|
+
title: 'Existing',
|
|
477
|
+
hash: 'old-hash-that-does-not-match',
|
|
478
|
+
createdAt: new Date().toISOString(),
|
|
479
|
+
modifiedAt: new Date().toISOString(),
|
|
480
|
+
active: true,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const watcher = startWatcher({
|
|
484
|
+
store: mockStore,
|
|
485
|
+
collections,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
489
|
+
|
|
490
|
+
expect(watcher.isDirty()).toBe(true);
|
|
491
|
+
|
|
492
|
+
watcher.stop();
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('multiple collections', () => {
|
|
497
|
+
it('should watch multiple collection paths', async () => {
|
|
498
|
+
const collection2Path = path.join(tmpDir, 'docs2');
|
|
499
|
+
fs.mkdirSync(collection2Path, { recursive: true });
|
|
500
|
+
|
|
501
|
+
const testFile1 = path.join(collectionPath, 'test1.md');
|
|
502
|
+
const testFile2 = path.join(collection2Path, 'test2.md');
|
|
503
|
+
|
|
504
|
+
fs.writeFileSync(testFile1, '# Test 1');
|
|
505
|
+
fs.writeFileSync(testFile2, '# Test 2');
|
|
506
|
+
|
|
507
|
+
const multiCollections: Collection[] = [
|
|
508
|
+
{
|
|
509
|
+
name: 'collection1',
|
|
510
|
+
path: collectionPath,
|
|
511
|
+
pattern: '**/*.md',
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: 'collection2',
|
|
515
|
+
path: collection2Path,
|
|
516
|
+
pattern: '**/*.md',
|
|
517
|
+
},
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
const watcher = startWatcher({
|
|
521
|
+
store: mockStore,
|
|
522
|
+
collections: multiCollections,
|
|
523
|
+
debounceMs: 100,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const stats = watcher.getStats();
|
|
527
|
+
expect(stats.filesWatched).toBe(2);
|
|
528
|
+
|
|
529
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
530
|
+
|
|
531
|
+
fs.writeFileSync(testFile1, '# Test 1 Modified');
|
|
532
|
+
fs.writeFileSync(testFile2, '# Test 2 Modified');
|
|
533
|
+
|
|
534
|
+
await new Promise(resolve => setTimeout(resolve, 400));
|
|
535
|
+
|
|
536
|
+
expect(watcher.isDirty()).toBe(true);
|
|
537
|
+
|
|
538
|
+
watcher.stop();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('edge cases', () => {
|
|
543
|
+
it('should handle empty collections array', () => {
|
|
544
|
+
const watcher = startWatcher({
|
|
545
|
+
store: mockStore,
|
|
546
|
+
collections: [],
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(watcher.isDirty()).toBe(false);
|
|
550
|
+
|
|
551
|
+
watcher.stop();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should handle non-existent collection path', () => {
|
|
555
|
+
const nonExistentCollections: Collection[] = [
|
|
556
|
+
{
|
|
557
|
+
name: 'non-existent',
|
|
558
|
+
path: '/path/that/does/not/exist',
|
|
559
|
+
pattern: '**/*.md',
|
|
560
|
+
},
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
const watcher = startWatcher({
|
|
564
|
+
store: mockStore,
|
|
565
|
+
collections: nonExistentCollections,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
expect(watcher.getStats().filesWatched).toBe(0);
|
|
569
|
+
|
|
570
|
+
watcher.stop();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('should handle files without markdown headers', async () => {
|
|
574
|
+
const testFile = path.join(collectionPath, 'no-header.md');
|
|
575
|
+
fs.writeFileSync(testFile, 'Just plain text without headers');
|
|
576
|
+
|
|
577
|
+
const watcher = startWatcher({
|
|
578
|
+
store: mockStore,
|
|
579
|
+
collections,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await watcher.triggerReindex();
|
|
583
|
+
|
|
584
|
+
expect(mockStore.insertDocument).toHaveBeenCalled();
|
|
585
|
+
|
|
586
|
+
watcher.stop();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe('auto-embed', () => {
|
|
591
|
+
it('should embed new chunks when embedder is provided', async () => {
|
|
592
|
+
const testFile = path.join(collectionPath, 'embed-test.md');
|
|
593
|
+
fs.writeFileSync(testFile, '# Embed Test\n\nContent to embed');
|
|
594
|
+
|
|
595
|
+
const mockEmbedder = {
|
|
596
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([
|
|
600
|
+
{ hash: 'abc123', body: 'Content to embed', path: testFile },
|
|
601
|
+
]);
|
|
602
|
+
|
|
603
|
+
const watcher = startWatcher({
|
|
604
|
+
store: mockStore,
|
|
605
|
+
collections,
|
|
606
|
+
embedder: mockEmbedder,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
await watcher.triggerReindex();
|
|
610
|
+
|
|
611
|
+
expect(mockEmbedder.embed).toHaveBeenCalledWith('Content to embed');
|
|
612
|
+
expect(mockStore.insertEmbedding).toHaveBeenCalled();
|
|
613
|
+
|
|
614
|
+
watcher.stop();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should skip embedding when no embedder provided', async () => {
|
|
618
|
+
const testFile = path.join(collectionPath, 'no-embed.md');
|
|
619
|
+
fs.writeFileSync(testFile, '# No Embed\n\nContent');
|
|
620
|
+
|
|
621
|
+
const watcher = startWatcher({
|
|
622
|
+
store: mockStore,
|
|
623
|
+
collections,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
await watcher.triggerReindex();
|
|
627
|
+
|
|
628
|
+
expect(mockStore.insertEmbedding).not.toHaveBeenCalled();
|
|
629
|
+
|
|
630
|
+
watcher.stop();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should handle embedding errors gracefully', async () => {
|
|
634
|
+
const testFile = path.join(collectionPath, 'error-embed.md');
|
|
635
|
+
fs.writeFileSync(testFile, '# Error Embed\n\nContent');
|
|
636
|
+
|
|
637
|
+
const mockEmbedder = {
|
|
638
|
+
embed: vi.fn().mockRejectedValue(new Error('Model unavailable')),
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([
|
|
642
|
+
{ hash: 'abc123', body: 'Content', path: testFile },
|
|
643
|
+
]);
|
|
644
|
+
|
|
645
|
+
const watcher = startWatcher({
|
|
646
|
+
store: mockStore,
|
|
647
|
+
collections,
|
|
648
|
+
embedder: mockEmbedder,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
await expect(watcher.triggerReindex()).resolves.not.toThrow();
|
|
652
|
+
|
|
653
|
+
watcher.stop();
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
});
|