@abarcenas/ai-workflow-template 1.45.0 → 1.46.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/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +110 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/AGENTS.md +42 -3
- package/README.md +5 -2
- package/package.json +1 -1
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// mcp-memory-server.test.js — Unit tests for the memory-bank MCP server
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The stdio bootstrap is direct-run guarded, so importing the module in tests
|
|
6
|
+
// does not open a transport. The MCP SDK and the index engine are mocked:
|
|
7
|
+
// tests exercise the exported `listTools` / `handleToolCall` handlers, the
|
|
8
|
+
// formatting of every tool response, error propagation, and the start-up
|
|
9
|
+
// (`main` / `bootstrap`) paths — including the fatal path.
|
|
10
|
+
//
|
|
11
|
+
// Covered scenarios:
|
|
12
|
+
// 1. listTools exposes all five tools with schemas
|
|
13
|
+
// 2. memory_search formats ranked results
|
|
14
|
+
// 3. memory_search defaults (topK=5, no source filter)
|
|
15
|
+
// 4. memory_search honours explicit topK/sourceFile
|
|
16
|
+
// 5. memory_search empty index message
|
|
17
|
+
// 6. memory_search error → isError response
|
|
18
|
+
// 7. memory_rebuild formats counts and stats
|
|
19
|
+
// 8. memory_update with changed files + stats
|
|
20
|
+
// 9. memory_update with no changes
|
|
21
|
+
// 10. memory_get returns content
|
|
22
|
+
// 11. memory_get missing file → available-files hint
|
|
23
|
+
// 12. memory_stats formats the index statistics
|
|
24
|
+
// 13. unknown tool → caught error response
|
|
25
|
+
// 14. main() connects a stdio transport and logs start-up
|
|
26
|
+
// 15. bootstrap() exits 1 when the transport fails
|
|
27
|
+
// 16. direct run auto-bootstraps; empty argv[1] does not
|
|
28
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
31
|
+
import { fileURLToPath } from 'url'
|
|
32
|
+
|
|
33
|
+
const sdk = vi.hoisted(() => ({ connect: vi.fn() }))
|
|
34
|
+
|
|
35
|
+
vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
36
|
+
Server: class {
|
|
37
|
+
setRequestHandler() {}
|
|
38
|
+
connect(transport) {
|
|
39
|
+
return sdk.connect(transport)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
|
45
|
+
StdioServerTransport: class {
|
|
46
|
+
constructor() {
|
|
47
|
+
this.kind = 'stdio'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
|
53
|
+
CallToolRequestSchema: 'call-tool',
|
|
54
|
+
ListToolsRequestSchema: 'list-tools',
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
vi.mock('./memory-index.js', () => ({
|
|
58
|
+
search: vi.fn(),
|
|
59
|
+
rebuildIndex: vi.fn(),
|
|
60
|
+
incrementalUpdate: vi.fn(),
|
|
61
|
+
getFile: vi.fn(),
|
|
62
|
+
getStats: vi.fn(),
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds an MCP tool-call request as the SDK delivers it.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} name
|
|
69
|
+
* @param {object} [args]
|
|
70
|
+
* @returns {{params: {name: string, arguments: object}}}
|
|
71
|
+
*/
|
|
72
|
+
function buildRequest(name, args = {}) {
|
|
73
|
+
return { params: { name, arguments: args } }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sampleResults = [
|
|
77
|
+
{
|
|
78
|
+
score: 0.91,
|
|
79
|
+
source_file: 'activeContext',
|
|
80
|
+
content: 'Current focus is the coverage push.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
score: 0.72,
|
|
84
|
+
source_file: 'progress',
|
|
85
|
+
content: 'All five scripts now have tests.',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
describe('mcp-memory-server', () => {
|
|
90
|
+
let mod
|
|
91
|
+
let index
|
|
92
|
+
let consoleErrorSpy
|
|
93
|
+
let exitSpy
|
|
94
|
+
let originalArgv1
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
vi.resetModules()
|
|
98
|
+
vi.clearAllMocks()
|
|
99
|
+
originalArgv1 = process.argv[1]
|
|
100
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
101
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
102
|
+
mod = await import('./mcp-memory-server.js')
|
|
103
|
+
index = await import('./memory-index.js')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
process.argv[1] = originalArgv1
|
|
108
|
+
consoleErrorSpy.mockRestore()
|
|
109
|
+
exitSpy.mockRestore()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ── 1. listTools ─────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
it('listTools: exposes the five memory-bank tools with schemas', async () => {
|
|
115
|
+
const result = await mod.listTools()
|
|
116
|
+
|
|
117
|
+
expect(result.tools.map((tool) => tool.name)).toEqual([
|
|
118
|
+
'memory_search',
|
|
119
|
+
'memory_rebuild',
|
|
120
|
+
'memory_update',
|
|
121
|
+
'memory_get',
|
|
122
|
+
'memory_stats',
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
const search = result.tools.find((tool) => tool.name === 'memory_search')
|
|
126
|
+
expect(search.inputSchema.required).toEqual(['query'])
|
|
127
|
+
expect(search.inputSchema.properties.topK.default).toBe(5)
|
|
128
|
+
|
|
129
|
+
const get = result.tools.find((tool) => tool.name === 'memory_get')
|
|
130
|
+
expect(get.inputSchema.required).toEqual(['file'])
|
|
131
|
+
expect(get.inputSchema.properties.file.enum).toContain('activeContext')
|
|
132
|
+
expect(get.inputSchema.properties.file.enum).toContain(
|
|
133
|
+
'playwright-mcp-configuration',
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── 2. memory_search formatting ──────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
it('memory_search: formats ranked results with scores and source files', async () => {
|
|
140
|
+
index.search.mockResolvedValue(sampleResults)
|
|
141
|
+
|
|
142
|
+
const response = await mod.handleToolCall(
|
|
143
|
+
buildRequest('memory_search', { query: 'what is the focus?' }),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
expect(index.search).toHaveBeenCalledWith('what is the focus?', {
|
|
147
|
+
topK: 5,
|
|
148
|
+
sourceFile: undefined,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const text = response.content[0].text
|
|
152
|
+
expect(text).toContain('### Result 1 (score: 0.91)')
|
|
153
|
+
expect(text).toContain('**File:** `activeContext`')
|
|
154
|
+
expect(text).toContain('Current focus is the coverage push.')
|
|
155
|
+
expect(text).toContain('### Result 2 (score: 0.72)')
|
|
156
|
+
expect(text).toContain('\n\n---\n\n')
|
|
157
|
+
expect(response.isError).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ── 3/4. memory_search options ───────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
it('memory_search: defaults topK to 5 when omitted', async () => {
|
|
163
|
+
index.search.mockResolvedValue([])
|
|
164
|
+
|
|
165
|
+
await mod.handleToolCall(buildRequest('memory_search', { query: 'x' }))
|
|
166
|
+
|
|
167
|
+
expect(index.search).toHaveBeenCalledWith('x', {
|
|
168
|
+
topK: 5,
|
|
169
|
+
sourceFile: undefined,
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('memory_search: forwards explicit topK and sourceFile', async () => {
|
|
174
|
+
index.search.mockResolvedValue(sampleResults)
|
|
175
|
+
|
|
176
|
+
await mod.handleToolCall(
|
|
177
|
+
buildRequest('memory_search', {
|
|
178
|
+
query: 'focus',
|
|
179
|
+
topK: 2,
|
|
180
|
+
sourceFile: 'activeContext',
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
expect(index.search).toHaveBeenCalledWith('focus', {
|
|
185
|
+
topK: 2,
|
|
186
|
+
sourceFile: 'activeContext',
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ── 5. Empty results ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
it('memory_search: returns a rebuild hint when the index is empty', async () => {
|
|
193
|
+
index.search.mockResolvedValue([])
|
|
194
|
+
|
|
195
|
+
const response = await mod.handleToolCall(
|
|
196
|
+
buildRequest('memory_search', { query: 'nothing' }),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
expect(response.content[0].text).toBe(
|
|
200
|
+
'No results found. The index may be empty. Try running memory_rebuild first.',
|
|
201
|
+
)
|
|
202
|
+
expect(response.isError).toBeUndefined()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// ── 6. Error propagation ─────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
it('memory_search: converts engine errors into an isError response', async () => {
|
|
208
|
+
index.search.mockRejectedValue(new Error('vector store offline'))
|
|
209
|
+
|
|
210
|
+
const response = await mod.handleToolCall(
|
|
211
|
+
buildRequest('memory_search', { query: 'x' }),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(response.isError).toBe(true)
|
|
215
|
+
expect(response.content[0].text).toBe('Error: vector store offline')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ── 7. memory_rebuild ────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
it('memory_rebuild: formats indexed/skipped counts and stats', async () => {
|
|
221
|
+
index.rebuildIndex.mockResolvedValue({
|
|
222
|
+
indexed: 12,
|
|
223
|
+
skipped: 3,
|
|
224
|
+
stats: { totalVectors: 42, totalFiles: 8, dbSize: '512 kB' },
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const response = await mod.handleToolCall(
|
|
228
|
+
buildRequest('memory_rebuild', {}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const text = response.content[0].text
|
|
232
|
+
expect(text).toContain('Index rebuilt successfully.')
|
|
233
|
+
expect(text).toContain('- Chunks indexed: 12')
|
|
234
|
+
expect(text).toContain('- Chunks skipped (unchanged): 3')
|
|
235
|
+
expect(text).toContain('- Total vectors: 42')
|
|
236
|
+
expect(text).toContain('- Total files: 8')
|
|
237
|
+
expect(text).toContain('- DB size: 512 kB')
|
|
238
|
+
expect(response.isError).toBeUndefined()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ── 8. memory_update with changes ────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
it('memory_update: lists changed files and the new vector total', async () => {
|
|
244
|
+
index.incrementalUpdate.mockResolvedValue({
|
|
245
|
+
indexed: 4,
|
|
246
|
+
skipped: 1,
|
|
247
|
+
changed: ['activeContext.md', 'progress.md'],
|
|
248
|
+
})
|
|
249
|
+
index.getStats.mockReturnValue({ totalVectors: 100 })
|
|
250
|
+
|
|
251
|
+
const response = await mod.handleToolCall(
|
|
252
|
+
buildRequest('memory_update', {}),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const text = response.content[0].text
|
|
256
|
+
expect(text).toContain('Index updated incrementally.')
|
|
257
|
+
expect(text).toContain('- Chunks re-indexed: 4')
|
|
258
|
+
expect(text).toContain('- Chunks cached (unchanged): 1')
|
|
259
|
+
expect(text).toContain('- Changed files: activeContext.md, progress.md')
|
|
260
|
+
expect(text).toContain('- Total vectors: 100')
|
|
261
|
+
expect(response.isError).toBeUndefined()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// ── 9. memory_update with no changes ────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
it('memory_update: reports "No files changed." when nothing changed', async () => {
|
|
267
|
+
index.incrementalUpdate.mockResolvedValue({
|
|
268
|
+
indexed: 0,
|
|
269
|
+
skipped: 7,
|
|
270
|
+
changed: [],
|
|
271
|
+
})
|
|
272
|
+
index.getStats.mockReturnValue({ totalVectors: 100 })
|
|
273
|
+
|
|
274
|
+
const response = await mod.handleToolCall(
|
|
275
|
+
buildRequest('memory_update', {}),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
expect(response.content[0].text).toContain('- No files changed.')
|
|
279
|
+
expect(response.content[0].text).not.toContain('- Changed files:')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ── 10. memory_get ───────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
it('memory_get: returns the full file content', async () => {
|
|
285
|
+
index.getFile.mockReturnValue('# Active Context\n\nWorking.')
|
|
286
|
+
|
|
287
|
+
const response = await mod.handleToolCall(
|
|
288
|
+
buildRequest('memory_get', { file: 'activeContext' }),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expect(index.getFile).toHaveBeenCalledWith('activeContext')
|
|
292
|
+
expect(response.content[0].text).toBe('# Active Context\n\nWorking.')
|
|
293
|
+
expect(response.isError).toBeUndefined()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// ── 11. memory_get missing file ─────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
it('memory_get: lists available files when the file does not exist', async () => {
|
|
299
|
+
index.getFile.mockReturnValue(undefined)
|
|
300
|
+
|
|
301
|
+
const response = await mod.handleToolCall(
|
|
302
|
+
buildRequest('memory_get', { file: 'nope' }),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const text = response.content[0].text
|
|
306
|
+
expect(text).toContain('File not found: nope.md.')
|
|
307
|
+
expect(text).toContain('Available files: activeContext,')
|
|
308
|
+
expect(response.isError).toBeUndefined()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ── 12. memory_stats ─────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
it('memory_stats: formats vector, file, and size statistics', async () => {
|
|
314
|
+
index.getStats.mockReturnValue({
|
|
315
|
+
totalVectors: 321,
|
|
316
|
+
totalFiles: 17,
|
|
317
|
+
dbSize: '1.2 MB',
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const response = await mod.handleToolCall(
|
|
321
|
+
buildRequest('memory_stats', {}),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const text = response.content[0].text
|
|
325
|
+
expect(text).toContain('**Memory Index Stats**')
|
|
326
|
+
expect(text).toContain('- Total vectors: 321')
|
|
327
|
+
expect(text).toContain('- Total files indexed: 17')
|
|
328
|
+
expect(text).toContain('- Database size: 1.2 MB')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// ── 13. Unknown tool ─────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
it('unknown tool: returns a caught error response', async () => {
|
|
334
|
+
const response = await mod.handleToolCall(
|
|
335
|
+
buildRequest('memory_bogus', {}),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
expect(response.isError).toBe(true)
|
|
339
|
+
expect(response.content[0].text).toBe('Error: Unknown tool: memory_bogus')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ── 14. main() ───────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
it('main: connects a stdio transport and logs start-up', async () => {
|
|
345
|
+
sdk.connect.mockResolvedValue(undefined)
|
|
346
|
+
|
|
347
|
+
await mod.main()
|
|
348
|
+
|
|
349
|
+
expect(sdk.connect).toHaveBeenCalledWith(
|
|
350
|
+
expect.objectContaining({ kind: 'stdio' }),
|
|
351
|
+
)
|
|
352
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
353
|
+
'[memory-bank-mcp] Server started via stdio transport',
|
|
354
|
+
)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ── 15. bootstrap() fatal path ───────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
it('bootstrap: logs the fatal error and exits 1 when connect fails', async () => {
|
|
360
|
+
const err = new Error('transport exploded')
|
|
361
|
+
sdk.connect.mockRejectedValue(err)
|
|
362
|
+
|
|
363
|
+
await mod.bootstrap()
|
|
364
|
+
|
|
365
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
366
|
+
'[memory-bank-mcp] Fatal error:',
|
|
367
|
+
err,
|
|
368
|
+
)
|
|
369
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// ── 16. Direct-run guard ─────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
it('direct run: auto-bootstraps when argv[1] is this module', async () => {
|
|
375
|
+
const modulePath = fileURLToPath(
|
|
376
|
+
new URL('./mcp-memory-server.js', import.meta.url),
|
|
377
|
+
)
|
|
378
|
+
process.argv[1] = modulePath
|
|
379
|
+
sdk.connect.mockResolvedValue(undefined)
|
|
380
|
+
vi.resetModules()
|
|
381
|
+
|
|
382
|
+
await import('./mcp-memory-server.js')
|
|
383
|
+
|
|
384
|
+
await vi.waitFor(() => expect(sdk.connect).toHaveBeenCalledTimes(1))
|
|
385
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
386
|
+
'[memory-bank-mcp] Server started via stdio transport',
|
|
387
|
+
)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('imported with an empty argv[1]: does not start the server', async () => {
|
|
391
|
+
process.argv[1] = ''
|
|
392
|
+
vi.resetModules()
|
|
393
|
+
|
|
394
|
+
await import('./mcp-memory-server.js')
|
|
395
|
+
|
|
396
|
+
expect(sdk.connect).not.toHaveBeenCalled()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// memory-cli.test.js — Unit tests for the manual memory-index CLI
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The CLI is a top-level script: it reads process.argv, dispatches one command,
|
|
6
|
+
// and always closes the DB in `finally`. Tests mock ./memory-index.js, set
|
|
7
|
+
// argv, and re-import the module per scenario. `process.exit` is stubbed so
|
|
8
|
+
// the assertions can inspect the requested exit code without terminating.
|
|
9
|
+
//
|
|
10
|
+
// Covered scenarios:
|
|
11
|
+
// 1. search <query> → JSON results
|
|
12
|
+
// 2. search defaults → topK=5, no file filter
|
|
13
|
+
// 3. search --topK/--file → flags parsed and forwarded
|
|
14
|
+
// 4. search with non-numeric --topK → falls back to 5
|
|
15
|
+
// 5. search with no results → empty result + rebuild hint
|
|
16
|
+
// 6. search without a query → usage + exit 1
|
|
17
|
+
// 7. rebuild → status ok + result fields
|
|
18
|
+
// 8. update → status ok + result fields
|
|
19
|
+
// 9. get <fileId> → raw content
|
|
20
|
+
// 10. get without a fileId → usage + exit 1
|
|
21
|
+
// 11. get unknown file → error + exit 1
|
|
22
|
+
// 12. stats → JSON stats
|
|
23
|
+
// 13. unknown command → usage + exit 1
|
|
24
|
+
// 14. no command → usage + exit 1
|
|
25
|
+
// 15. engine failure → `Error: …` + exit 1
|
|
26
|
+
// 16. flags at end of argv → treated as positional arguments
|
|
27
|
+
// 17. closeDB → always called (finally)
|
|
28
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
31
|
+
|
|
32
|
+
const index = vi.hoisted(() => ({
|
|
33
|
+
search: vi.fn(),
|
|
34
|
+
rebuildIndex: vi.fn(),
|
|
35
|
+
incrementalUpdate: vi.fn(),
|
|
36
|
+
getFile: vi.fn(),
|
|
37
|
+
getStats: vi.fn(),
|
|
38
|
+
closeDB: vi.fn(),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
vi.mock('./memory-index.js', () => index)
|
|
42
|
+
|
|
43
|
+
describe('memory-cli.js', () => {
|
|
44
|
+
let logSpy
|
|
45
|
+
let errorSpy
|
|
46
|
+
let exitSpy
|
|
47
|
+
let originalArgv
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
vi.clearAllMocks()
|
|
51
|
+
originalArgv = process.argv
|
|
52
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
53
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
54
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
process.argv = originalArgv
|
|
59
|
+
logSpy.mockRestore()
|
|
60
|
+
errorSpy.mockRestore()
|
|
61
|
+
exitSpy.mockRestore()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Runs the CLI with the given arguments and waits for `closeDB` (the
|
|
66
|
+
* `finally` block), which marks command completion.
|
|
67
|
+
*
|
|
68
|
+
* @param {string[]} argv - Arguments after the script name.
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
*/
|
|
71
|
+
async function runCli(argv) {
|
|
72
|
+
process.argv = ['node', 'scripts/memory-cli.js', ...argv]
|
|
73
|
+
vi.resetModules()
|
|
74
|
+
await import('./memory-cli.js')
|
|
75
|
+
await vi.waitFor(() => expect(index.closeDB).toHaveBeenCalled())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns the parsed first console.log payload.
|
|
80
|
+
*
|
|
81
|
+
* @returns {object}
|
|
82
|
+
*/
|
|
83
|
+
function loggedJson() {
|
|
84
|
+
return JSON.parse(logSpy.mock.calls[0][0])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── search ───────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
it('search: prints the results as JSON', async () => {
|
|
90
|
+
const results = [
|
|
91
|
+
{ source_file: 'activeContext', content: 'focus', score: 0.9 },
|
|
92
|
+
]
|
|
93
|
+
index.search.mockResolvedValue(results)
|
|
94
|
+
|
|
95
|
+
await runCli(['search', 'focus'])
|
|
96
|
+
|
|
97
|
+
expect(index.search).toHaveBeenCalledWith('focus', {
|
|
98
|
+
topK: 5,
|
|
99
|
+
sourceFile: undefined,
|
|
100
|
+
})
|
|
101
|
+
expect(loggedJson()).toEqual({ results })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('search: joins multiple positional words into one query', async () => {
|
|
105
|
+
index.search.mockResolvedValue([])
|
|
106
|
+
|
|
107
|
+
await runCli(['search', 'where', 'are', 'we'])
|
|
108
|
+
|
|
109
|
+
expect(index.search).toHaveBeenCalledWith('where are we', {
|
|
110
|
+
topK: 5,
|
|
111
|
+
sourceFile: undefined,
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('search: parses --topK and --file', async () => {
|
|
116
|
+
index.search.mockResolvedValue([])
|
|
117
|
+
|
|
118
|
+
await runCli([
|
|
119
|
+
'search',
|
|
120
|
+
'hello',
|
|
121
|
+
'world',
|
|
122
|
+
'--topK',
|
|
123
|
+
'2',
|
|
124
|
+
'--file',
|
|
125
|
+
'activeContext',
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
expect(index.search).toHaveBeenCalledWith('hello world', {
|
|
129
|
+
topK: 2,
|
|
130
|
+
sourceFile: 'activeContext',
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('search: a non-numeric --topK falls back to 5', async () => {
|
|
135
|
+
index.search.mockResolvedValue([])
|
|
136
|
+
|
|
137
|
+
await runCli(['search', 'q', '--topK', 'not-a-number'])
|
|
138
|
+
|
|
139
|
+
expect(index.search).toHaveBeenCalledWith('q', {
|
|
140
|
+
topK: 5,
|
|
141
|
+
sourceFile: undefined,
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('search: prints an empty result with a rebuild hint', async () => {
|
|
146
|
+
index.search.mockResolvedValue([])
|
|
147
|
+
|
|
148
|
+
await runCli(['search', 'nothing'])
|
|
149
|
+
|
|
150
|
+
expect(loggedJson()).toEqual({
|
|
151
|
+
results: [],
|
|
152
|
+
hint: 'No results. Try running "rebuild" first.',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('search: missing query prints usage and exits 1', async () => {
|
|
157
|
+
index.search.mockResolvedValue([])
|
|
158
|
+
|
|
159
|
+
await runCli(['search'])
|
|
160
|
+
|
|
161
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
162
|
+
'Usage: node scripts/memory-cli.js search "<query>" [--topK 5] [--file name]',
|
|
163
|
+
)
|
|
164
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('search: flags at the end of argv become positional query words', async () => {
|
|
168
|
+
index.search.mockResolvedValue([])
|
|
169
|
+
|
|
170
|
+
await runCli(['search', 'q', '--topK'])
|
|
171
|
+
|
|
172
|
+
expect(index.search).toHaveBeenCalledWith('q --topK', {
|
|
173
|
+
topK: 5,
|
|
174
|
+
sourceFile: undefined,
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('search: a trailing --file without a value becomes positional', async () => {
|
|
179
|
+
index.search.mockResolvedValue([])
|
|
180
|
+
|
|
181
|
+
await runCli(['search', 'q', '--topK', '2', '--file'])
|
|
182
|
+
|
|
183
|
+
expect(index.search).toHaveBeenCalledWith('q --file', {
|
|
184
|
+
topK: 2,
|
|
185
|
+
sourceFile: undefined,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ── rebuild / update ─────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
it('rebuild: prints status ok followed by the engine result', async () => {
|
|
192
|
+
index.rebuildIndex.mockResolvedValue({ indexed: 12, skipped: 3 })
|
|
193
|
+
|
|
194
|
+
await runCli(['rebuild'])
|
|
195
|
+
|
|
196
|
+
expect(index.rebuildIndex).toHaveBeenCalledTimes(1)
|
|
197
|
+
expect(loggedJson()).toEqual({
|
|
198
|
+
status: 'ok',
|
|
199
|
+
indexed: 12,
|
|
200
|
+
skipped: 3,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('update: prints status ok followed by the engine result', async () => {
|
|
205
|
+
index.incrementalUpdate.mockResolvedValue({ indexed: 4, skipped: 0 })
|
|
206
|
+
|
|
207
|
+
await runCli(['update'])
|
|
208
|
+
|
|
209
|
+
expect(index.incrementalUpdate).toHaveBeenCalledTimes(1)
|
|
210
|
+
expect(loggedJson()).toEqual({ status: 'ok', indexed: 4, skipped: 0 })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ── get ──────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
it('get: prints the raw file content', async () => {
|
|
216
|
+
index.getFile.mockReturnValue('# Active Context\n\nWorking.')
|
|
217
|
+
|
|
218
|
+
await runCli(['get', 'activeContext'])
|
|
219
|
+
|
|
220
|
+
expect(index.getFile).toHaveBeenCalledWith('activeContext')
|
|
221
|
+
expect(logSpy).toHaveBeenCalledWith('# Active Context\n\nWorking.')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('get: missing fileId prints usage and exits 1', async () => {
|
|
225
|
+
index.getFile.mockReturnValue(undefined)
|
|
226
|
+
|
|
227
|
+
await runCli(['get'])
|
|
228
|
+
|
|
229
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
230
|
+
'Usage: node scripts/memory-cli.js get <fileId>',
|
|
231
|
+
)
|
|
232
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('get: unknown file prints an error and exits 1', async () => {
|
|
236
|
+
index.getFile.mockReturnValue(undefined)
|
|
237
|
+
|
|
238
|
+
await runCli(['get', 'missing'])
|
|
239
|
+
|
|
240
|
+
expect(index.getFile).toHaveBeenCalledWith('missing')
|
|
241
|
+
expect(errorSpy).toHaveBeenCalledWith('File not found: missing')
|
|
242
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ── stats ────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
it('stats: prints the index statistics as JSON', async () => {
|
|
248
|
+
const stats = { totalVectors: 42, totalFiles: 7, dbSize: '256 kB' }
|
|
249
|
+
index.getStats.mockReturnValue(stats)
|
|
250
|
+
|
|
251
|
+
await runCli(['stats'])
|
|
252
|
+
|
|
253
|
+
expect(index.getStats).toHaveBeenCalledTimes(1)
|
|
254
|
+
expect(loggedJson()).toEqual(stats)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ── unknown / missing command ────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
it('unknown command: prints usage and exits 1', async () => {
|
|
260
|
+
await runCli(['bogus'])
|
|
261
|
+
|
|
262
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
263
|
+
'Usage: node scripts/memory-cli.js <search|rebuild|update|get|stats> [args...]',
|
|
264
|
+
)
|
|
265
|
+
expect(errorSpy).toHaveBeenCalledWith('Commands:')
|
|
266
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('no command: prints usage and exits 1', async () => {
|
|
270
|
+
await runCli([])
|
|
271
|
+
|
|
272
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
273
|
+
'Usage: node scripts/memory-cli.js <search|rebuild|update|get|stats> [args...]',
|
|
274
|
+
)
|
|
275
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// ── Error handling ───────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
it('engine failure: prints `Error: <message>` and exits 1', async () => {
|
|
281
|
+
index.search.mockRejectedValue(new Error('database is locked'))
|
|
282
|
+
|
|
283
|
+
await runCli(['search', 'anything'])
|
|
284
|
+
|
|
285
|
+
expect(errorSpy).toHaveBeenCalledWith('Error: database is locked')
|
|
286
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
287
|
+
expect(index.closeDB).toHaveBeenCalledTimes(1)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('always closes the DB, including on success', async () => {
|
|
291
|
+
index.search.mockResolvedValue([])
|
|
292
|
+
|
|
293
|
+
await runCli(['search', 'q'])
|
|
294
|
+
|
|
295
|
+
expect(index.closeDB).toHaveBeenCalledTimes(1)
|
|
296
|
+
})
|
|
297
|
+
})
|