@vheins/local-memory-mcp 0.1.4 → 0.1.5
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/dist/dashboard/dashboard.test.js +362 -0
- package/dist/mcp/client.test.js +130 -0
- package/dist/resources/index.test.js +96 -0
- package/dist/router.test.js +113 -0
- package/dist/storage/sqlite.test.js +358 -0
- package/dist/tools/memory.search.test.js +181 -0
- package/dist/utils/logger.test.js +84 -0
- package/dist/utils/normalize.test.js +159 -0
- package/dist/utils/query-expander.test.js +35 -0
- package/package.json +8 -2
- package/.kiro/specs/memory-mcp-optimization/.config.kiro +0 -1
- package/.vscode/tasks.json +0 -27
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Feature: Dashboard Filter & Export Property Tests
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import * as fc from "fast-check";
|
|
4
|
+
describe("Property 12: Dashboard filter logic correctness", () => {
|
|
5
|
+
// Mock memory data generator
|
|
6
|
+
function generateMemories(count) {
|
|
7
|
+
const types = ["code_fact", "decision", "mistake", "pattern"];
|
|
8
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
9
|
+
id: `test-${i}`,
|
|
10
|
+
type: types[i % types.length],
|
|
11
|
+
content: `Test memory content ${i} with some keywords`,
|
|
12
|
+
importance: (i % 5) + 1,
|
|
13
|
+
hit_count: i * 2,
|
|
14
|
+
recall_rate: i > 0 ? i / (i + 1) : 0,
|
|
15
|
+
created_at: new Date(Date.now() - i * 86400000).toISOString(),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
// Filter function (same as in dashboard)
|
|
19
|
+
function applyFilters(memories, searchQuery, typeFilter, minImportance, maxImportance) {
|
|
20
|
+
let filtered = [...memories];
|
|
21
|
+
if (searchQuery) {
|
|
22
|
+
const q = searchQuery.toLowerCase();
|
|
23
|
+
filtered = filtered.filter((m) => m.content.toLowerCase().includes(q) ||
|
|
24
|
+
m.type.toLowerCase().includes(q));
|
|
25
|
+
}
|
|
26
|
+
if (typeFilter) {
|
|
27
|
+
filtered = filtered.filter((m) => m.type === typeFilter);
|
|
28
|
+
}
|
|
29
|
+
if (minImportance > 0) {
|
|
30
|
+
filtered = filtered.filter((m) => m.importance >= minImportance);
|
|
31
|
+
}
|
|
32
|
+
if (maxImportance < 5) {
|
|
33
|
+
filtered = filtered.filter((m) => m.importance <= maxImportance);
|
|
34
|
+
}
|
|
35
|
+
return filtered;
|
|
36
|
+
}
|
|
37
|
+
it("property: search filter returns subset containing search term", () => {
|
|
38
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
39
|
+
id: fc.uuid(),
|
|
40
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
41
|
+
content: fc.string({ minLength: 10, maxLength: 100 }),
|
|
42
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
43
|
+
hit_count: fc.integer({ min: 0, max: 100 }),
|
|
44
|
+
})), fc.string({ minLength: 1, maxLength: 20 }), (memories, query) => {
|
|
45
|
+
if (memories.length === 0)
|
|
46
|
+
return true;
|
|
47
|
+
const filtered = applyFilters(memories, query, "", 0, 5);
|
|
48
|
+
// All results should contain query (if any returned)
|
|
49
|
+
for (const m of filtered) {
|
|
50
|
+
const containsQuery = m.content.toLowerCase().includes(query.toLowerCase()) ||
|
|
51
|
+
m.type.toLowerCase().includes(query.toLowerCase());
|
|
52
|
+
expect(containsQuery).toBe(true);
|
|
53
|
+
}
|
|
54
|
+
// Filtered should not be larger than original
|
|
55
|
+
expect(filtered.length).toBeLessThanOrEqual(memories.length);
|
|
56
|
+
}), { numRuns: 50 });
|
|
57
|
+
});
|
|
58
|
+
it("property: type filter returns only matching types", () => {
|
|
59
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
60
|
+
id: fc.uuid(),
|
|
61
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
62
|
+
content: fc.string({ minLength: 10, maxLength: 100 }),
|
|
63
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
64
|
+
})), fc.constantFrom("", "code_fact", "decision", "mistake", "pattern"), (memories, typeFilter) => {
|
|
65
|
+
const filtered = applyFilters(memories, "", typeFilter, 0, 5);
|
|
66
|
+
if (typeFilter === "") {
|
|
67
|
+
// No filter should return all
|
|
68
|
+
expect(filtered.length).toBe(memories.length);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// All results should have matching type
|
|
72
|
+
for (const m of filtered) {
|
|
73
|
+
expect(m.type).toBe(typeFilter);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}), { numRuns: 50 });
|
|
77
|
+
});
|
|
78
|
+
it("property: importance range filter returns correct subset", () => {
|
|
79
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
80
|
+
id: fc.uuid(),
|
|
81
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
82
|
+
content: fc.string({ minLength: 10 }),
|
|
83
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
84
|
+
})), fc.integer({ min: 0, max: 5 }), fc.integer({ min: 0, max: 5 }), (memories, minImp, maxImp) => {
|
|
85
|
+
const safeMin = Math.min(minImp, maxImp);
|
|
86
|
+
const safeMax = Math.max(minImp, maxImp);
|
|
87
|
+
const filtered = applyFilters(memories, "", "", safeMin, safeMax);
|
|
88
|
+
for (const m of filtered) {
|
|
89
|
+
expect(m.importance).toBeGreaterThanOrEqual(safeMin);
|
|
90
|
+
expect(m.importance).toBeLessThanOrEqual(safeMax);
|
|
91
|
+
}
|
|
92
|
+
}), { numRuns: 50 });
|
|
93
|
+
});
|
|
94
|
+
it("property: combination filters are additive (AND logic)", () => {
|
|
95
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
96
|
+
id: fc.uuid(),
|
|
97
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
98
|
+
content: fc.string({ minLength: 10 }),
|
|
99
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
100
|
+
})), fc.string({ maxLength: 10 }), fc.constantFrom("", "code_fact", "decision"), fc.integer({ min: 1, max: 3 }), fc.integer({ min: 3, max: 5 }), (memories, search, type, minImp, maxImp) => {
|
|
101
|
+
const safeMin = Math.min(minImp, maxImp);
|
|
102
|
+
const safeMax = Math.max(minImp, maxImp);
|
|
103
|
+
const filtered = applyFilters(memories, search, type, safeMin, safeMax);
|
|
104
|
+
// Verify each result matches ALL criteria
|
|
105
|
+
for (const m of filtered) {
|
|
106
|
+
if (search) {
|
|
107
|
+
expect(m.content.toLowerCase().includes(search.toLowerCase()) ||
|
|
108
|
+
m.type.toLowerCase().includes(search.toLowerCase())).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
if (type) {
|
|
111
|
+
expect(m.type).toBe(type);
|
|
112
|
+
}
|
|
113
|
+
expect(m.importance).toBeGreaterThanOrEqual(safeMin);
|
|
114
|
+
expect(m.importance).toBeLessThanOrEqual(safeMax);
|
|
115
|
+
}
|
|
116
|
+
}), { numRuns: 30 });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("Property 13: Pagination non-overlapping", () => {
|
|
120
|
+
function paginate(items, page, pageSize) {
|
|
121
|
+
const start = (page - 1) * pageSize;
|
|
122
|
+
return items.slice(start, start + pageSize);
|
|
123
|
+
}
|
|
124
|
+
it("property: different pages have no overlapping IDs", () => {
|
|
125
|
+
fc.assert(fc.property(fc.array(fc.uuid(), { minLength: 10, maxLength: 100 }), fc.integer({ min: 1, max: 10 }), (ids, pageSize) => {
|
|
126
|
+
if (pageSize < 1)
|
|
127
|
+
return true;
|
|
128
|
+
const page1 = paginate(ids, 1, pageSize);
|
|
129
|
+
const page2 = paginate(ids, 2, pageSize);
|
|
130
|
+
// No common IDs between pages
|
|
131
|
+
const overlap = page1.filter((id) => page2.includes(id));
|
|
132
|
+
expect(overlap.length).toBe(0);
|
|
133
|
+
}), { numRuns: 50 });
|
|
134
|
+
});
|
|
135
|
+
it("property: all items appear in exactly one page", () => {
|
|
136
|
+
fc.assert(fc.property(fc.array(fc.integer(), { minLength: 1, maxLength: 50 }), fc.integer({ min: 1, max: 10 }), (items, pageSize) => {
|
|
137
|
+
if (pageSize < 1)
|
|
138
|
+
return true;
|
|
139
|
+
if (items.length === 0)
|
|
140
|
+
return true;
|
|
141
|
+
const totalPages = Math.ceil(items.length / pageSize);
|
|
142
|
+
const allPages = [];
|
|
143
|
+
for (let page = 1; page <= totalPages; page++) {
|
|
144
|
+
const pageItems = paginate(items, page, pageSize);
|
|
145
|
+
allPages.push(...pageItems);
|
|
146
|
+
}
|
|
147
|
+
// Should have same total count
|
|
148
|
+
expect(allPages.length).toBe(items.length);
|
|
149
|
+
}), { numRuns: 30 });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe("Property 14: Export format correctness", () => {
|
|
153
|
+
function exportToCsv(memories) {
|
|
154
|
+
const headers = ["id", "type", "content", "importance", "hit_count", "created_at"];
|
|
155
|
+
const csvRows = [headers.join(",")];
|
|
156
|
+
for (const m of memories) {
|
|
157
|
+
const row = [
|
|
158
|
+
m.id,
|
|
159
|
+
m.type,
|
|
160
|
+
`"${m.content.replace(/"/g, '""')}"`,
|
|
161
|
+
m.importance,
|
|
162
|
+
m.hit_count,
|
|
163
|
+
m.created_at,
|
|
164
|
+
];
|
|
165
|
+
csvRows.push(row.join(","));
|
|
166
|
+
}
|
|
167
|
+
return csvRows.join("\n");
|
|
168
|
+
}
|
|
169
|
+
function exportToJson(memories) {
|
|
170
|
+
return JSON.stringify(memories, null, 2);
|
|
171
|
+
}
|
|
172
|
+
it("property: CSV export contains all filtered data", () => {
|
|
173
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
174
|
+
id: fc.uuid(),
|
|
175
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
176
|
+
content: fc.string({ minLength: 5, maxLength: 50 }),
|
|
177
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
178
|
+
hit_count: fc.integer({ min: 0, max: 20 }),
|
|
179
|
+
created_at: fc.date().map((d) => d.toISOString()),
|
|
180
|
+
})), (memories) => {
|
|
181
|
+
if (memories.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
const csv = exportToCsv(memories);
|
|
184
|
+
const lines = csv.split("\n");
|
|
185
|
+
// Header + data rows
|
|
186
|
+
expect(lines.length).toBe(memories.length + 1);
|
|
187
|
+
// Each row has 6 columns
|
|
188
|
+
for (let i = 1; i < lines.length; i++) {
|
|
189
|
+
const cols = lines[i].split(",");
|
|
190
|
+
expect(cols.length).toBeGreaterThanOrEqual(5);
|
|
191
|
+
}
|
|
192
|
+
}), { numRuns: 30, endOnFailure: true });
|
|
193
|
+
});
|
|
194
|
+
it("property: JSON export is valid parseable JSON", () => {
|
|
195
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
196
|
+
id: fc.uuid(),
|
|
197
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
198
|
+
content: fc.string({ minLength: 5 }),
|
|
199
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
200
|
+
hit_count: fc.integer({ min: 0, max: 10 }),
|
|
201
|
+
created_at: fc.string(),
|
|
202
|
+
})), (memories) => {
|
|
203
|
+
const json = exportToJson(memories);
|
|
204
|
+
const parsed = JSON.parse(json);
|
|
205
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
206
|
+
expect(parsed.length).toBe(memories.length);
|
|
207
|
+
}), { numRuns: 30 });
|
|
208
|
+
});
|
|
209
|
+
it("property: export respects filter (matches displayed data)", () => {
|
|
210
|
+
fc.assert(fc.property(fc.array(fc.record({
|
|
211
|
+
id: fc.uuid(),
|
|
212
|
+
type: fc.constantFrom("decision", "mistake", "code_fact", "pattern"),
|
|
213
|
+
content: fc.string({ minLength: 10 }),
|
|
214
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
215
|
+
hit_count: fc.integer({ min: 0, max: 100 }),
|
|
216
|
+
created_at: fc.date({ min: new Date("2020-01-01"), max: new Date("2030-12-31") }).map((d) => d.toISOString()),
|
|
217
|
+
})), fc.constantFrom("decision", "mistake", "code_fact", "pattern"), (memories, typeFilter) => {
|
|
218
|
+
// Apply filter (same as dashboard)
|
|
219
|
+
const filtered = memories.filter((m) => m.type === typeFilter);
|
|
220
|
+
const csv = exportToCsv(filtered);
|
|
221
|
+
const json = exportToJson(filtered);
|
|
222
|
+
// CSV should have filtered count + 1 header
|
|
223
|
+
expect(csv.split("\n").length).toBe(filtered.length + 1);
|
|
224
|
+
// JSON should parse to filtered array
|
|
225
|
+
const parsed = JSON.parse(json);
|
|
226
|
+
expect(parsed.length).toBe(filtered.length);
|
|
227
|
+
}), { numRuns: 30 });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
describe("Property 15: localStorage preferences round-trip", () => {
|
|
231
|
+
// Mock localStorage for Node.js environment
|
|
232
|
+
let mockStorage = {};
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
mockStorage = {};
|
|
235
|
+
global.localStorage = {
|
|
236
|
+
getItem: (key) => mockStorage[key] || null,
|
|
237
|
+
setItem: (key, value) => { mockStorage[key] = value; },
|
|
238
|
+
removeItem: (key) => { delete mockStorage[key]; },
|
|
239
|
+
clear: () => { mockStorage = {}; },
|
|
240
|
+
get length() { return Object.keys(mockStorage).length; },
|
|
241
|
+
key: (i) => Object.keys(mockStorage)[i] || null,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
afterEach(() => {
|
|
245
|
+
delete global.localStorage;
|
|
246
|
+
});
|
|
247
|
+
function savePrefs(prefs) {
|
|
248
|
+
localStorage.setItem("dashboard_prefs", JSON.stringify(prefs));
|
|
249
|
+
}
|
|
250
|
+
function loadPrefs() {
|
|
251
|
+
const stored = localStorage.getItem("dashboard_prefs");
|
|
252
|
+
return stored ? JSON.parse(stored) : null;
|
|
253
|
+
}
|
|
254
|
+
it("property: saved preferences can be loaded identically", () => {
|
|
255
|
+
fc.assert(fc.property(fc.record({
|
|
256
|
+
theme: fc.constantFrom("light", "dark"),
|
|
257
|
+
pageSize: fc.constantFrom(10, 25, 50, 100),
|
|
258
|
+
columnVisibility: fc.record({ id: fc.boolean(), type: fc.boolean(), content: fc.boolean() }),
|
|
259
|
+
columnWidths: fc.record({ id: fc.integer({ min: 50, max: 300 }), type: fc.integer({ min: 50, max: 300 }) }),
|
|
260
|
+
}), (prefs) => {
|
|
261
|
+
savePrefs(prefs);
|
|
262
|
+
const loaded = loadPrefs();
|
|
263
|
+
expect(loaded).not.toBeNull();
|
|
264
|
+
expect(loaded?.theme).toBe(prefs.theme);
|
|
265
|
+
expect(loaded?.pageSize).toBe(prefs.pageSize);
|
|
266
|
+
}), { numRuns: 30 });
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe("Property 16: Recent Queries feature", () => {
|
|
270
|
+
let mockStorage = {};
|
|
271
|
+
beforeEach(() => {
|
|
272
|
+
mockStorage = {};
|
|
273
|
+
global.localStorage = {
|
|
274
|
+
getItem: (key) => mockStorage[key] || null,
|
|
275
|
+
setItem: (key, value) => { mockStorage[key] = value; },
|
|
276
|
+
removeItem: (key) => { delete mockStorage[key]; },
|
|
277
|
+
clear: () => { mockStorage = {}; },
|
|
278
|
+
get length() { return Object.keys(mockStorage).length; },
|
|
279
|
+
key: (i) => Object.keys(mockStorage)[i] || null,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
delete global.localStorage;
|
|
284
|
+
});
|
|
285
|
+
// Simulate recent queries stored in localStorage (fallback)
|
|
286
|
+
function getRecentQueriesFromStorage() {
|
|
287
|
+
const stored = localStorage.getItem("recentQueries");
|
|
288
|
+
return stored ? JSON.parse(stored) : [];
|
|
289
|
+
}
|
|
290
|
+
function addRecentQueryToStorage(query) {
|
|
291
|
+
if (!query.trim())
|
|
292
|
+
return;
|
|
293
|
+
const queries = getRecentQueriesFromStorage();
|
|
294
|
+
const filtered = queries.filter(q => q !== query);
|
|
295
|
+
filtered.unshift(query);
|
|
296
|
+
const limited = filtered.slice(0, 20);
|
|
297
|
+
localStorage.setItem("recentQueries", JSON.stringify(limited));
|
|
298
|
+
}
|
|
299
|
+
it("property: adding query moves it to front and removes duplicates", () => {
|
|
300
|
+
addRecentQueryToStorage("database");
|
|
301
|
+
addRecentQueryToStorage("authentication");
|
|
302
|
+
addRecentQueryToStorage("database"); // duplicate - should move to front
|
|
303
|
+
const queries = getRecentQueriesFromStorage();
|
|
304
|
+
expect(queries[0]).toBe("database");
|
|
305
|
+
expect(queries[1]).toBe("authentication");
|
|
306
|
+
expect(queries.length).toBe(2);
|
|
307
|
+
});
|
|
308
|
+
it("property: recent queries are limited to 20", () => {
|
|
309
|
+
for (let i = 0; i < 25; i++) {
|
|
310
|
+
addRecentQueryToStorage(`query-${i}`);
|
|
311
|
+
}
|
|
312
|
+
const queries = getRecentQueriesFromStorage();
|
|
313
|
+
expect(queries.length).toBe(20);
|
|
314
|
+
expect(queries[0]).toBe("query-24");
|
|
315
|
+
expect(queries[19]).toBe("query-5");
|
|
316
|
+
});
|
|
317
|
+
it("property: empty query is not added", () => {
|
|
318
|
+
addRecentQueryToStorage("valid-query");
|
|
319
|
+
addRecentQueryToStorage("");
|
|
320
|
+
addRecentQueryToStorage(" ");
|
|
321
|
+
const queries = getRecentQueriesFromStorage();
|
|
322
|
+
expect(queries.length).toBe(1);
|
|
323
|
+
expect(queries[0]).toBe("valid-query");
|
|
324
|
+
});
|
|
325
|
+
it("property: queries maintain insertion order with unique constraint", () => {
|
|
326
|
+
addRecentQueryToStorage("first");
|
|
327
|
+
addRecentQueryToStorage("second");
|
|
328
|
+
addRecentQueryToStorage("third");
|
|
329
|
+
addRecentQueryToStorage("first"); // move to front
|
|
330
|
+
addRecentQueryToStorage("second"); // move to front
|
|
331
|
+
const queries = getRecentQueriesFromStorage();
|
|
332
|
+
expect(queries).toEqual(["second", "first", "third"]);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
describe("Property 17: Recent Queries API response format", () => {
|
|
336
|
+
function parseRecentQueriesResponse(json) {
|
|
337
|
+
const parsed = JSON.parse(json);
|
|
338
|
+
if (!Array.isArray(parsed.queries)) {
|
|
339
|
+
throw new Error("Invalid response: queries must be an array");
|
|
340
|
+
}
|
|
341
|
+
return parsed;
|
|
342
|
+
}
|
|
343
|
+
it("property: valid API response parses correctly", () => {
|
|
344
|
+
const apiResponse = JSON.stringify({
|
|
345
|
+
queries: ["database", "authentication", "user management"]
|
|
346
|
+
});
|
|
347
|
+
const result = parseRecentQueriesResponse(apiResponse);
|
|
348
|
+
expect(result.queries).toHaveLength(3);
|
|
349
|
+
expect(result.queries[0]).toBe("database");
|
|
350
|
+
});
|
|
351
|
+
it("property: empty queries array is valid", () => {
|
|
352
|
+
const apiResponse = JSON.stringify({ queries: [] });
|
|
353
|
+
const result = parseRecentQueriesResponse(apiResponse);
|
|
354
|
+
expect(result.queries).toHaveLength(0);
|
|
355
|
+
});
|
|
356
|
+
it("property: malformed response throws error", () => {
|
|
357
|
+
expect(() => parseRecentQueriesResponse("not json")).toThrow();
|
|
358
|
+
expect(() => parseRecentQueriesResponse("{}")).toThrow();
|
|
359
|
+
expect(() => parseRecentQueriesResponse('{"results": []}')).toThrow();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
//# sourceMappingURL=dashboard.test.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Feature: memory-mcp-optimization, Property 16: MCPClient cleanup pending requests
|
|
2
|
+
// Feature: memory-mcp-optimization, Property 17: MCPClient retry count
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import * as fc from "fast-check";
|
|
5
|
+
import { MCPClient } from "./client.js";
|
|
6
|
+
class TestableMCPClient extends MCPClient {
|
|
7
|
+
get pending() {
|
|
8
|
+
return this.pendingRequests;
|
|
9
|
+
}
|
|
10
|
+
injectPending(n) {
|
|
11
|
+
const promises = [];
|
|
12
|
+
for (let i = 0; i < n; i++) {
|
|
13
|
+
const id = 1000 + i;
|
|
14
|
+
const p = new Promise((resolve, reject) => {
|
|
15
|
+
this.pending.set(id, {
|
|
16
|
+
resolve,
|
|
17
|
+
reject,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
promises.push(p);
|
|
21
|
+
}
|
|
22
|
+
return promises;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
describe("Property 16: MCPClient cleanup pending requests saat stop atau timeout", () => {
|
|
26
|
+
it("stop() clears the pending map synchronously", () => {
|
|
27
|
+
const client = new TestableMCPClient();
|
|
28
|
+
const n = 5;
|
|
29
|
+
const promises = client.injectPending(n);
|
|
30
|
+
const rejections = promises.map((p) => p.catch(() => undefined));
|
|
31
|
+
client.stop();
|
|
32
|
+
expect(client.getPendingCount()).toBe(0);
|
|
33
|
+
void Promise.all(rejections);
|
|
34
|
+
});
|
|
35
|
+
it("stop() rejects all pending requests with 'Client stopped'", async () => {
|
|
36
|
+
const client = new TestableMCPClient();
|
|
37
|
+
const n = 5;
|
|
38
|
+
const promises = client.injectPending(n);
|
|
39
|
+
client.stop();
|
|
40
|
+
const results = await Promise.allSettled(promises);
|
|
41
|
+
for (const result of results) {
|
|
42
|
+
expect(result.status).toBe("rejected");
|
|
43
|
+
if (result.status === "rejected") {
|
|
44
|
+
expect(result.reason.message).toBe("Client stopped");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
it("getPendingCount() returns correct count before and after stop()", () => {
|
|
49
|
+
const client = new TestableMCPClient();
|
|
50
|
+
const n = 10;
|
|
51
|
+
const promises = client.injectPending(n);
|
|
52
|
+
const rejections = promises.map((p) => p.catch(() => undefined));
|
|
53
|
+
expect(client.getPendingCount()).toBe(n);
|
|
54
|
+
client.stop();
|
|
55
|
+
expect(client.getPendingCount()).toBe(0);
|
|
56
|
+
void Promise.all(rejections);
|
|
57
|
+
});
|
|
58
|
+
it("property: for any N >= 0, after stop() pendingCount === 0", () => {
|
|
59
|
+
fc.assert(fc.property(fc.integer({ min: 0, max: 20 }), (n) => {
|
|
60
|
+
const client = new TestableMCPClient();
|
|
61
|
+
const promises = client.injectPending(n);
|
|
62
|
+
promises.forEach((p) => p.catch(() => undefined));
|
|
63
|
+
client.stop();
|
|
64
|
+
return client.getPendingCount() === 0;
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("Property 17: MCPClient retry maksimal 3 kali dengan exponential backoff", () => {
|
|
69
|
+
it("retries exactly 3 times (4 total attempts) on timeout before rejecting", async () => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
try {
|
|
72
|
+
let callOnceCount = 0;
|
|
73
|
+
const client = new TestableMCPClient();
|
|
74
|
+
client.callOnce = async (_method, _params) => {
|
|
75
|
+
callOnceCount++;
|
|
76
|
+
throw new Error("Request timeout");
|
|
77
|
+
};
|
|
78
|
+
client.process = { stdin: { write: () => true } };
|
|
79
|
+
const retryPromise = client.callWithRetry("test/method", {});
|
|
80
|
+
retryPromise.catch(() => undefined);
|
|
81
|
+
await vi.runAllTimersAsync();
|
|
82
|
+
const result = await retryPromise.catch((e) => e);
|
|
83
|
+
expect(result).toBeInstanceOf(Error);
|
|
84
|
+
expect(result.message).toBe("Request timeout");
|
|
85
|
+
expect(callOnceCount).toBe(4);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it("does not retry on non-timeout errors", async () => {
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
try {
|
|
94
|
+
let callOnceCount = 0;
|
|
95
|
+
const client = new TestableMCPClient();
|
|
96
|
+
client.callOnce = async (_method, _params) => {
|
|
97
|
+
callOnceCount++;
|
|
98
|
+
throw new Error("Some other error");
|
|
99
|
+
};
|
|
100
|
+
client.process = { stdin: { write: () => true } };
|
|
101
|
+
const retryPromise = client.callWithRetry("test/method", {});
|
|
102
|
+
retryPromise.catch(() => undefined);
|
|
103
|
+
await vi.runAllTimersAsync();
|
|
104
|
+
const result = await retryPromise.catch((e) => e);
|
|
105
|
+
expect(result).toBeInstanceOf(Error);
|
|
106
|
+
expect(result.message).toBe("Some other error");
|
|
107
|
+
expect(callOnceCount).toBe(1);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
vi.useRealTimers();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
it("property: retry delays follow exponential backoff pattern", () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
try {
|
|
116
|
+
fc.assert(fc.property(fc.constant(null), () => {
|
|
117
|
+
const expectedDelays = [1000, 2000, 4000];
|
|
118
|
+
expect(expectedDelays).toHaveLength(3);
|
|
119
|
+
for (let i = 1; i < expectedDelays.length; i++) {
|
|
120
|
+
expect(expectedDelays[i]).toBe(expectedDelays[i - 1] * 2);
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
vi.useRealTimers();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
//# sourceMappingURL=client.test.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Feature: memory-mcp-optimization, Property 19: memory://index filter repo
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import * as fc from "fast-check";
|
|
4
|
+
import { SQLiteStore } from "../storage/sqlite.js";
|
|
5
|
+
import { readResource } from "./index.js";
|
|
6
|
+
function makeEntry(id, repo) {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
type: "code_fact",
|
|
10
|
+
title: `Memory ${id}`,
|
|
11
|
+
content: `Content for memory ${id} in repo ${repo}`,
|
|
12
|
+
importance: 3,
|
|
13
|
+
scope: { repo },
|
|
14
|
+
created_at: new Date().toISOString(),
|
|
15
|
+
updated_at: new Date().toISOString(),
|
|
16
|
+
hit_count: 0,
|
|
17
|
+
recall_count: 0,
|
|
18
|
+
last_used_at: null,
|
|
19
|
+
expires_at: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe("readResource memory://index", () => {
|
|
23
|
+
it("returns recent entries when no repo filter", () => {
|
|
24
|
+
const db = new SQLiteStore(":memory:");
|
|
25
|
+
db.insert(makeEntry("id-1", "repo-a"));
|
|
26
|
+
db.insert(makeEntry("id-2", "repo-b"));
|
|
27
|
+
const result = readResource("memory://index", db);
|
|
28
|
+
const entries = JSON.parse(result.contents[0].text);
|
|
29
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
30
|
+
db.close();
|
|
31
|
+
});
|
|
32
|
+
it("returns only entries for the specified repo when ?repo=X is given", () => {
|
|
33
|
+
const db = new SQLiteStore(":memory:");
|
|
34
|
+
db.insert(makeEntry("id-a1", "repo-alpha"));
|
|
35
|
+
db.insert(makeEntry("id-a2", "repo-alpha"));
|
|
36
|
+
db.insert(makeEntry("id-b1", "repo-beta"));
|
|
37
|
+
const result = readResource("memory://index?repo=repo-alpha", db);
|
|
38
|
+
const entries = JSON.parse(result.contents[0].text);
|
|
39
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
expect(entry.scope.repo).toBe("repo-alpha");
|
|
42
|
+
}
|
|
43
|
+
db.close();
|
|
44
|
+
});
|
|
45
|
+
it("returns empty array when repo has no entries", () => {
|
|
46
|
+
const db = new SQLiteStore(":memory:");
|
|
47
|
+
db.insert(makeEntry("id-1", "repo-a"));
|
|
48
|
+
const result = readResource("memory://index?repo=nonexistent", db);
|
|
49
|
+
const entries = JSON.parse(result.contents[0].text);
|
|
50
|
+
expect(entries).toEqual([]);
|
|
51
|
+
db.close();
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Property 19: memory://index dengan filter repo mengembalikan subset yang benar
|
|
55
|
+
* Validates: Requirements 19.1, 19.3
|
|
56
|
+
*/
|
|
57
|
+
it("Property 19: all returned entries have repo === queried repo", () => {
|
|
58
|
+
fc.assert(fc.property(
|
|
59
|
+
// Generate 2-4 distinct repo names
|
|
60
|
+
fc.uniqueArray(fc.stringMatching(/^[a-z][a-z0-9-]{2,8}$/), { minLength: 2, maxLength: 4 }),
|
|
61
|
+
// Generate 1-5 memories per repo
|
|
62
|
+
fc.integer({ min: 1, max: 5 }), (repos, memoriesPerRepo) => {
|
|
63
|
+
const db = new SQLiteStore(":memory:");
|
|
64
|
+
// Insert memories for each repo
|
|
65
|
+
let counter = 0;
|
|
66
|
+
for (const repo of repos) {
|
|
67
|
+
for (let i = 0; i < memoriesPerRepo; i++) {
|
|
68
|
+
db.insert(makeEntry(`id-${counter++}`, repo));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Query with the first repo as filter
|
|
72
|
+
const targetRepo = repos[0];
|
|
73
|
+
const result = readResource(`memory://index?repo=${targetRepo}`, db);
|
|
74
|
+
const entries = JSON.parse(result.contents[0].text);
|
|
75
|
+
// All returned entries must belong to targetRepo
|
|
76
|
+
const allMatch = entries.every((e) => e.scope.repo === targetRepo);
|
|
77
|
+
db.close();
|
|
78
|
+
return allMatch;
|
|
79
|
+
}), { numRuns: 100 });
|
|
80
|
+
});
|
|
81
|
+
it("Property 19 (no filter): returns entries from all repos", () => {
|
|
82
|
+
fc.assert(fc.property(fc.uniqueArray(fc.stringMatching(/^[a-z][a-z0-9-]{2,8}$/), { minLength: 2, maxLength: 3 }), (repos) => {
|
|
83
|
+
const db = new SQLiteStore(":memory:");
|
|
84
|
+
for (const repo of repos) {
|
|
85
|
+
db.insert(makeEntry(`id-${repo}`, repo));
|
|
86
|
+
}
|
|
87
|
+
const result = readResource("memory://index", db);
|
|
88
|
+
const entries = JSON.parse(result.contents[0].text);
|
|
89
|
+
// Without filter, should return entries (listRecent returns id/type/repo)
|
|
90
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
91
|
+
db.close();
|
|
92
|
+
return true;
|
|
93
|
+
}), { numRuns: 100 });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
//# sourceMappingURL=index.test.js.map
|