@cachly-dev/mcp-server 0.10.61 → 0.10.64
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/CHANGELOG.md.tmp +0 -0
- package/PROGRESS.md +137 -0
- package/README.md +210 -180
- package/VISION_10X.md +133 -0
- package/dist/src/__tests__/brain-flow.test.js +102 -1
- package/dist/src/__tests__/brain-flow.test.js.map +1 -1
- package/dist/src/__tests__/utils.test.d.ts +1 -0
- package/dist/src/__tests__/utils.test.js +83 -0
- package/dist/src/__tests__/utils.test.js.map +1 -0
- package/dist/src/handlers/brain.js +118 -64
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/fedbrain.js +4 -2
- package/dist/src/handlers/fedbrain.js.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/src/tools.d.ts +17 -0
- package/dist/src/tools.js +16 -0
- package/dist/src/tools.js.map +1 -1
- package/dist/src/utils.d.ts +33 -0
- package/dist/src/utils.js +76 -0
- package/dist/src/utils.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/src/__tests__/brain-flow.test.ts +119 -1
- package/src/__tests__/utils.test.ts +81 -0
- package/src/handlers/brain.ts +130 -60
- package/src/handlers/fedbrain.ts +3 -2
- package/src/index.ts +3 -3
- package/src/tools.ts +17 -0
- package/src/utils.ts +80 -0
package/dist/src/utils.js
CHANGED
|
@@ -10,4 +10,80 @@ export function safeJsonParse(raw, fallback) {
|
|
|
10
10
|
return fallback;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs a promise with a hard timeout. If it doesn't settle within `ms`, resolves
|
|
15
|
+
* with `fallback` instead of hanging. A memory tool must NEVER block the agent's
|
|
16
|
+
* turn — a slow Redis scan or unreachable backend degrades gracefully to a fallback.
|
|
17
|
+
*/
|
|
18
|
+
export async function withTimeout(p, ms, fallback) {
|
|
19
|
+
let timer;
|
|
20
|
+
const guard = new Promise((resolve) => {
|
|
21
|
+
timer = setTimeout(() => resolve(fallback), ms);
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
return await Promise.race([p, guard]);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
if (timer)
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Collects keys matching a pattern with a hard cap and timeout. Prevents a tool
|
|
36
|
+
* from hanging on a huge keyspace: stops early at `max` keys, and resolves with
|
|
37
|
+
* whatever was gathered if the scan exceeds `timeoutMs`. Never rejects.
|
|
38
|
+
*/
|
|
39
|
+
export function scanKeys(redis, pattern, opts = {}) {
|
|
40
|
+
const max = opts.max ?? 1000;
|
|
41
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const keys = [];
|
|
44
|
+
let done = false;
|
|
45
|
+
const finish = () => { if (!done) {
|
|
46
|
+
done = true;
|
|
47
|
+
resolve(keys);
|
|
48
|
+
} };
|
|
49
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
50
|
+
try {
|
|
51
|
+
const stream = redis.scanStream({ match: pattern, count: opts.count ?? 200 });
|
|
52
|
+
stream.on('data', (batch) => {
|
|
53
|
+
for (const k of batch) {
|
|
54
|
+
keys.push(k);
|
|
55
|
+
if (keys.length >= max) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
finish();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
stream.on('end', () => { clearTimeout(timer); finish(); });
|
|
63
|
+
stream.on('error', () => { clearTimeout(timer); finish(); });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
finish();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Normalizes a git path that may come from `--name-only` with rename notation,
|
|
73
|
+
* e.g. `src/{old => new}/file.ts` → `src/new/file.ts`, and `a => b` → `b`.
|
|
74
|
+
* Returns the post-rename path so file nodes don't fragment on renames.
|
|
75
|
+
*/
|
|
76
|
+
export function normalizeGitPath(raw) {
|
|
77
|
+
let p = raw.trim();
|
|
78
|
+
if (!p)
|
|
79
|
+
return p;
|
|
80
|
+
// Brace rename: dir/{old => new}/file → dir/new/file
|
|
81
|
+
p = p.replace(/\{[^}]*=>\s*([^}]*)\}/g, (_m, after) => after.trim());
|
|
82
|
+
// Bare rename: old => new → new
|
|
83
|
+
const arrow = p.split('=>');
|
|
84
|
+
if (arrow.length === 2)
|
|
85
|
+
p = arrow[1].trim();
|
|
86
|
+
// Collapse any double slashes introduced by an empty rename segment.
|
|
87
|
+
return p.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '');
|
|
88
|
+
}
|
|
13
89
|
//# sourceMappingURL=utils.js.map
|
package/dist/src/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,gFAAgF;AAChF,MAAM,UAAU,aAAa,CAAI,GAA8B,EAAE,QAAW;IAC1E,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAC;IAC1B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAC;IAClB,CAAC;AACH,CAAC"}
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cachly-dev/mcp-server",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.64",
|
|
4
4
|
"mcpName": "io.github.cachly-dev/cachly-mcp",
|
|
5
|
-
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen.
|
|
5
|
+
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen. 101 MCP tools · Free tier forever · GDPR · EU servers.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"cachly-mcp": "dist/src/index.js"
|
package/server.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.cachly-dev/cachly-mcp",
|
|
4
|
-
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem.
|
|
4
|
+
"description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 101 MCP tools · Free tier · EU servers · GDPR.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/cachly-dev/cachly-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.10.
|
|
9
|
+
"version": "0.10.64",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"name": "@cachly-dev/mcp-server",
|
|
14
14
|
"identifier": "@cachly-dev/mcp-server",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.64",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -33,11 +33,19 @@ class MockRedis {
|
|
|
33
33
|
return this.store.get(key) ?? null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
async set(key: string, value: string): Promise<'OK'> {
|
|
36
|
+
async set(key: string, value: string, ...opts: unknown[]): Promise<'OK' | null> {
|
|
37
|
+
// Honor a trailing 'NX' flag so first-wins semantics (born_at, first_recall_at) work.
|
|
38
|
+
if (opts.includes('NX') && this.store.has(key)) return null;
|
|
37
39
|
this.store.set(key, value);
|
|
38
40
|
return 'OK';
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
async incr(key: string): Promise<number> {
|
|
44
|
+
const next = parseInt(this.store.get(key) ?? '0', 10) + 1;
|
|
45
|
+
this.store.set(key, String(next));
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
async rpush(key: string, ...values: string[]): Promise<number> {
|
|
42
50
|
const list = this.lists.get(key) ?? [];
|
|
43
51
|
list.push(...values);
|
|
@@ -1352,3 +1360,113 @@ describe('Phase 3C: brain_coverage', () => {
|
|
|
1352
1360
|
expect(scoreAfter).toBeGreaterThan(scoreBefore);
|
|
1353
1361
|
});
|
|
1354
1362
|
});
|
|
1363
|
+
|
|
1364
|
+
describe('Phase 3 stability: input guards', () => {
|
|
1365
|
+
let redis: MockRedis;
|
|
1366
|
+
const iid = 'i1';
|
|
1367
|
+
const getConn = async () => redis as unknown as Redis;
|
|
1368
|
+
|
|
1369
|
+
beforeEach(() => { redis = new MockRedis(); });
|
|
1370
|
+
|
|
1371
|
+
it('brain_who_knows rejects empty topic without crashing', async () => {
|
|
1372
|
+
const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: '' }, getConn, noopApiFetch);
|
|
1373
|
+
expect(out).toContain('requires a non-empty');
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('brain_who_knows rejects undefined topic without crashing', async () => {
|
|
1377
|
+
const out = await handleBrainTool('brain_who_knows', { instance_id: iid }, getConn, noopApiFetch);
|
|
1378
|
+
expect(out).toContain('requires a non-empty');
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it('brain_who_knows clamps an absurd limit', async () => {
|
|
1382
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1383
|
+
instance_id: iid, topic: 'fix:x', outcome: 'success',
|
|
1384
|
+
what_worked: 'patch', author: 'alice',
|
|
1385
|
+
}, getConn, noopApiFetch);
|
|
1386
|
+
const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'fix', limit: 99999 }, getConn, noopApiFetch);
|
|
1387
|
+
expect(out).toContain('alice');
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('brain_file_map filters out empty path strings', async () => {
|
|
1391
|
+
const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: ['', ' ', ''] }, getConn, noopApiFetch);
|
|
1392
|
+
expect(out).toContain('Pass at least one file path');
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
it('brain_file_map handles non-array file_paths gracefully', async () => {
|
|
1396
|
+
const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: 'not-an-array' as unknown as string[] }, getConn, noopApiFetch);
|
|
1397
|
+
expect(out).toContain('Pass at least one file path');
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
describe('Phase 3C: brain_metrics — the three decisive metrics', () => {
|
|
1402
|
+
let redis: MockRedis;
|
|
1403
|
+
const iid = 'i1';
|
|
1404
|
+
const getConn = async () => redis as unknown as Redis;
|
|
1405
|
+
|
|
1406
|
+
beforeEach(() => { redis = new MockRedis(); });
|
|
1407
|
+
|
|
1408
|
+
it('reports "not enough data" before any learning', async () => {
|
|
1409
|
+
const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
|
|
1410
|
+
expect(out).toContain('Brain Metrics');
|
|
1411
|
+
expect(out).toContain('Not enough data');
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it('always shows the published recall-lift moat number', async () => {
|
|
1415
|
+
const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
|
|
1416
|
+
expect(out).toContain('+22.2 % Precision@1');
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it('records born_at on first learn (first-wins NX semantics)', async () => {
|
|
1420
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1421
|
+
instance_id: iid, topic: 'fix:a', outcome: 'success', what_worked: 'x', author: 'alice',
|
|
1422
|
+
}, getConn, noopApiFetch);
|
|
1423
|
+
const born1 = await redis.get(`cachly:stats:born_at:${iid}`);
|
|
1424
|
+
expect(born1).not.toBeNull();
|
|
1425
|
+
// Second learn must NOT overwrite born_at
|
|
1426
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1427
|
+
instance_id: iid, topic: 'fix:b', outcome: 'success', what_worked: 'y', author: 'bob',
|
|
1428
|
+
}, getConn, noopApiFetch);
|
|
1429
|
+
const born2 = await redis.get(`cachly:stats:born_at:${iid}`);
|
|
1430
|
+
expect(born2).toBe(born1);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it('tracks cross-author reuse when one person recalls another\'s lesson', async () => {
|
|
1434
|
+
// Alice stores a proven lesson, recalled once so it counts as "proven"
|
|
1435
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1436
|
+
instance_id: iid, topic: 'fix:race', outcome: 'success',
|
|
1437
|
+
what_worked: 'add mutex around xyzzy counter', author: 'alice',
|
|
1438
|
+
}, getConn, noopApiFetch);
|
|
1439
|
+
// Bob recalls it (twice → first recall marks proven, second counts reuse on a proven lesson)
|
|
1440
|
+
await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
|
|
1441
|
+
await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
|
|
1442
|
+
|
|
1443
|
+
const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
|
|
1444
|
+
expect(Number(cross)).toBeGreaterThanOrEqual(1);
|
|
1445
|
+
|
|
1446
|
+
const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
|
|
1447
|
+
expect(out).toContain('Team-knowledge-reuse');
|
|
1448
|
+
expect(out).toContain('distinct reuse relationship');
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('does not count self-recall as cross-author reuse', async () => {
|
|
1452
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1453
|
+
instance_id: iid, topic: 'fix:solo', outcome: 'success',
|
|
1454
|
+
what_worked: 'fixed the wibble myself', author: 'alice',
|
|
1455
|
+
}, getConn, noopApiFetch);
|
|
1456
|
+
await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
|
|
1457
|
+
await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
|
|
1458
|
+
const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
|
|
1459
|
+
expect(Number(cross ?? 0)).toBe(0);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
it('surfaces the team-reuse banner inline in smart_recall', async () => {
|
|
1463
|
+
await handleBrainTool('learn_from_attempts', {
|
|
1464
|
+
instance_id: iid, topic: 'fix:plimsoll', outcome: 'success',
|
|
1465
|
+
what_worked: 'adjust the plimsoll threshold', author: 'carol',
|
|
1466
|
+
}, getConn, noopApiFetch);
|
|
1467
|
+
// First recall by dave marks it proven; second recall shows the reuse banner
|
|
1468
|
+
await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
|
|
1469
|
+
const out = await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
|
|
1470
|
+
expect(out).toContain('Team knowledge reuse');
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { safeJsonParse, withTimeout, normalizeGitPath, scanKeys } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
describe('safeJsonParse', () => {
|
|
6
|
+
it('parses valid JSON', () => {
|
|
7
|
+
expect(safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
|
|
8
|
+
});
|
|
9
|
+
it('returns fallback on null/empty/malformed', () => {
|
|
10
|
+
expect(safeJsonParse(null, 'fb')).toBe('fb');
|
|
11
|
+
expect(safeJsonParse('', 'fb')).toBe('fb');
|
|
12
|
+
expect(safeJsonParse('{not json', 'fb')).toBe('fb');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('withTimeout', () => {
|
|
17
|
+
it('resolves with the promise value when it settles in time', async () => {
|
|
18
|
+
const out = await withTimeout(Promise.resolve('ok'), 1000, 'fallback');
|
|
19
|
+
expect(out).toBe('ok');
|
|
20
|
+
});
|
|
21
|
+
it('resolves with fallback when the promise is too slow', async () => {
|
|
22
|
+
const slow = new Promise<string>((r) => setTimeout(() => r('late'), 100));
|
|
23
|
+
const out = await withTimeout(slow, 10, 'fallback');
|
|
24
|
+
expect(out).toBe('fallback');
|
|
25
|
+
});
|
|
26
|
+
it('resolves with fallback when the promise rejects', async () => {
|
|
27
|
+
const out = await withTimeout(Promise.reject(new Error('boom')), 1000, 'fallback');
|
|
28
|
+
expect(out).toBe('fallback');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('normalizeGitPath', () => {
|
|
33
|
+
it('passes a normal path through', () => {
|
|
34
|
+
expect(normalizeGitPath('src/auth/jwt.ts')).toBe('src/auth/jwt.ts');
|
|
35
|
+
});
|
|
36
|
+
it('resolves brace rename notation to the post-rename path', () => {
|
|
37
|
+
expect(normalizeGitPath('src/{old => new}/file.ts')).toBe('src/new/file.ts');
|
|
38
|
+
});
|
|
39
|
+
it('resolves bare rename notation', () => {
|
|
40
|
+
expect(normalizeGitPath('old/path.ts => new/path.ts')).toBe('new/path.ts');
|
|
41
|
+
});
|
|
42
|
+
it('collapses an empty rename segment cleanly', () => {
|
|
43
|
+
// dir/{ => sub}/f.ts → dir/sub/f.ts
|
|
44
|
+
expect(normalizeGitPath('dir/{ => sub}/f.ts')).toBe('dir/sub/f.ts');
|
|
45
|
+
});
|
|
46
|
+
it('trims whitespace', () => {
|
|
47
|
+
expect(normalizeGitPath(' src/x.ts ')).toBe('src/x.ts');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Minimal scan-capable mock
|
|
52
|
+
class ScanMock {
|
|
53
|
+
constructor(private keys: string[], private opts: { hang?: boolean } = {}) {}
|
|
54
|
+
scanStream(_o: { match: string; count?: number }) {
|
|
55
|
+
const em = new EventEmitter();
|
|
56
|
+
if (this.opts.hang) return em; // never emits — exercises the timeout path
|
|
57
|
+
setImmediate(() => {
|
|
58
|
+
em.emit('data', this.keys);
|
|
59
|
+
em.emit('end');
|
|
60
|
+
});
|
|
61
|
+
return em;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('scanKeys', () => {
|
|
66
|
+
it('collects all matching keys', async () => {
|
|
67
|
+
const mock = new ScanMock(['a', 'b', 'c']);
|
|
68
|
+
const out = await scanKeys(mock, 'pattern:*');
|
|
69
|
+
expect(out).toEqual(['a', 'b', 'c']);
|
|
70
|
+
});
|
|
71
|
+
it('caps at max keys', async () => {
|
|
72
|
+
const mock = new ScanMock(['a', 'b', 'c', 'd', 'e']);
|
|
73
|
+
const out = await scanKeys(mock, 'pattern:*', { max: 3 });
|
|
74
|
+
expect(out).toHaveLength(3);
|
|
75
|
+
});
|
|
76
|
+
it('resolves with gathered keys on timeout instead of hanging', async () => {
|
|
77
|
+
const mock = new ScanMock([], { hang: true });
|
|
78
|
+
const out = await scanKeys(mock, 'pattern:*', { timeoutMs: 20 });
|
|
79
|
+
expect(out).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|