754 lines
34 KiB
JavaScript
754 lines
34 KiB
JavaScript
/**
|
|
* Tests for AutoMemoryBridge
|
|
*
|
|
* TDD London School (mock-first) tests for the bidirectional bridge
|
|
* between Claude Code auto memory and AgentDB.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as fsSync from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { AutoMemoryBridge, resolveAutoMemoryDir, findGitRoot, parseMarkdownEntries, extractSummaries, formatInsightLine, hashContent, pruneTopicFile, hasSummaryLine, } from './auto-memory-bridge.js';
|
|
// ===== Mock Backend =====
|
|
function createMockBackend() {
|
|
const storedEntries = [];
|
|
return {
|
|
storedEntries,
|
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
store: vi.fn().mockImplementation(async (entry) => {
|
|
storedEntries.push(entry);
|
|
}),
|
|
get: vi.fn().mockResolvedValue(null),
|
|
getByKey: vi.fn().mockResolvedValue(null),
|
|
update: vi.fn().mockResolvedValue(null),
|
|
delete: vi.fn().mockResolvedValue(true),
|
|
query: vi.fn().mockResolvedValue([]),
|
|
search: vi.fn().mockResolvedValue([]),
|
|
bulkInsert: vi.fn().mockResolvedValue(undefined),
|
|
bulkDelete: vi.fn().mockResolvedValue(0),
|
|
count: vi.fn().mockResolvedValue(0),
|
|
listNamespaces: vi.fn().mockResolvedValue([]),
|
|
clearNamespace: vi.fn().mockResolvedValue(0),
|
|
getStats: vi.fn().mockResolvedValue({
|
|
totalEntries: 0,
|
|
entriesByNamespace: {},
|
|
entriesByType: {},
|
|
memoryUsage: 0,
|
|
avgQueryTime: 0,
|
|
avgSearchTime: 0,
|
|
}),
|
|
healthCheck: vi.fn().mockResolvedValue({
|
|
status: 'healthy',
|
|
components: {
|
|
storage: { status: 'healthy', latency: 0 },
|
|
index: { status: 'healthy', latency: 0 },
|
|
cache: { status: 'healthy', latency: 0 },
|
|
},
|
|
timestamp: Date.now(),
|
|
issues: [],
|
|
recommendations: [],
|
|
}),
|
|
};
|
|
}
|
|
// ===== Test Fixtures =====
|
|
function createTestInsight(overrides = {}) {
|
|
return {
|
|
category: 'debugging',
|
|
summary: 'HNSW index requires initialization before search',
|
|
source: 'agent:tester',
|
|
confidence: 0.95,
|
|
...overrides,
|
|
};
|
|
}
|
|
// ===== Utility Function Tests =====
|
|
describe('resolveAutoMemoryDir', () => {
|
|
it('should derive path from working directory', () => {
|
|
const result = resolveAutoMemoryDir('/workspaces/my-project');
|
|
expect(result).toContain('.claude/projects/');
|
|
expect(result).toContain('memory');
|
|
expect(result).not.toContain('//');
|
|
});
|
|
it('should replace slashes with dashes', () => {
|
|
const result = resolveAutoMemoryDir('/workspaces/my-project');
|
|
expect(result).toContain('workspaces-my-project');
|
|
});
|
|
it('should produce consistent paths for same input', () => {
|
|
const a = resolveAutoMemoryDir('/workspaces/my-project');
|
|
const b = resolveAutoMemoryDir('/workspaces/my-project');
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|
|
describe('findGitRoot', () => {
|
|
it('should find git root for a directory inside a repo', () => {
|
|
// We know /workspaces/claude-flow is a git repo
|
|
const root = findGitRoot('/workspaces/claude-flow/v3/@claude-flow/memory');
|
|
expect(root).toBe('/workspaces/claude-flow');
|
|
});
|
|
it('should return the directory itself if it is the git root', () => {
|
|
const root = findGitRoot('/workspaces/claude-flow');
|
|
expect(root).toBe('/workspaces/claude-flow');
|
|
});
|
|
it('should return null for root filesystem', () => {
|
|
// /proc is almost certainly not in a git repo
|
|
const result = findGitRoot('/proc');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
describe('parseMarkdownEntries', () => {
|
|
it('should parse markdown with ## headings into entries', () => {
|
|
const content = `# Main Title
|
|
|
|
## Section One
|
|
Content of section one.
|
|
More content here.
|
|
|
|
## Section Two
|
|
Content of section two.
|
|
`;
|
|
const entries = parseMarkdownEntries(content);
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries[0].heading).toBe('Section One');
|
|
expect(entries[0].content).toContain('Content of section one');
|
|
expect(entries[1].heading).toBe('Section Two');
|
|
});
|
|
it('should return empty array for content without ## headings', () => {
|
|
const content = '# Only h1 heading\nSome text\n';
|
|
const entries = parseMarkdownEntries(content);
|
|
expect(entries).toHaveLength(0);
|
|
});
|
|
it('should handle multiple lines under a heading', () => {
|
|
const content = `## Heading
|
|
Line 1
|
|
Line 2
|
|
Line 3
|
|
`;
|
|
const entries = parseMarkdownEntries(content);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].content).toContain('Line 1');
|
|
expect(entries[0].content).toContain('Line 3');
|
|
});
|
|
it('should trim whitespace from section content', () => {
|
|
const content = '## Padded\n\n Text here \n\n';
|
|
const entries = parseMarkdownEntries(content);
|
|
expect(entries[0].content).toBe('Text here');
|
|
});
|
|
it('should handle empty content', () => {
|
|
expect(parseMarkdownEntries('')).toHaveLength(0);
|
|
});
|
|
});
|
|
describe('extractSummaries', () => {
|
|
it('should extract bullet points from content', () => {
|
|
const content = `# Topic
|
|
|
|
- First summary
|
|
- Second summary
|
|
- See \`details.md\` for more
|
|
Some other text
|
|
`;
|
|
const summaries = extractSummaries(content);
|
|
expect(summaries).toHaveLength(2);
|
|
expect(summaries[0]).toBe('First summary');
|
|
expect(summaries[1]).toBe('Second summary');
|
|
});
|
|
it('should skip "See" references', () => {
|
|
const content = '- Good item\n- See `file.md` for details\n';
|
|
const summaries = extractSummaries(content);
|
|
expect(summaries).toHaveLength(1);
|
|
expect(summaries[0]).toBe('Good item');
|
|
});
|
|
it('should return empty array for content without bullets', () => {
|
|
const content = 'No bullets here\n';
|
|
const summaries = extractSummaries(content);
|
|
expect(summaries).toHaveLength(0);
|
|
});
|
|
it('should strip metadata annotations from summaries', () => {
|
|
const content = '- Use Int8 quantization _(agent:tester, 2026-02-08, conf: 0.95)_\n';
|
|
const summaries = extractSummaries(content);
|
|
expect(summaries).toHaveLength(1);
|
|
expect(summaries[0]).toBe('Use Int8 quantization');
|
|
});
|
|
it('should handle summaries without annotations', () => {
|
|
const content = '- Clean summary without annotation\n';
|
|
const summaries = extractSummaries(content);
|
|
expect(summaries[0]).toBe('Clean summary without annotation');
|
|
});
|
|
});
|
|
describe('formatInsightLine', () => {
|
|
it('should format insight as a markdown bullet', () => {
|
|
const insight = createTestInsight();
|
|
const line = formatInsightLine(insight);
|
|
expect(line.startsWith('- HNSW index requires initialization before search')).toBe(true);
|
|
expect(line).toContain('agent:tester');
|
|
expect(line).toContain('0.95');
|
|
});
|
|
it('should include detail as indented content for multi-line details', () => {
|
|
const insight = createTestInsight({
|
|
detail: 'Line 1\nLine 2\nLine 3',
|
|
});
|
|
const line = formatInsightLine(insight);
|
|
expect(line).toContain(' Line 1');
|
|
expect(line).toContain(' Line 2');
|
|
});
|
|
it('should not add indented detail for single-line details', () => {
|
|
const insight = createTestInsight({ detail: 'Short detail' });
|
|
const line = formatInsightLine(insight);
|
|
// Single-line detail should not produce indented lines
|
|
expect(line.split('\n')).toHaveLength(1);
|
|
});
|
|
});
|
|
describe('hashContent', () => {
|
|
it('should produce consistent hashes', () => {
|
|
const hash1 = hashContent('test content');
|
|
const hash2 = hashContent('test content');
|
|
expect(hash1).toBe(hash2);
|
|
});
|
|
it('should produce different hashes for different content', () => {
|
|
const hash1 = hashContent('content A');
|
|
const hash2 = hashContent('content B');
|
|
expect(hash1).not.toBe(hash2);
|
|
});
|
|
it('should return a 16-character hex string', () => {
|
|
const hash = hashContent('test');
|
|
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
|
});
|
|
});
|
|
describe('pruneTopicFile', () => {
|
|
it('should not prune if under limit', () => {
|
|
const content = '# Header\n\nSubheader\n- Item 1\n- Item 2\n';
|
|
const result = pruneTopicFile(content, 100);
|
|
expect(result).toBe(content);
|
|
});
|
|
it('should keep header and newest entries when pruning', () => {
|
|
const lines = ['# Header', '', 'Description'];
|
|
for (let i = 0; i < 20; i++) {
|
|
lines.push(`- Entry ${i}`);
|
|
}
|
|
const content = lines.join('\n');
|
|
const result = pruneTopicFile(content, 13);
|
|
const resultLines = result.split('\n');
|
|
expect(resultLines[0]).toBe('# Header');
|
|
expect(resultLines).toHaveLength(13);
|
|
expect(resultLines[resultLines.length - 1]).toBe('- Entry 19');
|
|
});
|
|
});
|
|
describe('hasSummaryLine', () => {
|
|
it('should find exact summary at start of bullet line', () => {
|
|
const content = '- Use Int8 quantization _(source, date)_\n- Other item\n';
|
|
expect(hasSummaryLine(content, 'Use Int8 quantization')).toBe(true);
|
|
});
|
|
it('should not match substrings inside other bullets', () => {
|
|
const content = '- Do not use Int8 for this case\n';
|
|
// "Use Int8" should NOT match because the line starts with "- Do not use..."
|
|
expect(hasSummaryLine(content, 'Use Int8')).toBe(false);
|
|
});
|
|
it('should return false when summary is absent', () => {
|
|
const content = '- Something else\n';
|
|
expect(hasSummaryLine(content, 'Missing summary')).toBe(false);
|
|
});
|
|
it('should handle empty content', () => {
|
|
expect(hasSummaryLine('', 'anything')).toBe(false);
|
|
});
|
|
});
|
|
// ===== AutoMemoryBridge Tests =====
|
|
describe('AutoMemoryBridge', () => {
|
|
let bridge;
|
|
let backend;
|
|
let testDir;
|
|
beforeEach(() => {
|
|
testDir = path.join('/tmp', `auto-memory-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
fsSync.mkdirSync(testDir, { recursive: true });
|
|
backend = createMockBackend();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
syncMode: 'on-session-end',
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
bridge.destroy();
|
|
if (fsSync.existsSync(testDir)) {
|
|
fsSync.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
describe('constructor', () => {
|
|
it('should initialize with default config', () => {
|
|
const b = new AutoMemoryBridge(backend);
|
|
expect(b.getMemoryDir()).toBeTruthy();
|
|
b.destroy();
|
|
});
|
|
it('should use provided memory directory', () => {
|
|
expect(bridge.getMemoryDir()).toBe(testDir);
|
|
});
|
|
});
|
|
describe('getIndexPath', () => {
|
|
it('should return path to MEMORY.md', () => {
|
|
expect(bridge.getIndexPath()).toBe(path.join(testDir, 'MEMORY.md'));
|
|
});
|
|
});
|
|
describe('getTopicPath', () => {
|
|
it('should return path to topic file based on category', () => {
|
|
const p = bridge.getTopicPath('debugging');
|
|
expect(p).toBe(path.join(testDir, 'debugging.md'));
|
|
});
|
|
it('should use custom topic mapping', () => {
|
|
const custom = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
topicMapping: { debugging: 'bugs.md' },
|
|
});
|
|
const p = custom.getTopicPath('debugging');
|
|
expect(p).toBe(path.join(testDir, 'bugs.md'));
|
|
custom.destroy();
|
|
});
|
|
});
|
|
describe('recordInsight', () => {
|
|
it('should store insight in AgentDB', async () => {
|
|
const insight = createTestInsight();
|
|
await bridge.recordInsight(insight);
|
|
expect(backend.store).toHaveBeenCalledTimes(1);
|
|
const storedEntry = backend.storedEntries[0];
|
|
expect(storedEntry.namespace).toBe('learnings');
|
|
expect(storedEntry.tags).toContain('insight');
|
|
expect(storedEntry.tags).toContain('debugging');
|
|
});
|
|
it('should emit insight:recorded event', async () => {
|
|
const handler = vi.fn();
|
|
bridge.on('insight:recorded', handler);
|
|
const insight = createTestInsight();
|
|
await bridge.recordInsight(insight);
|
|
expect(handler).toHaveBeenCalledWith(insight);
|
|
});
|
|
it('should write to files immediately in on-write mode', async () => {
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
syncMode: 'on-write',
|
|
});
|
|
const insight = createTestInsight();
|
|
await bridge.recordInsight(insight);
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
expect(fsSync.existsSync(topicPath)).toBe(true);
|
|
const content = fsSync.readFileSync(topicPath, 'utf-8');
|
|
expect(content).toContain(insight.summary);
|
|
});
|
|
it('should not write to files immediately in on-session-end mode', async () => {
|
|
const insight = createTestInsight();
|
|
await bridge.recordInsight(insight);
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
expect(fsSync.existsSync(topicPath)).toBe(false);
|
|
});
|
|
it('should store confidence in metadata', async () => {
|
|
await bridge.recordInsight(createTestInsight({ confidence: 0.42 }));
|
|
const stored = backend.storedEntries[0];
|
|
expect(stored.metadata.confidence).toBe(0.42);
|
|
});
|
|
});
|
|
describe('syncToAutoMemory', () => {
|
|
it('should flush buffered insights to files', async () => {
|
|
await bridge.recordInsight(createTestInsight());
|
|
await bridge.recordInsight(createTestInsight({
|
|
category: 'performance',
|
|
summary: 'Use Int8 quantization for 3.92x memory reduction',
|
|
}));
|
|
const result = await bridge.syncToAutoMemory();
|
|
expect(result.synced).toBeGreaterThan(0);
|
|
expect(result.categories).toContain('debugging');
|
|
expect(result.categories).toContain('performance');
|
|
expect(result.errors).toHaveLength(0);
|
|
expect(fsSync.existsSync(bridge.getTopicPath('debugging'))).toBe(true);
|
|
expect(fsSync.existsSync(bridge.getTopicPath('performance'))).toBe(true);
|
|
});
|
|
it('should create MEMORY.md index', async () => {
|
|
await bridge.recordInsight(createTestInsight());
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getIndexPath())).toBe(true);
|
|
});
|
|
it('should emit sync:completed event', async () => {
|
|
const handler = vi.fn();
|
|
bridge.on('sync:completed', handler);
|
|
await bridge.syncToAutoMemory();
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
|
durationMs: expect.any(Number),
|
|
}));
|
|
});
|
|
it('should not duplicate insights on repeated sync', async () => {
|
|
await bridge.recordInsight(createTestInsight());
|
|
await bridge.syncToAutoMemory();
|
|
await bridge.syncToAutoMemory();
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
const content = fsSync.readFileSync(topicPath, 'utf-8');
|
|
const matches = content.match(/HNSW index requires/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
it('should clear the insight buffer after sync', async () => {
|
|
await bridge.recordInsight(createTestInsight());
|
|
expect(bridge.getStatus().bufferedInsights).toBe(1);
|
|
await bridge.syncToAutoMemory();
|
|
expect(bridge.getStatus().bufferedInsights).toBe(0);
|
|
});
|
|
it('should skip AgentDB entries already synced from the buffer', async () => {
|
|
// Record an insight (stored in buffer AND AgentDB)
|
|
await bridge.recordInsight(createTestInsight());
|
|
// Mock backend.query to return the same insight from AgentDB
|
|
const mockEntry = {
|
|
id: 'test-1',
|
|
key: 'insight:debugging:12345',
|
|
content: 'HNSW index requires initialization before search',
|
|
tags: ['insight', 'debugging'],
|
|
metadata: {
|
|
category: 'debugging',
|
|
summary: 'HNSW index requires initialization before search',
|
|
confidence: 0.95,
|
|
},
|
|
};
|
|
backend.query.mockResolvedValueOnce([mockEntry]);
|
|
await bridge.syncToAutoMemory();
|
|
// Should only appear once
|
|
const content = fsSync.readFileSync(bridge.getTopicPath('debugging'), 'utf-8');
|
|
const matches = content.match(/HNSW index requires/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
});
|
|
describe('importFromAutoMemory', () => {
|
|
it('should import entries from existing markdown files', async () => {
|
|
const topicContent = `# Debugging Insights
|
|
|
|
## Known Issues
|
|
- Always init HNSW before search
|
|
- SQLite WASM needs sql.js
|
|
`;
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), topicContent, 'utf-8');
|
|
const result = await bridge.importFromAutoMemory();
|
|
expect(result.imported).toBeGreaterThan(0);
|
|
expect(result.files).toContain('debugging.md');
|
|
// Should use bulkInsert, not individual store calls
|
|
expect(backend.bulkInsert).toHaveBeenCalled();
|
|
});
|
|
it('should skip entries already in AgentDB', async () => {
|
|
const topicContent = `# Test
|
|
|
|
## Existing
|
|
Already in DB
|
|
`;
|
|
fsSync.writeFileSync(path.join(testDir, 'test.md'), topicContent, 'utf-8');
|
|
// Mock backend to return existing entry with matching content hash
|
|
backend.query.mockResolvedValue([{
|
|
id: 'existing-1',
|
|
metadata: { contentHash: hashContent('Already in DB') },
|
|
}]);
|
|
const result = await bridge.importFromAutoMemory();
|
|
expect(result.skipped).toBeGreaterThan(0);
|
|
});
|
|
it('should return zero imported for non-existent directory', async () => {
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: '/tmp/nonexistent-auto-memory-dir-xyz',
|
|
});
|
|
const result = await bridge.importFromAutoMemory();
|
|
expect(result.imported).toBe(0);
|
|
expect(result.files).toHaveLength(0);
|
|
});
|
|
it('should batch imports with bulkInsert', async () => {
|
|
// Create multiple files with multiple sections
|
|
fsSync.writeFileSync(path.join(testDir, 'file1.md'), '## A\nContent A\n## B\nContent B\n', 'utf-8');
|
|
fsSync.writeFileSync(path.join(testDir, 'file2.md'), '## C\nContent C\n', 'utf-8');
|
|
await bridge.importFromAutoMemory();
|
|
// bulkInsert should be called once with all entries
|
|
expect(backend.bulkInsert).toHaveBeenCalledTimes(1);
|
|
const batchArg = backend.bulkInsert.mock.calls[0][0];
|
|
expect(batchArg).toHaveLength(3);
|
|
});
|
|
});
|
|
describe('curateIndex', () => {
|
|
it('should generate MEMORY.md from topic files', async () => {
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), '# Debugging\n\n- Init HNSW before search\n- Check embeddings dimension\n', 'utf-8');
|
|
fsSync.writeFileSync(path.join(testDir, 'performance.md'), '# Performance\n\n- Use Int8 quantization\n', 'utf-8');
|
|
await bridge.curateIndex();
|
|
const indexContent = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
expect(indexContent).toContain('# Claude Flow V3 Project Memory');
|
|
expect(indexContent).toContain('Init HNSW before search');
|
|
expect(indexContent).toContain('Use Int8 quantization');
|
|
});
|
|
it('should stay under maxIndexLines', async () => {
|
|
const lines = ['# Debugging', ''];
|
|
for (let i = 0; i < 200; i++) {
|
|
lines.push(`- Item ${i} is a debugging insight`);
|
|
}
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), lines.join('\n'), 'utf-8');
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
maxIndexLines: 20,
|
|
});
|
|
await bridge.curateIndex();
|
|
const indexContent = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
const indexLines = indexContent.split('\n');
|
|
expect(indexLines.length).toBeLessThanOrEqual(20);
|
|
});
|
|
it('should strip metadata from summaries in the index', async () => {
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), '# Debugging\n\n- Fixed a bug _(agent:tester, 2026-02-08, conf: 0.90)_\n', 'utf-8');
|
|
await bridge.curateIndex();
|
|
const indexContent = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
expect(indexContent).toContain('- Fixed a bug');
|
|
expect(indexContent).not.toContain('_(agent:tester');
|
|
});
|
|
it('should handle pruneStrategy=fifo by removing oldest entries', async () => {
|
|
const lines = ['# Debugging', ''];
|
|
for (let i = 0; i < 50; i++) {
|
|
lines.push(`- Item ${i}`);
|
|
}
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), lines.join('\n'), 'utf-8');
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
maxIndexLines: 10,
|
|
pruneStrategy: 'fifo',
|
|
});
|
|
await bridge.curateIndex();
|
|
const indexContent = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
// FIFO removes oldest (first) items, keeps newest
|
|
expect(indexContent).toContain('Item 49');
|
|
expect(indexContent).not.toContain('Item 0');
|
|
});
|
|
});
|
|
describe('getStatus', () => {
|
|
it('should report status for existing directory', async () => {
|
|
fsSync.writeFileSync(path.join(testDir, 'MEMORY.md'), '# Memory\n- Item 1\n- Item 2\n', 'utf-8');
|
|
const status = bridge.getStatus();
|
|
expect(status.exists).toBe(true);
|
|
expect(status.memoryDir).toBe(testDir);
|
|
expect(status.files.length).toBeGreaterThan(0);
|
|
expect(status.indexLines).toBeGreaterThanOrEqual(3);
|
|
});
|
|
it('should report status for non-existent directory', () => {
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: '/tmp/nonexistent-dir-xyz',
|
|
});
|
|
const status = bridge.getStatus();
|
|
expect(status.exists).toBe(false);
|
|
expect(status.files).toHaveLength(0);
|
|
});
|
|
it('should count buffered insights', async () => {
|
|
await bridge.recordInsight(createTestInsight());
|
|
await bridge.recordInsight(createTestInsight({ summary: 'Another insight' }));
|
|
const status = bridge.getStatus();
|
|
expect(status.bufferedInsights).toBe(2);
|
|
});
|
|
it('should report lastSyncTime after sync', async () => {
|
|
expect(bridge.getStatus().lastSyncTime).toBe(0);
|
|
await bridge.syncToAutoMemory();
|
|
expect(bridge.getStatus().lastSyncTime).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
describe('recordInsight - key uniqueness', () => {
|
|
it('should generate unique keys for rapid sequential inserts', async () => {
|
|
// Record multiple insights as fast as possible (same ms possible)
|
|
await bridge.recordInsight(createTestInsight({ summary: 'Insight A' }));
|
|
await bridge.recordInsight(createTestInsight({ summary: 'Insight B' }));
|
|
await bridge.recordInsight(createTestInsight({ summary: 'Insight C' }));
|
|
// All three should have unique keys
|
|
const keys = backend.storedEntries.map(e => e.key);
|
|
const uniqueKeys = new Set(keys);
|
|
expect(uniqueKeys.size).toBe(3);
|
|
});
|
|
});
|
|
describe('syncToAutoMemory - error handling', () => {
|
|
it('should emit sync:failed on backend query error', async () => {
|
|
const handler = vi.fn();
|
|
bridge.on('sync:failed', handler);
|
|
// Make ensureMemoryDir throw
|
|
backend.query.mockRejectedValueOnce(new Error('DB connection lost'));
|
|
// Record an insight so there's something to sync
|
|
await bridge.recordInsight(createTestInsight());
|
|
// The sync should still succeed for the buffered part
|
|
// because queryRecentInsights has its own try/catch
|
|
const result = await bridge.syncToAutoMemory();
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
it('should report errors for individual insight write failures', async () => {
|
|
// Create a read-only file to force a write error
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
fsSync.writeFileSync(topicPath, '# Debugging\n\n- Existing\n', 'utf-8');
|
|
fsSync.chmodSync(topicPath, 0o444); // read-only
|
|
await bridge.recordInsight(createTestInsight());
|
|
const result = await bridge.syncToAutoMemory();
|
|
// Should have error from trying to write to read-only file
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
// Restore permissions for cleanup
|
|
fsSync.chmodSync(topicPath, 0o644);
|
|
});
|
|
});
|
|
describe('syncToAutoMemory - append to existing topic file', () => {
|
|
it('should append new insight to existing topic file', async () => {
|
|
// Create initial topic file
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
fsSync.writeFileSync(topicPath, '# Debugging\n\n- Existing item\n', 'utf-8');
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
syncMode: 'on-write',
|
|
});
|
|
await bridge.recordInsight(createTestInsight({ summary: 'New insight' }));
|
|
const content = fsSync.readFileSync(topicPath, 'utf-8');
|
|
expect(content).toContain('Existing item');
|
|
expect(content).toContain('New insight');
|
|
});
|
|
it('should prune topic file when it exceeds maxTopicFileLines', async () => {
|
|
// Create a topic file near the limit
|
|
const topicPath = bridge.getTopicPath('debugging');
|
|
const lines = ['# Debugging', '', 'Description'];
|
|
for (let i = 0; i < 500; i++) {
|
|
lines.push(`- Entry ${i}`);
|
|
}
|
|
fsSync.writeFileSync(topicPath, lines.join('\n'), 'utf-8');
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
syncMode: 'on-write',
|
|
maxTopicFileLines: 500,
|
|
});
|
|
await bridge.recordInsight(createTestInsight({ summary: 'Overflow insight' }));
|
|
const content = fsSync.readFileSync(topicPath, 'utf-8');
|
|
expect(content).toContain('Overflow insight');
|
|
// Old entries near the top should have been pruned
|
|
expect(content).not.toContain('Entry 0');
|
|
// Header should be preserved
|
|
expect(content).toContain('# Debugging');
|
|
});
|
|
});
|
|
describe('syncToAutoMemory - classifyEntry coverage', () => {
|
|
it('should classify by metadata category when present', async () => {
|
|
const entry = {
|
|
id: 'e1',
|
|
key: 'insight:security:999:0',
|
|
content: 'SQL injection found',
|
|
tags: ['insight'],
|
|
metadata: { category: 'security', summary: 'SQL injection found', confidence: 0.9 },
|
|
};
|
|
backend.query.mockResolvedValueOnce([entry]);
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getTopicPath('security'))).toBe(true);
|
|
const content = fsSync.readFileSync(bridge.getTopicPath('security'), 'utf-8');
|
|
expect(content).toContain('SQL injection found');
|
|
});
|
|
it('should classify by tags when metadata category is absent', async () => {
|
|
const entry = {
|
|
id: 'e2',
|
|
key: 'insight:unknown:999:0',
|
|
content: 'Performance is slow',
|
|
tags: ['insight', 'performance', 'benchmark'],
|
|
metadata: { summary: 'Performance is slow', confidence: 0.85 },
|
|
};
|
|
backend.query.mockResolvedValueOnce([entry]);
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getTopicPath('performance'))).toBe(true);
|
|
});
|
|
it('should default to project-patterns for unclassifiable entries', async () => {
|
|
const entry = {
|
|
id: 'e3',
|
|
key: 'insight:misc:999:0',
|
|
content: 'Miscellaneous note',
|
|
tags: ['insight'],
|
|
metadata: { summary: 'Miscellaneous note', confidence: 0.8 },
|
|
};
|
|
backend.query.mockResolvedValueOnce([entry]);
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getTopicPath('project-patterns'))).toBe(true);
|
|
});
|
|
it('should classify debugging tags correctly', async () => {
|
|
const bugEntry = {
|
|
id: 'e4',
|
|
key: 'insight:bug:999:0',
|
|
content: 'Found a bug',
|
|
tags: ['insight', 'bug'],
|
|
metadata: { summary: 'Found a bug', confidence: 0.9 },
|
|
};
|
|
backend.query.mockResolvedValueOnce([bugEntry]);
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getTopicPath('debugging'))).toBe(true);
|
|
});
|
|
it('should classify swarm/agent tags correctly', async () => {
|
|
const swarmEntry = {
|
|
id: 'e5',
|
|
key: 'insight:swarm:999:0',
|
|
content: 'Swarm completed successfully',
|
|
tags: ['insight', 'swarm'],
|
|
metadata: { summary: 'Swarm completed successfully', confidence: 0.9 },
|
|
};
|
|
backend.query.mockResolvedValueOnce([swarmEntry]);
|
|
await bridge.syncToAutoMemory();
|
|
expect(fsSync.existsSync(bridge.getTopicPath('swarm-results'))).toBe(true);
|
|
});
|
|
});
|
|
describe('importFromAutoMemory - edge cases', () => {
|
|
it('should emit import:completed event', async () => {
|
|
const handler = vi.fn();
|
|
bridge.on('import:completed', handler);
|
|
fsSync.writeFileSync(path.join(testDir, 'test.md'), '## Section\nContent here\n', 'utf-8');
|
|
await bridge.importFromAutoMemory();
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
|
imported: expect.any(Number),
|
|
durationMs: expect.any(Number),
|
|
}));
|
|
});
|
|
it('should handle files with no ## headings', async () => {
|
|
fsSync.writeFileSync(path.join(testDir, 'empty.md'), '# Just a title\nSome text without sections\n', 'utf-8');
|
|
const result = await bridge.importFromAutoMemory();
|
|
expect(result.imported).toBe(0);
|
|
expect(result.files).toContain('empty.md');
|
|
});
|
|
});
|
|
describe('curateIndex - edge cases', () => {
|
|
it('should handle empty topic files', async () => {
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), '# Debugging\n\n', 'utf-8');
|
|
await bridge.curateIndex();
|
|
const content = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
// Should not include empty section
|
|
expect(content).not.toContain('Debugging');
|
|
});
|
|
it('should emit index:curated event', async () => {
|
|
const handler = vi.fn();
|
|
bridge.on('index:curated', handler);
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), '# Debugging\n\n- An item\n', 'utf-8');
|
|
await bridge.curateIndex();
|
|
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
|
lines: expect.any(Number),
|
|
}));
|
|
});
|
|
it('should handle pruneStrategy=lru same as fifo', async () => {
|
|
const lines = ['# Debugging', ''];
|
|
for (let i = 0; i < 50; i++) {
|
|
lines.push(`- Item ${i}`);
|
|
}
|
|
fsSync.writeFileSync(path.join(testDir, 'debugging.md'), lines.join('\n'), 'utf-8');
|
|
bridge.destroy();
|
|
bridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
maxIndexLines: 10,
|
|
pruneStrategy: 'lru',
|
|
});
|
|
await bridge.curateIndex();
|
|
const indexContent = fsSync.readFileSync(bridge.getIndexPath(), 'utf-8');
|
|
// LRU removes oldest (first) items, same as FIFO
|
|
expect(indexContent).toContain('Item 49');
|
|
expect(indexContent).not.toContain('Item 0');
|
|
});
|
|
});
|
|
describe('destroy', () => {
|
|
it('should clean up periodic sync timer', () => {
|
|
const periodicBridge = new AutoMemoryBridge(backend, {
|
|
memoryDir: testDir,
|
|
syncMode: 'periodic',
|
|
syncIntervalMs: 1000,
|
|
});
|
|
periodicBridge.destroy();
|
|
});
|
|
it('should remove all listeners', () => {
|
|
bridge.on('insight:recorded', () => { });
|
|
bridge.on('sync:completed', () => { });
|
|
bridge.destroy();
|
|
expect(bridge.listenerCount('insight:recorded')).toBe(0);
|
|
expect(bridge.listenerCount('sync:completed')).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
//# sourceMappingURL=auto-memory-bridge.test.js.map
|