wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation
This commit is contained in:
58
.agent/services/claude-mem/tests/CLAUDE.md
Normal file
58
.agent/services/claude-mem/tests/CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Nov 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6358 | 3:14 PM | 🔵 | SDK Agent Spatial Awareness Implementation | ~309 |
|
||||
|
||||
### Nov 21, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #13289 | 2:20 PM | 🟣 | Comprehensive Test Suite for Transcript Transformation | ~320 |
|
||||
|
||||
### Nov 23, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14617 | 6:15 PM | 🟣 | Test Suite Successfully Passing - All 8 Tests Green | ~498 |
|
||||
| #14615 | 6:14 PM | 🟣 | YAGNI-Focused Test Suite for Transcript Transformation | ~457 |
|
||||
|
||||
### Dec 5, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20732 | 9:07 PM | 🔵 | Smart Install Version Marker Tests for Upgrade Detection | ~452 |
|
||||
| #20399 | 7:17 PM | 🔵 | Smart install tests validate version tracking with backward compatibility | ~311 |
|
||||
| #20392 | 7:15 PM | 🔵 | Memory tag stripping tests validate dual-tag system for JSON context filtering | ~404 |
|
||||
| #20391 | " | 🔵 | User prompt tag stripping tests validate privacy controls for memory exclusion | ~182 |
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36663 | 11:06 PM | ✅ | Third Validation Test Updated: Resume Safety Check Now Uses NULL Comparison | ~417 |
|
||||
| #36662 | " | ✅ | Second Validation Test Updated: Post-Capture Check Now Uses NULL Comparison | ~418 |
|
||||
| #36661 | 11:05 PM | ✅ | First Validation Test Updated: Placeholder Detection Now Checks for NULL | ~482 |
|
||||
| #36660 | " | ✅ | Updated Session ID Usage Validation Test Header to Reflect NULL-Based Architecture | ~588 |
|
||||
| #36659 | " | ✅ | Sixth Test Fix: Updated Multi-Observation Test to Use Memory Session ID | ~486 |
|
||||
| #36658 | " | ✅ | Fifth Test Fix: Updated storeSummary Tests to Use Actual Memory Session ID After Capture | ~555 |
|
||||
| #36657 | 11:04 PM | ✅ | Fourth Test Fix: Updated storeObservation Tests to Use Actual Memory Session ID After Capture | ~547 |
|
||||
| #36656 | " | ✅ | Third Test Fix: Updated getSessionById Test to Expect NULL for Uncaptured Memory Session ID | ~436 |
|
||||
| #36655 | " | ✅ | Second Test Fix: Updated updateMemorySessionId Test to Expect NULL Before Update | ~395 |
|
||||
| #36654 | " | ✅ | First Test Fix: Updated Memory Session ID Initialization Test to Expect NULL | ~426 |
|
||||
| #36650 | 11:02 PM | 🔵 | Phase 1 Analysis Reveals Implementation-Test Mismatch on NULL vs Placeholder Initialization | ~687 |
|
||||
| #36648 | " | 🔵 | Session ID Refactor Test Suite Documents Database Migration 17 and Dual ID System | ~651 |
|
||||
| #36647 | 11:01 PM | 🔵 | SessionStore Test Suite Validates Prompt Counting and Timestamp Override Features | ~506 |
|
||||
| #36646 | " | 🔵 | Session ID Architecture Revealed Through Test File Analysis | ~611 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36858 | 1:50 AM | 🟣 | Phase 1 Implementation Completed via Subagent | ~499 |
|
||||
| #36854 | 1:49 AM | 🟣 | gemini-3-flash Model Tests Added to GeminiAgent Test Suite | ~470 |
|
||||
| #36851 | " | 🔵 | GeminiAgent Test Structure Analyzed | ~565 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,528 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before importing the formatter
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
getActiveMode: () => ({
|
||||
name: 'code',
|
||||
prompts: {},
|
||||
observation_types: [
|
||||
{ id: 'decision', emoji: 'D' },
|
||||
{ id: 'bugfix', emoji: 'B' },
|
||||
{ id: 'discovery', emoji: 'I' },
|
||||
],
|
||||
observation_concepts: [],
|
||||
}),
|
||||
getTypeIcon: (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
decision: 'D',
|
||||
bugfix: 'B',
|
||||
discovery: 'I',
|
||||
};
|
||||
return icons[type] || '?';
|
||||
},
|
||||
getWorkEmoji: () => 'W',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
renderMarkdownHeader,
|
||||
renderMarkdownLegend,
|
||||
renderMarkdownColumnKey,
|
||||
renderMarkdownContextIndex,
|
||||
renderMarkdownContextEconomics,
|
||||
renderMarkdownDayHeader,
|
||||
renderMarkdownFileHeader,
|
||||
renderMarkdownTableRow,
|
||||
renderMarkdownFullObservation,
|
||||
renderMarkdownSummaryItem,
|
||||
renderMarkdownSummaryField,
|
||||
renderMarkdownPreviouslySection,
|
||||
renderMarkdownFooter,
|
||||
renderMarkdownEmptyState,
|
||||
} from '../../../src/services/context/formatters/MarkdownFormatter.js';
|
||||
|
||||
import type { Observation, TokenEconomics, ContextConfig, PriorMessages } from '../../../src/services/context/types.js';
|
||||
|
||||
// Helper to create a minimal observation
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
type: 'discovery',
|
||||
title: 'Test Observation',
|
||||
subtitle: null,
|
||||
narrative: 'A test narrative',
|
||||
facts: '["fact1"]',
|
||||
concepts: '["concept1"]',
|
||||
files_read: null,
|
||||
files_modified: null,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create token economics
|
||||
function createTestEconomics(overrides: Partial<TokenEconomics> = {}): TokenEconomics {
|
||||
return {
|
||||
totalObservations: 10,
|
||||
totalReadTokens: 500,
|
||||
totalDiscoveryTokens: 5000,
|
||||
savings: 4500,
|
||||
savingsPercent: 90,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create context config
|
||||
function createTestConfig(overrides: Partial<ContextConfig> = {}): ContextConfig {
|
||||
return {
|
||||
totalObservationCount: 50,
|
||||
fullObservationCount: 5,
|
||||
sessionCount: 3,
|
||||
showReadTokens: true,
|
||||
showWorkTokens: true,
|
||||
showSavingsAmount: true,
|
||||
showSavingsPercent: true,
|
||||
observationTypes: new Set(['discovery', 'decision', 'bugfix']),
|
||||
observationConcepts: new Set(['concept1', 'concept2']),
|
||||
fullObservationField: 'narrative',
|
||||
showLastSummary: true,
|
||||
showLastMessage: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MarkdownFormatter', () => {
|
||||
describe('renderMarkdownHeader', () => {
|
||||
it('should produce valid markdown header with project name', () => {
|
||||
const result = renderMarkdownHeader('my-project');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatch(/^# \[my-project\] recent context, \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
|
||||
expect(result[1]).toBe('');
|
||||
});
|
||||
|
||||
it('should handle special characters in project name', () => {
|
||||
const result = renderMarkdownHeader('project-with-special_chars.v2');
|
||||
|
||||
expect(result[0]).toContain('project-with-special_chars.v2');
|
||||
});
|
||||
|
||||
it('should handle empty project name', () => {
|
||||
const result = renderMarkdownHeader('');
|
||||
|
||||
expect(result[0]).toMatch(/^# \[\] recent context, \d{4}-\d{2}-\d{2} \d{1,2}:\d{2}[ap]m [A-Z]{3,4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownLegend', () => {
|
||||
it('should produce legend with type items', () => {
|
||||
const result = renderMarkdownLegend();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toContain('**Legend:**');
|
||||
expect(result[1]).toBe('');
|
||||
});
|
||||
|
||||
it('should include session-request in legend', () => {
|
||||
const result = renderMarkdownLegend();
|
||||
|
||||
expect(result[0]).toContain('session-request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownColumnKey', () => {
|
||||
it('should produce column key explanation', () => {
|
||||
const result = renderMarkdownColumnKey();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toContain('**Column Key**');
|
||||
});
|
||||
|
||||
it('should explain Read column', () => {
|
||||
const result = renderMarkdownColumnKey();
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('Read');
|
||||
expect(joined).toContain('Tokens to read');
|
||||
});
|
||||
|
||||
it('should explain Work column', () => {
|
||||
const result = renderMarkdownColumnKey();
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('Work');
|
||||
expect(joined).toContain('Tokens spent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownContextIndex', () => {
|
||||
it('should produce context index instructions', () => {
|
||||
const result = renderMarkdownContextIndex();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toContain('**Context Index:**');
|
||||
});
|
||||
|
||||
it('should mention mem-search skill', () => {
|
||||
const result = renderMarkdownContextIndex();
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('mem-search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownContextEconomics', () => {
|
||||
it('should include observation count', () => {
|
||||
const economics = createTestEconomics({ totalObservations: 25 });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('25 observations');
|
||||
});
|
||||
|
||||
it('should include read tokens', () => {
|
||||
const economics = createTestEconomics({ totalReadTokens: 1500 });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('1,500 tokens');
|
||||
});
|
||||
|
||||
it('should include work investment', () => {
|
||||
const economics = createTestEconomics({ totalDiscoveryTokens: 10000 });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('10,000 tokens');
|
||||
});
|
||||
|
||||
it('should show savings when config has showSavingsAmount', () => {
|
||||
const economics = createTestEconomics({ savings: 4500, savingsPercent: 90, totalDiscoveryTokens: 5000 });
|
||||
const config = createTestConfig({ showSavingsAmount: true, showSavingsPercent: false });
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('savings');
|
||||
expect(joined).toContain('4,500 tokens');
|
||||
});
|
||||
|
||||
it('should show savings percent when config has showSavingsPercent', () => {
|
||||
const economics = createTestEconomics({ savingsPercent: 85, totalDiscoveryTokens: 1000 });
|
||||
const config = createTestConfig({ showSavingsAmount: false, showSavingsPercent: true });
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('85%');
|
||||
});
|
||||
|
||||
it('should not show savings when discovery tokens is 0', () => {
|
||||
const economics = createTestEconomics({ totalDiscoveryTokens: 0, savings: 0, savingsPercent: 0 });
|
||||
const config = createTestConfig({ showSavingsAmount: true, showSavingsPercent: true });
|
||||
|
||||
const result = renderMarkdownContextEconomics(economics, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).not.toContain('Your savings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownDayHeader', () => {
|
||||
it('should render day as h3 heading', () => {
|
||||
const result = renderMarkdownDayHeader('2025-01-01');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('### 2025-01-01');
|
||||
expect(result[1]).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownFileHeader', () => {
|
||||
it('should render file name in bold', () => {
|
||||
const result = renderMarkdownFileHeader('src/index.ts');
|
||||
|
||||
expect(result[0]).toBe('**src/index.ts**');
|
||||
});
|
||||
|
||||
it('should include table headers', () => {
|
||||
const result = renderMarkdownFileHeader('test.ts');
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('| ID |');
|
||||
expect(joined).toContain('| Time |');
|
||||
expect(joined).toContain('| T |');
|
||||
expect(joined).toContain('| Title |');
|
||||
expect(joined).toContain('| Read |');
|
||||
expect(joined).toContain('| Work |');
|
||||
});
|
||||
|
||||
it('should include separator row', () => {
|
||||
const result = renderMarkdownFileHeader('test.ts');
|
||||
|
||||
expect(result[2]).toContain('|----');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownTableRow', () => {
|
||||
it('should include observation ID with hash prefix', () => {
|
||||
const obs = createTestObservation({ id: 42 });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '10:30', config);
|
||||
|
||||
expect(result).toContain('#42');
|
||||
});
|
||||
|
||||
it('should include time display', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '14:30', config);
|
||||
|
||||
expect(result).toContain('14:30');
|
||||
});
|
||||
|
||||
it('should include title', () => {
|
||||
const obs = createTestObservation({ title: 'Important Discovery' });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '10:00', config);
|
||||
|
||||
expect(result).toContain('Important Discovery');
|
||||
});
|
||||
|
||||
it('should use "Untitled" when title is null', () => {
|
||||
const obs = createTestObservation({ title: null });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '10:00', config);
|
||||
|
||||
expect(result).toContain('Untitled');
|
||||
});
|
||||
|
||||
it('should show read tokens when config enabled', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig({ showReadTokens: true });
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '10:00', config);
|
||||
|
||||
expect(result).toContain('~');
|
||||
});
|
||||
|
||||
it('should hide read tokens when config disabled', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig({ showReadTokens: false });
|
||||
|
||||
const result = renderMarkdownTableRow(obs, '10:00', config);
|
||||
|
||||
// Row should have empty read column
|
||||
const columns = result.split('|');
|
||||
// Find the Read column (5th column, index 5)
|
||||
expect(columns[5].trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should use quote mark for repeated time', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig();
|
||||
|
||||
// Empty string timeDisplay means "same as previous"
|
||||
const result = renderMarkdownTableRow(obs, '', config);
|
||||
|
||||
expect(result).toContain('"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownFullObservation', () => {
|
||||
it('should include observation ID and title', () => {
|
||||
const obs = createTestObservation({ id: 7, title: 'Full Observation' });
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownFullObservation(obs, '10:00', 'Detail content', config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('**#7**');
|
||||
expect(joined).toContain('**Full Observation**');
|
||||
});
|
||||
|
||||
it('should include detail field when provided', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownFullObservation(obs, '10:00', 'The detailed narrative here', config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('The detailed narrative here');
|
||||
});
|
||||
|
||||
it('should not include detail field when null', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig();
|
||||
|
||||
const result = renderMarkdownFullObservation(obs, '10:00', null, config);
|
||||
|
||||
// Should not have an extra content block
|
||||
expect(result.length).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('should include token info when enabled', () => {
|
||||
const obs = createTestObservation({ discovery_tokens: 250 });
|
||||
const config = createTestConfig({ showReadTokens: true, showWorkTokens: true });
|
||||
|
||||
const result = renderMarkdownFullObservation(obs, '10:00', null, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('Read:');
|
||||
expect(joined).toContain('Work:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownSummaryItem', () => {
|
||||
it('should include session ID with S prefix', () => {
|
||||
const summary = { id: 5, request: 'Implement feature' };
|
||||
|
||||
const result = renderMarkdownSummaryItem(summary, '2025-01-01 10:00');
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('**#S5**');
|
||||
});
|
||||
|
||||
it('should include request text', () => {
|
||||
const summary = { id: 1, request: 'Build authentication' };
|
||||
|
||||
const result = renderMarkdownSummaryItem(summary, '10:00');
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('Build authentication');
|
||||
});
|
||||
|
||||
it('should use "Session started" when request is null', () => {
|
||||
const summary = { id: 1, request: null };
|
||||
|
||||
const result = renderMarkdownSummaryItem(summary, '10:00');
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('Session started');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownSummaryField', () => {
|
||||
it('should render label and value in bold', () => {
|
||||
const result = renderMarkdownSummaryField('Learned', 'How to test');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('**Learned**: How to test');
|
||||
expect(result[1]).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty array when value is null', () => {
|
||||
const result = renderMarkdownSummaryField('Learned', null);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when value is empty string', () => {
|
||||
const result = renderMarkdownSummaryField('Learned', '');
|
||||
|
||||
// Empty string is falsy, so should return empty array
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownPreviouslySection', () => {
|
||||
it('should render section when assistantMessage exists', () => {
|
||||
const priorMessages: PriorMessages = {
|
||||
userMessage: '',
|
||||
assistantMessage: 'I completed the task successfully.',
|
||||
};
|
||||
|
||||
const result = renderMarkdownPreviouslySection(priorMessages);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('**Previously**');
|
||||
expect(joined).toContain('A: I completed the task successfully.');
|
||||
});
|
||||
|
||||
it('should return empty when assistantMessage is empty', () => {
|
||||
const priorMessages: PriorMessages = {
|
||||
userMessage: '',
|
||||
assistantMessage: '',
|
||||
};
|
||||
|
||||
const result = renderMarkdownPreviouslySection(priorMessages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include separator', () => {
|
||||
const priorMessages: PriorMessages = {
|
||||
userMessage: '',
|
||||
assistantMessage: 'Some message',
|
||||
};
|
||||
|
||||
const result = renderMarkdownPreviouslySection(priorMessages);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownFooter', () => {
|
||||
it('should include token amounts', () => {
|
||||
const result = renderMarkdownFooter(10000, 500);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('10k');
|
||||
expect(joined).toContain('500');
|
||||
});
|
||||
|
||||
it('should mention claude-mem skill', () => {
|
||||
const result = renderMarkdownFooter(5000, 100);
|
||||
const joined = result.join('\n');
|
||||
|
||||
expect(joined).toContain('claude-mem');
|
||||
});
|
||||
|
||||
it('should round work tokens to nearest thousand', () => {
|
||||
const result = renderMarkdownFooter(15500, 100);
|
||||
const joined = result.join('\n');
|
||||
|
||||
// 15500 / 1000 = 15.5 -> rounds to 16
|
||||
expect(joined).toContain('16k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdownEmptyState', () => {
|
||||
it('should return helpful message with project name', () => {
|
||||
const result = renderMarkdownEmptyState('my-project');
|
||||
|
||||
expect(result).toContain('# [my-project] recent context');
|
||||
expect(result).toContain('No previous sessions found');
|
||||
});
|
||||
|
||||
it('should be valid markdown', () => {
|
||||
const result = renderMarkdownEmptyState('test');
|
||||
|
||||
// Should start with h1
|
||||
expect(result.startsWith('#')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty project name', () => {
|
||||
const result = renderMarkdownEmptyState('');
|
||||
|
||||
expect(result).toContain('# [] recent context');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { buildTimeline } from '../../src/services/context/index.js';
|
||||
import type { Observation, SummaryTimelineItem } from '../../src/services/context/types.js';
|
||||
|
||||
/**
|
||||
* Timeline building tests - validates real sorting and merging logic
|
||||
*
|
||||
* Removed: queryObservations, querySummaries tests (mock database - not testing real behavior)
|
||||
* Kept: buildTimeline tests (tests actual sorting algorithm)
|
||||
*/
|
||||
|
||||
// Helper to create a minimal observation
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
type: 'discovery',
|
||||
title: 'Test Observation',
|
||||
subtitle: null,
|
||||
narrative: 'A test narrative',
|
||||
facts: '["fact1"]',
|
||||
concepts: '["concept1"]',
|
||||
files_read: null,
|
||||
files_modified: null,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a summary timeline item
|
||||
function createTestSummaryTimelineItem(overrides: Partial<SummaryTimelineItem> = {}): SummaryTimelineItem {
|
||||
return {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
request: 'Test Request',
|
||||
investigated: 'Investigated things',
|
||||
learned: 'Learned things',
|
||||
completed: 'Completed things',
|
||||
next_steps: 'Next steps',
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000,
|
||||
displayEpoch: 1735732800000,
|
||||
displayTime: '2025-01-01T12:00:00.000Z',
|
||||
shouldShowLink: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTimeline', () => {
|
||||
it('should combine observations and summaries into timeline', () => {
|
||||
const observations = [
|
||||
createTestObservation({ id: 1, created_at_epoch: 1000 }),
|
||||
];
|
||||
const summaries = [
|
||||
createTestSummaryTimelineItem({ id: 1, displayEpoch: 2000 }),
|
||||
];
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
expect(timeline).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should sort timeline items chronologically by epoch', () => {
|
||||
const observations = [
|
||||
createTestObservation({ id: 1, created_at_epoch: 3000 }),
|
||||
createTestObservation({ id: 2, created_at_epoch: 1000 }),
|
||||
];
|
||||
const summaries = [
|
||||
createTestSummaryTimelineItem({ id: 1, displayEpoch: 2000 }),
|
||||
];
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
// Should be sorted: obs2 (1000), summary (2000), obs1 (3000)
|
||||
expect(timeline).toHaveLength(3);
|
||||
expect(timeline[0].type).toBe('observation');
|
||||
expect((timeline[0].data as Observation).id).toBe(2);
|
||||
expect(timeline[1].type).toBe('summary');
|
||||
expect(timeline[2].type).toBe('observation');
|
||||
expect((timeline[2].data as Observation).id).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle empty observations array', () => {
|
||||
const summaries = [
|
||||
createTestSummaryTimelineItem({ id: 1, displayEpoch: 1000 }),
|
||||
];
|
||||
|
||||
const timeline = buildTimeline([], summaries);
|
||||
|
||||
expect(timeline).toHaveLength(1);
|
||||
expect(timeline[0].type).toBe('summary');
|
||||
});
|
||||
|
||||
it('should handle empty summaries array', () => {
|
||||
const observations = [
|
||||
createTestObservation({ id: 1, created_at_epoch: 1000 }),
|
||||
];
|
||||
|
||||
const timeline = buildTimeline(observations, []);
|
||||
|
||||
expect(timeline).toHaveLength(1);
|
||||
expect(timeline[0].type).toBe('observation');
|
||||
});
|
||||
|
||||
it('should handle both empty arrays', () => {
|
||||
const timeline = buildTimeline([], []);
|
||||
|
||||
expect(timeline).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should correctly tag items with their type', () => {
|
||||
const observations = [createTestObservation()];
|
||||
const summaries = [createTestSummaryTimelineItem()];
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
const observationItem = timeline.find(item => item.type === 'observation');
|
||||
const summaryItem = timeline.find(item => item.type === 'summary');
|
||||
|
||||
expect(observationItem).toBeDefined();
|
||||
expect(summaryItem).toBeDefined();
|
||||
expect(observationItem!.data).toHaveProperty('narrative');
|
||||
expect(summaryItem!.data).toHaveProperty('request');
|
||||
});
|
||||
|
||||
it('should use displayEpoch for summary sorting, not created_at_epoch', () => {
|
||||
const observations = [
|
||||
createTestObservation({ id: 1, created_at_epoch: 2000 }),
|
||||
];
|
||||
const summaries = [
|
||||
createTestSummaryTimelineItem({
|
||||
id: 1,
|
||||
created_at_epoch: 3000, // Created later
|
||||
displayEpoch: 1000, // But displayed earlier
|
||||
}),
|
||||
];
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
// Summary should come first because its displayEpoch is earlier
|
||||
expect(timeline[0].type).toBe('summary');
|
||||
expect(timeline[1].type).toBe('observation');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import {
|
||||
calculateObservationTokens,
|
||||
calculateTokenEconomics,
|
||||
} from '../../src/services/context/index.js';
|
||||
import type { Observation } from '../../src/services/context/types.js';
|
||||
import { CHARS_PER_TOKEN_ESTIMATE } from '../../src/services/context/types.js';
|
||||
|
||||
// Helper to create a minimal observation for testing
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
type: 'discovery',
|
||||
title: null,
|
||||
subtitle: null,
|
||||
narrative: null,
|
||||
facts: null,
|
||||
concepts: null,
|
||||
files_read: null,
|
||||
files_modified: null,
|
||||
discovery_tokens: null,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TokenCalculator', () => {
|
||||
describe('CHARS_PER_TOKEN_ESTIMATE constant', () => {
|
||||
it('should be 4 characters per token', () => {
|
||||
expect(CHARS_PER_TOKEN_ESTIMATE).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateObservationTokens', () => {
|
||||
it('should return 0 for an observation with no content', () => {
|
||||
const obs = createTestObservation();
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// Even empty observations have facts as "[]" when stringified
|
||||
// null facts becomes '[]' = 2 chars / 4 = 0.5 -> ceil = 1
|
||||
expect(tokens).toBe(1);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on title length', () => {
|
||||
const title = 'A'.repeat(40); // 40 chars = 10 tokens
|
||||
const obs = createTestObservation({ title });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// title (40) + facts stringified (null -> '[]' = 2) = 42 / 4 = 10.5 -> 11
|
||||
expect(tokens).toBe(11);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on subtitle length', () => {
|
||||
const subtitle = 'B'.repeat(20); // 20 chars = 5 tokens
|
||||
const obs = createTestObservation({ subtitle });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// subtitle (20) + facts (2) = 22 / 4 = 5.5 -> 6
|
||||
expect(tokens).toBe(6);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on narrative length', () => {
|
||||
const narrative = 'C'.repeat(80); // 80 chars = 20 tokens
|
||||
const obs = createTestObservation({ narrative });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// narrative (80) + facts (2) = 82 / 4 = 20.5 -> 21
|
||||
expect(tokens).toBe(21);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on facts JSON length', () => {
|
||||
// When facts is a string, JSON.stringify adds quotes around it
|
||||
// '["fact"]' as string becomes '"[\\"fact\\"]"' when stringified
|
||||
// But in practice, obs.facts is a string that gets stringified
|
||||
const facts = '["fact one", "fact two", "fact three"]'; // 38 chars
|
||||
const obs = createTestObservation({ facts });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// JSON.stringify of string adds quotes: 38 + 2 = 40, plus escaping
|
||||
// Actually becomes: '"[\"fact one\", \"fact two\", \"fact three\"]"' = 46 chars
|
||||
// 46 / 4 = 11.5 -> 12
|
||||
expect(tokens).toBe(12);
|
||||
});
|
||||
|
||||
it('should combine all fields for total token estimate', () => {
|
||||
const obs = createTestObservation({
|
||||
title: 'A'.repeat(20), // 20 chars
|
||||
subtitle: 'B'.repeat(20), // 20 chars
|
||||
narrative: 'C'.repeat(40), // 40 chars
|
||||
facts: '["test"]', // 8 chars, but JSON.stringify adds quotes = 10 chars
|
||||
});
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 20 + 20 + 40 + 10 (stringified) = 90 / 4 = 22.5 -> 23
|
||||
expect(tokens).toBe(23);
|
||||
});
|
||||
|
||||
it('should handle large observations correctly', () => {
|
||||
const largeNarrative = 'X'.repeat(4000); // 4000 chars = 1000 tokens
|
||||
const obs = createTestObservation({ narrative: largeNarrative });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 4000 + 2 (null facts) = 4002 / 4 = 1000.5 -> 1001
|
||||
expect(tokens).toBe(1001);
|
||||
});
|
||||
|
||||
it('should round up fractional tokens using ceil', () => {
|
||||
// 9 chars / 4 = 2.25 -> should be 3
|
||||
const obs = createTestObservation({ title: 'ABCDEFGHI' }); // 9 chars
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 9 + 2 = 11 / 4 = 2.75 -> 3
|
||||
expect(tokens).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTokenEconomics', () => {
|
||||
it('should return zeros for empty observations array', () => {
|
||||
const economics = calculateTokenEconomics([]);
|
||||
|
||||
expect(economics.totalObservations).toBe(0);
|
||||
expect(economics.totalReadTokens).toBe(0);
|
||||
expect(economics.totalDiscoveryTokens).toBe(0);
|
||||
expect(economics.savings).toBe(0);
|
||||
expect(economics.savingsPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('should count total observations', () => {
|
||||
const observations = [
|
||||
createTestObservation({ id: 1 }),
|
||||
createTestObservation({ id: 2 }),
|
||||
createTestObservation({ id: 3 }),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalObservations).toBe(3);
|
||||
});
|
||||
|
||||
it('should sum read tokens from all observations', () => {
|
||||
const observations = [
|
||||
createTestObservation({ title: 'A'.repeat(40) }), // ~11 tokens
|
||||
createTestObservation({ title: 'B'.repeat(40) }), // ~11 tokens
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalReadTokens).toBe(22);
|
||||
});
|
||||
|
||||
it('should sum discovery tokens from all observations', () => {
|
||||
const observations = [
|
||||
createTestObservation({ discovery_tokens: 100 }),
|
||||
createTestObservation({ discovery_tokens: 200 }),
|
||||
createTestObservation({ discovery_tokens: 300 }),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalDiscoveryTokens).toBe(600);
|
||||
});
|
||||
|
||||
it('should handle null discovery_tokens as 0', () => {
|
||||
const observations = [
|
||||
createTestObservation({ discovery_tokens: 100 }),
|
||||
createTestObservation({ discovery_tokens: null }),
|
||||
createTestObservation({ discovery_tokens: 50 }),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalDiscoveryTokens).toBe(150);
|
||||
});
|
||||
|
||||
it('should calculate savings as discovery minus read tokens', () => {
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
title: 'A'.repeat(40), // ~11 read tokens
|
||||
discovery_tokens: 500,
|
||||
}),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.savings).toBe(500 - 11);
|
||||
expect(economics.savings).toBe(489);
|
||||
});
|
||||
|
||||
it('should calculate savings percent correctly', () => {
|
||||
// If discovery = 1000 and read = 100, savings = 900, percent = 90%
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
title: 'A'.repeat(396), // 396 + 2 = 398 / 4 = 99.5 -> 100 read tokens
|
||||
discovery_tokens: 1000,
|
||||
}),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalReadTokens).toBe(100);
|
||||
expect(economics.totalDiscoveryTokens).toBe(1000);
|
||||
expect(economics.savings).toBe(900);
|
||||
expect(economics.savingsPercent).toBe(90);
|
||||
});
|
||||
|
||||
it('should return 0% savings when discovery tokens is 0', () => {
|
||||
const observations = [
|
||||
createTestObservation({ discovery_tokens: 0 }),
|
||||
createTestObservation({ discovery_tokens: null }),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.savingsPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle negative savings correctly', () => {
|
||||
// When read tokens > discovery tokens, savings is negative
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
narrative: 'X'.repeat(400), // ~101 read tokens
|
||||
discovery_tokens: 50,
|
||||
}),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.savings).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should round savings percent to nearest integer', () => {
|
||||
// Create a scenario where savings percent is fractional
|
||||
// discovery = 100, read = 33, savings = 67, percent = 67%
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
title: 'A'.repeat(130), // 130 + 2 = 132 / 4 = 33 read tokens
|
||||
discovery_tokens: 100,
|
||||
}),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalReadTokens).toBe(33);
|
||||
expect(economics.savingsPercent).toBe(67);
|
||||
});
|
||||
|
||||
it('should aggregate correctly with multiple observations', () => {
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
id: 1,
|
||||
title: 'A'.repeat(20),
|
||||
narrative: 'X'.repeat(60),
|
||||
discovery_tokens: 500,
|
||||
}),
|
||||
createTestObservation({
|
||||
id: 2,
|
||||
title: 'B'.repeat(40),
|
||||
subtitle: 'Y'.repeat(40),
|
||||
discovery_tokens: 300,
|
||||
}),
|
||||
createTestObservation({
|
||||
id: 3,
|
||||
narrative: 'Z'.repeat(100),
|
||||
facts: '["fact1", "fact2"]',
|
||||
discovery_tokens: 200,
|
||||
}),
|
||||
];
|
||||
const economics = calculateTokenEconomics(observations);
|
||||
|
||||
expect(economics.totalObservations).toBe(3);
|
||||
expect(economics.totalDiscoveryTokens).toBe(1000);
|
||||
expect(economics.totalReadTokens).toBeGreaterThan(0);
|
||||
expect(economics.savings).toBe(economics.totalDiscoveryTokens - economics.totalReadTokens);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
.agent/services/claude-mem/tests/cursor-context-update.test.ts
Normal file
220
.agent/services/claude-mem/tests/cursor-context-update.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { writeContextFile, readContextFile } from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Context Update functionality
|
||||
*
|
||||
* These tests validate that context files are correctly written to
|
||||
* .cursor/rules/claude-mem-context.mdc for registered projects.
|
||||
*
|
||||
* The context file uses Cursor's MDC format with frontmatter.
|
||||
*/
|
||||
|
||||
describe('Cursor Context Update', () => {
|
||||
let tempDir: string;
|
||||
let workspacePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-context-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
workspacePath = join(tempDir, 'my-project');
|
||||
mkdirSync(workspacePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('writeContextFile', () => {
|
||||
it('creates .cursor/rules directory structure', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesDir = join(workspacePath, '.cursor', 'rules');
|
||||
expect(existsSync(rulesDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates claude-mem-context.mdc file', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc');
|
||||
expect(existsSync(rulesFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes alwaysApply: true in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('includes description in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('description: "Claude-mem context from past sessions (auto-updated)"');
|
||||
});
|
||||
|
||||
it('includes the provided context in the file body', () => {
|
||||
const testContext = `## Recent Session
|
||||
|
||||
- Fixed authentication bug
|
||||
- Added new feature`;
|
||||
|
||||
writeContextFile(workspacePath, testContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Fixed authentication bug');
|
||||
expect(content).toContain('Added new feature');
|
||||
});
|
||||
|
||||
it('includes Memory Context header', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('# Memory Context from Past Sessions');
|
||||
});
|
||||
|
||||
it('includes footer with MCP tools mention', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain("Use claude-mem's MCP search tools for more detailed queries");
|
||||
});
|
||||
|
||||
it('uses atomic write (no temp file left behind)', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const tempFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc.tmp');
|
||||
expect(existsSync(tempFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites existing context file', () => {
|
||||
writeContextFile(workspacePath, 'first context');
|
||||
writeContextFile(workspacePath, 'second context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).not.toContain('first context');
|
||||
expect(content).toContain('second context');
|
||||
});
|
||||
|
||||
it('handles empty context gracefully', () => {
|
||||
writeContextFile(workspacePath, '');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toBeDefined();
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('preserves multi-line context with proper formatting', () => {
|
||||
const multilineContext = `Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
Paragraph 2`;
|
||||
|
||||
writeContextFile(workspacePath, multilineContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Line 1\nLine 2\nLine 3');
|
||||
expect(content).toContain('Paragraph 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MDC format validation', () => {
|
||||
it('has valid YAML frontmatter delimiters', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
|
||||
// First line should be ---
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Should have closing --- for frontmatter
|
||||
const secondDashIndex = lines.indexOf('---', 1);
|
||||
expect(secondDashIndex).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('frontmatter is parseable as YAML', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
const frontmatterEnd = lines.indexOf('---', 1);
|
||||
|
||||
const frontmatter = lines.slice(1, frontmatterEnd).join('\n');
|
||||
|
||||
// Should contain valid YAML key-value pairs
|
||||
expect(frontmatter).toMatch(/alwaysApply:\s*true/);
|
||||
expect(frontmatter).toMatch(/description:\s*"/);
|
||||
});
|
||||
|
||||
it('content after frontmatter is proper markdown', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
|
||||
// Should have markdown header
|
||||
expect(content).toMatch(/^# Memory Context/m);
|
||||
|
||||
// Should have horizontal rule (---)
|
||||
// Note: The footer uses --- which is also a horizontal rule in markdown
|
||||
const bodyPart = content.split('---')[2]; // After frontmatter
|
||||
expect(bodyPart).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles special characters in context', () => {
|
||||
const specialContext = '`code` **bold** _italic_ <html> $variable @mention #tag';
|
||||
|
||||
writeContextFile(workspacePath, specialContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('**bold**');
|
||||
expect(content).toContain('<html>');
|
||||
});
|
||||
|
||||
it('handles unicode in context', () => {
|
||||
const unicodeContext = 'Emoji: 🚀 Japanese: 日本語 Arabic: العربية';
|
||||
|
||||
writeContextFile(workspacePath, unicodeContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('🚀');
|
||||
expect(content).toContain('日本語');
|
||||
expect(content).toContain('العربية');
|
||||
});
|
||||
|
||||
it('handles very long context', () => {
|
||||
// 100KB of context
|
||||
const longContext = 'x'.repeat(100 * 1024);
|
||||
|
||||
writeContextFile(workspacePath, longContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain(longContext);
|
||||
});
|
||||
|
||||
it('works when .cursor directory already exists', () => {
|
||||
// Pre-create .cursor with other content
|
||||
mkdirSync(join(workspacePath, '.cursor', 'other'), { recursive: true });
|
||||
writeFileSync(join(workspacePath, '.cursor', 'other', 'file.txt'), 'existing');
|
||||
|
||||
writeContextFile(workspacePath, 'new context');
|
||||
|
||||
// Should not destroy existing content
|
||||
expect(existsSync(join(workspacePath, '.cursor', 'other', 'file.txt'))).toBe(true);
|
||||
expect(readContextFile(workspacePath)).toContain('new context');
|
||||
});
|
||||
});
|
||||
});
|
||||
265
.agent/services/claude-mem/tests/cursor-hooks-json-utils.test.ts
Normal file
265
.agent/services/claude-mem/tests/cursor-hooks-json-utils.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
parseArrayField,
|
||||
jsonGet,
|
||||
getProjectName,
|
||||
isEmpty,
|
||||
urlEncode
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Hooks JSON/Utility Functions
|
||||
*
|
||||
* These tests validate the logic used in common.sh bash utilities.
|
||||
* The TypeScript implementations in cursor-utils.ts mirror the bash logic,
|
||||
* allowing us to verify correct behavior and catch edge cases.
|
||||
*
|
||||
* The bash scripts use these functions:
|
||||
* - json_get: Extract fields from JSON, including array access
|
||||
* - get_project_name: Extract project name from workspace path
|
||||
* - is_empty: Check if a string is empty/null
|
||||
* - url_encode: URL-encode a string
|
||||
*/
|
||||
|
||||
describe('Cursor Hooks JSON Utilities', () => {
|
||||
describe('parseArrayField', () => {
|
||||
it('parses simple array access', () => {
|
||||
const result = parseArrayField('workspace_roots[0]');
|
||||
expect(result).toEqual({ field: 'workspace_roots', index: 0 });
|
||||
});
|
||||
|
||||
it('parses array access with higher index', () => {
|
||||
const result = parseArrayField('items[42]');
|
||||
expect(result).toEqual({ field: 'items', index: 42 });
|
||||
});
|
||||
|
||||
it('returns null for simple field', () => {
|
||||
const result = parseArrayField('conversation_id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
const result = parseArrayField('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed array syntax', () => {
|
||||
expect(parseArrayField('field[]')).toBeNull();
|
||||
expect(parseArrayField('field[-1]')).toBeNull();
|
||||
expect(parseArrayField('[0]')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles underscores in field name', () => {
|
||||
const result = parseArrayField('my_array_field[5]');
|
||||
expect(result).toEqual({ field: 'my_array_field', index: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonGet', () => {
|
||||
const testJson = {
|
||||
conversation_id: 'conv-123',
|
||||
workspace_roots: ['/path/to/project', '/another/path'],
|
||||
nested: { value: 'nested-value' },
|
||||
empty_string: '',
|
||||
null_value: null
|
||||
};
|
||||
|
||||
it('gets simple field', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id')).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('gets array element with [0]', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[0]')).toBe('/path/to/project');
|
||||
});
|
||||
|
||||
it('gets array element with higher index', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[1]')).toBe('/another/path');
|
||||
});
|
||||
|
||||
it('returns fallback for missing field', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for out-of-bounds array access', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[99]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for array access on non-array', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id[0]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns empty string fallback by default', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent')).toBe('');
|
||||
});
|
||||
|
||||
it('returns fallback for null value', () => {
|
||||
expect(jsonGet(testJson, 'null_value', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns empty string value (not fallback)', () => {
|
||||
// Empty string is a valid value, should not trigger fallback
|
||||
expect(jsonGet(testJson, 'empty_string', 'fallback')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectName', () => {
|
||||
it('extracts basename from Unix path', () => {
|
||||
expect(getProjectName('/Users/alex/projects/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('extracts basename from Windows path', () => {
|
||||
expect(getProjectName('C:\\Users\\alex\\projects\\my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('handles path with trailing slash', () => {
|
||||
expect(getProjectName('/path/to/project/')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns unknown-project for empty string', () => {
|
||||
expect(getProjectName('')).toBe('unknown-project');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:\\', () => {
|
||||
expect(getProjectName('C:\\')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:', () => {
|
||||
expect(getProjectName('C:')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles lowercase drive letter', () => {
|
||||
expect(getProjectName('d:\\')).toBe('drive-D');
|
||||
});
|
||||
|
||||
it('handles project name with dots', () => {
|
||||
expect(getProjectName('/path/to/my.project.v2')).toBe('my.project.v2');
|
||||
});
|
||||
|
||||
it('handles project name with spaces', () => {
|
||||
expect(getProjectName('/path/to/My Project')).toBe('My Project');
|
||||
});
|
||||
|
||||
it('handles project name with special characters', () => {
|
||||
expect(getProjectName('/path/to/project-name_v2.0')).toBe('project-name_v2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('returns true for null', () => {
|
||||
expect(isEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for undefined', () => {
|
||||
expect(isEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for empty string', () => {
|
||||
expect(isEmpty('')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "null" string', () => {
|
||||
// This is important - jq returns "null" as string when value is null
|
||||
expect(isEmpty('null')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "empty" string', () => {
|
||||
expect(isEmpty('empty')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-empty string', () => {
|
||||
expect(isEmpty('some-value')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
// Whitespace is not empty
|
||||
expect(isEmpty(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "0" string', () => {
|
||||
expect(isEmpty('0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "false" string', () => {
|
||||
expect(isEmpty('false')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urlEncode', () => {
|
||||
it('encodes spaces', () => {
|
||||
expect(urlEncode('hello world')).toBe('hello%20world');
|
||||
});
|
||||
|
||||
it('encodes special characters', () => {
|
||||
expect(urlEncode('a&b=c')).toBe('a%26b%3Dc');
|
||||
});
|
||||
|
||||
it('encodes unicode', () => {
|
||||
const encoded = urlEncode('日本語');
|
||||
expect(encoded).toContain('%');
|
||||
expect(decodeURIComponent(encoded)).toBe('日本語');
|
||||
});
|
||||
|
||||
it('preserves alphanumeric characters', () => {
|
||||
expect(urlEncode('abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('preserves dashes and underscores', () => {
|
||||
expect(urlEncode('my-project_name')).toBe('my-project_name');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(urlEncode('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodes forward slash', () => {
|
||||
expect(urlEncode('path/to/file')).toBe('path%2Fto%2Ffile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: hook payload parsing', () => {
|
||||
// Simulates parsing a real Cursor hook payload
|
||||
|
||||
it('extracts all fields from typical beforeSubmitPrompt payload', () => {
|
||||
const payload = {
|
||||
conversation_id: 'abc-123',
|
||||
generation_id: 'gen-456',
|
||||
prompt: 'Fix the bug',
|
||||
workspace_roots: ['/Users/alex/projects/my-project'],
|
||||
hook_event_name: 'beforeSubmitPrompt'
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]');
|
||||
const projectName = getProjectName(workspaceRoot);
|
||||
const hookEvent = jsonGet(payload, 'hook_event_name');
|
||||
|
||||
expect(conversationId).toBe('abc-123');
|
||||
expect(workspaceRoot).toBe('/Users/alex/projects/my-project');
|
||||
expect(projectName).toBe('my-project');
|
||||
expect(hookEvent).toBe('beforeSubmitPrompt');
|
||||
});
|
||||
|
||||
it('handles payload with missing optional fields', () => {
|
||||
const payload = {
|
||||
generation_id: 'gen-456',
|
||||
// No conversation_id, no workspace_roots
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id', '');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]', '');
|
||||
|
||||
expect(isEmpty(conversationId)).toBe(true);
|
||||
expect(isEmpty(workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it('constructs valid API URL with encoded project name', () => {
|
||||
const projectName = 'my project (v2)';
|
||||
const port = 37777;
|
||||
const encoded = urlEncode(projectName);
|
||||
|
||||
const url = `http://127.0.0.1:${port}/api/context/inject?project=${encoded}`;
|
||||
|
||||
expect(url).toBe('http://127.0.0.1:37777/api/context/inject?project=my%20project%20(v2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
247
.agent/services/claude-mem/tests/cursor-mcp-config.test.ts
Normal file
247
.agent/services/claude-mem/tests/cursor-mcp-config.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
configureCursorMcp,
|
||||
removeMcpConfig,
|
||||
type CursorMcpConfig
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor MCP Configuration
|
||||
*
|
||||
* These tests validate the MCP server configuration that gets written
|
||||
* to .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (user-level).
|
||||
*
|
||||
* The config must match Cursor's expected format for MCP servers.
|
||||
*/
|
||||
|
||||
describe('Cursor MCP Configuration', () => {
|
||||
let tempDir: string;
|
||||
let mcpJsonPath: string;
|
||||
const mcpServerPath = '/path/to/mcp-server.cjs';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
mcpJsonPath = join(tempDir, '.cursor', 'mcp.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('configureCursorMcp', () => {
|
||||
it('creates mcp.json if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(mcpJsonPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates .cursor directory if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(join(tempDir, '.cursor'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds claude-mem server with correct structure', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem'].command).toBe('node');
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([mcpServerPath]);
|
||||
});
|
||||
|
||||
it('preserves existing MCP servers when adding claude-mem', () => {
|
||||
// Pre-create config with another server
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const existingConfig = {
|
||||
mcpServers: {
|
||||
'other-server': {
|
||||
command: 'python',
|
||||
args: ['/path/to/other.py']
|
||||
}
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(existingConfig));
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Both servers should exist
|
||||
expect(config.mcpServers['other-server']).toBeDefined();
|
||||
expect(config.mcpServers['other-server'].command).toBe('python');
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing claude-mem server path', () => {
|
||||
// First config
|
||||
configureCursorMcp(mcpJsonPath, '/old/path.cjs');
|
||||
|
||||
// Update with new path
|
||||
const newPath = '/new/path.cjs';
|
||||
configureCursorMcp(mcpJsonPath, newPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([newPath]);
|
||||
});
|
||||
|
||||
it('recovers from corrupt mcp.json', () => {
|
||||
// Create corrupt file
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, 'not valid json {{{{');
|
||||
|
||||
// Should not throw, should overwrite
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles mcp.json with missing mcpServers key', () => {
|
||||
// Create file with empty object
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, '{}');
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP config format validation', () => {
|
||||
it('produces valid JSON', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should not throw
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('uses pretty-printed JSON (2-space indent)', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should contain newlines and indentation
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' "mcpServers"');
|
||||
});
|
||||
|
||||
it('matches Cursor MCP server schema', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Top-level must have mcpServers
|
||||
expect(config).toHaveProperty('mcpServers');
|
||||
expect(typeof config.mcpServers).toBe('object');
|
||||
|
||||
// Each server must have command (string) and optionally args (array)
|
||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||
expect(typeof name).toBe('string');
|
||||
expect((server as { command: string }).command).toBeDefined();
|
||||
expect(typeof (server as { command: string }).command).toBe('string');
|
||||
|
||||
const args = (server as { args?: string[] }).args;
|
||||
if (args !== undefined) {
|
||||
expect(Array.isArray(args)).toBe(true);
|
||||
args.forEach((arg: string) => expect(typeof arg).toBe('string'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMcpConfig', () => {
|
||||
it('removes claude-mem server from config', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other servers when removing claude-mem', () => {
|
||||
// Setup: both servers
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] },
|
||||
'claude-mem': { command: 'node', args: ['/mcp.cjs'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
expect(updated.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing if mcp.json does not exist', () => {
|
||||
// Should not throw
|
||||
expect(() => removeMcpConfig(mcpJsonPath)).not.toThrow();
|
||||
expect(existsSync(mcpJsonPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if claude-mem not in config', () => {
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path handling', () => {
|
||||
it('handles absolute path with spaces', () => {
|
||||
const pathWithSpaces = '/path/to/my project/mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, pathWithSpaces);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([pathWithSpaces]);
|
||||
});
|
||||
|
||||
it('handles Windows-style path', () => {
|
||||
const windowsPath = 'C:\\Users\\alex\\.claude\\plugins\\mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, windowsPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([windowsPath]);
|
||||
});
|
||||
|
||||
it('handles path with special characters', () => {
|
||||
const specialPath = "/path/to/project-name_v2.0 (beta)/mcp-server.cjs";
|
||||
configureCursorMcp(mcpJsonPath, specialPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([specialPath]);
|
||||
|
||||
// Verify it survives JSON round-trip
|
||||
const reread: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(reread.mcpServers['claude-mem'].args![0]).toBe(specialPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
.agent/services/claude-mem/tests/cursor-registry.test.ts
Normal file
171
.agent/services/claude-mem/tests/cursor-registry.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
readCursorRegistry,
|
||||
writeCursorRegistry,
|
||||
registerCursorProject,
|
||||
unregisterCursorProject
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Project Registry functionality
|
||||
*
|
||||
* These tests validate the file-based registry that tracks which projects
|
||||
* have Cursor hooks installed for automatic context updates.
|
||||
*
|
||||
* The registry is stored at ~/.claude-mem/cursor-projects.json
|
||||
*/
|
||||
|
||||
describe('Cursor Project Registry', () => {
|
||||
let tempDir: string;
|
||||
let registryFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-registry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
registryFile = join(tempDir, 'cursor-projects.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('readCursorRegistry', () => {
|
||||
it('returns empty object when registry file does not exist', () => {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when registry file is corrupt JSON', () => {
|
||||
writeFileSync(registryFile, 'not valid json {{{');
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed registry when file exists', () => {
|
||||
const expected = {
|
||||
'my-project': {
|
||||
workspacePath: '/home/user/projects/my-project',
|
||||
installedAt: '2025-01-01T00:00:00.000Z'
|
||||
}
|
||||
};
|
||||
writeFileSync(registryFile, JSON.stringify(expected));
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCursorProject', () => {
|
||||
it('creates registry file if it does not exist', () => {
|
||||
registerCursorProject(registryFile, 'new-project', '/path/to/project');
|
||||
|
||||
expect(existsSync(registryFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores project with workspacePath and installedAt', () => {
|
||||
const before = Date.now();
|
||||
registerCursorProject(registryFile, 'test-project', '/workspace/test');
|
||||
const after = Date.now();
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['test-project']).toBeDefined();
|
||||
expect(registry['test-project'].workspacePath).toBe('/workspace/test');
|
||||
|
||||
// Verify installedAt is a valid ISO timestamp within the test window
|
||||
const installedAt = new Date(registry['test-project'].installedAt).getTime();
|
||||
expect(installedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(installedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('preserves existing projects when registering new one', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(2);
|
||||
expect(registry['project-a'].workspacePath).toBe('/path/a');
|
||||
expect(registry['project-b'].workspacePath).toBe('/path/b');
|
||||
});
|
||||
|
||||
it('overwrites existing project with same name', () => {
|
||||
registerCursorProject(registryFile, 'my-project', '/old/path');
|
||||
registerCursorProject(registryFile, 'my-project', '/new/path');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(1);
|
||||
expect(registry['my-project'].workspacePath).toBe('/new/path');
|
||||
});
|
||||
|
||||
it('handles special characters in project name', () => {
|
||||
const projectName = 'my-project_v2.0 (beta)';
|
||||
registerCursorProject(registryFile, projectName, '/path/to/project');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry[projectName]).toBeDefined();
|
||||
expect(registry[projectName].workspacePath).toBe('/path/to/project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterCursorProject', () => {
|
||||
it('removes specified project from registry', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
unregisterCursorProject(registryFile, 'project-a');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['project-a']).toBeUndefined();
|
||||
expect(registry['project-b']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does nothing when unregistering non-existent project', () => {
|
||||
registerCursorProject(registryFile, 'existing', '/path');
|
||||
|
||||
// Should not throw
|
||||
unregisterCursorProject(registryFile, 'non-existent');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['existing']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles unregister when registry file does not exist', () => {
|
||||
// Should not throw even when file doesn't exist
|
||||
unregisterCursorProject(registryFile, 'any-project');
|
||||
|
||||
// File should not be created by unregister
|
||||
expect(existsSync(registryFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry format validation', () => {
|
||||
it('stores registry as pretty-printed JSON', () => {
|
||||
registerCursorProject(registryFile, 'test', '/path');
|
||||
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
// Should be indented (pretty-printed)
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' ');
|
||||
});
|
||||
|
||||
it('registry file is valid JSON that can be read by other tools', () => {
|
||||
registerCursorProject(registryFile, 'project-1', '/path/1');
|
||||
registerCursorProject(registryFile, 'project-2', '/path/2');
|
||||
|
||||
// Read raw and parse with JSON.parse (not our helper)
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('project-1');
|
||||
expect(parsed).toHaveProperty('project-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
.agent/services/claude-mem/tests/fk-constraint-fix.test.ts
Normal file
139
.agent/services/claude-mem/tests/fk-constraint-fix.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for FK constraint fix (Issue #846)
|
||||
*
|
||||
* Problem: When worker restarts, observations fail because:
|
||||
* 1. Session created with memory_session_id = NULL
|
||||
* 2. SDK generates new memory_session_id
|
||||
* 3. storeObservation() tries to INSERT with new ID
|
||||
* 4. FK constraint fails - parent row doesn't have this ID yet
|
||||
*
|
||||
* Fix: ensureMemorySessionIdRegistered() updates parent table before child INSERT
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
describe('FK Constraint Fix (Issue #846)', () => {
|
||||
let store: SessionStore;
|
||||
let testDbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use unique temp database for each test (randomUUID prevents collision in parallel runs)
|
||||
testDbPath = `/tmp/test-fk-fix-${crypto.randomUUID()}.db`;
|
||||
store = new SessionStore(testDbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
// Clean up test database
|
||||
try {
|
||||
require('fs').unlinkSync(testDbPath);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should auto-register memory_session_id before observation INSERT', () => {
|
||||
// Create session with NULL memory_session_id (simulates initial creation)
|
||||
const sessionDbId = store.createSDKSession('test-content-id', 'test-project', 'test prompt');
|
||||
|
||||
// Verify memory_session_id starts as NULL
|
||||
const beforeSession = store.getSessionById(sessionDbId);
|
||||
expect(beforeSession?.memory_session_id).toBeNull();
|
||||
|
||||
// Simulate SDK providing new memory_session_id
|
||||
const newMemorySessionId = 'new-uuid-from-sdk-' + Date.now();
|
||||
|
||||
// Call ensureMemorySessionIdRegistered (the fix)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify parent table was updated
|
||||
const afterSession = store.getSessionById(sessionDbId);
|
||||
expect(afterSession?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Now storeObservation should succeed (FK target exists)
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
{
|
||||
type: 'discovery',
|
||||
title: 'Test observation',
|
||||
subtitle: 'Testing FK fix',
|
||||
facts: ['fact1'],
|
||||
narrative: 'Test narrative',
|
||||
concepts: ['test'],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
},
|
||||
1,
|
||||
100
|
||||
);
|
||||
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not update if memory_session_id already matches', () => {
|
||||
// Create session
|
||||
const sessionDbId = store.createSDKSession('test-content-id-2', 'test-project', 'test prompt');
|
||||
const memorySessionId = 'fixed-memory-id-' + Date.now();
|
||||
|
||||
// Register it once
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Call again with same ID - should be a no-op
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Verify still has the same ID
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(memorySessionId);
|
||||
});
|
||||
|
||||
it('should throw if session does not exist', () => {
|
||||
const nonExistentSessionId = 99999;
|
||||
|
||||
expect(() => {
|
||||
store.ensureMemorySessionIdRegistered(nonExistentSessionId, 'some-id');
|
||||
}).toThrow('Session 99999 not found in sdk_sessions');
|
||||
});
|
||||
|
||||
it('should handle observation storage after worker restart scenario', () => {
|
||||
// Simulate: Session exists from previous worker instance
|
||||
const sessionDbId = store.createSDKSession('restart-test-id', 'test-project', 'test prompt');
|
||||
|
||||
// Simulate: Previous worker had set a memory_session_id
|
||||
const oldMemorySessionId = 'old-stale-id';
|
||||
store.updateMemorySessionId(sessionDbId, oldMemorySessionId);
|
||||
|
||||
// Verify old ID is set
|
||||
const before = store.getSessionById(sessionDbId);
|
||||
expect(before?.memory_session_id).toBe(oldMemorySessionId);
|
||||
|
||||
// Simulate: New worker gets new memory_session_id from SDK
|
||||
const newMemorySessionId = 'new-fresh-id-from-sdk';
|
||||
|
||||
// The fix: ensure new ID is registered before storage
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify update happened
|
||||
const after = store.getSessionById(sessionDbId);
|
||||
expect(after?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Storage should now succeed
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
{
|
||||
type: 'bugfix',
|
||||
title: 'Worker restart fix test',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
412
.agent/services/claude-mem/tests/gemini_agent.test.ts
Normal file
412
.agent/services/claude-mem/tests/gemini_agent.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { GeminiAgent } from '../src/services/worker/GeminiAgent';
|
||||
import { DatabaseManager } from '../src/services/worker/DatabaseManager';
|
||||
import { SessionManager } from '../src/services/worker/SessionManager';
|
||||
import { ModeManager } from '../src/services/domain/ModeManager';
|
||||
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
|
||||
|
||||
// Track rate limiting setting (controls Gemini RPM throttling)
|
||||
// Set to 'false' to disable rate limiting for faster tests
|
||||
let rateLimitingEnabled = 'false';
|
||||
|
||||
// Mock mode config
|
||||
const mockMode = {
|
||||
name: 'code',
|
||||
prompts: {
|
||||
init: 'init prompt',
|
||||
observation: 'obs prompt',
|
||||
summary: 'summary prompt'
|
||||
},
|
||||
observation_types: [{ id: 'discovery' }, { id: 'bugfix' }],
|
||||
observation_concepts: []
|
||||
};
|
||||
|
||||
// Use spyOn for all dependencies to avoid affecting other test files
|
||||
// spyOn restores automatically, unlike mock.module which persists
|
||||
let loadFromFileSpy: ReturnType<typeof spyOn>;
|
||||
let getSpy: ReturnType<typeof spyOn>;
|
||||
let modeManagerSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
describe('GeminiAgent', () => {
|
||||
let agent: GeminiAgent;
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
// Mocks
|
||||
let mockStoreObservation: any;
|
||||
let mockStoreObservations: any; // Plural - atomic transaction method used by ResponseProcessor
|
||||
let mockStoreSummary: any;
|
||||
let mockMarkSessionCompleted: any;
|
||||
let mockSyncObservation: any;
|
||||
let mockSyncSummary: any;
|
||||
let mockMarkProcessed: any;
|
||||
let mockCleanupProcessed: any;
|
||||
let mockResetStuckMessages: any;
|
||||
let mockDbManager: DatabaseManager;
|
||||
let mockSessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset rate limiting to disabled by default (speeds up tests)
|
||||
rateLimitingEnabled = 'false';
|
||||
|
||||
// Mock ModeManager using spyOn (restores properly)
|
||||
modeManagerSpy = spyOn(ModeManager, 'getInstance').mockImplementation(() => ({
|
||||
getActiveMode: () => mockMode,
|
||||
loadMode: () => {},
|
||||
} as any));
|
||||
|
||||
// Mock SettingsDefaultsManager methods using spyOn (restores properly)
|
||||
loadFromFileSpy = spyOn(SettingsDefaultsManager, 'loadFromFile').mockImplementation(() => ({
|
||||
...SettingsDefaultsManager.getAllDefaults(),
|
||||
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
|
||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: rateLimitingEnabled,
|
||||
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
|
||||
}));
|
||||
|
||||
getSpy = spyOn(SettingsDefaultsManager, 'get').mockImplementation((key: string) => {
|
||||
if (key === 'CLAUDE_MEM_GEMINI_API_KEY') return 'test-api-key';
|
||||
if (key === 'CLAUDE_MEM_GEMINI_MODEL') return 'gemini-2.5-flash-lite';
|
||||
if (key === 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED') return rateLimitingEnabled;
|
||||
if (key === 'CLAUDE_MEM_DATA_DIR') return '/tmp/claude-mem-test';
|
||||
return SettingsDefaultsManager.getAllDefaults()[key as keyof ReturnType<typeof SettingsDefaultsManager.getAllDefaults>] ?? '';
|
||||
});
|
||||
|
||||
// Initialize mocks
|
||||
mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
||||
mockStoreSummary = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
||||
mockMarkSessionCompleted = mock(() => {});
|
||||
mockSyncObservation = mock(() => Promise.resolve());
|
||||
mockSyncSummary = mock(() => Promise.resolve());
|
||||
mockMarkProcessed = mock(() => {});
|
||||
mockCleanupProcessed = mock(() => 0);
|
||||
mockResetStuckMessages = mock(() => 0);
|
||||
|
||||
// Mock for storeObservations (plural) - the atomic transaction method called by ResponseProcessor
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: 1,
|
||||
createdAtEpoch: Date.now()
|
||||
}));
|
||||
|
||||
const mockSessionStore = {
|
||||
storeObservation: mockStoreObservation,
|
||||
storeObservations: mockStoreObservations, // Required by ResponseProcessor.ts
|
||||
storeSummary: mockStoreSummary,
|
||||
markSessionCompleted: mockMarkSessionCompleted,
|
||||
getSessionById: mock(() => ({ memory_session_id: 'mem-session-123' })), // Required by ResponseProcessor.ts for FK fix
|
||||
ensureMemorySessionIdRegistered: mock(() => {}) // Required by ResponseProcessor.ts for FK constraint fix (Issue #846)
|
||||
};
|
||||
|
||||
const mockChromaSync = {
|
||||
syncObservation: mockSyncObservation,
|
||||
syncSummary: mockSyncSummary
|
||||
};
|
||||
|
||||
mockDbManager = {
|
||||
getSessionStore: () => mockSessionStore,
|
||||
getChromaSync: () => mockChromaSync
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
const mockPendingMessageStore = {
|
||||
markProcessed: mockMarkProcessed,
|
||||
confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage
|
||||
cleanupProcessed: mockCleanupProcessed,
|
||||
resetStuckMessages: mockResetStuckMessages
|
||||
};
|
||||
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () { yield* []; },
|
||||
getPendingMessageStore: () => mockPendingMessageStore
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent = new GeminiAgent(mockDbManager, mockSessionManager);
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
// Restore spied methods
|
||||
if (modeManagerSpy) modeManagerSpy.mockRestore();
|
||||
if (loadFromFileSpy) loadFromFileSpy.mockRestore();
|
||||
if (getSpy) getSpy.mockRestore();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('should initialize with correct config', async () => {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [{ text: '<observation><type>discovery</type><title>Test</title></observation>' }]
|
||||
}
|
||||
}],
|
||||
usageMetadata: { totalTokenCount: 100 }
|
||||
}))));
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const url = (global.fetch as any).mock.calls[0][0];
|
||||
expect(url).toContain('https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash-lite:generateContent');
|
||||
expect(url).toContain('key=test-api-key');
|
||||
});
|
||||
|
||||
it('should handle multi-turn conversation', async () => {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [{ role: 'user', content: 'prev context' }, { role: 'assistant', content: 'prev response' }],
|
||||
lastPromptNumber: 2,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'response' }] } }]
|
||||
}))));
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||
expect(body.contents).toHaveLength(3);
|
||||
expect(body.contents[0].role).toBe('user');
|
||||
expect(body.contents[1].role).toBe('model');
|
||||
expect(body.contents[2].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should process observations and store them', async () => {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
const observationXml = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Found bug</title>
|
||||
<subtitle>Null pointer</subtitle>
|
||||
<narrative>Found a null pointer in the code</narrative>
|
||||
<facts><fact>Null check missing</fact></facts>
|
||||
<concepts><concept>bug</concept></concepts>
|
||||
<files_read><file>src/main.ts</file></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: observationXml }] } }],
|
||||
usageMetadata: { totalTokenCount: 50 }
|
||||
}))));
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
// ResponseProcessor uses storeObservations (plural) for atomic transactions
|
||||
expect(mockStoreObservations).toHaveBeenCalled();
|
||||
expect(mockSyncObservation).toHaveBeenCalled();
|
||||
expect(session.cumulativeInputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fallback to Claude on rate limit error', async () => {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Resource has been exhausted (e.g. check quota).', { status: 429 })));
|
||||
|
||||
const fallbackAgent = {
|
||||
startSession: mock(() => Promise.resolve())
|
||||
};
|
||||
agent.setFallbackAgent(fallbackAgent);
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
// Verify fallback to Claude was triggered
|
||||
expect(fallbackAgent.startSession).toHaveBeenCalledWith(session, undefined);
|
||||
// Note: resetStuckMessages is called by worker-service.ts, not by GeminiAgent
|
||||
});
|
||||
|
||||
it('should NOT fallback on other errors', async () => {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Invalid argument', { status: 400 })));
|
||||
|
||||
const fallbackAgent = {
|
||||
startSession: mock(() => Promise.resolve())
|
||||
};
|
||||
agent.setFallbackAgent(fallbackAgent);
|
||||
|
||||
await expect(agent.startSession(session)).rejects.toThrow('Gemini API error: 400 - Invalid argument');
|
||||
expect(fallbackAgent.startSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect rate limits when rate limiting enabled', async () => {
|
||||
// Enable rate limiting - this means requests will be throttled
|
||||
// Note: CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED !== 'false' means enabled
|
||||
rateLimitingEnabled = 'true';
|
||||
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const mockSetTimeout = mock((cb: any) => cb());
|
||||
global.setTimeout = mockSetTimeout as any;
|
||||
|
||||
try {
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] } }]
|
||||
}))));
|
||||
|
||||
await agent.startSession(session);
|
||||
await agent.startSession(session);
|
||||
|
||||
expect(mockSetTimeout).toHaveBeenCalled();
|
||||
} finally {
|
||||
global.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
describe('gemini-3-flash-preview model support', () => {
|
||||
it('should accept gemini-3-flash-preview as a valid model', async () => {
|
||||
// The GeminiModel type includes gemini-3-flash-preview - compile-time check
|
||||
const validModels = [
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-3-flash-preview'
|
||||
];
|
||||
|
||||
// Verify all models are strings (type guard)
|
||||
expect(validModels.every(m => typeof m === 'string')).toBe(true);
|
||||
expect(validModels).toContain('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should have rate limit defined for gemini-3-flash-preview', async () => {
|
||||
// GEMINI_RPM_LIMITS['gemini-3-flash-preview'] = 5
|
||||
// This is enforced at compile time, but we can test the rate limiting behavior
|
||||
// by checking that the rate limit is applied when using gemini-3-flash-preview
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
memorySessionId: 'mem-session-123',
|
||||
project: 'test-project',
|
||||
userPrompt: 'test prompt',
|
||||
conversationHistory: [],
|
||||
lastPromptNumber: 1,
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] } }],
|
||||
usageMetadata: { totalTokenCount: 10 }
|
||||
}))));
|
||||
|
||||
// This validates that gemini-3-flash-preview is a valid model at runtime
|
||||
// The agent's validation array includes gemini-3-flash-preview
|
||||
await agent.startSession(session);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
.agent/services/claude-mem/tests/hook-command.test.ts
Normal file
164
.agent/services/claude-mem/tests/hook-command.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tests for hook-command error classifier
|
||||
*
|
||||
* Validates that isWorkerUnavailableError correctly distinguishes between:
|
||||
* - Transport failures (ECONNREFUSED, etc.) → true (graceful degradation)
|
||||
* - Server errors (5xx) → true (graceful degradation)
|
||||
* - Client errors (4xx) → false (handler bug, blocking)
|
||||
* - Programming errors (TypeError, etc.) → false (code bug, blocking)
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isWorkerUnavailableError } from '../src/cli/hook-command.js';
|
||||
|
||||
describe('isWorkerUnavailableError', () => {
|
||||
describe('transport failures → true (graceful)', () => {
|
||||
it('should classify ECONNREFUSED as worker unavailable', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:37777');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ECONNRESET as worker unavailable', () => {
|
||||
const error = new Error('socket hang up ECONNRESET');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify EPIPE as worker unavailable', () => {
|
||||
const error = new Error('write EPIPE');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ETIMEDOUT as worker unavailable', () => {
|
||||
const error = new Error('connect ETIMEDOUT 127.0.0.1:37777');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "fetch failed" as worker unavailable', () => {
|
||||
const error = new TypeError('fetch failed');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "Unable to connect" as worker unavailable', () => {
|
||||
const error = new Error('Unable to connect to server');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ENOTFOUND as worker unavailable', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND localhost');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "socket hang up" as worker unavailable', () => {
|
||||
const error = new Error('socket hang up');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify ECONNABORTED as worker unavailable', () => {
|
||||
const error = new Error('ECONNABORTED');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout errors → true (graceful)', () => {
|
||||
it('should classify "timed out" as worker unavailable', () => {
|
||||
const error = new Error('Request timed out after 3000ms');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "timeout" as worker unavailable', () => {
|
||||
const error = new Error('Connection timeout');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 5xx server errors → true (graceful)', () => {
|
||||
it('should classify 500 status as worker unavailable', () => {
|
||||
const error = new Error('Context generation failed: 500');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify 502 status as worker unavailable', () => {
|
||||
const error = new Error('Observation storage failed: 502');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify 503 status as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 503');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "status: 500" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 500');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 429 rate limit → true (graceful)', () => {
|
||||
it('should classify 429 as worker unavailable (rate limit is transient)', () => {
|
||||
const error = new Error('Request failed: 429');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify "status: 429" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 429');
|
||||
expect(isWorkerUnavailableError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP 4xx client errors → false (blocking)', () => {
|
||||
it('should NOT classify 400 Bad Request as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 400');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify 404 Not Found as worker unavailable', () => {
|
||||
const error = new Error('Observation storage failed: 404');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify 422 Validation Error as worker unavailable', () => {
|
||||
const error = new Error('Request failed: 422');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify "status: 400" format as worker unavailable', () => {
|
||||
const error = new Error('HTTP error status: 400');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('programming errors → false (blocking)', () => {
|
||||
it('should NOT classify TypeError as worker unavailable', () => {
|
||||
const error = new TypeError('Cannot read properties of undefined');
|
||||
// Note: TypeError with "fetch failed" IS classified as unavailable (transport layer)
|
||||
// But generic TypeErrors are NOT
|
||||
expect(isWorkerUnavailableError(new TypeError('Cannot read properties of undefined'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify ReferenceError as worker unavailable', () => {
|
||||
const error = new ReferenceError('foo is not defined');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT classify SyntaxError as worker unavailable', () => {
|
||||
const error = new SyntaxError('Unexpected token');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown errors → false (blocking, conservative)', () => {
|
||||
it('should NOT classify generic Error as worker unavailable', () => {
|
||||
const error = new Error('Something unexpected happened');
|
||||
expect(isWorkerUnavailableError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string errors', () => {
|
||||
expect(isWorkerUnavailableError('ECONNREFUSED')).toBe(true);
|
||||
expect(isWorkerUnavailableError('random error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined errors', () => {
|
||||
expect(isWorkerUnavailableError(null)).toBe(false);
|
||||
expect(isWorkerUnavailableError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
.agent/services/claude-mem/tests/hook-constants.test.ts
Normal file
118
.agent/services/claude-mem/tests/hook-constants.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Tests for hook timeout and exit code constants
|
||||
*
|
||||
* Mock Justification (~12% mock code):
|
||||
* - process.platform: Only mocked to test cross-platform timeout multiplier
|
||||
* logic - ensures Windows users get appropriate longer timeouts
|
||||
*
|
||||
* Value: Prevents regressions in timeout values that could cause
|
||||
* hook failures on slow systems or Windows
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { HOOK_TIMEOUTS, HOOK_EXIT_CODES, getTimeout } from '../src/shared/hook-constants.js';
|
||||
|
||||
describe('hook-constants', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original platform after each test
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
describe('HOOK_TIMEOUTS', () => {
|
||||
it('should define DEFAULT timeout', () => {
|
||||
expect(HOOK_TIMEOUTS.DEFAULT).toBe(300000);
|
||||
});
|
||||
|
||||
it('should define HEALTH_CHECK timeout as 3s (reduced from 30s)', () => {
|
||||
expect(HOOK_TIMEOUTS.HEALTH_CHECK).toBe(3000);
|
||||
});
|
||||
|
||||
it('should define POST_SPAWN_WAIT as 5s', () => {
|
||||
expect(HOOK_TIMEOUTS.POST_SPAWN_WAIT).toBe(5000);
|
||||
});
|
||||
|
||||
it('should define PORT_IN_USE_WAIT as 3s', () => {
|
||||
expect(HOOK_TIMEOUTS.PORT_IN_USE_WAIT).toBe(3000);
|
||||
});
|
||||
|
||||
it('should define WORKER_STARTUP_WAIT', () => {
|
||||
expect(HOOK_TIMEOUTS.WORKER_STARTUP_WAIT).toBe(1000);
|
||||
});
|
||||
|
||||
it('should define PRE_RESTART_SETTLE_DELAY', () => {
|
||||
expect(HOOK_TIMEOUTS.PRE_RESTART_SETTLE_DELAY).toBe(2000);
|
||||
});
|
||||
|
||||
it('should define WINDOWS_MULTIPLIER', () => {
|
||||
expect(HOOK_TIMEOUTS.WINDOWS_MULTIPLIER).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should define POWERSHELL_COMMAND timeout as 10000ms', () => {
|
||||
expect(HOOK_TIMEOUTS.POWERSHELL_COMMAND).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HOOK_EXIT_CODES', () => {
|
||||
it('should define SUCCESS exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.SUCCESS).toBe(0);
|
||||
});
|
||||
|
||||
it('should define FAILURE exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.FAILURE).toBe(1);
|
||||
});
|
||||
|
||||
it('should define BLOCKING_ERROR exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.BLOCKING_ERROR).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeout', () => {
|
||||
it('should return base timeout on non-Windows platforms', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1000);
|
||||
expect(getTimeout(5000)).toBe(5000);
|
||||
});
|
||||
|
||||
it('should apply Windows multiplier on Windows platform', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1500);
|
||||
expect(getTimeout(2000)).toBe(3000);
|
||||
});
|
||||
|
||||
it('should round Windows timeout to nearest integer', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 333 * 1.5 = 499.5, should round to 500
|
||||
expect(getTimeout(333)).toBe(500);
|
||||
});
|
||||
|
||||
it('should return base timeout on Linux', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
.agent/services/claude-mem/tests/hook-lifecycle.test.ts
Normal file
407
.agent/services/claude-mem/tests/hook-lifecycle.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Tests for Hook Lifecycle Fixes (TRIAGE-04)
|
||||
*
|
||||
* Validates:
|
||||
* - Stop hook returns suppressOutput: true (prevents infinite loop #987)
|
||||
* - All handlers return suppressOutput: true (prevents conversation pollution #598, #784)
|
||||
* - Unknown event types handled gracefully (fixes #984)
|
||||
* - stderr suppressed in hook context (fixes #1181)
|
||||
* - Claude Code adapter defaults suppressOutput to true
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
// --- Event Handler Tests ---
|
||||
|
||||
describe('Hook Lifecycle - Event Handlers', () => {
|
||||
describe('getEventHandler', () => {
|
||||
it('should return handler for all recognized event types', async () => {
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
const recognizedTypes = [
|
||||
'context', 'session-init', 'observation',
|
||||
'summarize', 'session-complete', 'user-message', 'file-edit'
|
||||
];
|
||||
for (const type of recognizedTypes) {
|
||||
const handler = getEventHandler(type);
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler.execute).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return no-op handler for unknown event types (#984)', async () => {
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
const handler = getEventHandler('nonexistent-event');
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler.execute).toBeDefined();
|
||||
|
||||
const result = await handler.execute({
|
||||
sessionId: 'test-session',
|
||||
cwd: '/tmp'
|
||||
});
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should include session-complete as a recognized event type (#984)', async () => {
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
const handler = getEventHandler('session-complete');
|
||||
// session-complete should NOT be the no-op handler
|
||||
// We can verify this by checking it's not the same as an unknown type handler
|
||||
expect(handler).toBeDefined();
|
||||
// The real handler has different behavior than the no-op
|
||||
// (it tries to call the worker, while no-op just returns immediately)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Codex CLI Compatibility Tests (#744) ---
|
||||
|
||||
describe('Codex CLI Compatibility (#744)', () => {
|
||||
describe('getPlatformAdapter', () => {
|
||||
it('should return rawAdapter for unknown platforms like codex', async () => {
|
||||
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
|
||||
// Should not throw for unknown platforms — falls back to rawAdapter
|
||||
const adapter = getPlatformAdapter('codex');
|
||||
expect(adapter).toBe(rawAdapter);
|
||||
});
|
||||
|
||||
it('should return rawAdapter for any unrecognized platform string', async () => {
|
||||
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
|
||||
const adapter = getPlatformAdapter('some-future-cli');
|
||||
expect(adapter).toBe(rawAdapter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claudeCodeAdapter session_id fallbacks', () => {
|
||||
it('should use session_id when present', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
const input = claudeCodeAdapter.normalizeInput({ session_id: 'claude-123', cwd: '/tmp' });
|
||||
expect(input.sessionId).toBe('claude-123');
|
||||
});
|
||||
|
||||
it('should fall back to id field (Codex CLI format)', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
const input = claudeCodeAdapter.normalizeInput({ id: 'codex-456', cwd: '/tmp' });
|
||||
expect(input.sessionId).toBe('codex-456');
|
||||
});
|
||||
|
||||
it('should fall back to sessionId field (camelCase format)', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
const input = claudeCodeAdapter.normalizeInput({ sessionId: 'camel-789', cwd: '/tmp' });
|
||||
expect(input.sessionId).toBe('camel-789');
|
||||
});
|
||||
|
||||
it('should return undefined when no session ID field is present', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
const input = claudeCodeAdapter.normalizeInput({ cwd: '/tmp' });
|
||||
expect(input.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined input gracefully', async () => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
const input = claudeCodeAdapter.normalizeInput(undefined);
|
||||
expect(input.sessionId).toBeUndefined();
|
||||
expect(input.cwd).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-init handler undefined prompt', () => {
|
||||
it('should not throw when prompt is undefined', () => {
|
||||
// Verify the short-circuit logic works for undefined
|
||||
const rawPrompt: string | undefined = undefined;
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
expect(prompt).toBe('[media prompt]');
|
||||
});
|
||||
|
||||
it('should not throw when prompt is empty string', () => {
|
||||
const rawPrompt = '';
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
expect(prompt).toBe('[media prompt]');
|
||||
});
|
||||
|
||||
it('should not throw when prompt is whitespace-only', () => {
|
||||
const rawPrompt = ' ';
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
expect(prompt).toBe('[media prompt]');
|
||||
});
|
||||
|
||||
it('should preserve valid prompts', () => {
|
||||
const rawPrompt = 'fix the bug';
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
expect(prompt).toBe('fix the bug');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Cursor IDE Compatibility Tests (#838, #1049) ---
|
||||
|
||||
describe('Cursor IDE Compatibility (#838, #1049)', () => {
|
||||
describe('cursorAdapter session ID fallbacks', () => {
|
||||
it('should use conversation_id when present', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'conv-123', workspace_roots: ['/project'] });
|
||||
expect(input.sessionId).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('should fall back to generation_id', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ generation_id: 'gen-456', workspace_roots: ['/project'] });
|
||||
expect(input.sessionId).toBe('gen-456');
|
||||
});
|
||||
|
||||
it('should fall back to id field', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ id: 'id-789', workspace_roots: ['/project'] });
|
||||
expect(input.sessionId).toBe('id-789');
|
||||
});
|
||||
|
||||
it('should return undefined when no session ID field is present', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ workspace_roots: ['/project'] });
|
||||
expect(input.sessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorAdapter prompt field fallbacks', () => {
|
||||
it('should use prompt when present', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', prompt: 'fix the bug' });
|
||||
expect(input.prompt).toBe('fix the bug');
|
||||
});
|
||||
|
||||
it('should fall back to query field', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', query: 'search for files' });
|
||||
expect(input.prompt).toBe('search for files');
|
||||
});
|
||||
|
||||
it('should fall back to input field', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', input: 'user typed this' });
|
||||
expect(input.prompt).toBe('user typed this');
|
||||
});
|
||||
|
||||
it('should fall back to message field', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', message: 'hello cursor' });
|
||||
expect(input.prompt).toBe('hello cursor');
|
||||
});
|
||||
|
||||
it('should return undefined when no prompt field is present', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1' });
|
||||
expect(input.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prefer prompt over query', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', prompt: 'primary', query: 'secondary' });
|
||||
expect(input.prompt).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorAdapter cwd fallbacks', () => {
|
||||
it('should use workspace_roots[0] when present', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', workspace_roots: ['/my/project'] });
|
||||
expect(input.cwd).toBe('/my/project');
|
||||
});
|
||||
|
||||
it('should fall back to cwd field', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1', cwd: '/fallback/dir' });
|
||||
expect(input.cwd).toBe('/fallback/dir');
|
||||
});
|
||||
|
||||
it('should fall back to process.cwd() when nothing provided', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput({ conversation_id: 'c1' });
|
||||
expect(input.cwd).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorAdapter undefined input handling', () => {
|
||||
it('should handle undefined input gracefully', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput(undefined);
|
||||
expect(input.sessionId).toBeUndefined();
|
||||
expect(input.prompt).toBeUndefined();
|
||||
expect(input.cwd).toBe(process.cwd());
|
||||
});
|
||||
|
||||
it('should handle null input gracefully', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const input = cursorAdapter.normalizeInput(null);
|
||||
expect(input.sessionId).toBeUndefined();
|
||||
expect(input.prompt).toBeUndefined();
|
||||
expect(input.cwd).toBe(process.cwd());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursorAdapter formatOutput', () => {
|
||||
it('should return simple continue flag', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const output = cursorAdapter.formatOutput({ continue: true, suppressOutput: true });
|
||||
expect(output).toEqual({ continue: true });
|
||||
});
|
||||
|
||||
it('should default continue to true', async () => {
|
||||
const { cursorAdapter } = await import('../src/cli/adapters/cursor.js');
|
||||
const output = cursorAdapter.formatOutput({});
|
||||
expect(output).toEqual({ continue: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Platform Adapter Tests ---
|
||||
|
||||
describe('Hook Lifecycle - Claude Code Adapter', () => {
|
||||
const fmt = async (input: any) => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
return claudeCodeAdapter.formatOutput(input);
|
||||
};
|
||||
|
||||
// --- Happy paths ---
|
||||
|
||||
it('should return empty object for empty result', async () => {
|
||||
expect(await fmt({})).toEqual({});
|
||||
});
|
||||
|
||||
it('should include systemMessage when present', async () => {
|
||||
expect(await fmt({ systemMessage: 'test message' })).toEqual({ systemMessage: 'test message' });
|
||||
});
|
||||
|
||||
it('should use hookSpecificOutput format with systemMessage', async () => {
|
||||
const output = await fmt({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'test context' },
|
||||
systemMessage: 'test message'
|
||||
}) as Record<string, unknown>;
|
||||
expect(output.hookSpecificOutput).toEqual({ hookEventName: 'SessionStart', additionalContext: 'test context' });
|
||||
expect(output.systemMessage).toBe('test message');
|
||||
});
|
||||
|
||||
it('should return hookSpecificOutput without systemMessage when absent', async () => {
|
||||
expect(await fmt({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' },
|
||||
})).toEqual({
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'ctx' },
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge cases / unhappy paths (addresses PR #1291 review) ---
|
||||
|
||||
it('should return empty object for malformed input (undefined/null)', async () => {
|
||||
expect(await fmt(undefined)).toEqual({});
|
||||
expect(await fmt(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('should exclude falsy systemMessage values', async () => {
|
||||
expect(await fmt({ systemMessage: '' })).toEqual({});
|
||||
expect(await fmt({ systemMessage: null })).toEqual({});
|
||||
expect(await fmt({ systemMessage: 0 })).toEqual({});
|
||||
});
|
||||
|
||||
it('should strip all non-contract fields', async () => {
|
||||
expect(await fmt({
|
||||
continue: false,
|
||||
suppressOutput: false,
|
||||
systemMessage: 'msg',
|
||||
exitCode: 2,
|
||||
hookSpecificOutput: undefined,
|
||||
})).toEqual({ systemMessage: 'msg' });
|
||||
});
|
||||
|
||||
it('should only emit keys from the Claude Code hook contract', async () => {
|
||||
const allowedKeys = new Set(['hookSpecificOutput', 'systemMessage', 'decision', 'reason']);
|
||||
const cases = [
|
||||
{},
|
||||
{ systemMessage: 'x' },
|
||||
{ continue: true, suppressOutput: true, systemMessage: 'x', exitCode: 1 },
|
||||
{ hookSpecificOutput: { hookEventName: 'E', additionalContext: 'C' }, systemMessage: 'x' },
|
||||
];
|
||||
for (const input of cases) {
|
||||
for (const key of Object.keys(await fmt(input) as object)) {
|
||||
expect(allowedKeys.has(key)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- stderr Suppression Tests ---
|
||||
|
||||
describe('Hook Lifecycle - stderr Suppression (#1181)', () => {
|
||||
let originalStderrWrite: typeof process.stderr.write;
|
||||
let stderrOutput: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
stderrOutput = [];
|
||||
// Capture stderr writes
|
||||
process.stderr.write = ((chunk: any) => {
|
||||
stderrOutput.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
});
|
||||
|
||||
it('should not use console.error in handlers/index.ts for unknown events', async () => {
|
||||
// Re-import to get fresh module
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
|
||||
// Clear any stderr from import
|
||||
stderrOutput.length = 0;
|
||||
|
||||
// Call with unknown event — should use logger (writes to file), not console.error (writes to stderr)
|
||||
const handler = getEventHandler('unknown-event-type');
|
||||
await handler.execute({ sessionId: 'test', cwd: '/tmp' });
|
||||
|
||||
// No stderr output should have leaked from the handler dispatcher itself
|
||||
// (logger may write to stderr as fallback if log file unavailable, but that's
|
||||
// the logger's responsibility, not the dispatcher's)
|
||||
const dispatcherStderr = stderrOutput.filter(s => s.includes('[claude-mem] Unknown event'));
|
||||
expect(dispatcherStderr).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Hook Response Constants ---
|
||||
|
||||
describe('Hook Lifecycle - Standard Response', () => {
|
||||
it('should define standard hook response with suppressOutput: true', async () => {
|
||||
const { STANDARD_HOOK_RESPONSE } = await import('../src/hooks/hook-response.js');
|
||||
const parsed = JSON.parse(STANDARD_HOOK_RESPONSE);
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- hookCommand stderr suppression ---
|
||||
|
||||
describe('hookCommand - stderr suppression', () => {
|
||||
it('should not use console.error for worker unavailable errors', async () => {
|
||||
// The hookCommand function should use logger.warn instead of console.error
|
||||
// for worker unavailable errors, so stderr stays clean (#1181)
|
||||
const { hookCommand } = await import('../src/cli/hook-command.js');
|
||||
|
||||
// Verify the import includes logger
|
||||
const hookCommandSource = await Bun.file(
|
||||
new URL('../src/cli/hook-command.ts', import.meta.url).pathname
|
||||
).text();
|
||||
|
||||
// Should import logger
|
||||
expect(hookCommandSource).toContain("import { logger }");
|
||||
// Should use logger.warn for worker unavailable
|
||||
expect(hookCommandSource).toContain("logger.warn('HOOK'");
|
||||
// Should use logger.error for hook errors
|
||||
expect(hookCommandSource).toContain("logger.error('HOOK'");
|
||||
// Should suppress stderr
|
||||
expect(hookCommandSource).toContain("process.stderr.write = (() => true)");
|
||||
// Should restore stderr in finally block
|
||||
expect(hookCommandSource).toContain("process.stderr.write = originalStderrWrite");
|
||||
// Should NOT have console.error for error reporting
|
||||
expect(hookCommandSource).not.toContain("console.error(`[claude-mem]");
|
||||
expect(hookCommandSource).not.toContain("console.error(`Hook error:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Tests for Context Re-Injection Guard (#1079)
|
||||
*
|
||||
* Validates:
|
||||
* - session-init handler skips SDK agent init when contextInjected=true
|
||||
* - session-init handler proceeds with SDK agent init when contextInjected=false
|
||||
* - SessionManager.getSession returns undefined for uninitialized sessions
|
||||
* - SessionManager.getSession returns session after initialization
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Mock modules that cause import chain issues - MUST be before handler imports
|
||||
// paths.ts calls SettingsDefaultsManager.get() at module load time
|
||||
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
SettingsDefaultsManager: {
|
||||
get: (key: string) => {
|
||||
if (key === 'CLAUDE_MEM_DATA_DIR') return join(homedir(), '.claude-mem');
|
||||
return '';
|
||||
},
|
||||
getInt: () => 0,
|
||||
loadFromFile: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../src/shared/worker-utils.js', () => ({
|
||||
ensureWorkerRunning: () => Promise.resolve(true),
|
||||
getWorkerPort: () => 37777,
|
||||
workerHttpRequest: (apiPath: string, options?: any) => {
|
||||
// Delegate to global fetch so tests can mock fetch behavior
|
||||
const url = `http://127.0.0.1:37777${apiPath}`;
|
||||
return globalThis.fetch(url, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../src/utils/project-name.js', () => ({
|
||||
getProjectName: () => 'test-project',
|
||||
}));
|
||||
|
||||
mock.module('../../src/utils/project-filter.js', () => ({
|
||||
isProjectExcluded: () => false,
|
||||
}));
|
||||
|
||||
// Now import after mocks
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
spyOn(logger, 'failure').mockImplementation(() => {}),
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
});
|
||||
|
||||
describe('Context Re-Injection Guard (#1079)', () => {
|
||||
describe('session-init handler - contextInjected flag behavior', () => {
|
||||
it('should skip SDK agent init when contextInjected is true', async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString();
|
||||
fetchedUrls.push(urlStr);
|
||||
|
||||
if (urlStr.includes('/api/sessions/init')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
sessionDbId: 42,
|
||||
promptNumber: 2,
|
||||
skipped: false,
|
||||
contextInjected: true // SDK agent already running
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// The /sessions/42/init call — should NOT be reached
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'initialized' })
|
||||
});
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
|
||||
|
||||
const result = await sessionInitHandler.execute({
|
||||
sessionId: 'test-session-123',
|
||||
cwd: '/test/project',
|
||||
prompt: 'second prompt in this session',
|
||||
platform: 'claude-code',
|
||||
});
|
||||
|
||||
// Should return success without making the second /sessions/42/init call
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
|
||||
// Only the /api/sessions/init call should have been made
|
||||
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
|
||||
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
|
||||
|
||||
expect(apiInitCalls.length).toBe(1);
|
||||
expect(sdkInitCalls.length).toBe(0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should proceed with SDK agent init when contextInjected is false', async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString();
|
||||
fetchedUrls.push(urlStr);
|
||||
|
||||
if (urlStr.includes('/api/sessions/init')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
sessionDbId: 42,
|
||||
promptNumber: 1,
|
||||
skipped: false,
|
||||
contextInjected: false // First prompt — SDK agent not yet started
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// The /sessions/42/init call — SHOULD be reached
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'initialized' })
|
||||
});
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
|
||||
|
||||
const result = await sessionInitHandler.execute({
|
||||
sessionId: 'test-session-456',
|
||||
cwd: '/test/project',
|
||||
prompt: 'first prompt in session',
|
||||
platform: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
|
||||
// Both calls should have been made
|
||||
const apiInitCalls = fetchedUrls.filter(u => u.includes('/api/sessions/init'));
|
||||
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
|
||||
|
||||
expect(apiInitCalls.length).toBe(1);
|
||||
expect(sdkInitCalls.length).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('should proceed with SDK agent init when contextInjected is undefined (backward compat)', async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString();
|
||||
fetchedUrls.push(urlStr);
|
||||
|
||||
if (urlStr.includes('/api/sessions/init')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
sessionDbId: 42,
|
||||
promptNumber: 1,
|
||||
skipped: false
|
||||
// contextInjected not present (older worker version)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'initialized' })
|
||||
});
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockFetch as any;
|
||||
|
||||
try {
|
||||
const { sessionInitHandler } = await import('../../src/cli/handlers/session-init.js');
|
||||
|
||||
const result = await sessionInitHandler.execute({
|
||||
sessionId: 'test-session-789',
|
||||
cwd: '/test/project',
|
||||
prompt: 'test prompt',
|
||||
platform: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.continue).toBe(true);
|
||||
|
||||
// When contextInjected is undefined/missing, should still make the SDK init call
|
||||
const sdkInitCalls = fetchedUrls.filter(u => u.includes('/sessions/42/init'));
|
||||
expect(sdkInitCalls.length).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionManager contextInjected logic', () => {
|
||||
it('should return undefined for getSession when no active session exists', async () => {
|
||||
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
|
||||
|
||||
const mockDbManager = {
|
||||
getSessionById: () => ({
|
||||
id: 1,
|
||||
content_session_id: 'test-session',
|
||||
project: 'test',
|
||||
user_prompt: 'test prompt',
|
||||
memory_session_id: null,
|
||||
status: 'active',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
}),
|
||||
getSessionStore: () => ({ db: {} }),
|
||||
} as any;
|
||||
|
||||
const sessionManager = new SessionManager(mockDbManager);
|
||||
|
||||
// Session 42 has not been initialized in memory
|
||||
const session = sessionManager.getSession(42);
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return active session after initializeSession is called', async () => {
|
||||
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
|
||||
|
||||
const mockDbManager = {
|
||||
getSessionById: () => ({
|
||||
id: 42,
|
||||
content_session_id: 'test-session',
|
||||
project: 'test',
|
||||
user_prompt: 'test prompt',
|
||||
memory_session_id: null,
|
||||
status: 'active',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
}),
|
||||
getSessionStore: () => ({
|
||||
db: {},
|
||||
clearMemorySessionId: () => {},
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const sessionManager = new SessionManager(mockDbManager);
|
||||
|
||||
// Initialize session (simulates first SDK agent init)
|
||||
sessionManager.initializeSession(42, 'first prompt', 1);
|
||||
|
||||
// Now getSession should return the active session
|
||||
const session = sessionManager.getSession(42);
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.contentSessionId).toBe('test-session');
|
||||
});
|
||||
|
||||
it('should return contextInjected=true pattern for subsequent prompts', async () => {
|
||||
const { SessionManager } = await import('../../src/services/worker/SessionManager.js');
|
||||
|
||||
const mockDbManager = {
|
||||
getSessionById: () => ({
|
||||
id: 42,
|
||||
content_session_id: 'test-session',
|
||||
project: 'test',
|
||||
user_prompt: 'test prompt',
|
||||
memory_session_id: 'sdk-session-abc',
|
||||
status: 'active',
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
}),
|
||||
getSessionStore: () => ({
|
||||
db: {},
|
||||
clearMemorySessionId: () => {},
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const sessionManager = new SessionManager(mockDbManager);
|
||||
|
||||
// Before initialization: contextInjected would be false
|
||||
expect(sessionManager.getSession(42)).toBeUndefined();
|
||||
|
||||
// After initialization: contextInjected would be true
|
||||
sessionManager.initializeSession(42, 'first prompt', 1);
|
||||
expect(sessionManager.getSession(42)).toBeDefined();
|
||||
|
||||
// Second call to initializeSession returns existing session (idempotent)
|
||||
const session2 = sessionManager.initializeSession(42, 'second prompt', 2);
|
||||
expect(session2.contentSessionId).toBe('test-session');
|
||||
expect(session2.userPrompt).toBe('second prompt');
|
||||
expect(session2.lastPromptNumber).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
.agent/services/claude-mem/tests/infrastructure/CLAUDE.md
Normal file
13
.agent/services/claude-mem/tests/infrastructure/CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36870 | 1:54 AM | 🟣 | Phase 2 Implementation Completed via Subagent | ~572 |
|
||||
| #36866 | 1:53 AM | 🔄 | WMIC Test Refactored to Use Direct Logic Testing | ~533 |
|
||||
| #36865 | 1:52 AM | ✅ | WMIC Test File Updated with Improved Mock Implementation | ~370 |
|
||||
| #36863 | 1:51 AM | 🟣 | WMIC Parsing Test File Created | ~581 |
|
||||
| #36861 | " | 🔵 | Existing ProcessManager Test File Structure Analyzed | ~516 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import {
|
||||
performGracefulShutdown,
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
type GracefulShutdownConfig,
|
||||
type ShutdownableService,
|
||||
type CloseableClient,
|
||||
type CloseableDatabase,
|
||||
type PidInfo
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('GracefulShutdown', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
|
||||
// Ensure we're testing on non-Windows to avoid child process enumeration
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
const { writeFileSync } = require('fs');
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
originalPidContent = null;
|
||||
} else {
|
||||
removePidFile();
|
||||
}
|
||||
|
||||
// Restore platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
describe('performGracefulShutdown', () => {
|
||||
it('should call shutdown steps in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockServer = {
|
||||
closeAllConnections: mock(() => {
|
||||
callOrder.push('closeAllConnections');
|
||||
}),
|
||||
close: mock((cb: (err?: Error) => void) => {
|
||||
callOrder.push('serverClose');
|
||||
cb();
|
||||
})
|
||||
} as unknown as http.Server;
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {
|
||||
callOrder.push('sessionManager.shutdownAll');
|
||||
})
|
||||
};
|
||||
|
||||
const mockMcpClient: CloseableClient = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('mcpClient.close');
|
||||
})
|
||||
};
|
||||
|
||||
const mockDbManager: CloseableDatabase = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('dbManager.close');
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaMcpManager = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaMcpManager.stop');
|
||||
})
|
||||
};
|
||||
|
||||
// Create a PID file so we can verify it's removed
|
||||
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: mockServer,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager,
|
||||
chromaMcpManager: mockChromaMcpManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then Chroma, then DB
|
||||
expect(callOrder).toContain('closeAllConnections');
|
||||
expect(callOrder).toContain('serverClose');
|
||||
expect(callOrder).toContain('sessionManager.shutdownAll');
|
||||
expect(callOrder).toContain('mcpClient.close');
|
||||
expect(callOrder).toContain('chromaMcpManager.stop');
|
||||
expect(callOrder).toContain('dbManager.close');
|
||||
|
||||
// Verify server closes before session manager
|
||||
expect(callOrder.indexOf('serverClose')).toBeLessThan(callOrder.indexOf('sessionManager.shutdownAll'));
|
||||
|
||||
// Verify session manager shuts down before MCP client
|
||||
expect(callOrder.indexOf('sessionManager.shutdownAll')).toBeLessThan(callOrder.indexOf('mcpClient.close'));
|
||||
|
||||
// Verify MCP closes before database
|
||||
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
|
||||
// Verify Chroma stops before DB closes
|
||||
expect(callOrder.indexOf('chromaMcpManager.stop')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
});
|
||||
|
||||
it('should remove PID file during shutdown', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
// Create PID file
|
||||
writePidFile({ pid: 99999, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// PID file should be removed
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing optional services gracefully', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
// mcpClient and dbManager are undefined
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
|
||||
// Session manager should still be called
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle null server gracefully', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call sessionManager.shutdownAll even without server', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop chroma server before database close', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {
|
||||
callOrder.push('sessionManager');
|
||||
})
|
||||
};
|
||||
|
||||
const mockMcpClient: CloseableClient = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('mcpClient');
|
||||
})
|
||||
};
|
||||
|
||||
const mockDbManager: CloseableDatabase = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('dbManager');
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaMcpManager = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaMcpManager');
|
||||
})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager,
|
||||
chromaMcpManager: mockChromaMcpManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'chromaMcpManager', 'dbManager']);
|
||||
});
|
||||
|
||||
it('should handle shutdown when PID file does not exist', async () => {
|
||||
// Ensure PID file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import {
|
||||
isPortInUse,
|
||||
waitForHealth,
|
||||
waitForPortFree,
|
||||
getInstalledPluginVersion,
|
||||
checkVersionMatch
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
describe('HealthMonitor', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('isPortInUse', () => {
|
||||
it('should return true for occupied port (health check succeeds)', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://127.0.0.1:37777/api/health');
|
||||
});
|
||||
|
||||
it('should return false for free port (connection refused)', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const result = await isPortInUse(39999);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when health check returns non-ok', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: false, status: 503 } as Response));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on network timeout', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ETIMEDOUT')));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on fetch failed error', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('fetch failed')));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForHealth', () => {
|
||||
it('should succeed immediately when server responds', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should return quickly (within first poll cycle)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should timeout when no server responds', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForHealth(39999, 1500);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should take close to timeout duration
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('should succeed after server becomes available', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = mock(() => {
|
||||
callCount++;
|
||||
// Fail first 2 calls, succeed on third
|
||||
if (callCount < 3) {
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(callCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should check health endpoint for liveness', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await waitForHealth(37777, 1000);
|
||||
|
||||
// waitForHealth uses /api/health (liveness), not /api/readiness
|
||||
// This is because hooks have 15-second timeout but full initialization can take 5+ minutes
|
||||
// See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
const calls = fetchMock.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls[0][0]).toBe('http://127.0.0.1:37777/api/health');
|
||||
});
|
||||
|
||||
it('should use default timeout when not specified', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForHealth(37777);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstalledPluginVersion', () => {
|
||||
it('should return a valid semver string', () => {
|
||||
const version = getInstalledPluginVersion();
|
||||
|
||||
// Should be a string matching semver pattern or 'unknown'
|
||||
if (version !== 'unknown') {
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not throw on ENOENT (graceful degradation)', () => {
|
||||
// The function handles ENOENT internally — should not throw
|
||||
// If package.json exists, it returns the version; if not, 'unknown'
|
||||
expect(() => getInstalledPluginVersion()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVersionMatch', () => {
|
||||
it('should assume match when worker version is unavailable', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const result = await checkVersionMatch(39999);
|
||||
|
||||
expect(result.matches).toBe(true);
|
||||
expect(result.workerVersion).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect version mismatch', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: '0.0.0-definitely-wrong' }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
// Unless the plugin version is also '0.0.0-definitely-wrong', this should be a mismatch
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion !== 'unknown' && pluginVersion !== '0.0.0-definitely-wrong') {
|
||||
expect(result.matches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect version match', async () => {
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion === 'unknown') return; // Skip if can't read plugin version
|
||||
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: pluginVersion }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
expect(result.matches).toBe(true);
|
||||
expect(result.pluginVersion).toBe(pluginVersion);
|
||||
expect(result.workerVersion).toBe(pluginVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForPortFree', () => {
|
||||
it('should return true immediately when port is already free', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForPortFree(39999, 5000);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should return quickly
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should timeout when port remains occupied', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForPortFree(37777, 1500);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should take close to timeout duration
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('should succeed when port becomes free', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = mock(() => {
|
||||
callCount++;
|
||||
// Port occupied for first 2 checks, then free
|
||||
if (callCount < 3) {
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
}
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
});
|
||||
|
||||
const result = await waitForPortFree(37777, 5000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(callCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should use default timeout when not specified', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForPortFree(39999);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { isPluginDisabledInClaudeSettings } from '../../src/shared/plugin-state.js';
|
||||
|
||||
/**
|
||||
* Tests for isPluginDisabledInClaudeSettings() (#781).
|
||||
*
|
||||
* The function reads CLAUDE_CONFIG_DIR/settings.json and checks if
|
||||
* enabledPlugins["claude-mem@thedotmack"] === false.
|
||||
*
|
||||
* We test by setting CLAUDE_CONFIG_DIR to a temp directory with mock settings.
|
||||
*/
|
||||
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `plugin-disabled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('isPluginDisabledInClaudeSettings (#781)', () => {
|
||||
it('should return false when settings.json does not exist', () => {
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugin is explicitly enabled', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'claude-mem@thedotmack': true
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when plugin is explicitly disabled', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'claude-mem@thedotmack': false
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when enabledPlugins key is missing', () => {
|
||||
const settings = {
|
||||
permissions: { allow: [] }
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugin key is absent from enabledPlugins', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'other-plugin@marketplace': true
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when settings.json contains invalid JSON', () => {
|
||||
writeFileSync(join(tempDir, 'settings.json'), '{ invalid json }}}');
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when settings.json is empty', () => {
|
||||
writeFileSync(join(tempDir, 'settings.json'), '');
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Regression tests for plugin distribution completeness.
|
||||
* Ensures all required files (skills, hooks, manifests) are present
|
||||
* and correctly structured for end-user installs.
|
||||
*
|
||||
* Prevents issue #1187 (missing skills/ directory after install).
|
||||
*/
|
||||
describe('Plugin Distribution - Skills', () => {
|
||||
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
|
||||
|
||||
it('should include plugin/skills/mem-search/SKILL.md', () => {
|
||||
expect(existsSync(skillPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have valid YAML frontmatter with name and description', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
|
||||
// Must start with YAML frontmatter
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
|
||||
// Extract frontmatter
|
||||
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
||||
expect(frontmatterEnd).toBeGreaterThan(0);
|
||||
|
||||
const frontmatter = content.slice(4, frontmatterEnd);
|
||||
expect(frontmatter).toContain('name:');
|
||||
expect(frontmatter).toContain('description:');
|
||||
});
|
||||
|
||||
it('should reference the 3-layer search workflow', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
// The skill must document the search → timeline → get_observations workflow
|
||||
expect(content).toContain('search');
|
||||
expect(content).toContain('timeline');
|
||||
expect(content).toContain('get_observations');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - Required Files', () => {
|
||||
const requiredFiles = [
|
||||
'plugin/hooks/hooks.json',
|
||||
'plugin/.claude-plugin/plugin.json',
|
||||
'plugin/skills/mem-search/SKILL.md',
|
||||
];
|
||||
|
||||
for (const filePath of requiredFiles) {
|
||||
it(`should include ${filePath}`, () => {
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
expect(existsSync(fullPath)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
it('should have valid JSON in hooks.json', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const content = readFileSync(hooksPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain(expectedFallbackPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - package.json Files Field', () => {
|
||||
it('should include "plugin" in root package.json files field', () => {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
expect(packageJson.files).toBeDefined();
|
||||
expect(packageJson.files).toContain('plugin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - Build Script Verification', () => {
|
||||
it('should verify distribution files in build-hooks.js', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
const content = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Build script must check for critical distribution files
|
||||
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
|
||||
expect(content).toContain('plugin/hooks/hooks.json');
|
||||
expect(content).toContain('plugin/.claude-plugin/plugin.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
getPlatformTimeout,
|
||||
parseElapsedTime,
|
||||
isProcessAlive,
|
||||
cleanStalePidFile,
|
||||
isPidFileRecent,
|
||||
touchPidFile,
|
||||
spawnDaemon,
|
||||
resolveWorkerRuntimePath,
|
||||
runOneTimeChromaMigration,
|
||||
type PidInfo
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('ProcessManager', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
originalPidContent = null;
|
||||
} else {
|
||||
removePidFile();
|
||||
}
|
||||
});
|
||||
|
||||
describe('writePidFile', () => {
|
||||
it('should create file with PID info', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 12345,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
writePidFile(testInfo);
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
||||
expect(content.pid).toBe(12345);
|
||||
expect(content.port).toBe(37777);
|
||||
expect(content.startedAt).toBe(testInfo.startedAt);
|
||||
});
|
||||
|
||||
it('should overwrite existing PID file', () => {
|
||||
const firstInfo: PidInfo = {
|
||||
pid: 11111,
|
||||
port: 37777,
|
||||
startedAt: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
const secondInfo: PidInfo = {
|
||||
pid: 22222,
|
||||
port: 37888,
|
||||
startedAt: '2024-01-02T00:00:00.000Z'
|
||||
};
|
||||
|
||||
writePidFile(firstInfo);
|
||||
writePidFile(secondInfo);
|
||||
|
||||
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
||||
expect(content.pid).toBe(22222);
|
||||
expect(content.port).toBe(37888);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPidFile', () => {
|
||||
it('should return PidInfo object for valid file', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 54321,
|
||||
port: 37999,
|
||||
startedAt: '2024-06-15T12:00:00.000Z'
|
||||
};
|
||||
writePidFile(testInfo);
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pid).toBe(54321);
|
||||
expect(result!.port).toBe(37999);
|
||||
expect(result!.startedAt).toBe('2024-06-15T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should return null for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for corrupted JSON', () => {
|
||||
writeFileSync(PID_FILE, 'not valid json {{{');
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePidFile', () => {
|
||||
it('should delete existing file', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 99999,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writePidFile(testInfo);
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
removePidFile();
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => removePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseElapsedTime', () => {
|
||||
it('should parse MM:SS format', () => {
|
||||
expect(parseElapsedTime('05:30')).toBe(5);
|
||||
expect(parseElapsedTime('00:45')).toBe(0);
|
||||
expect(parseElapsedTime('59:59')).toBe(59);
|
||||
});
|
||||
|
||||
it('should parse HH:MM:SS format', () => {
|
||||
expect(parseElapsedTime('01:30:00')).toBe(90);
|
||||
expect(parseElapsedTime('02:15:30')).toBe(135);
|
||||
expect(parseElapsedTime('00:05:00')).toBe(5);
|
||||
});
|
||||
|
||||
it('should parse DD-HH:MM:SS format', () => {
|
||||
expect(parseElapsedTime('1-00:00:00')).toBe(1440); // 1 day
|
||||
expect(parseElapsedTime('2-12:30:00')).toBe(3630); // 2 days + 12.5 hours
|
||||
expect(parseElapsedTime('0-01:00:00')).toBe(60); // 1 hour
|
||||
});
|
||||
|
||||
it('should return -1 for empty or invalid input', () => {
|
||||
expect(parseElapsedTime('')).toBe(-1);
|
||||
expect(parseElapsedTime(' ')).toBe(-1);
|
||||
expect(parseElapsedTime('invalid')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatformTimeout', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same value on non-Windows platforms', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const result = getPlatformTimeout(1000);
|
||||
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
|
||||
it('should return doubled value on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const result = getPlatformTimeout(1000);
|
||||
|
||||
expect(result).toBe(2000);
|
||||
});
|
||||
|
||||
it('should apply 2.0x multiplier consistently on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getPlatformTimeout(500)).toBe(1000);
|
||||
expect(getPlatformTimeout(5000)).toBe(10000);
|
||||
expect(getPlatformTimeout(100)).toBe(200);
|
||||
});
|
||||
|
||||
it('should round Windows timeout values', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 2.0x of 333 = 666 (rounds to 666)
|
||||
const result = getPlatformTimeout(333);
|
||||
|
||||
expect(result).toBe(666);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkerRuntimePath', () => {
|
||||
it('should return current runtime on non-Windows platforms', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'linux',
|
||||
execPath: '/usr/bin/node'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('/usr/bin/node');
|
||||
});
|
||||
|
||||
it('should reuse execPath when already running under Bun on Windows', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Users\\alice\\.bun\\bin\\bun.exe'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\Users\\alice\\.bun\\bin\\bun.exe');
|
||||
});
|
||||
|
||||
it('should prefer configured Bun path from environment when available', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: { BUN: 'C:\\tools\\bun.exe' } as NodeJS.ProcessEnv,
|
||||
pathExists: candidatePath => candidatePath === 'C:\\tools\\bun.exe',
|
||||
lookupInPath: () => null
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\tools\\bun.exe');
|
||||
});
|
||||
|
||||
it('should fall back to PATH lookup when no Bun candidate exists', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
pathExists: () => false,
|
||||
lookupInPath: () => 'C:\\Program Files\\Bun\\bun.exe'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\Program Files\\Bun\\bun.exe');
|
||||
});
|
||||
|
||||
it('should return null when Bun cannot be resolved on Windows', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
pathExists: () => false,
|
||||
lookupInPath: () => null
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('should return true for the current process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a non-existent PID', () => {
|
||||
// Use a very high PID that's extremely unlikely to exist
|
||||
expect(isProcessAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for PID 0 (Windows WMIC sentinel)', () => {
|
||||
expect(isProcessAlive(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for negative PIDs', () => {
|
||||
expect(isProcessAlive(-1)).toBe(false);
|
||||
expect(isProcessAlive(-999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-integer PIDs', () => {
|
||||
expect(isProcessAlive(1.5)).toBe(false);
|
||||
expect(isProcessAlive(NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanStalePidFile', () => {
|
||||
it('should remove PID file when process is dead', () => {
|
||||
// Write a PID file with a non-existent PID
|
||||
const staleInfo: PidInfo = {
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
startedAt: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
writePidFile(staleInfo);
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep PID file when process is alive', () => {
|
||||
// Write a PID file with the current process PID (definitely alive)
|
||||
const liveInfo: PidInfo = {
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writePidFile(liveInfo);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
// PID file should still exist since process.pid is alive
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should do nothing when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => cleanStalePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPidFileRecent', () => {
|
||||
it('should return true for a recently written PID file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// File was just written, should be very recent
|
||||
expect(isPidFileRecent(15000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
|
||||
expect(isPidFileRecent(15000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a very short threshold on a real file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// With a 0ms threshold, even a just-written file should be "too old"
|
||||
// (mtime is at least 1ms in the past by the time we check)
|
||||
// Use a negative threshold to guarantee false
|
||||
expect(isPidFileRecent(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchPidFile', () => {
|
||||
it('should update mtime of existing PID file', async () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// Wait a bit to ensure measurable mtime difference
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const statsBefore = require('fs').statSync(PID_FILE);
|
||||
const mtimeBefore = statsBefore.mtimeMs;
|
||||
|
||||
// Wait again to ensure mtime advances
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
touchPidFile();
|
||||
|
||||
const statsAfter = require('fs').statSync(PID_FILE);
|
||||
const mtimeAfter = statsAfter.mtimeMs;
|
||||
|
||||
expect(mtimeAfter).toBeGreaterThanOrEqual(mtimeBefore);
|
||||
});
|
||||
|
||||
it('should not throw when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
|
||||
expect(() => touchPidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnDaemon', () => {
|
||||
it('should use setsid on Linux when available', () => {
|
||||
// setsid should exist at /usr/bin/setsid on Linux
|
||||
if (process.platform === 'win32') return; // Skip on Windows
|
||||
|
||||
const setsidAvailable = existsSync('/usr/bin/setsid');
|
||||
if (!setsidAvailable) return; // Skip if setsid not installed
|
||||
|
||||
// Spawn a daemon with a non-existent script (it will fail to start, but we can verify the spawn attempt)
|
||||
// Use a harmless script path — the child will exit immediately
|
||||
const pid = spawnDaemon('/dev/null', 39999);
|
||||
|
||||
// setsid spawn should return a PID (the setsid process itself)
|
||||
expect(pid).toBeDefined();
|
||||
expect(typeof pid).toBe('number');
|
||||
|
||||
// Clean up: kill the spawned process if it's still alive
|
||||
if (pid !== undefined && pid > 0) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined when spawn fails on Windows path', () => {
|
||||
// On non-Windows, this tests the Unix path which should succeed
|
||||
// The function should not throw, only return undefined on failure
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Spawning with a totally invalid script should still return a PID
|
||||
// (setsid/spawn succeeds even if the child will exit immediately)
|
||||
const result = spawnDaemon('/nonexistent/script.cjs', 39998);
|
||||
// spawn itself should succeed (returns PID), even if child exits
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
if (result !== undefined && result > 0) {
|
||||
try { process.kill(result, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SIGHUP handling', () => {
|
||||
it('should have SIGHUP listeners registered (integration check)', () => {
|
||||
// Verify that SIGHUP listener registration is possible on Unix
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Register a test handler, verify it works, then remove it
|
||||
let received = false;
|
||||
const testHandler = () => { received = true; };
|
||||
|
||||
process.on('SIGHUP', testHandler);
|
||||
expect(process.listenerCount('SIGHUP')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clean up the test handler
|
||||
process.removeListener('SIGHUP', testHandler);
|
||||
});
|
||||
|
||||
it('should ignore SIGHUP when --daemon is in process.argv', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Simulate the daemon SIGHUP handler logic
|
||||
const isDaemon = process.argv.includes('--daemon');
|
||||
// In test context, --daemon is not in argv, so this tests the branch logic
|
||||
expect(isDaemon).toBe(false);
|
||||
|
||||
// Verify the non-daemon path: SIGHUP should trigger shutdown (covered by registerSignalHandlers)
|
||||
// This is a logic verification test — actual signal delivery is tested manually
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOneTimeChromaMigration', () => {
|
||||
let testDataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDataDir = path.join(tmpdir(), `claude-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDataDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should wipe chroma directory and write marker file', () => {
|
||||
// Create a fake chroma directory with data
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'test-data.bin'), 'fake chroma data');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should be gone
|
||||
expect(existsSync(chromaDir)).toBe(false);
|
||||
// Marker file should exist
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip when marker file already exists (idempotent)', () => {
|
||||
// Write marker file first
|
||||
writeFileSync(path.join(testDataDir, '.chroma-cleaned-v10.3'), 'already done');
|
||||
|
||||
// Create a chroma directory that should NOT be wiped
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'important.bin'), 'should survive');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should still exist (migration was skipped)
|
||||
expect(existsSync(chromaDir)).toBe(true);
|
||||
expect(existsSync(path.join(chromaDir, 'important.bin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing chroma directory gracefully', () => {
|
||||
// No chroma dir exists — should just write marker without error
|
||||
expect(() => runOneTimeChromaMigration(testDataDir)).not.toThrow();
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Test suite to ensure version consistency across all package.json files
|
||||
* and built artifacts.
|
||||
*
|
||||
* This prevents the infinite restart loop issue where:
|
||||
* - Plugin reads version from plugin/package.json
|
||||
* - Worker returns built-in version from bundled code
|
||||
* - Mismatch triggers restart on every hook call
|
||||
*/
|
||||
describe('Version Consistency', () => {
|
||||
let rootVersion: string;
|
||||
|
||||
it('should read version from root package.json', () => {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
expect(existsSync(packageJsonPath)).toBe(true);
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
expect(packageJson.version).toBeDefined();
|
||||
expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
rootVersion = packageJson.version;
|
||||
});
|
||||
|
||||
it('should have matching version in plugin/package.json', () => {
|
||||
const pluginPackageJsonPath = path.join(projectRoot, 'plugin/package.json');
|
||||
expect(existsSync(pluginPackageJsonPath)).toBe(true);
|
||||
|
||||
const pluginPackageJson = JSON.parse(readFileSync(pluginPackageJsonPath, 'utf-8'));
|
||||
expect(pluginPackageJson.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have matching version in plugin/.claude-plugin/plugin.json', () => {
|
||||
const pluginJsonPath = path.join(projectRoot, 'plugin/.claude-plugin/plugin.json');
|
||||
expect(existsSync(pluginJsonPath)).toBe(true);
|
||||
|
||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||
expect(pluginJson.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have matching version in .claude-plugin/marketplace.json', () => {
|
||||
const marketplaceJsonPath = path.join(projectRoot, '.claude-plugin/marketplace.json');
|
||||
expect(existsSync(marketplaceJsonPath)).toBe(true);
|
||||
|
||||
const marketplaceJson = JSON.parse(readFileSync(marketplaceJsonPath, 'utf-8'));
|
||||
expect(marketplaceJson.plugins).toBeDefined();
|
||||
expect(marketplaceJson.plugins.length).toBeGreaterThan(0);
|
||||
|
||||
const claudeMemPlugin = marketplaceJson.plugins.find((p: any) => p.name === 'claude-mem');
|
||||
expect(claudeMemPlugin).toBeDefined();
|
||||
expect(claudeMemPlugin.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have version injected into built worker-service.cjs', () => {
|
||||
const workerServicePath = path.join(projectRoot, 'plugin/scripts/worker-service.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(workerServicePath)) {
|
||||
console.log('⚠️ worker-service.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
const workerServiceContent = readFileSync(workerServicePath, 'utf-8');
|
||||
|
||||
// The build script injects version via esbuild define:
|
||||
// define: { '__DEFAULT_PACKAGE_VERSION__': `"${version}"` }
|
||||
// This becomes: const BUILT_IN_VERSION = "9.0.0" (or minified: Bre="9.0.0")
|
||||
|
||||
// Check for the version string in the minified code
|
||||
const versionPattern = new RegExp(`"${rootVersion.replace(/\./g, '\\.')}"`, 'g');
|
||||
const matches = workerServiceContent.match(versionPattern);
|
||||
|
||||
expect(matches).toBeTruthy();
|
||||
expect(matches!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have built mcp-server.cjs', () => {
|
||||
const mcpServerPath = path.join(projectRoot, 'plugin/scripts/mcp-server.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(mcpServerPath)) {
|
||||
console.log('⚠️ mcp-server.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
// mcp-server.cjs doesn't use __DEFAULT_PACKAGE_VERSION__ - it's a search server
|
||||
// that doesn't need to expose version info. Just verify it exists and is built.
|
||||
const mcpServerContent = readFileSync(mcpServerPath, 'utf-8');
|
||||
expect(mcpServerContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate version format is semver compliant', () => {
|
||||
// Ensure version follows semantic versioning: MAJOR.MINOR.PATCH
|
||||
expect(rootVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
const [major, minor, patch] = rootVersion.split('.').map(Number);
|
||||
expect(major).toBeGreaterThanOrEqual(0);
|
||||
expect(minor).toBeGreaterThanOrEqual(0);
|
||||
expect(patch).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional test to ensure build script properly reads and injects version
|
||||
*/
|
||||
describe('Build Script Version Handling', () => {
|
||||
it('should read version from package.json in build-hooks.js', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
expect(existsSync(buildScriptPath)).toBe(true);
|
||||
|
||||
const buildScriptContent = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Verify build script reads from package.json
|
||||
expect(buildScriptContent).toContain("readFileSync('package.json'");
|
||||
expect(buildScriptContent).toContain('packageJson.version');
|
||||
|
||||
// Verify it generates plugin/package.json with the version
|
||||
expect(buildScriptContent).toContain('version: version');
|
||||
|
||||
// Verify it injects version into esbuild define
|
||||
expect(buildScriptContent).toContain('__DEFAULT_PACKAGE_VERSION__');
|
||||
expect(buildScriptContent).toContain('`"${version}"`');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for PowerShell output parsing logic used in Windows process enumeration.
|
||||
*
|
||||
* This tests the parsing behavior directly since mocking promisified exec
|
||||
* is unreliable across module boundaries. The parsing logic matches exactly
|
||||
* what's in ProcessManager.getChildProcesses().
|
||||
*/
|
||||
|
||||
// Extract the parsing logic from ProcessManager for direct testing
|
||||
// This matches the implementation in src/services/infrastructure/ProcessManager.ts lines 95-100
|
||||
function parsePowerShellOutput(stdout: string): number[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && /^\d+$/.test(line))
|
||||
.map(line => parseInt(line, 10))
|
||||
.filter(pid => pid > 0);
|
||||
}
|
||||
|
||||
// Validate parent PID - matches ProcessManager.getChildProcesses() lines 85-88
|
||||
function isValidParentPid(parentPid: number): boolean {
|
||||
return Number.isInteger(parentPid) && parentPid > 0;
|
||||
}
|
||||
|
||||
describe('PowerShell output parsing (Windows)', () => {
|
||||
describe('parsePowerShellOutput - simple number format parsing', () => {
|
||||
it('should parse simple number format correctly', () => {
|
||||
const stdout = '12345\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should parse single PID from PowerShell output', () => {
|
||||
const stdout = '54321\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([54321]);
|
||||
});
|
||||
|
||||
it('should handle empty PowerShell output', () => {
|
||||
const stdout = '';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle PowerShell output with only whitespace', () => {
|
||||
const stdout = ' \r\n \r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter invalid PIDs from PowerShell output', () => {
|
||||
const stdout = '12345\r\ninvalid\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should filter negative PIDs from PowerShell output', () => {
|
||||
const stdout = '12345\r\n-1\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should filter zero PIDs from PowerShell output', () => {
|
||||
const stdout = '0\r\n12345\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345]);
|
||||
});
|
||||
|
||||
it('should handle PowerShell output with extra lines and noise', () => {
|
||||
const stdout = '\r\n\r\n12345\r\n\r\nSome other output\r\n67890\r\n\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should handle Windows line endings (CRLF)', () => {
|
||||
const stdout = '111\r\n222\r\n333\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([111, 222, 333]);
|
||||
});
|
||||
|
||||
it('should handle Unix line endings (LF)', () => {
|
||||
const stdout = '111\n222\n333\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([111, 222, 333]);
|
||||
});
|
||||
|
||||
it('should handle very large PIDs', () => {
|
||||
// Windows PIDs can be large but are still 32-bit integers
|
||||
const stdout = '2147483647\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([2147483647]);
|
||||
});
|
||||
|
||||
it('should handle typical PowerShell output with blank lines and extra spacing', () => {
|
||||
const stdout = `
|
||||
|
||||
1234
|
||||
|
||||
|
||||
5678
|
||||
|
||||
`;
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([1234, 5678]);
|
||||
});
|
||||
|
||||
it('should filter lines with text and numbers mixed', () => {
|
||||
const stdout = '12345\r\nPID: 67890\r\n11111\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 11111]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent PID validation', () => {
|
||||
it('should reject zero PID', () => {
|
||||
expect(isValidParentPid(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject negative PID', () => {
|
||||
expect(isValidParentPid(-1)).toBe(false);
|
||||
expect(isValidParentPid(-100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject NaN', () => {
|
||||
expect(isValidParentPid(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-integer (float)', () => {
|
||||
expect(isValidParentPid(1.5)).toBe(false);
|
||||
expect(isValidParentPid(100.1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject Infinity', () => {
|
||||
expect(isValidParentPid(Infinity)).toBe(false);
|
||||
expect(isValidParentPid(-Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept valid positive integer PID', () => {
|
||||
expect(isValidParentPid(1)).toBe(true);
|
||||
expect(isValidParentPid(1000)).toBe(true);
|
||||
expect(isValidParentPid(12345)).toBe(true);
|
||||
expect(isValidParentPid(2147483647)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildProcesses platform behavior', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on non-Windows platforms (darwin)', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Import fresh to get updated platform value
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
const result = await getChildProcesses(1000);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on non-Windows platforms (linux)', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
const result = await getChildProcesses(1000);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid parent PID regardless of platform', async () => {
|
||||
// Even on Windows, invalid parent PIDs should be rejected before exec
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
expect(await getChildProcesses(0)).toEqual([]);
|
||||
expect(await getChildProcesses(-1)).toEqual([]);
|
||||
expect(await getChildProcesses(NaN)).toEqual([]);
|
||||
expect(await getChildProcesses(1.5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Tests for worker JSON status output structure
|
||||
*
|
||||
* Tests the buildStatusOutput pure function extracted from worker-service.ts
|
||||
* to ensure JSON output matches the hook framework contract.
|
||||
*
|
||||
* Also tests CLI output capture for the 'start' command to verify
|
||||
* actual JSON output matches expected structure.
|
||||
*
|
||||
* No mocks needed - tests a pure function directly and captures real CLI output.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildStatusOutput, StatusOutput } from '../../src/services/worker-service.js';
|
||||
|
||||
const WORKER_SCRIPT = path.join(__dirname, '../../plugin/scripts/worker-service.cjs');
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout + exit code
|
||||
* Uses spawnSync for synchronous output capture
|
||||
*/
|
||||
function runWorkerStart(): { stdout: string; exitCode: number } {
|
||||
const result = spawnSync('bun', [WORKER_SCRIPT, 'start'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
return { stdout: result.stdout?.trim() || '', exitCode: result.status || 0 };
|
||||
}
|
||||
|
||||
describe('worker-json-status', () => {
|
||||
describe('buildStatusOutput', () => {
|
||||
describe('ready status', () => {
|
||||
it('should return valid JSON with required fields for ready status', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include message field when not provided', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
expect(result.message).toBeUndefined();
|
||||
expect('message' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should include message field when explicitly provided for ready status', () => {
|
||||
const result = buildStatusOutput('ready', 'Worker started successfully');
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.message).toBe('Worker started successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error status', () => {
|
||||
it('should return valid JSON with required fields for error status', () => {
|
||||
const result = buildStatusOutput('error');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
it('should include message field when provided for error status', () => {
|
||||
const result = buildStatusOutput('error', 'Port in use but worker not responding');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Port in use but worker not responding');
|
||||
});
|
||||
|
||||
it('should handle various error messages correctly', () => {
|
||||
const errorMessages = [
|
||||
'Port did not free after version mismatch restart',
|
||||
'Failed to spawn worker daemon',
|
||||
'Worker failed to start (health check timeout)'
|
||||
];
|
||||
|
||||
for (const msg of errorMessages) {
|
||||
const result = buildStatusOutput('error', msg);
|
||||
expect(result.message).toBe(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('required fields always present', () => {
|
||||
it('should always include continue: true', () => {
|
||||
expect(buildStatusOutput('ready').continue).toBe(true);
|
||||
expect(buildStatusOutput('error').continue).toBe(true);
|
||||
expect(buildStatusOutput('ready', 'msg').continue).toBe(true);
|
||||
expect(buildStatusOutput('error', 'msg').continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should always include suppressOutput: true', () => {
|
||||
expect(buildStatusOutput('ready').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('error').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('ready', 'msg').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('error', 'msg').suppressOutput).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON serialization', () => {
|
||||
it('should produce valid JSON when stringified', () => {
|
||||
const readyResult = buildStatusOutput('ready');
|
||||
const errorResult = buildStatusOutput('error', 'Test error message');
|
||||
|
||||
expect(() => JSON.stringify(readyResult)).not.toThrow();
|
||||
expect(() => JSON.stringify(errorResult)).not.toThrow();
|
||||
|
||||
const parsedReady = JSON.parse(JSON.stringify(readyResult));
|
||||
expect(parsedReady.status).toBe('ready');
|
||||
expect(parsedReady.continue).toBe(true);
|
||||
|
||||
const parsedError = JSON.parse(JSON.stringify(errorResult));
|
||||
expect(parsedError.status).toBe('error');
|
||||
expect(parsedError.message).toBe('Test error message');
|
||||
});
|
||||
|
||||
it('should match expected JSON structure for hook framework', () => {
|
||||
const readyOutput = JSON.stringify(buildStatusOutput('ready'));
|
||||
const errorOutput = JSON.stringify(buildStatusOutput('error', 'error msg'));
|
||||
|
||||
// Verify exact structure (order may vary, but content must match)
|
||||
const parsedReady = JSON.parse(readyOutput);
|
||||
expect(parsedReady).toEqual({
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
status: 'ready'
|
||||
});
|
||||
|
||||
const parsedError = JSON.parse(errorOutput);
|
||||
expect(parsedError).toEqual({
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
status: 'error',
|
||||
message: 'error msg'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should only accept valid status values', () => {
|
||||
// TypeScript ensures these are the only valid values at compile time
|
||||
// This runtime test validates the behavior
|
||||
const readyResult: StatusOutput = buildStatusOutput('ready');
|
||||
const errorResult: StatusOutput = buildStatusOutput('error');
|
||||
|
||||
expect(['ready', 'error']).toContain(readyResult.status);
|
||||
expect(['ready', 'error']).toContain(errorResult.status);
|
||||
});
|
||||
|
||||
it('should have correct type structure', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
// Verify literal types
|
||||
expect(result.continue).toBe(true as const);
|
||||
expect(result.suppressOutput).toBe(true as const);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string message', () => {
|
||||
// Empty string is falsy, so message should NOT be included
|
||||
const result = buildStatusOutput('error', '');
|
||||
expect('message' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle message with special characters', () => {
|
||||
const specialMessage = 'Error: "quoted" & special <chars>';
|
||||
const result = buildStatusOutput('error', specialMessage);
|
||||
expect(result.message).toBe(specialMessage);
|
||||
|
||||
// Verify it serializes correctly
|
||||
const parsed = JSON.parse(JSON.stringify(result));
|
||||
expect(parsed.message).toBe(specialMessage);
|
||||
});
|
||||
|
||||
it('should handle very long message', () => {
|
||||
const longMessage = 'A'.repeat(10000);
|
||||
const result = buildStatusOutput('error', longMessage);
|
||||
expect(result.message).toBe(longMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('start command JSON output', () => {
|
||||
describe('when worker already healthy', () => {
|
||||
it('should output valid JSON with status: ready', () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout, exitCode } = runWorkerStart();
|
||||
|
||||
// The start command always exits with 0 (Windows Terminal compatibility)
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should output valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// Verify required fields per hook framework contract
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
expect(['ready', 'error']).toContain(parsed.status);
|
||||
});
|
||||
|
||||
it('should match expected JSON structure when worker is healthy', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// When worker is already healthy, status should be 'ready'
|
||||
// (or 'error' if something unexpected happens)
|
||||
if (parsed.status === 'ready') {
|
||||
// Ready status should not include message unless explicitly set
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
} else if (parsed.status === 'error') {
|
||||
// Error status may include a message explaining the failure
|
||||
expect(typeof parsed.message).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
// These tests require complex setup (mocking ports, killing processes)
|
||||
// Skipped for now - the pure function tests above cover the JSON structure
|
||||
it.skip('should output JSON with status: error when port in use but not responding', () => {
|
||||
// Would require: start a non-worker server on the port, then call start
|
||||
});
|
||||
|
||||
it.skip('should output JSON with status: error on spawn failure', () => {
|
||||
// Would require: mock spawnDaemon to fail
|
||||
});
|
||||
|
||||
it.skip('should output JSON with status: error on health check timeout', () => {
|
||||
// Would require: start worker that never becomes healthy
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code hook framework compatibility tests
|
||||
*
|
||||
* These tests verify that the worker 'start' command output conforms to
|
||||
* Claude Code's hook output contract. Key requirements:
|
||||
*
|
||||
* 1. Exit code 0 - Required for Windows Terminal compatibility (prevents
|
||||
* tab accumulation from spawned processes)
|
||||
*
|
||||
* 2. JSON on stdout - Claude Code parses stdout as JSON. Logs must go to
|
||||
* stderr to avoid breaking JSON parsing.
|
||||
*
|
||||
* 3. `continue: true` - CRITICAL: This field tells Claude Code to continue
|
||||
* processing. If missing or false, Claude Code stops after the hook.
|
||||
* Per docs: "If continue is false, Claude stops processing after the
|
||||
* hooks run."
|
||||
*
|
||||
* 4. `suppressOutput: true` - Hides output from transcript mode (Ctrl-R).
|
||||
* Optional but recommended for non-user-facing status.
|
||||
*
|
||||
* Reference: private/context/claude-code/hooks.md
|
||||
*/
|
||||
describe('Claude Code hook framework compatibility', () => {
|
||||
/**
|
||||
* Windows Terminal compatibility requirement
|
||||
*
|
||||
* When hooks run in Windows Terminal, each spawned process can open a
|
||||
* new tab. Exit code 0 tells the terminal the process completed
|
||||
* successfully and prevents tab accumulation.
|
||||
*
|
||||
* Even for error states (worker failed to start), we exit 0 and
|
||||
* communicate the error via JSON { status: 'error', message: '...' }
|
||||
*/
|
||||
it('should always exit with code 0', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { exitCode } = runWorkerStart();
|
||||
|
||||
// Per Windows Terminal compatibility requirement, exit code is always 0
|
||||
// Error states are communicated via JSON status field, not exit codes
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must go to stdout, not stderr
|
||||
*
|
||||
* Claude Code parses stdout as JSON for hook output. Any non-JSON on
|
||||
* stdout breaks parsing. Logs, warnings, and debug info must go to
|
||||
* stderr.
|
||||
*
|
||||
* Structure: { status, continue, suppressOutput, message? }
|
||||
*/
|
||||
it('should output JSON on stdout (not stderr)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', [WORKER_SCRIPT, 'start'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
const stdout = result.stdout?.trim() || '';
|
||||
const stderr = result.stderr?.trim() || '';
|
||||
|
||||
// stdout should contain valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
// stderr should NOT contain the JSON output (it may have logs)
|
||||
// The JSON structure should only appear in stdout
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed).toHaveProperty('status');
|
||||
expect(parsed).toHaveProperty('continue');
|
||||
|
||||
// Verify stderr doesn't accidentally contain the JSON output
|
||||
if (stderr) {
|
||||
try {
|
||||
const stderrParsed = JSON.parse(stderr);
|
||||
// If stderr parses as JSON with our structure, that's wrong
|
||||
expect(stderrParsed).not.toHaveProperty('suppressOutput');
|
||||
} catch {
|
||||
// stderr is not JSON, which is expected (logs, etc.)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must be parseable as valid JSON
|
||||
*
|
||||
* This seems obvious but is critical - any extraneous output (console.log
|
||||
* statements, warnings, etc.) will break JSON parsing and cause Claude
|
||||
* Code to fail processing the hook output.
|
||||
*/
|
||||
it('should be parseable as valid JSON', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
|
||||
// Should not throw on parse
|
||||
let parsed: unknown;
|
||||
expect(() => {
|
||||
parsed = JSON.parse(stdout);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should be an object, not a string, array, etc.
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* `continue: true` is CRITICAL
|
||||
*
|
||||
* From Claude Code docs: "If continue is false, Claude stops processing
|
||||
* after the hooks run."
|
||||
*
|
||||
* For SessionStart hooks (which start the worker), we MUST return
|
||||
* continue: true so Claude Code continues to process the user's prompt.
|
||||
* If we returned continue: false, Claude would stop immediately after
|
||||
* starting the worker and never respond to the user.
|
||||
*
|
||||
* This is why continue: true is a required literal in our StatusOutput
|
||||
* type - it can never be false.
|
||||
*/
|
||||
it('should always include continue: true (required for Claude Code to proceed)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// continue: true is CRITICAL - without it, Claude Code stops processing
|
||||
// This is not optional; it must always be true for our hooks
|
||||
expect(parsed.continue).toBe(true);
|
||||
|
||||
// Also verify it's the literal `true`, not a truthy value
|
||||
expect(parsed.continue).toStrictEqual(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* suppressOutput hides from transcript mode
|
||||
*
|
||||
* When suppressOutput: true, the hook output doesn't appear in transcript
|
||||
* mode (Ctrl-R). This is useful for status messages that aren't relevant
|
||||
* to the user's conversation history.
|
||||
*
|
||||
* For the worker start command, we suppress output since "worker started"
|
||||
* is infrastructure noise, not conversation content.
|
||||
*/
|
||||
it('should include suppressOutput: true to hide from transcript mode', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// suppressOutput prevents infrastructure noise from polluting transcript
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* status field communicates outcome
|
||||
*
|
||||
* The status field tells Claude Code (and debugging tools) whether the
|
||||
* hook succeeded. Valid values: 'ready' | 'error'
|
||||
*
|
||||
* Unlike exit codes (which are always 0), status can indicate failure.
|
||||
* This allows Claude Code to potentially take remedial action or log
|
||||
* the issue.
|
||||
*/
|
||||
it('should include a valid status field', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
expect(parsed).toHaveProperty('status');
|
||||
expect(['ready', 'error']).toContain(parsed.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Chroma Vector Sync Integration Tests
|
||||
*
|
||||
* Tests ChromaSync vector embedding and semantic search.
|
||||
* Skips tests if uvx/chroma not installed (CI-safe).
|
||||
*
|
||||
* Sources:
|
||||
* - ChromaSync implementation from src/services/sync/ChromaSync.ts
|
||||
* - MCP patterns from the Chroma MCP server
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
// Check if uvx/chroma is available
|
||||
let chromaAvailable = false;
|
||||
let skipReason = '';
|
||||
|
||||
async function checkChromaAvailability(): Promise<{ available: boolean; reason: string }> {
|
||||
try {
|
||||
// Check if uvx is available
|
||||
const uvxCheck = Bun.spawn(['uvx', '--version'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await uvxCheck.exited;
|
||||
|
||||
if (uvxCheck.exitCode !== 0) {
|
||||
return { available: false, reason: 'uvx not installed' };
|
||||
}
|
||||
|
||||
return { available: true, reason: '' };
|
||||
} catch (error) {
|
||||
return { available: false, reason: `uvx check failed: ${error}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ChromaSync Vector Sync Integration', () => {
|
||||
const testProject = `test-project-${Date.now()}`;
|
||||
const testVectorDbDir = path.join(os.tmpdir(), `chroma-test-${Date.now()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
const check = await checkChromaAvailability();
|
||||
chromaAvailable = check.available;
|
||||
skipReason = check.reason;
|
||||
|
||||
// Create temp directory for vector db
|
||||
if (chromaAvailable) {
|
||||
fs.mkdirSync(testVectorDbDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
if (fs.existsSync(testVectorDbDir)) {
|
||||
fs.rmSync(testVectorDbDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
});
|
||||
|
||||
describe('ChromaSync availability check', () => {
|
||||
it('should detect uvx availability status', async () => {
|
||||
const check = await checkChromaAvailability();
|
||||
// This test always passes - it just logs the status
|
||||
expect(typeof check.available).toBe('boolean');
|
||||
if (!check.available) {
|
||||
console.log(`Chroma tests will be skipped: ${check.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChromaSync class structure', () => {
|
||||
it('should be importable', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
expect(ChromaSync).toBeDefined();
|
||||
expect(typeof ChromaSync).toBe('function');
|
||||
});
|
||||
|
||||
it('should instantiate with project name', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync('test-project');
|
||||
expect(sync).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Document formatting', () => {
|
||||
it('should format observation documents correctly', async () => {
|
||||
if (!chromaAvailable) {
|
||||
console.log(`Skipping: ${skipReason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Test the document formatting logic by examining the class
|
||||
// The formatObservationDocs method is private, but we can verify
|
||||
// the sync method signature exists
|
||||
expect(typeof sync.syncObservation).toBe('function');
|
||||
expect(typeof sync.syncSummary).toBe('function');
|
||||
expect(typeof sync.syncUserPrompt).toBe('function');
|
||||
});
|
||||
|
||||
it('should have query method', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
expect(typeof sync.queryChroma).toBe('function');
|
||||
});
|
||||
|
||||
it('should have close method for cleanup', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
expect(typeof sync.close).toBe('function');
|
||||
});
|
||||
|
||||
it('should have ensureBackfilled method', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
expect(typeof sync.ensureBackfilled).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Observation sync interface', () => {
|
||||
it('should accept ParsedObservation format', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncObservation method should accept these parameters
|
||||
const observationId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
const observation = {
|
||||
type: 'discovery',
|
||||
title: 'Test Title',
|
||||
subtitle: 'Test Subtitle',
|
||||
facts: ['fact1', 'fact2'],
|
||||
narrative: 'Test narrative',
|
||||
concepts: ['concept1'],
|
||||
files_read: ['/path/to/file.ts'],
|
||||
files_modified: []
|
||||
};
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method signature accepts these parameters
|
||||
// We don't actually call it to avoid needing a running Chroma server
|
||||
expect(sync.syncObservation.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary sync interface', () => {
|
||||
it('should accept ParsedSummary format', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncSummary method should accept these parameters
|
||||
const summaryId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
const summary = {
|
||||
request: 'Test request',
|
||||
investigated: 'Test investigated',
|
||||
learned: 'Test learned',
|
||||
completed: 'Test completed',
|
||||
next_steps: 'Test next steps',
|
||||
notes: 'Test notes'
|
||||
};
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method exists
|
||||
expect(typeof sync.syncSummary).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User prompt sync interface', () => {
|
||||
it('should accept prompt text format', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncUserPrompt method should accept these parameters
|
||||
const promptId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
const promptText = 'Help me write a function';
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method exists
|
||||
expect(typeof sync.syncUserPrompt).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query interface', () => {
|
||||
it('should accept query string and options', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Verify method signature
|
||||
expect(typeof sync.queryChroma).toBe('function');
|
||||
|
||||
// The method should return a promise
|
||||
// (without calling it since no server is running)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection naming', () => {
|
||||
it('should use project-based collection name', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
|
||||
// Collection name format is cm__{project}
|
||||
const projectName = 'my-project';
|
||||
const sync = new ChromaSync(projectName);
|
||||
|
||||
// The collection name is private, but we can verify the class
|
||||
// was constructed successfully with the project name
|
||||
expect(sync).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle special characters in project names', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
|
||||
// Projects with special characters should work
|
||||
const projectName = 'my-project_v2.0';
|
||||
const sync = new ChromaSync(projectName);
|
||||
expect(sync).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle connection failures gracefully', async () => {
|
||||
if (!chromaAvailable) {
|
||||
console.log(`Skipping: ${skipReason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Calling syncObservation without a running server should throw
|
||||
// but not crash the process
|
||||
const observation = {
|
||||
type: 'discovery' as const,
|
||||
title: 'Test',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
};
|
||||
|
||||
// This should either throw or fail gracefully
|
||||
try {
|
||||
await sync.syncObservation(
|
||||
1,
|
||||
'session-123',
|
||||
'test',
|
||||
observation,
|
||||
1,
|
||||
Date.now()
|
||||
);
|
||||
// If it didn't throw, the connection might have succeeded
|
||||
} catch (error) {
|
||||
// Expected - server not running
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await sync.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should handle close on unconnected instance', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Close without ever connecting should not throw
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should be safe to call close multiple times', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Multiple close calls should be safe
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Process leak prevention (Issue #761)', () => {
|
||||
/**
|
||||
* Regression test for GitHub Issue #761:
|
||||
* "Feature Request: Option to disable Chroma (RAM usage / zombie processes)"
|
||||
*
|
||||
* Root cause: When connection errors occur (MCP error -32000, Connection closed),
|
||||
* the code was resetting `connected` and `client` but NOT closing the transport,
|
||||
* leaving the chroma-mcp subprocess alive. Each reconnection attempt spawned
|
||||
* a NEW process while old ones accumulated as zombies.
|
||||
*
|
||||
* Fix: Transport lifecycle is now managed by ChromaMcpManager (singleton),
|
||||
* which handles connect/disconnect/cleanup. ChromaSync delegates to it.
|
||||
*/
|
||||
it('should have transport cleanup in ChromaMcpManager error handlers', async () => {
|
||||
// ChromaSync now delegates connection management to ChromaMcpManager.
|
||||
// Verify that ChromaMcpManager source includes transport cleanup.
|
||||
const sourceFile = await Bun.file(
|
||||
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
|
||||
).text();
|
||||
|
||||
// Verify that error handlers include transport cleanup
|
||||
expect(sourceFile).toContain('this.transport.close()');
|
||||
|
||||
// Verify transport is set to null after close
|
||||
expect(sourceFile).toContain('this.transport = null');
|
||||
|
||||
// Verify connected is set to false after close
|
||||
expect(sourceFile).toContain('this.connected = false');
|
||||
});
|
||||
|
||||
it('should reset state after close regardless of connection status', async () => {
|
||||
// ChromaSync.close() is now a lightweight method that logs and returns.
|
||||
// Connection state is managed by ChromaMcpManager singleton.
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// close() should complete without error regardless of state
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up transport in ChromaMcpManager close() method', async () => {
|
||||
// Read the ChromaMcpManager source to verify transport.close() is in the close path
|
||||
const sourceFile = await Bun.file(
|
||||
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
|
||||
).text();
|
||||
|
||||
// Verify the close/disconnect method properly cleans up transport
|
||||
expect(sourceFile).toContain('await this.transport.close()');
|
||||
expect(sourceFile).toContain('this.transport = null');
|
||||
expect(sourceFile).toContain('this.connected = false');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Hook Execution End-to-End Integration Tests
|
||||
*
|
||||
* Tests the full session lifecycle: SessionStart -> PostToolUse -> SessionEnd
|
||||
* Uses real worker on test port with in-memory SQLite database.
|
||||
*
|
||||
* Sources:
|
||||
* - Hook implementations from src/hooks/*.ts
|
||||
* - Session routes from src/services/worker/http/routes/SessionRoutes.ts
|
||||
* - Server patterns from tests/server/server.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Hook Execution E2E', () => {
|
||||
let server: Server;
|
||||
let testPort: number;
|
||||
let mockOptions: ServerOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
mockOptions = {
|
||||
getInitializationComplete: () => true,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({
|
||||
provider: 'claude',
|
||||
authMethod: 'cli',
|
||||
lastInteraction: null,
|
||||
}),
|
||||
};
|
||||
|
||||
testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
|
||||
if (server && server.getHttpServer()) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// Ignore errors on cleanup
|
||||
}
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('health and readiness endpoints', () => {
|
||||
it('should return 200 with status ok from /api/health', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.initialized).toBe(true);
|
||||
expect(body.mcpReady).toBe(true);
|
||||
expect(body.platform).toBeDefined();
|
||||
expect(typeof body.pid).toBe('number');
|
||||
});
|
||||
|
||||
it('should return 200 with status ready from /api/readiness when initialized', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should return 503 from /api/readiness when not initialized', async () => {
|
||||
const uninitializedOptions: ServerOptions = {
|
||||
getInitializationComplete: () => false,
|
||||
getMcpReady: () => false,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(uninitializedOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('initializing');
|
||||
expect(body.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return version from /api/version', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/version`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.version).toBeDefined();
|
||||
expect(typeof body.version).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server lifecycle', () => {
|
||||
it('should start and stop cleanly', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const httpServer = server.getHttpServer();
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
|
||||
// Verify health endpoint works
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Close server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const httpServerAfter = server.getHttpServer();
|
||||
if (httpServerAfter) {
|
||||
expect(httpServerAfter.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reflect initialization state changes dynamically', async () => {
|
||||
let isInitialized = false;
|
||||
const dynamicOptions: ServerOptions = {
|
||||
getInitializationComplete: () => isInitialized,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check when not initialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.initialized).toBe(false);
|
||||
|
||||
// Change state
|
||||
isInitialized = true;
|
||||
|
||||
// Check when initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.initialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route handling', () => {
|
||||
it('should return 404 for unknown routes after finalizeRoutes', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/nonexistent`);
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('should accept JSON content type for POST requests', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Even though this endpoint doesn't exist, verify JSON handling
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/test-json`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
});
|
||||
|
||||
// Should get 404 (not found), not 400 (bad request due to JSON parsing)
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy tag handling simulation', () => {
|
||||
it('should demonstrate privacy skip flow for entirely private prompt', async () => {
|
||||
// This test simulates what the session init endpoint does
|
||||
// with private prompts, without needing the full route handler
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Import tag stripping utility
|
||||
const { stripMemoryTagsFromPrompt } = await import('../../src/utils/tag-stripping.js');
|
||||
|
||||
// Simulate the flow
|
||||
const privatePrompt = '<private>secret command</private>';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(privatePrompt);
|
||||
|
||||
// Verify privacy check would skip this prompt
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(true);
|
||||
});
|
||||
|
||||
it('should demonstrate partial privacy for mixed prompts', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const { stripMemoryTagsFromPrompt } = await import('../../src/utils/tag-stripping.js');
|
||||
|
||||
const mixedPrompt = '<private>my password is secret123</private> Help me write a function';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(mixedPrompt);
|
||||
|
||||
// Should not skip - has public content
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(false);
|
||||
expect(cleanedPrompt.trim()).toBe('Help me write a function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Worker API Endpoints Integration Tests
|
||||
*
|
||||
* Tests all REST API endpoints with real HTTP and database.
|
||||
* Uses real Server instance with in-memory database.
|
||||
*
|
||||
* Sources:
|
||||
* - Server patterns from tests/server/server.test.ts
|
||||
* - Session routes from src/services/worker/http/routes/SessionRoutes.ts
|
||||
* - Search routes from src/services/worker/http/routes/SearchRoutes.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Worker API Endpoints Integration', () => {
|
||||
let server: Server;
|
||||
let testPort: number;
|
||||
let mockOptions: ServerOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
mockOptions = {
|
||||
getInitializationComplete: () => true,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({
|
||||
provider: 'claude',
|
||||
authMethod: 'cli',
|
||||
lastInteraction: null,
|
||||
}),
|
||||
};
|
||||
|
||||
testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
|
||||
if (server && server.getHttpServer()) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('Health/Readiness/Version Endpoints', () => {
|
||||
describe('GET /api/health', () => {
|
||||
it('should return status, initialized, mcpReady, platform, pid', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('status', 'ok');
|
||||
expect(body).toHaveProperty('initialized', true);
|
||||
expect(body).toHaveProperty('mcpReady', true);
|
||||
expect(body).toHaveProperty('platform');
|
||||
expect(body).toHaveProperty('pid');
|
||||
expect(typeof body.platform).toBe('string');
|
||||
expect(typeof body.pid).toBe('number');
|
||||
});
|
||||
|
||||
it('should reflect uninitialized state', async () => {
|
||||
const uninitOptions: ServerOptions = {
|
||||
getInitializationComplete: () => false,
|
||||
getMcpReady: () => false,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(uninitOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.status).toBe('ok'); // Health always returns ok
|
||||
expect(body.initialized).toBe(false);
|
||||
expect(body.mcpReady).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/readiness', () => {
|
||||
it('should return 200 with status ready when initialized', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ready');
|
||||
expect(body.mcpReady).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 503 with status initializing when not ready', async () => {
|
||||
const uninitOptions: ServerOptions = {
|
||||
getInitializationComplete: () => false,
|
||||
getMcpReady: () => false,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(uninitOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('initializing');
|
||||
expect(body.message).toContain('initializing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/version', () => {
|
||||
it('should return version string', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/version`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('version');
|
||||
expect(typeof body.version).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
describe('404 Not Found', () => {
|
||||
it('should return 404 for unknown GET routes', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/unknown-endpoint`);
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('should return 404 for unknown POST routes', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/unknown-endpoint`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for nested unknown routes', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/search/nonexistent/nested`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Method handling', () => {
|
||||
it('should handle OPTIONS requests', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`, {
|
||||
method: 'OPTIONS'
|
||||
});
|
||||
// OPTIONS should either return 200 or 204 (CORS preflight)
|
||||
expect([200, 204]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content-Type Handling', () => {
|
||||
it('should accept application/json content type', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/nonexistent`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'value' })
|
||||
});
|
||||
|
||||
// Should get 404 (route not found), not a content-type error
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return JSON responses with correct content type', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
expect(contentType).toContain('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server State Management', () => {
|
||||
it('should track initialization state dynamically', async () => {
|
||||
let initialized = false;
|
||||
const dynamicOptions: ServerOptions = {
|
||||
getInitializationComplete: () => initialized,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check uninitialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
// Initialize
|
||||
initialized = true;
|
||||
|
||||
// Check initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should track MCP ready state dynamically', async () => {
|
||||
let mcpReady = false;
|
||||
const dynamicOptions: ServerOptions = {
|
||||
getInitializationComplete: () => true,
|
||||
getMcpReady: () => mcpReady,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check MCP not ready
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.mcpReady).toBe(false);
|
||||
|
||||
// Set MCP ready
|
||||
mcpReady = true;
|
||||
|
||||
// Check MCP ready
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.mcpReady).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server Lifecycle', () => {
|
||||
it('should start listening on specified port', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const httpServer = server.getHttpServer();
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
});
|
||||
|
||||
it('should close gracefully', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Verify it's running
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Close
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') throw e;
|
||||
}
|
||||
|
||||
// Verify closed
|
||||
const httpServer = server.getHttpServer();
|
||||
if (httpServer) {
|
||||
expect(httpServer.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle port conflicts', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const server2 = new Server(mockOptions);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Second server should fail on same port
|
||||
await expect(server2.listen(testPort, '127.0.0.1')).rejects.toThrow();
|
||||
|
||||
// Clean up second server if it has a reference
|
||||
const httpServer2 = server2.getHttpServer();
|
||||
if (httpServer2) {
|
||||
expect(httpServer2.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow restart on same port after close', async () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Close first server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') throw e;
|
||||
}
|
||||
|
||||
// Wait for port to be released
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Start second server on same port
|
||||
const server2 = new Server(mockOptions);
|
||||
await server2.listen(testPort, '127.0.0.1');
|
||||
|
||||
expect(server2.getHttpServer()!.listening).toBe(true);
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await server2.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route Registration', () => {
|
||||
it('should register route handlers', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
const setupRoutesMock = mock(() => {});
|
||||
const mockRouteHandler = {
|
||||
setupRoutes: setupRoutesMock,
|
||||
};
|
||||
|
||||
server.registerRoutes(mockRouteHandler);
|
||||
|
||||
expect(setupRoutesMock).toHaveBeenCalledTimes(1);
|
||||
expect(setupRoutesMock).toHaveBeenCalledWith(server.app);
|
||||
});
|
||||
|
||||
it('should register multiple route handlers', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
const handler1Mock = mock(() => {});
|
||||
const handler2Mock = mock(() => {});
|
||||
|
||||
server.registerRoutes({ setupRoutes: handler1Mock });
|
||||
server.registerRoutes({ setupRoutes: handler2Mock });
|
||||
|
||||
expect(handler1Mock).toHaveBeenCalledTimes(1);
|
||||
expect(handler2Mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
309
.agent/services/claude-mem/tests/log-level-audit.test.ts
Normal file
309
.agent/services/claude-mem/tests/log-level-audit.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Log Level Audit Test
|
||||
*
|
||||
* This test scans all TypeScript files in src/ to find logger calls,
|
||||
* extracts the message text, and groups them by log level for review.
|
||||
*
|
||||
* Purpose: Help identify misclassified log messages that should be at a different level.
|
||||
*
|
||||
* Log Level Guidelines:
|
||||
* - ERROR/failure: Critical failures that require investigation (data loss, service down)
|
||||
* - WARN: Non-critical issues with fallback behavior (degraded, but functional)
|
||||
* - INFO: Normal operational events (session started, request processed)
|
||||
* - DEBUG: Detailed diagnostic information (variable values, flow tracing)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dir, '..');
|
||||
const SRC_DIR = join(PROJECT_ROOT, 'src');
|
||||
|
||||
interface LoggerCall {
|
||||
file: string;
|
||||
line: number;
|
||||
level: string;
|
||||
component: string;
|
||||
message: string;
|
||||
errorParam: string | null;
|
||||
fullMatch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all TypeScript files in a directory
|
||||
*/
|
||||
async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findTypeScriptFiles(fullPath)));
|
||||
} else if (entry.isFile() && /\.ts$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract logger calls from file content
|
||||
* Handles multiline calls and captures error parameter (4th arg)
|
||||
*/
|
||||
function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
const calls: LoggerCall[] = [];
|
||||
const lines = content.split('\n');
|
||||
const seenCalls = new Set<string>();
|
||||
|
||||
// Build line number index for position-to-line lookup
|
||||
const lineStarts: number[] = [0];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === '\n') {
|
||||
lineStarts.push(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getLineNumber(pos: number): number {
|
||||
for (let i = lineStarts.length - 1; i >= 0; i--) {
|
||||
if (lineStarts[i] <= pos) return i + 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pattern that matches logger calls across multiple lines
|
||||
// Captures: method, component, message, and everything up to closing paren
|
||||
// Uses [\s\S] instead of . to match newlines
|
||||
const loggerPattern = /logger\.(error|warn|info|debug|failure|success|timing|dataIn|dataOut|happyPathError)\s*\(\s*['"]([^'"]+)['"][\s\S]*?\)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = loggerPattern.exec(content)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const method = match[1];
|
||||
const component = match[2];
|
||||
const lineNum = getLineNumber(match.index);
|
||||
|
||||
// Extract message (2nd string arg) - could be single, double, or template
|
||||
const messageMatch = fullMatch.match(/['"][^'"]+['"]\s*,\s*(['"`])([\s\S]*?)\1/);
|
||||
const message = messageMatch ? messageMatch[2] : '(message not captured)';
|
||||
|
||||
// Extract error parameter (4th arg) - look for "error as Error" or similar patterns
|
||||
let errorParam: string | null = null;
|
||||
const errorMatch = fullMatch.match(/,\s*(error|err|e)\s+as\s+Error\s*\)/i) ||
|
||||
fullMatch.match(/,\s*(error|err|e)\s*\)/i) ||
|
||||
fullMatch.match(/,\s*new\s+Error\s*\([^)]*\)\s*\)/i);
|
||||
if (errorMatch) {
|
||||
errorParam = errorMatch[0].replace(/^\s*,\s*/, '').replace(/\s*\)\s*$/, '');
|
||||
}
|
||||
|
||||
const key = `${filePath}:${lineNum}:${method}:${message.substring(0, 50)}`;
|
||||
if (!seenCalls.has(key)) {
|
||||
seenCalls.add(key);
|
||||
calls.push({
|
||||
file: relative(PROJECT_ROOT, filePath),
|
||||
line: lineNum,
|
||||
level: normalizeLevel(method),
|
||||
component,
|
||||
message,
|
||||
errorParam,
|
||||
fullMatch: fullMatch.replace(/\s+/g, ' ').trim() // Normalize whitespace for display
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize log level names to standard categories
|
||||
*/
|
||||
function normalizeLevel(method: string): string {
|
||||
switch (method) {
|
||||
case 'error':
|
||||
case 'failure':
|
||||
return 'ERROR';
|
||||
case 'warn':
|
||||
case 'happyPathError':
|
||||
return 'WARN';
|
||||
case 'info':
|
||||
case 'success':
|
||||
case 'timing':
|
||||
case 'dataIn':
|
||||
case 'dataOut':
|
||||
return 'INFO';
|
||||
case 'debug':
|
||||
return 'DEBUG';
|
||||
default:
|
||||
return method.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate formatted audit report
|
||||
*/
|
||||
function generateReport(calls: LoggerCall[]): string {
|
||||
const byLevel: Record<string, LoggerCall[]> = {
|
||||
'ERROR': [],
|
||||
'WARN': [],
|
||||
'INFO': [],
|
||||
'DEBUG': []
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
if (byLevel[call.level]) {
|
||||
byLevel[call.level].push(call);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('\n=== LOG LEVEL AUDIT REPORT ===\n');
|
||||
lines.push(`Total logger calls found: ${calls.length}\n`);
|
||||
|
||||
// ERROR level
|
||||
lines.push('');
|
||||
lines.push('ERROR (should be critical failures only):');
|
||||
lines.push('─'.repeat(60));
|
||||
if (byLevel['ERROR'].length === 0) {
|
||||
lines.push(' (none found)');
|
||||
} else {
|
||||
for (const call of byLevel['ERROR'].sort((a, b) => a.file.localeCompare(b.file))) {
|
||||
lines.push(` ${call.file}:${call.line} [${call.component}]`);
|
||||
lines.push(` message: "${formatMessage(call.message)}"`);
|
||||
if (call.errorParam) {
|
||||
lines.push(` error: ${call.errorParam}`);
|
||||
}
|
||||
lines.push(` full: ${call.fullMatch}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
lines.push(` Count: ${byLevel['ERROR'].length}`);
|
||||
|
||||
// WARN level
|
||||
lines.push('');
|
||||
lines.push('WARN (should be non-critical, has fallback):');
|
||||
lines.push('─'.repeat(60));
|
||||
if (byLevel['WARN'].length === 0) {
|
||||
lines.push(' (none found)');
|
||||
} else {
|
||||
for (const call of byLevel['WARN'].sort((a, b) => a.file.localeCompare(b.file))) {
|
||||
lines.push(` ${call.file}:${call.line} [${call.component}]`);
|
||||
lines.push(` message: "${formatMessage(call.message)}"`);
|
||||
if (call.errorParam) {
|
||||
lines.push(` error: ${call.errorParam}`);
|
||||
}
|
||||
lines.push(` full: ${call.fullMatch}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
lines.push(` Count: ${byLevel['WARN'].length}`);
|
||||
|
||||
// INFO level
|
||||
lines.push('');
|
||||
lines.push('INFO (informational):');
|
||||
lines.push('─'.repeat(60));
|
||||
if (byLevel['INFO'].length === 0) {
|
||||
lines.push(' (none found)');
|
||||
} else {
|
||||
for (const call of byLevel['INFO'].sort((a, b) => a.file.localeCompare(b.file))) {
|
||||
lines.push(` ${call.file}:${call.line} [${call.component}]`);
|
||||
lines.push(` message: "${formatMessage(call.message)}"`);
|
||||
if (call.errorParam) {
|
||||
lines.push(` error: ${call.errorParam}`);
|
||||
}
|
||||
lines.push(` full: ${call.fullMatch}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
lines.push(` Count: ${byLevel['INFO'].length}`);
|
||||
|
||||
// DEBUG level
|
||||
lines.push('');
|
||||
lines.push('DEBUG (detailed diagnostics):');
|
||||
lines.push('─'.repeat(60));
|
||||
if (byLevel['DEBUG'].length === 0) {
|
||||
lines.push(' (none found)');
|
||||
} else {
|
||||
for (const call of byLevel['DEBUG'].sort((a, b) => a.file.localeCompare(b.file))) {
|
||||
lines.push(` ${call.file}:${call.line} [${call.component}]`);
|
||||
lines.push(` message: "${formatMessage(call.message)}"`);
|
||||
if (call.errorParam) {
|
||||
lines.push(` error: ${call.errorParam}`);
|
||||
}
|
||||
lines.push(` full: ${call.fullMatch}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
lines.push(` Count: ${byLevel['DEBUG'].length}`);
|
||||
|
||||
// Summary
|
||||
lines.push('');
|
||||
lines.push('=== SUMMARY ===');
|
||||
lines.push(` ERROR: ${byLevel['ERROR'].length}`);
|
||||
lines.push(` WARN: ${byLevel['WARN'].length}`);
|
||||
lines.push(` INFO: ${byLevel['INFO'].length}`);
|
||||
lines.push(` DEBUG: ${byLevel['DEBUG'].length}`);
|
||||
lines.push(` TOTAL: ${calls.length}`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message for display - NO TRUNCATION
|
||||
*/
|
||||
function formatMessage(message: string): string {
|
||||
return message;
|
||||
}
|
||||
|
||||
describe('Log Level Audit', () => {
|
||||
let allCalls: LoggerCall[] = [];
|
||||
|
||||
it('should scan all TypeScript files and extract logger calls', async () => {
|
||||
const files = await findTypeScriptFiles(SRC_DIR);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, 'utf-8');
|
||||
const calls = extractLoggerCalls(content, file);
|
||||
allCalls.push(...calls);
|
||||
}
|
||||
|
||||
expect(allCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate audit report for log level review', () => {
|
||||
const report = generateReport(allCalls);
|
||||
console.log(report);
|
||||
|
||||
// This test always passes - it's for generating a review report
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should have summary statistics', () => {
|
||||
const byLevel: Record<string, number> = {
|
||||
'ERROR': 0,
|
||||
'WARN': 0,
|
||||
'INFO': 0,
|
||||
'DEBUG': 0
|
||||
};
|
||||
|
||||
for (const call of allCalls) {
|
||||
if (byLevel[call.level] !== undefined) {
|
||||
byLevel[call.level]++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n📊 Log Level Distribution:');
|
||||
console.log(` ERROR: ${byLevel['ERROR']} (${((byLevel['ERROR'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
console.log(` WARN: ${byLevel['WARN']} (${((byLevel['WARN'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
console.log(` INFO: ${byLevel['INFO']} (${((byLevel['INFO'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
console.log(` DEBUG: ${byLevel['DEBUG']} (${((byLevel['DEBUG'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
|
||||
// Log distribution health check - not a hard failure, just informational
|
||||
// A healthy codebase typically has: DEBUG > INFO > WARN > ERROR
|
||||
expect(allCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
220
.agent/services/claude-mem/tests/logger-usage-standards.test.ts
Normal file
220
.agent/services/claude-mem/tests/logger-usage-standards.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readdir } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
/**
|
||||
* Logger Usage Standards - Enforces coding standards for logging
|
||||
*
|
||||
* This test enforces logging standards by:
|
||||
* 1. Detecting console.log/console.error usage in background services (invisible logs)
|
||||
* 2. Ensuring high-priority service files import the logger
|
||||
* 3. Reporting coverage statistics for observability
|
||||
*
|
||||
* Note: This is a legitimate coding standard enforcement test, not a coverage metric.
|
||||
*/
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dir, "..");
|
||||
const SRC_DIR = join(PROJECT_ROOT, "src");
|
||||
|
||||
// Files/directories that don't require logging
|
||||
const EXCLUDED_PATTERNS = [
|
||||
/types\//, // Type definition files
|
||||
/constants\//, // Pure constants
|
||||
/\.d\.ts$/, // Type declaration files
|
||||
/^ui\//, // UI components (separate logging context)
|
||||
/^bin\//, // CLI utilities (may use console.log for output)
|
||||
/index\.ts$/, // Re-export files
|
||||
/logger\.ts$/, // Logger itself
|
||||
/hook-response\.ts$/, // Pure data structure
|
||||
/hook-constants\.ts$/, // Pure constants
|
||||
/paths\.ts$/, // Path utilities
|
||||
/bun-path\.ts$/, // Path utilities
|
||||
/migrations\.ts$/, // Database migrations (console.log for migration output)
|
||||
/worker-service\.ts$/, // CLI entry point with interactive setup wizard (console.log for user prompts)
|
||||
/integrations\/.*Installer\.ts$/, // CLI installer commands (console.log for interactive installation output)
|
||||
/SettingsDefaultsManager\.ts$/, // Must use console.log to avoid circular dependency with logger
|
||||
/user-message-hook\.ts$/, // Deprecated - kept for reference only, not registered in hooks.json
|
||||
/cli\/hook-command\.ts$/, // CLI hook command uses console.log/error for hook protocol output
|
||||
/cli\/handlers\/user-message\.ts$/, // User message handler uses console.error for user-visible context
|
||||
/services\/transcripts\/cli\.ts$/, // CLI transcript subcommands use console.log for user-visible interactive output
|
||||
];
|
||||
|
||||
// Files that should always use logger (core business logic)
|
||||
// Excludes UI files, type files, and pure utilities
|
||||
const HIGH_PRIORITY_PATTERNS = [
|
||||
/^services\/worker\/(?!.*types\.ts$)/, // Worker services (not type files)
|
||||
/^services\/sqlite\/(?!types\.ts$|index\.ts$)/, // SQLite services
|
||||
/^services\/sync\//,
|
||||
/^services\/context-generator\.ts$/,
|
||||
/^hooks\/(?!hook-response\.ts$)/, // All src/hooks/* except hook-response.ts (NOT ui/hooks)
|
||||
/^sdk\/(?!.*types?\.ts$)/, // SDK files (not type files)
|
||||
/^servers\/(?!.*types?\.ts$)/, // Server files (not type files)
|
||||
];
|
||||
|
||||
// Additional check: exclude UI files from high priority
|
||||
const isUIFile = (path: string) => /^ui\//.test(path);
|
||||
|
||||
interface FileAnalysis {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
hasLoggerImport: boolean;
|
||||
usesConsoleLog: boolean;
|
||||
consoleLogLines: number[];
|
||||
loggerCallCount: number;
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all TypeScript files in a directory
|
||||
*/
|
||||
async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findTypeScriptFiles(fullPath)));
|
||||
} else if (entry.isFile() && /\.ts$/.test(entry.name)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be excluded from logger requirements
|
||||
*/
|
||||
function shouldExclude(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
return EXCLUDED_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is high priority for logging
|
||||
*/
|
||||
function isHighPriority(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
|
||||
// UI files are never high priority
|
||||
if (isUIFile(relativePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return HIGH_PRIORITY_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single TypeScript file for logger usage
|
||||
*/
|
||||
function analyzeFile(filePath: string): FileAnalysis {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const relativePath = relative(PROJECT_ROOT, filePath);
|
||||
|
||||
// Check for logger import (handles both .ts and .js extensions in import paths)
|
||||
const hasLoggerImport = /import\s+.*logger.*from\s+['"].*logger(\.(js|ts))?['"]/.test(content);
|
||||
|
||||
// Find console.log/console.error usage with line numbers
|
||||
const consoleLogLines: number[] = [];
|
||||
lines.forEach((line, index) => {
|
||||
if (/console\.(log|error|warn|info|debug)/.test(line)) {
|
||||
consoleLogLines.push(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Count logger method calls
|
||||
const loggerCallMatches = content.match(/logger\.(debug|info|warn|error|success|failure|timing|dataIn|dataOut|happyPathError)\(/g);
|
||||
const loggerCallCount = loggerCallMatches ? loggerCallMatches.length : 0;
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
relativePath,
|
||||
hasLoggerImport,
|
||||
usesConsoleLog: consoleLogLines.length > 0,
|
||||
consoleLogLines,
|
||||
loggerCallCount,
|
||||
isHighPriority: isHighPriority(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Logger Usage Standards", () => {
|
||||
let allFiles: FileAnalysis[] = [];
|
||||
let relevantFiles: FileAnalysis[] = [];
|
||||
|
||||
it("should scan all TypeScript files in src/", async () => {
|
||||
const files = await findTypeScriptFiles(SRC_DIR);
|
||||
allFiles = files.map(analyzeFile);
|
||||
relevantFiles = allFiles.filter(f => !shouldExclude(f.path));
|
||||
|
||||
expect(allFiles.length).toBeGreaterThan(0);
|
||||
expect(relevantFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should NOT use console.log/console.error (these logs are invisible in background services)", () => {
|
||||
// Only hook files can use console.log for their final output response
|
||||
// Everything else (services, workers, sqlite, etc.) runs in background - console.log is USELESS there
|
||||
const filesWithConsole = relevantFiles.filter(f => {
|
||||
const isHookFile = /^src\/hooks\//.test(f.relativePath);
|
||||
return f.usesConsoleLog && !isHookFile;
|
||||
});
|
||||
|
||||
if (filesWithConsole.length > 0) {
|
||||
const report = filesWithConsole
|
||||
.map(f => ` ${f.relativePath}:${f.consoleLogLines.join(",")}`)
|
||||
.join("\n");
|
||||
|
||||
throw new Error(
|
||||
`❌ CRITICAL: Found console.log/console.error in ${filesWithConsole.length} background service file(s):\n${report}\n\n` +
|
||||
`These logs are INVISIBLE - they run in background processes where console output goes nowhere.\n` +
|
||||
`Replace with logger.debug/info/warn/error calls immediately.\n\n` +
|
||||
`Only hook files (src/hooks/*) should use console.log for their output response.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have logger coverage in high-priority files", () => {
|
||||
const highPriorityFiles = relevantFiles.filter(f => f.isHighPriority);
|
||||
const withoutLogger = highPriorityFiles.filter(f => !f.hasLoggerImport);
|
||||
|
||||
if (withoutLogger.length > 0) {
|
||||
const report = withoutLogger
|
||||
.map(f => ` ${f.relativePath}`)
|
||||
.join("\n");
|
||||
|
||||
throw new Error(
|
||||
`High-priority files missing logger import (${withoutLogger.length}):\n${report}\n\n` +
|
||||
`These files should import and use logger for debugging and observability.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should report logger coverage statistics", () => {
|
||||
const withLogger = relevantFiles.filter(f => f.hasLoggerImport);
|
||||
const withoutLogger = relevantFiles.filter(f => !f.hasLoggerImport);
|
||||
const totalCalls = relevantFiles.reduce((sum, f) => sum + f.loggerCallCount, 0);
|
||||
|
||||
const coverage = ((withLogger.length / relevantFiles.length) * 100).toFixed(1);
|
||||
|
||||
console.log("\n📊 Logger Coverage Report:");
|
||||
console.log(` Total files analyzed: ${relevantFiles.length}`);
|
||||
console.log(` Files with logger: ${withLogger.length} (${coverage}%)`);
|
||||
console.log(` Files without logger: ${withoutLogger.length}`);
|
||||
console.log(` Total logger calls: ${totalCalls}`);
|
||||
console.log(` Excluded files: ${allFiles.length - relevantFiles.length}`);
|
||||
|
||||
if (withoutLogger.length > 0) {
|
||||
console.log("\n📝 Files without logger:");
|
||||
withoutLogger.forEach(f => {
|
||||
const priority = f.isHighPriority ? "🔴 HIGH" : " ";
|
||||
console.log(` ${priority} ${f.relativePath}`);
|
||||
});
|
||||
}
|
||||
|
||||
// This is an informational test - we expect some files won't need logging
|
||||
expect(withLogger.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
161
.agent/services/claude-mem/tests/sdk-agent-resume.test.ts
Normal file
161
.agent/services/claude-mem/tests/sdk-agent-resume.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for SDKAgent resume parameter logic
|
||||
*
|
||||
* The resume parameter should ONLY be passed when:
|
||||
* 1. memorySessionId exists (was captured from a previous SDK response)
|
||||
* 2. lastPromptNumber > 1 (this is a continuation within the same SDK session)
|
||||
*
|
||||
* On worker restart or crash recovery, memorySessionId may exist from a previous
|
||||
* SDK session but we must NOT resume because the SDK context was lost.
|
||||
*/
|
||||
describe('SDKAgent Resume Parameter Logic', () => {
|
||||
/**
|
||||
* Helper function that mirrors the logic in SDKAgent.startSession()
|
||||
* This is the exact condition used at SDKAgent.ts line 99
|
||||
*/
|
||||
function shouldPassResumeParameter(session: {
|
||||
memorySessionId: string | null;
|
||||
lastPromptNumber: number;
|
||||
}): boolean {
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
return hasRealMemorySessionId && session.lastPromptNumber > 1;
|
||||
}
|
||||
|
||||
describe('INIT prompt scenarios (lastPromptNumber === 1)', () => {
|
||||
it('should NOT pass resume parameter when lastPromptNumber === 1 even if memorySessionId exists', () => {
|
||||
// Scenario: Worker restart with stale memorySessionId from previous session
|
||||
const session = {
|
||||
memorySessionId: 'stale-session-id-from-previous-run',
|
||||
lastPromptNumber: 1, // INIT prompt
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true); // memorySessionId exists
|
||||
expect(shouldResume).toBe(false); // but should NOT resume because it's INIT
|
||||
});
|
||||
|
||||
it('should NOT pass resume parameter when memorySessionId is null and lastPromptNumber === 1', () => {
|
||||
// Scenario: Fresh session, first prompt ever
|
||||
const session = {
|
||||
memorySessionId: null,
|
||||
lastPromptNumber: 1,
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTINUATION prompt scenarios (lastPromptNumber > 1)', () => {
|
||||
it('should pass resume parameter when lastPromptNumber > 1 AND memorySessionId exists', () => {
|
||||
// Scenario: Normal continuation within same SDK session
|
||||
const session = {
|
||||
memorySessionId: 'valid-session-id',
|
||||
lastPromptNumber: 2, // CONTINUATION prompt
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true);
|
||||
expect(shouldResume).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass resume parameter for higher prompt numbers', () => {
|
||||
// Scenario: Later in a multi-turn conversation
|
||||
const session = {
|
||||
memorySessionId: 'valid-session-id',
|
||||
lastPromptNumber: 5, // 5th prompt in session
|
||||
};
|
||||
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
expect(shouldResume).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT pass resume parameter when memorySessionId is null even for lastPromptNumber > 1', () => {
|
||||
// Scenario: Bug case - somehow got to prompt 2 without capturing memorySessionId
|
||||
// This shouldn't happen in practice but we should handle it safely
|
||||
const session = {
|
||||
memorySessionId: null,
|
||||
lastPromptNumber: 2,
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string memorySessionId as falsy', () => {
|
||||
// Empty string should be treated as "no session ID"
|
||||
const session = {
|
||||
memorySessionId: '' as unknown as null,
|
||||
lastPromptNumber: 2,
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined memorySessionId as falsy', () => {
|
||||
const session = {
|
||||
memorySessionId: undefined as unknown as null,
|
||||
lastPromptNumber: 2,
|
||||
};
|
||||
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug reproduction: stale session resume crash', () => {
|
||||
it('should NOT resume when worker restarts with stale memorySessionId', () => {
|
||||
// This is the exact bug scenario from the logs:
|
||||
// [17:30:21.773] Starting SDK query {
|
||||
// hasRealMemorySessionId=true,
|
||||
// resume_parameter=5439891b-...,
|
||||
// lastPromptNumber=1 ← NEW SDK session!
|
||||
// }
|
||||
// [17:30:24.450] Generator failed {error=Claude Code process exited with code 1}
|
||||
|
||||
const session = {
|
||||
memorySessionId: '5439891b-7d4b-4ee3-8662-c000f66bc199', // Stale from previous session
|
||||
lastPromptNumber: 1, // But this is a NEW session after restart
|
||||
};
|
||||
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
// The fix: should NOT try to resume, should start fresh
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
|
||||
it('should resume correctly for normal continuation (not after restart)', () => {
|
||||
// Normal case: same SDK session, continuing conversation
|
||||
const session = {
|
||||
memorySessionId: '5439891b-7d4b-4ee3-8662-c000f66bc199',
|
||||
lastPromptNumber: 2, // Second prompt in SAME session
|
||||
};
|
||||
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
// Should resume - same session, valid memorySessionId
|
||||
expect(shouldResume).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
328
.agent/services/claude-mem/tests/server/error-handler.test.ts
Normal file
328
.agent/services/claude-mem/tests/server/error-handler.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Tests for Express error handling middleware
|
||||
*
|
||||
* Mock Justification (~11% mock code):
|
||||
* - Logger spies: Suppress console output during tests (standard practice)
|
||||
* - Express req/res mocks: Required because Express middleware expects these
|
||||
* objects - testing the actual formatting and status code logic
|
||||
*
|
||||
* What's NOT mocked: AppError class, createErrorResponse function (tested directly)
|
||||
*/
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
import {
|
||||
AppError,
|
||||
createErrorResponse,
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
} from '../../src/services/server/ErrorHandler.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
// Using spyOn instead of mock.module to avoid polluting global module cache
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ErrorHandler', () => {
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('AppError', () => {
|
||||
it('should extend Error', () => {
|
||||
const error = new AppError('Test error');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(AppError);
|
||||
});
|
||||
|
||||
it('should set default statusCode to 500', () => {
|
||||
const error = new AppError('Test error');
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it('should set custom statusCode', () => {
|
||||
const error = new AppError('Not found', 404);
|
||||
expect(error.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should set error code when provided', () => {
|
||||
const error = new AppError('Invalid input', 400, 'INVALID_INPUT');
|
||||
expect(error.code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('should set details when provided', () => {
|
||||
const details = { field: 'email', reason: 'invalid format' };
|
||||
const error = new AppError('Validation failed', 400, 'VALIDATION_ERROR', details);
|
||||
expect(error.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should set message correctly', () => {
|
||||
const error = new AppError('Something went wrong');
|
||||
expect(error.message).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should set name to AppError', () => {
|
||||
const error = new AppError('Test error');
|
||||
expect(error.name).toBe('AppError');
|
||||
});
|
||||
|
||||
it('should handle all parameters together', () => {
|
||||
const details = { userId: 123 };
|
||||
const error = new AppError('User not found', 404, 'USER_NOT_FOUND', details);
|
||||
|
||||
expect(error.message).toBe('User not found');
|
||||
expect(error.statusCode).toBe(404);
|
||||
expect(error.code).toBe('USER_NOT_FOUND');
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.name).toBe('AppError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createErrorResponse', () => {
|
||||
it('should create basic error response with error and message', () => {
|
||||
const response = createErrorResponse('Error', 'Something went wrong');
|
||||
|
||||
expect(response.error).toBe('Error');
|
||||
expect(response.message).toBe('Something went wrong');
|
||||
expect(response.code).toBeUndefined();
|
||||
expect(response.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include code when provided', () => {
|
||||
const response = createErrorResponse('ValidationError', 'Invalid input', 'INVALID_INPUT');
|
||||
|
||||
expect(response.error).toBe('ValidationError');
|
||||
expect(response.message).toBe('Invalid input');
|
||||
expect(response.code).toBe('INVALID_INPUT');
|
||||
expect(response.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include details when provided', () => {
|
||||
const details = { fields: ['email', 'password'] };
|
||||
const response = createErrorResponse('ValidationError', 'Multiple errors', 'VALIDATION_ERROR', details);
|
||||
|
||||
expect(response.error).toBe('ValidationError');
|
||||
expect(response.message).toBe('Multiple errors');
|
||||
expect(response.code).toBe('VALIDATION_ERROR');
|
||||
expect(response.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should not include code or details keys when not provided', () => {
|
||||
const response = createErrorResponse('Error', 'Basic error');
|
||||
|
||||
expect(Object.keys(response)).toEqual(['error', 'message']);
|
||||
});
|
||||
|
||||
it('should handle empty string code as falsy and exclude it', () => {
|
||||
const response = createErrorResponse('Error', 'Test', '');
|
||||
|
||||
// Empty string is falsy, so code should not be set
|
||||
expect(response.code).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorHandler middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: NextFunction;
|
||||
let statusSpy: ReturnType<typeof mock>;
|
||||
let jsonSpy: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
statusSpy = mock(() => mockResponse);
|
||||
jsonSpy = mock(() => mockResponse);
|
||||
|
||||
mockRequest = {
|
||||
method: 'GET',
|
||||
path: '/api/test',
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: statusSpy as unknown as Response['status'],
|
||||
json: jsonSpy as unknown as Response['json'],
|
||||
};
|
||||
|
||||
mockNext = mock(() => {});
|
||||
});
|
||||
|
||||
it('should handle AppError with custom status code', () => {
|
||||
const error = new AppError('Not found', 404, 'NOT_FOUND');
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(404);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.error).toBe('AppError');
|
||||
expect(responseBody.message).toBe('Not found');
|
||||
expect(responseBody.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should handle AppError with details', () => {
|
||||
const details = { resourceId: 'abc123' };
|
||||
const error = new AppError('Resource not found', 404, 'RESOURCE_NOT_FOUND', details);
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should handle generic Error with 500 status code', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(500);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.error).toBe('Error');
|
||||
expect(responseBody.message).toBe('Something went wrong');
|
||||
expect(responseBody.code).toBeUndefined();
|
||||
expect(responseBody.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not call next after handling error', () => {
|
||||
const error = new AppError('Test error', 400);
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use error name in response', () => {
|
||||
const error = new TypeError('Invalid type');
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.error).toBe('TypeError');
|
||||
});
|
||||
|
||||
it('should handle AppError with default 500 status', () => {
|
||||
const error = new AppError('Server error');
|
||||
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notFoundHandler', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let statusSpy: ReturnType<typeof mock>;
|
||||
let jsonSpy: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
statusSpy = mock(() => mockResponse);
|
||||
jsonSpy = mock(() => mockResponse);
|
||||
|
||||
mockResponse = {
|
||||
status: statusSpy as unknown as Response['status'],
|
||||
json: jsonSpy as unknown as Response['json'],
|
||||
};
|
||||
});
|
||||
|
||||
it('should return 404 status', () => {
|
||||
mockRequest = {
|
||||
method: 'GET',
|
||||
path: '/api/unknown',
|
||||
};
|
||||
|
||||
notFoundHandler(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(404);
|
||||
});
|
||||
|
||||
it('should include method and path in message', () => {
|
||||
mockRequest = {
|
||||
method: 'POST',
|
||||
path: '/api/users',
|
||||
};
|
||||
|
||||
notFoundHandler(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.error).toBe('NotFound');
|
||||
expect(responseBody.message).toBe('Cannot POST /api/users');
|
||||
});
|
||||
|
||||
it('should handle DELETE method', () => {
|
||||
mockRequest = {
|
||||
method: 'DELETE',
|
||||
path: '/api/items/123',
|
||||
};
|
||||
|
||||
notFoundHandler(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.message).toBe('Cannot DELETE /api/items/123');
|
||||
});
|
||||
|
||||
it('should handle PUT method', () => {
|
||||
mockRequest = {
|
||||
method: 'PUT',
|
||||
path: '/api/config',
|
||||
};
|
||||
|
||||
notFoundHandler(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(responseBody.message).toBe('Cannot PUT /api/config');
|
||||
});
|
||||
|
||||
it('should return structured error response', () => {
|
||||
mockRequest = {
|
||||
method: 'GET',
|
||||
path: '/missing',
|
||||
};
|
||||
|
||||
notFoundHandler(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
const responseBody = jsonSpy.mock.calls[0][0];
|
||||
expect(Object.keys(responseBody)).toEqual(['error', 'message']);
|
||||
});
|
||||
});
|
||||
});
|
||||
389
.agent/services/claude-mem/tests/server/server.test.ts
Normal file
389
.agent/services/claude-mem/tests/server/server.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { RouteHandler, ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Server', () => {
|
||||
let server: Server;
|
||||
let mockOptions: ServerOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
mockOptions = {
|
||||
getInitializationComplete: () => true,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({
|
||||
provider: 'claude',
|
||||
authMethod: 'cli',
|
||||
lastInteraction: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
// Clean up server if created and still has an active http server
|
||||
if (server && server.getHttpServer()) {
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// Ignore errors on cleanup
|
||||
}
|
||||
}
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create Express app', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
expect(server.app).toBeDefined();
|
||||
expect(typeof server.app.get).toBe('function');
|
||||
expect(typeof server.app.post).toBe('function');
|
||||
expect(typeof server.app.use).toBe('function');
|
||||
});
|
||||
|
||||
it('should expose app as readonly property', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// App should be accessible
|
||||
expect(server.app).toBeDefined();
|
||||
|
||||
// App should be an Express application
|
||||
expect(typeof server.app.listen).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listen', () => {
|
||||
it('should start server on specified port', async () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// Use a random high port to avoid conflicts
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Server should now be listening
|
||||
const httpServer = server.getHttpServer();
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject if port is already in use', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const server2 = new Server(mockOptions);
|
||||
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Start first server
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Second server should fail on same port
|
||||
await expect(server2.listen(testPort, '127.0.0.1')).rejects.toThrow();
|
||||
|
||||
// The server object was created but not successfully listening
|
||||
const httpServer = server2.getHttpServer();
|
||||
if (httpServer) {
|
||||
expect(httpServer.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should stop server from listening after close', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Server should exist and be listening
|
||||
const httpServerBefore = server.getHttpServer();
|
||||
expect(httpServerBefore).not.toBeNull();
|
||||
expect(httpServerBefore!.listening).toBe(true);
|
||||
|
||||
// Close the server - may throw ERR_SERVER_NOT_RUNNING on some platforms
|
||||
// because closeAllConnections() might immediately close the server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
// ERR_SERVER_NOT_RUNNING is acceptable - closeAllConnections() already closed it
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// The server should no longer be listening (even if ref is not null due to early throw)
|
||||
const httpServerAfter = server.getHttpServer();
|
||||
if (httpServerAfter) {
|
||||
expect(httpServerAfter.listening).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle close when server not started', async () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// Should not throw when closing unstarted server
|
||||
await expect(server.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow starting a new server on same port after close', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Close the server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
// ERR_SERVER_NOT_RUNNING is acceptable
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure port is released
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should be able to listen again on same port with a new server
|
||||
const server2 = new Server(mockOptions);
|
||||
await server2.listen(testPort, '127.0.0.1');
|
||||
|
||||
expect(server2.getHttpServer()!.listening).toBe(true);
|
||||
|
||||
// Clean up server2
|
||||
try {
|
||||
await server2.close();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHttpServer', () => {
|
||||
it('should return null before listen', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
expect(server.getHttpServer()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return http.Server after listen', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const httpServer = server.getHttpServer();
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerRoutes', () => {
|
||||
it('should call setupRoutes on route handler', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
const setupRoutesMock = mock(() => {});
|
||||
const mockRouteHandler: RouteHandler = {
|
||||
setupRoutes: setupRoutesMock,
|
||||
};
|
||||
|
||||
server.registerRoutes(mockRouteHandler);
|
||||
|
||||
expect(setupRoutesMock).toHaveBeenCalledTimes(1);
|
||||
expect(setupRoutesMock).toHaveBeenCalledWith(server.app);
|
||||
});
|
||||
|
||||
it('should register multiple route handlers', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
const handler1Mock = mock(() => {});
|
||||
const handler2Mock = mock(() => {});
|
||||
|
||||
const handler1: RouteHandler = { setupRoutes: handler1Mock };
|
||||
const handler2: RouteHandler = { setupRoutes: handler2Mock };
|
||||
|
||||
server.registerRoutes(handler1);
|
||||
server.registerRoutes(handler2);
|
||||
|
||||
expect(handler1Mock).toHaveBeenCalledTimes(1);
|
||||
expect(handler2Mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeRoutes', () => {
|
||||
it('should not throw when called', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
expect(() => server.finalizeRoutes()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('health endpoint', () => {
|
||||
it('should return 200 with status ok', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('should include initialization status', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.initialized).toBe(true);
|
||||
expect(body.mcpReady).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect initialization state changes', async () => {
|
||||
let isInitialized = false;
|
||||
const dynamicOptions: ServerOptions = {
|
||||
getInitializationComplete: () => isInitialized,
|
||||
getMcpReady: () => true,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(dynamicOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check when not initialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.initialized).toBe(false);
|
||||
|
||||
// Change state
|
||||
isInitialized = true;
|
||||
|
||||
// Check when initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should include platform and pid', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.platform).toBeDefined();
|
||||
expect(body.pid).toBeDefined();
|
||||
expect(typeof body.pid).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readiness endpoint', () => {
|
||||
it('should return 200 when initialized', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should return 503 when not initialized', async () => {
|
||||
const uninitializedOptions: ServerOptions = {
|
||||
getInitializationComplete: () => false,
|
||||
getMcpReady: () => false,
|
||||
onShutdown: mock(() => Promise.resolve()),
|
||||
onRestart: mock(() => Promise.resolve()),
|
||||
workerPath: '/test/worker-service.cjs',
|
||||
getAiStatus: () => ({ provider: 'claude', authMethod: 'cli', lastInteraction: null }),
|
||||
};
|
||||
|
||||
server = new Server(uninitializedOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('initializing');
|
||||
expect(body.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version endpoint', () => {
|
||||
it('should return 200 with version', async () => {
|
||||
server = new Server(mockOptions);
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/version`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.version).toBeDefined();
|
||||
expect(typeof body.version).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 handling', () => {
|
||||
it('should return 404 for unknown routes after finalizeRoutes', async () => {
|
||||
server = new Server(mockOptions);
|
||||
server.finalizeRoutes();
|
||||
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/nonexistent`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe('NotFound');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests for readLastLines() — tail-read function for /api/logs endpoint (#1203)
|
||||
*
|
||||
* Verifies that log files are read from the end without loading the entire
|
||||
* file into memory, preventing OOM on large log files.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { readLastLines } from '../../src/services/worker/http/routes/LogsRoutes.js';
|
||||
|
||||
describe('readLastLines (#1203 OOM fix)', () => {
|
||||
const testDir = join(tmpdir(), `claude-mem-logs-test-${Date.now()}`);
|
||||
const testFile = join(testDir, 'test.log');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty string for empty file', () => {
|
||||
writeFileSync(testFile, '', 'utf-8');
|
||||
const result = readLastLines(testFile, 10);
|
||||
expect(result.lines).toBe('');
|
||||
expect(result.totalEstimate).toBe(0);
|
||||
});
|
||||
|
||||
it('should return all lines when file has fewer lines than requested', () => {
|
||||
writeFileSync(testFile, 'line1\nline2\nline3\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 10);
|
||||
expect(result.lines).toBe('line1\nline2\nline3');
|
||||
expect(result.totalEstimate).toBe(3);
|
||||
});
|
||||
|
||||
it('should return exactly the last N lines', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
|
||||
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 5);
|
||||
expect(result.lines).toBe('line16\nline17\nline18\nline19\nline20');
|
||||
});
|
||||
|
||||
it('should return single line when requested', () => {
|
||||
writeFileSync(testFile, 'first\nsecond\nthird\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 1);
|
||||
expect(result.lines).toBe('third');
|
||||
});
|
||||
|
||||
it('should handle file without trailing newline', () => {
|
||||
writeFileSync(testFile, 'line1\nline2\nline3', 'utf-8');
|
||||
const result = readLastLines(testFile, 2);
|
||||
expect(result.lines).toBe('line2\nline3');
|
||||
});
|
||||
|
||||
it('should handle single line file', () => {
|
||||
writeFileSync(testFile, 'only line\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 5);
|
||||
expect(result.lines).toBe('only line');
|
||||
expect(result.totalEstimate).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle file with exactly requested number of lines', () => {
|
||||
writeFileSync(testFile, 'a\nb\nc\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 3);
|
||||
expect(result.lines).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
it('should work with lines larger than initial chunk size', () => {
|
||||
// Create a file where lines are long enough to exceed the 64KB initial chunk
|
||||
const longLine = 'X'.repeat(10000);
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `${i}:${longLine}`);
|
||||
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 3);
|
||||
const resultLines = result.lines.split('\n');
|
||||
expect(resultLines.length).toBe(3);
|
||||
expect(resultLines[0]).toStartWith('17:');
|
||||
expect(resultLines[1]).toStartWith('18:');
|
||||
expect(resultLines[2]).toStartWith('19:');
|
||||
});
|
||||
|
||||
it('should provide accurate totalEstimate when entire file is read', () => {
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `line${i}`);
|
||||
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 100);
|
||||
// When file fits in one chunk, totalEstimate should be exact
|
||||
expect(result.totalEstimate).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle requesting zero lines', () => {
|
||||
writeFileSync(testFile, 'line1\nline2\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 0);
|
||||
expect(result.lines).toBe('');
|
||||
});
|
||||
|
||||
it('should handle file with only newlines', () => {
|
||||
writeFileSync(testFile, '\n\n\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 2);
|
||||
const resultLines = result.lines.split('\n');
|
||||
// The last two "lines" before trailing newline are empty strings
|
||||
expect(resultLines.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not load entire large file for small tail request', () => {
|
||||
// This test verifies the core fix: a file with many lines should
|
||||
// not be fully loaded when only a few lines are requested.
|
||||
// We create a file larger than the initial 64KB chunk.
|
||||
const line = 'A'.repeat(100) + '\n'; // ~101 bytes per line
|
||||
const lineCount = 1000; // ~101KB total
|
||||
writeFileSync(testFile, line.repeat(lineCount), 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 5);
|
||||
const resultLines = result.lines.split('\n');
|
||||
expect(resultLines.length).toBe(5);
|
||||
// Each returned line should be our repeated 'A' pattern
|
||||
for (const l of resultLines) {
|
||||
expect(l).toBe('A'.repeat(100));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import { SessionQueueProcessor, CreateIteratorOptions } from '../../../src/services/queue/SessionQueueProcessor.js';
|
||||
import type { PendingMessageStore, PersistentPendingMessage } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
|
||||
/**
|
||||
* Mock PendingMessageStore that returns null (empty queue) by default.
|
||||
* Individual tests can override claimNextMessage behavior.
|
||||
*/
|
||||
function createMockStore(): PendingMessageStore {
|
||||
return {
|
||||
claimNextMessage: mock(() => null),
|
||||
toPendingMessage: mock((msg: PersistentPendingMessage) => ({
|
||||
type: msg.message_type,
|
||||
tool_name: msg.tool_name || undefined,
|
||||
tool_input: msg.tool_input ? JSON.parse(msg.tool_input) : undefined,
|
||||
tool_response: msg.tool_response ? JSON.parse(msg.tool_response) : undefined,
|
||||
prompt_number: msg.prompt_number || undefined,
|
||||
cwd: msg.cwd || undefined,
|
||||
last_assistant_message: msg.last_assistant_message || undefined
|
||||
}))
|
||||
} as unknown as PendingMessageStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock PersistentPendingMessage for testing
|
||||
*/
|
||||
function createMockMessage(overrides: Partial<PersistentPendingMessage> = {}): PersistentPendingMessage {
|
||||
return {
|
||||
id: 1,
|
||||
session_db_id: 123,
|
||||
content_session_id: 'test-session',
|
||||
message_type: 'observation',
|
||||
tool_name: 'Read',
|
||||
tool_input: JSON.stringify({ file: 'test.ts' }),
|
||||
tool_response: JSON.stringify({ content: 'file contents' }),
|
||||
cwd: '/test',
|
||||
last_assistant_message: null,
|
||||
prompt_number: 1,
|
||||
status: 'pending',
|
||||
retry_count: 0,
|
||||
created_at_epoch: Date.now(),
|
||||
started_processing_at_epoch: null,
|
||||
completed_at_epoch: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionQueueProcessor', () => {
|
||||
let store: PendingMessageStore;
|
||||
let events: EventEmitter;
|
||||
let processor: SessionQueueProcessor;
|
||||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
events = new EventEmitter();
|
||||
processor = new SessionQueueProcessor(store, events);
|
||||
abortController = new AbortController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure abort controller is triggered to clean up any pending iterators
|
||||
abortController.abort();
|
||||
// Remove all listeners to prevent memory leaks
|
||||
events.removeAllListeners();
|
||||
});
|
||||
|
||||
describe('createIterator', () => {
|
||||
describe('idle timeout behavior', () => {
|
||||
it('should exit after idle timeout when no messages arrive', async () => {
|
||||
// Use a very short timeout for testing (50ms)
|
||||
const SHORT_TIMEOUT_MS = 50;
|
||||
|
||||
// Mock the private waitForMessage to use short timeout
|
||||
// We'll test with real timing but short durations
|
||||
const onIdleTimeout = mock(() => {});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Store returns null (empty queue), so iterator waits for message event
|
||||
// With no messages arriving, it should eventually timeout
|
||||
|
||||
const startTime = Date.now();
|
||||
const results: any[] = [];
|
||||
|
||||
// We need to trigger the timeout scenario
|
||||
// The iterator uses IDLE_TIMEOUT_MS (3 minutes) which is too long for tests
|
||||
// Instead, we'll test the abort path and verify callback behavior
|
||||
|
||||
// Abort after a short delay to simulate timeout-like behavior
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Iterator should exit cleanly when aborted
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should invoke onIdleTimeout callback when idle timeout occurs', async () => {
|
||||
// This test verifies the callback mechanism works
|
||||
// We can't easily test the full 3-minute timeout, so we verify the wiring
|
||||
|
||||
const onIdleTimeout = mock(() => {
|
||||
// Callback should trigger abort in real usage
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
// To test this properly, we'd need to mock the internal waitForMessage
|
||||
// For now, verify that abort signal exits cleanly
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Simulate external abort (which is what onIdleTimeout should do)
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reset idle timer when message arrives', async () => {
|
||||
const onIdleTimeout = mock(() => abortController.abort());
|
||||
let callCount = 0;
|
||||
|
||||
// Return a message on first call, then null
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return createMockMessage({ id: 1 });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// First message should be yielded
|
||||
// Then queue is empty, wait for more
|
||||
// Abort after receiving first message
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have received exactly one message
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]._persistentId).toBe(1);
|
||||
|
||||
// Store's claimNextMessage should have been called at least twice
|
||||
// (once returning message, once returning null)
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should exit immediately when abort signal is triggered', async () => {
|
||||
const onIdleTimeout = mock(() => {});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort immediately
|
||||
abortController.abort();
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should exit with no messages
|
||||
expect(results).toHaveLength(0);
|
||||
// onIdleTimeout should NOT be called when abort signal is used
|
||||
expect(onIdleTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should take precedence over timeout when both could fire', async () => {
|
||||
const onIdleTimeout = mock(() => {});
|
||||
|
||||
// Return null to trigger wait
|
||||
(store.claimNextMessage as any) = mock(() => null);
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal,
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort very quickly - before any timeout could fire
|
||||
setTimeout(() => abortController.abort(), 10);
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have exited cleanly
|
||||
expect(results).toHaveLength(0);
|
||||
// onIdleTimeout should NOT have been called
|
||||
expect(onIdleTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message event handling', () => {
|
||||
it('should wake up when message event is emitted', async () => {
|
||||
let callCount = 0;
|
||||
const mockMessages = [
|
||||
createMockMessage({ id: 1 }),
|
||||
createMockMessage({ id: 2 })
|
||||
];
|
||||
|
||||
// First call: return null (queue empty)
|
||||
// After message event: return message
|
||||
// Then return null again
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First check - queue empty, will wait
|
||||
return null;
|
||||
} else if (callCount === 2) {
|
||||
// After wake-up - return message
|
||||
return mockMessages[0];
|
||||
} else if (callCount === 3) {
|
||||
// Second check after message processed - empty again
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// Emit message event after a short delay to wake up the iterator
|
||||
setTimeout(() => events.emit('message'), 50);
|
||||
|
||||
// Abort after collecting results
|
||||
setTimeout(() => abortController.abort(), 150);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have received exactly one message
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
if (results.length > 0) {
|
||||
expect(results[0]._persistentId).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('event listener cleanup', () => {
|
||||
it('should clean up event listeners on abort', async () => {
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Get initial listener count
|
||||
const initialListenerCount = events.listenerCount('message');
|
||||
|
||||
// Abort to trigger cleanup
|
||||
abortController.abort();
|
||||
|
||||
// Consume the iterator
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// After iterator completes, listener count should be same or less
|
||||
// (the cleanup happens inside waitForMessage which may not be called)
|
||||
const finalListenerCount = events.listenerCount('message');
|
||||
expect(finalListenerCount).toBeLessThanOrEqual(initialListenerCount + 1);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when message received', async () => {
|
||||
// Return a message immediately
|
||||
(store.claimNextMessage as any) = mock(() => createMockMessage({ id: 1 }));
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Get first message
|
||||
const firstResult = await iterator.next();
|
||||
expect(firstResult.done).toBe(false);
|
||||
expect(firstResult.value._persistentId).toBe(1);
|
||||
|
||||
// Now abort and complete iteration
|
||||
abortController.abort();
|
||||
|
||||
// Drain remaining
|
||||
for await (const _ of iterator) {
|
||||
// Should not get here since we aborted
|
||||
}
|
||||
|
||||
// Verify no leftover listeners (accounting for potential timing)
|
||||
const finalListenerCount = events.listenerCount('message');
|
||||
expect(finalListenerCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue after store error with backoff', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return createMockMessage({ id: 1 });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// Abort after giving time for retry
|
||||
setTimeout(() => abortController.abort(), 1500);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
break; // Exit after first message
|
||||
}
|
||||
|
||||
// Should have recovered and received message after error
|
||||
expect(results).toHaveLength(1);
|
||||
expect(callCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should exit cleanly if aborted during error backoff', async () => {
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort during the backoff period
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should exit cleanly with no messages
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
it('should convert PersistentPendingMessage to PendingMessageWithId', async () => {
|
||||
const mockPersistentMessage = createMockMessage({
|
||||
id: 42,
|
||||
message_type: 'observation',
|
||||
tool_name: 'Grep',
|
||||
tool_input: JSON.stringify({ pattern: 'test' }),
|
||||
tool_response: JSON.stringify({ matches: ['file.ts'] }),
|
||||
prompt_number: 5,
|
||||
created_at_epoch: 1704067200000
|
||||
});
|
||||
|
||||
(store.claimNextMessage as any) = mock(() => mockPersistentMessage);
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
sessionDbId: 123,
|
||||
signal: abortController.signal
|
||||
};
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
const result = await iterator.next();
|
||||
|
||||
// Abort to clean up
|
||||
abortController.abort();
|
||||
|
||||
expect(result.done).toBe(false);
|
||||
expect(result.value).toMatchObject({
|
||||
_persistentId: 42,
|
||||
_originalTimestamp: 1704067200000,
|
||||
type: 'observation',
|
||||
tool_name: 'Grep',
|
||||
prompt_number: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { PendingMessageStore } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
import { createSDKSession } from '../../../src/services/sqlite/Sessions.js';
|
||||
import type { PendingMessage } from '../../../src/services/worker-types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
let db: Database;
|
||||
let store: PendingMessageStore;
|
||||
let sessionDbId: number;
|
||||
const CONTENT_SESSION_ID = 'test-self-heal';
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
store = new PendingMessageStore(db, 3);
|
||||
sessionDbId = createSDKSession(db, CONTENT_SESSION_ID, 'test-project', 'Test prompt');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function enqueueMessage(overrides: Partial<PendingMessage> = {}): number {
|
||||
const message: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
...overrides,
|
||||
};
|
||||
return store.enqueue(sessionDbId, CONTENT_SESSION_ID, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to simulate a stuck processing message by directly updating the DB
|
||||
* to set started_processing_at_epoch to a time in the past (>60s ago)
|
||||
*/
|
||||
function makeMessageStaleProcessing(messageId: number): void {
|
||||
const staleTimestamp = Date.now() - 120_000; // 2 minutes ago (well past 60s threshold)
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[staleTimestamp, messageId]
|
||||
);
|
||||
}
|
||||
|
||||
test('stuck processing messages are recovered on next claim', () => {
|
||||
// Enqueue a message and make it stuck in processing
|
||||
const msgId = enqueueMessage();
|
||||
makeMessageStaleProcessing(msgId);
|
||||
|
||||
// Verify it's stuck (status = processing)
|
||||
const beforeClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(beforeClaim.status).toBe('processing');
|
||||
|
||||
// claimNextMessage should self-heal: reset the stuck message, then claim it
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId);
|
||||
// It should now be in 'processing' status again (freshly claimed)
|
||||
const afterClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(afterClaim.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('actively processing messages are NOT recovered', () => {
|
||||
// Enqueue two messages
|
||||
const activeId = enqueueMessage();
|
||||
const pendingId = enqueueMessage();
|
||||
|
||||
// Make the first one actively processing (recent timestamp, NOT stale)
|
||||
const recentTimestamp = Date.now() - 5_000; // 5 seconds ago (well within 60s threshold)
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[recentTimestamp, activeId]
|
||||
);
|
||||
|
||||
// claimNextMessage should NOT reset the active one — should claim the pending one instead
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(pendingId);
|
||||
|
||||
// The active message should still be processing
|
||||
const activeMsg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(activeId) as { status: string };
|
||||
expect(activeMsg.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('recovery and claim is atomic within single call', () => {
|
||||
// Enqueue three messages
|
||||
const stuckId = enqueueMessage();
|
||||
const pendingId1 = enqueueMessage();
|
||||
const pendingId2 = enqueueMessage();
|
||||
|
||||
// Make the first one stuck
|
||||
makeMessageStaleProcessing(stuckId);
|
||||
|
||||
// Single claimNextMessage should reset stuck AND claim oldest pending (which is the reset stuck one)
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
// The stuck message was reset to pending, and being oldest, it gets claimed
|
||||
expect(claimed!.id).toBe(stuckId);
|
||||
|
||||
// The other two should still be pending
|
||||
const msg1 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId1) as { status: string };
|
||||
const msg2 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId2) as { status: string };
|
||||
expect(msg1.status).toBe('pending');
|
||||
expect(msg2.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('no messages returns null without error', () => {
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
|
||||
test('self-healing only affects the specified session', () => {
|
||||
// Create a second session
|
||||
const session2Id = createSDKSession(db, 'other-session', 'test-project', 'Test');
|
||||
|
||||
// Enqueue and make stuck in session 1
|
||||
const stuckInSession1 = enqueueMessage();
|
||||
makeMessageStaleProcessing(stuckInSession1);
|
||||
|
||||
// Enqueue in session 2
|
||||
const msg: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
};
|
||||
const session2MsgId = store.enqueue(session2Id, 'other-session', msg);
|
||||
makeMessageStaleProcessing(session2MsgId);
|
||||
|
||||
// Claim for session 2 — should only heal session 2's stuck message
|
||||
const claimed = store.claimNextMessage(session2Id);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(session2MsgId);
|
||||
|
||||
// Session 1's stuck message should still be stuck (not healed by session 2's claim)
|
||||
const session1Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(stuckInSession1) as { status: string };
|
||||
expect(session1Msg.status).toBe('processing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Tests for MigrationRunner idempotency and schema initialization (#979)
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with ':memory:' — tests actual migration SQL
|
||||
* - Validates idempotency by running migrations multiple times
|
||||
* - Covers the version-conflict scenario from issue #979
|
||||
*
|
||||
* Value: Prevents regression where old DatabaseManager migrations mask core table creation
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
|
||||
|
||||
interface TableNameRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TableColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
}
|
||||
|
||||
interface SchemaVersion {
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface ForeignKeyInfo {
|
||||
table: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
}
|
||||
|
||||
function getTableNames(db: Database): string[] {
|
||||
const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as TableNameRow[];
|
||||
return rows.map(r => r.name);
|
||||
}
|
||||
|
||||
function getColumns(db: Database, table: string): TableColumnInfo[] {
|
||||
return db.prepare(`PRAGMA table_info(${table})`).all() as TableColumnInfo[];
|
||||
}
|
||||
|
||||
function getSchemaVersions(db: Database): number[] {
|
||||
const rows = db.prepare('SELECT version FROM schema_versions ORDER BY version').all() as SchemaVersion[];
|
||||
return rows.map(r => r.version);
|
||||
}
|
||||
|
||||
describe('MigrationRunner', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('fresh database initialization', () => {
|
||||
it('should create all core tables on a fresh database', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const tables = getTableNames(db);
|
||||
expect(tables).toContain('schema_versions');
|
||||
expect(tables).toContain('sdk_sessions');
|
||||
expect(tables).toContain('observations');
|
||||
expect(tables).toContain('session_summaries');
|
||||
expect(tables).toContain('user_prompts');
|
||||
expect(tables).toContain('pending_messages');
|
||||
});
|
||||
|
||||
it('should create sdk_sessions with all expected columns', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const columns = getColumns(db, 'sdk_sessions');
|
||||
const columnNames = columns.map(c => c.name);
|
||||
|
||||
expect(columnNames).toContain('id');
|
||||
expect(columnNames).toContain('content_session_id');
|
||||
expect(columnNames).toContain('memory_session_id');
|
||||
expect(columnNames).toContain('project');
|
||||
expect(columnNames).toContain('status');
|
||||
expect(columnNames).toContain('worker_port');
|
||||
expect(columnNames).toContain('prompt_counter');
|
||||
});
|
||||
|
||||
it('should create observations with all expected columns including content_hash', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const columns = getColumns(db, 'observations');
|
||||
const columnNames = columns.map(c => c.name);
|
||||
|
||||
expect(columnNames).toContain('id');
|
||||
expect(columnNames).toContain('memory_session_id');
|
||||
expect(columnNames).toContain('project');
|
||||
expect(columnNames).toContain('type');
|
||||
expect(columnNames).toContain('title');
|
||||
expect(columnNames).toContain('narrative');
|
||||
expect(columnNames).toContain('prompt_number');
|
||||
expect(columnNames).toContain('discovery_tokens');
|
||||
expect(columnNames).toContain('content_hash');
|
||||
});
|
||||
|
||||
it('should record all migration versions', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const versions = getSchemaVersions(db);
|
||||
// Core set of expected versions
|
||||
expect(versions).toContain(4); // initializeSchema
|
||||
expect(versions).toContain(5); // worker_port
|
||||
expect(versions).toContain(6); // prompt tracking
|
||||
expect(versions).toContain(7); // remove unique constraint
|
||||
expect(versions).toContain(8); // hierarchical fields
|
||||
expect(versions).toContain(9); // text nullable
|
||||
expect(versions).toContain(10); // user_prompts
|
||||
expect(versions).toContain(11); // discovery_tokens
|
||||
expect(versions).toContain(16); // pending_messages
|
||||
expect(versions).toContain(17); // rename columns
|
||||
expect(versions).toContain(19); // repair (noop)
|
||||
expect(versions).toContain(20); // failed_at_epoch
|
||||
expect(versions).toContain(21); // ON UPDATE CASCADE
|
||||
expect(versions).toContain(22); // content_hash
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency — running migrations twice', () => {
|
||||
it('should succeed when run twice on the same database', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
|
||||
// First run
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Second run — must not throw
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce identical schema when run twice', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const tablesAfterFirst = getTableNames(db);
|
||||
const versionsAfterFirst = getSchemaVersions(db);
|
||||
|
||||
runner.runAllMigrations();
|
||||
|
||||
const tablesAfterSecond = getTableNames(db);
|
||||
const versionsAfterSecond = getSchemaVersions(db);
|
||||
|
||||
expect(tablesAfterSecond).toEqual(tablesAfterFirst);
|
||||
expect(versionsAfterSecond).toEqual(versionsAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #979 — old DatabaseManager version conflict', () => {
|
||||
it('should create core tables even when old migration versions 1-7 are in schema_versions', () => {
|
||||
// Simulate the old DatabaseManager having applied its migrations 1-7
|
||||
// (which are completely different operations with the same version numbers)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
for (let v = 1; v <= 7; v++) {
|
||||
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(v, now);
|
||||
}
|
||||
|
||||
// Now run MigrationRunner — core tables MUST still be created
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const tables = getTableNames(db);
|
||||
expect(tables).toContain('sdk_sessions');
|
||||
expect(tables).toContain('observations');
|
||||
expect(tables).toContain('session_summaries');
|
||||
expect(tables).toContain('user_prompts');
|
||||
expect(tables).toContain('pending_messages');
|
||||
});
|
||||
|
||||
it('should handle version 5 conflict (old=drop tables, new=add column) correctly', () => {
|
||||
// Old migration 5 drops streaming_sessions/observation_queue
|
||||
// New migration 5 adds worker_port column to sdk_sessions
|
||||
// With old version 5 already recorded, MigrationRunner must still add the column
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// sdk_sessions should exist and have worker_port (added by later migrations even if v5 is skipped)
|
||||
const columns = getColumns(db, 'sdk_sessions');
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('content_session_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crash recovery — leftover temp tables', () => {
|
||||
it('should handle leftover session_summaries_new table from crashed migration 7', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Simulate a leftover temp table from a crash
|
||||
db.run(`
|
||||
CREATE TABLE session_summaries_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
test TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Remove version 7 so migration tries to re-run
|
||||
db.prepare('DELETE FROM schema_versions WHERE version = 7').run();
|
||||
|
||||
// Re-run should handle the leftover table gracefully
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle leftover observations_new table from crashed migration 9', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Simulate a leftover temp table from a crash
|
||||
db.run(`
|
||||
CREATE TABLE observations_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
test TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Remove version 9 so migration tries to re-run
|
||||
db.prepare('DELETE FROM schema_versions WHERE version = 9').run();
|
||||
|
||||
// Re-run should handle the leftover table gracefully
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ON UPDATE CASCADE FK constraints', () => {
|
||||
it('should have ON UPDATE CASCADE on observations FK after migration 21', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const fks = db.prepare('PRAGMA foreign_key_list(observations)').all() as ForeignKeyInfo[];
|
||||
const memorySessionFk = fks.find(fk => fk.table === 'sdk_sessions');
|
||||
|
||||
expect(memorySessionFk).toBeDefined();
|
||||
expect(memorySessionFk!.on_update).toBe('CASCADE');
|
||||
expect(memorySessionFk!.on_delete).toBe('CASCADE');
|
||||
});
|
||||
|
||||
it('should have ON UPDATE CASCADE on session_summaries FK after migration 21', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as ForeignKeyInfo[];
|
||||
const memorySessionFk = fks.find(fk => fk.table === 'sdk_sessions');
|
||||
|
||||
expect(memorySessionFk).toBeDefined();
|
||||
expect(memorySessionFk!.on_update).toBe('CASCADE');
|
||||
expect(memorySessionFk!.on_delete).toBe('CASCADE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data integrity during migration', () => {
|
||||
it('should preserve existing data through all migrations', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Insert test data
|
||||
const now = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('test-content-1', 'test-memory-1', 'test-project', now, epoch, 'active');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO observations (memory_session_id, project, text, type, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('test-memory-1', 'test-project', 'test observation', 'discovery', now, epoch);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO session_summaries (memory_session_id, project, request, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('test-memory-1', 'test-project', 'test request', now, epoch);
|
||||
|
||||
// Run migrations again — data should survive
|
||||
runner.runAllMigrations();
|
||||
|
||||
const sessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
||||
const observations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
const summaries = db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
|
||||
|
||||
expect(sessions.count).toBe(1);
|
||||
expect(observations.count).toBe(1);
|
||||
expect(summaries.count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Tests for malformed schema repair in Database.ts
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with temp file — tests actual schema repair logic
|
||||
* - Uses Python sqlite3 to simulate cross-version schema corruption
|
||||
* (bun:sqlite doesn't allow writable_schema modifications)
|
||||
* - Covers the cross-machine sync scenario from issue #1307
|
||||
*
|
||||
* Value: Prevents the silent 503 failure loop when a DB is synced between
|
||||
* machines running different claude-mem versions
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
|
||||
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
|
||||
function tempDbPath(): string {
|
||||
return join(tmpdir(), `claude-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
}
|
||||
|
||||
function cleanup(path: string): void {
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const p = path + suffix;
|
||||
if (existsSync(p)) unlinkSync(p);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPython(): boolean {
|
||||
try {
|
||||
execSync('python3 --version', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Python's sqlite3 to corrupt a DB by removing the content_hash column
|
||||
* from the observations table definition while leaving the index intact.
|
||||
* This simulates what happens when a DB from a newer version is synced.
|
||||
*/
|
||||
function corruptDbViaPython(dbPath: string): void {
|
||||
const script = join(tmpdir(), `corrupt-${Date.now()}.py`);
|
||||
writeFileSync(script, `
|
||||
import sqlite3, re, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
c.execute("PRAGMA writable_schema = ON")
|
||||
row = c.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='observations'").fetchone()
|
||||
if row:
|
||||
new_sql = re.sub(r',\\s*content_hash\\s+TEXT', '', row[0])
|
||||
c.execute("UPDATE sqlite_master SET sql = ? WHERE type='table' AND name='observations'", (new_sql,))
|
||||
c.execute("PRAGMA writable_schema = OFF")
|
||||
c.commit()
|
||||
c.close()
|
||||
`);
|
||||
try {
|
||||
execSync(`python3 "${script}" "${dbPath}"`, { timeout: 10000 });
|
||||
} finally {
|
||||
if (existsSync(script)) unlinkSync(script);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Schema repair on malformed database', () => {
|
||||
it('should repair a database with an orphaned index referencing a non-existent column', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a valid database with all migrations
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Verify content_hash column and index exist
|
||||
const hasContentHash = db.prepare('PRAGMA table_info(observations)').all()
|
||||
.some((col: any) => col.name === 'content_hash');
|
||||
expect(hasContentHash).toBe(true);
|
||||
|
||||
// Checkpoint WAL so all data is in the main file
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Verify the DB is actually corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
} catch (e: any) {
|
||||
threw = true;
|
||||
expect(e.message).toContain('malformed database schema');
|
||||
expect(e.message).toContain('idx_observations_content_hash');
|
||||
}
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// Step 4: Open via ClaudeMemDatabase — it should auto-repair
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Verify the DB is functional
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('observations');
|
||||
expect(tableNames).toContain('sdk_sessions');
|
||||
|
||||
// Verify the index was recreated by the migration runner
|
||||
const indexes = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_observations_content_hash'")
|
||||
.all() as { name: string }[];
|
||||
expect(indexes.length).toBe(1);
|
||||
|
||||
// Verify the content_hash column was re-added by the migration
|
||||
const columns = repaired.db.prepare('PRAGMA table_info(observations)').all() as { name: string }[];
|
||||
expect(columns.some(c => c.name === 'content_hash')).toBe(true);
|
||||
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle a fresh database without triggering repair', () => {
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
const db = new ClaudeMemDatabase(dbPath);
|
||||
const tables = db.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
expect(tables.length).toBeGreaterThan(0);
|
||||
db.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should repair a corrupted DB that has no schema_versions table', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
const scriptPath = join(tmpdir(), `corrupt-nosv-${Date.now()}.py`);
|
||||
try {
|
||||
// Build a minimal DB with only a malformed observations table and orphaned index
|
||||
// — no schema_versions table. This simulates a partially-initialized DB that was
|
||||
// synced before migrations ever ran.
|
||||
writeFileSync(scriptPath, `
|
||||
import sqlite3, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
c.execute('PRAGMA writable_schema = ON')
|
||||
# Inject an orphaned index into sqlite_master without any backing table.
|
||||
# This simulates a partially-synced DB where index metadata arrived but
|
||||
# the table schema is incomplete or missing columns.
|
||||
idx_sql = 'CREATE INDEX idx_observations_content_hash ON observations(content_hash, created_at_epoch)'
|
||||
c.execute(
|
||||
"INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) VALUES ('index', 'idx_observations_content_hash', 'observations', 0, ?)",
|
||||
(idx_sql,)
|
||||
)
|
||||
c.execute('PRAGMA writable_schema = OFF')
|
||||
c.commit()
|
||||
c.close()
|
||||
`);
|
||||
execFileSync('python3', [scriptPath, dbPath], { timeout: 10000 });
|
||||
|
||||
// Verify it's corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
||||
} catch (e: any) {
|
||||
threw = true;
|
||||
expect(e.message).toContain('malformed database schema');
|
||||
}
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// ClaudeMemDatabase must repair and fully initialize despite missing schema_versions
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('schema_versions');
|
||||
expect(tableNames).toContain('observations');
|
||||
expect(tableNames).toContain('sdk_sessions');
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
if (existsSync(scriptPath)) unlinkSync(scriptPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve existing data through repair and re-migration', () => {
|
||||
if (!hasPython()) {
|
||||
console.log('Python3 not available, skipping test');
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a fully migrated DB and insert a session + observation
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run('test-content-1', 'test-memory-1', 'test-project', now, epoch, 'active');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO observations (memory_session_id, project, type, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('test-memory-1', 'test-project', 'discovery', now, epoch);
|
||||
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Repair via ClaudeMemDatabase
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Data must survive the repair + re-migration
|
||||
const sessions = repaired.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
||||
const observations = repaired.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
expect(sessions.count).toBe(1);
|
||||
expect(observations.count).toBe(1);
|
||||
|
||||
repaired.close();
|
||||
} finally {
|
||||
cleanup(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isDirectChild, normalizePath } from '../../../src/shared/path-utils.js';
|
||||
|
||||
/**
|
||||
* Tests for path matching logic, specifically the isDirectChild() algorithm
|
||||
* Covers fix for issue #794: Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*
|
||||
* These tests validate the shared path-utils module which is used by:
|
||||
* - SessionSearch.ts (runtime folder CLAUDE.md generation)
|
||||
* - regenerate-claude-md.ts (CLI regeneration tool)
|
||||
*/
|
||||
|
||||
describe('isDirectChild path matching', () => {
|
||||
describe('same path format', () => {
|
||||
test('returns true for direct child with relative paths', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for direct child with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with relative paths', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', 'app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory with absolute paths', () => {
|
||||
expect(isDirectChild('/Users/dev/project/app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for unrelated paths', () => {
|
||||
expect(isDirectChild('lib/utils/helper.py', 'app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed path formats (absolute folder, relative file) - fixes #794', () => {
|
||||
test('returns true when absolute folder ends with relative file directory', () => {
|
||||
// This is the exact bug case from #794
|
||||
expect(isDirectChild('app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for deeply nested folder match', () => {
|
||||
expect(isDirectChild('src/components/Button.tsx', '/home/user/project/src/components')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for files in subdirectory of matched folder', () => {
|
||||
expect(isDirectChild('app/api/v1/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when file path does not match folder suffix', () => {
|
||||
expect(isDirectChild('lib/api/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path normalization', () => {
|
||||
test('handles Windows backslash paths', () => {
|
||||
expect(isDirectChild('app\\api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles mixed slashes', () => {
|
||||
expect(isDirectChild('app/api\\router.py', 'app\\api')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles trailing slashes on folder path', () => {
|
||||
expect(isDirectChild('app/api/router.py', 'app/api/')).toBe(true);
|
||||
});
|
||||
|
||||
test('handles double slashes (path normalization bug)', () => {
|
||||
expect(isDirectChild('app//api/router.py', 'app/api')).toBe(true);
|
||||
});
|
||||
|
||||
test('collapses multiple consecutive slashes', () => {
|
||||
expect(isDirectChild('app///api///router.py', 'app//api//')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('returns false for single segment file path', () => {
|
||||
expect(isDirectChild('router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty paths', () => {
|
||||
expect(isDirectChild('', 'app/api')).toBe(false);
|
||||
expect(isDirectChild('app/api/router.py', '')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles root-level folders', () => {
|
||||
expect(isDirectChild('src/file.ts', '/project/src')).toBe(true);
|
||||
});
|
||||
|
||||
test('prevents false positive from partial segment match', () => {
|
||||
// "api" folder should not match "api-v2" folder
|
||||
expect(isDirectChild('app/api-v2/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles similar folder names correctly', () => {
|
||||
// "components" should not match "components-old"
|
||||
expect(isDirectChild('src/components-old/Button.tsx', '/project/src/components')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('converts backslashes to forward slashes', () => {
|
||||
expect(normalizePath('app\\api\\router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('collapses consecutive slashes', () => {
|
||||
expect(normalizePath('app//api///router.py')).toBe('app/api/router.py');
|
||||
});
|
||||
|
||||
test('removes trailing slashes', () => {
|
||||
expect(normalizePath('app/api/')).toBe('app/api');
|
||||
expect(normalizePath('app/api///')).toBe('app/api');
|
||||
});
|
||||
|
||||
test('handles Windows UNC paths', () => {
|
||||
expect(normalizePath('\\\\server\\share\\file.txt')).toBe('/server/share/file.txt');
|
||||
});
|
||||
|
||||
test('preserves leading slash for absolute paths', () => {
|
||||
expect(normalizePath('/Users/dev/project')).toBe('/Users/dev/project');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for Issue #1099: Stale AbortController queue stall prevention
|
||||
*
|
||||
* Validates that:
|
||||
* 1. ActiveSession tracks lastGeneratorActivity timestamp
|
||||
* 2. deleteSession uses a 30s timeout to prevent indefinite stalls
|
||||
* 3. Stale generators (>30s no activity) are detected and aborted
|
||||
* 4. processAgentResponse updates lastGeneratorActivity
|
||||
*/
|
||||
|
||||
describe('Stale AbortController Guard (#1099)', () => {
|
||||
describe('ActiveSession.lastGeneratorActivity', () => {
|
||||
it('should be defined in ActiveSession type', () => {
|
||||
// Verify the type includes lastGeneratorActivity
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test',
|
||||
memorySessionId: null,
|
||||
project: 'test',
|
||||
userPrompt: 'test',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 1,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [],
|
||||
currentProvider: null,
|
||||
consecutiveRestarts: 0,
|
||||
processingMessageIds: [],
|
||||
lastGeneratorActivity: Date.now()
|
||||
};
|
||||
|
||||
expect(session.lastGeneratorActivity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should update when set to current time', () => {
|
||||
const before = Date.now();
|
||||
const activity = Date.now();
|
||||
expect(activity).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stale generator detection logic', () => {
|
||||
const STALE_THRESHOLD_MS = 30_000;
|
||||
|
||||
it('should detect generator as stale when no activity for >30s', () => {
|
||||
const lastActivity = Date.now() - 31_000; // 31 seconds ago
|
||||
const timeSinceActivity = Date.now() - lastActivity;
|
||||
expect(timeSinceActivity).toBeGreaterThan(STALE_THRESHOLD_MS);
|
||||
});
|
||||
|
||||
it('should NOT detect generator as stale when activity within 30s', () => {
|
||||
const lastActivity = Date.now() - 5_000; // 5 seconds ago
|
||||
const timeSinceActivity = Date.now() - lastActivity;
|
||||
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
|
||||
});
|
||||
|
||||
it('should reset activity timestamp when generator restarts', () => {
|
||||
const session = {
|
||||
lastGeneratorActivity: Date.now() - 60_000, // 60 seconds ago (stale)
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: Promise.resolve() as Promise<void> | null,
|
||||
};
|
||||
|
||||
// Simulate stale recovery: abort, reset, restart
|
||||
session.abortController.abort();
|
||||
session.generatorPromise = null;
|
||||
session.abortController = new AbortController();
|
||||
session.lastGeneratorActivity = Date.now();
|
||||
|
||||
// After reset, should no longer be stale
|
||||
const timeSinceActivity = Date.now() - session.lastGeneratorActivity;
|
||||
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbortSignal.timeout for deleteSession', () => {
|
||||
it('should resolve timeout signal after specified ms', async () => {
|
||||
const start = Date.now();
|
||||
const timeoutMs = 50; // Use short timeout for test
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
// Allow some margin for timing
|
||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10);
|
||||
});
|
||||
|
||||
it('should race generator promise against timeout', async () => {
|
||||
// Simulate a hung generator (never resolves)
|
||||
const hungGenerator = new Promise<void>(() => {});
|
||||
const timeoutMs = 50;
|
||||
|
||||
const timeoutDone = new Promise<string>(resolve => {
|
||||
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve('timeout'), { once: true });
|
||||
});
|
||||
|
||||
const generatorDone = hungGenerator.then(() => 'generator');
|
||||
|
||||
const result = await Promise.race([generatorDone, timeoutDone]);
|
||||
expect(result).toBe('timeout');
|
||||
});
|
||||
|
||||
it('should prefer generator completion over timeout when fast', async () => {
|
||||
// Simulate a generator that resolves quickly
|
||||
const fastGenerator = Promise.resolve('generator');
|
||||
const timeoutMs = 5000;
|
||||
|
||||
const timeoutDone = new Promise<string>(resolve => {
|
||||
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve('timeout'), { once: true });
|
||||
});
|
||||
|
||||
const result = await Promise.race([fastGenerator, timeoutDone]);
|
||||
expect(result).toBe('generator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbortController replacement on stale recovery', () => {
|
||||
it('should create fresh AbortController that is not aborted', () => {
|
||||
const oldController = new AbortController();
|
||||
oldController.abort();
|
||||
expect(oldController.signal.aborted).toBe(true);
|
||||
|
||||
const newController = new AbortController();
|
||||
expect(newController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should not affect new controller when old is aborted', () => {
|
||||
const oldController = new AbortController();
|
||||
const newController = new AbortController();
|
||||
|
||||
oldController.abort();
|
||||
|
||||
expect(oldController.signal.aborted).toBe(true);
|
||||
expect(newController.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Regression tests for ChromaMcpManager SSL flag handling (PR #1286)
|
||||
*
|
||||
* Validates that buildCommandArgs() always emits the correct `--ssl` flag
|
||||
* based on CLAUDE_MEM_CHROMA_SSL, and omits it entirely in local mode.
|
||||
*
|
||||
* Strategy: mock StdioClientTransport to capture the spawned args without
|
||||
* actually launching a subprocess, then inspect the captured args array.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
|
||||
// ── Mutable settings closure (updated per test) ────────────────────────
|
||||
let currentSettings: Record<string, string> = {};
|
||||
|
||||
// ── Mock modules BEFORE importing the module under test ────────────────
|
||||
// Capture the args passed to StdioClientTransport constructor
|
||||
let capturedTransportOpts: { command: string; args: string[] } | null = null;
|
||||
|
||||
mock.module('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: class FakeTransport {
|
||||
// Required: ChromaMcpManager assigns transport.onclose after connect()
|
||||
onclose: (() => void) | null = null;
|
||||
constructor(opts: { command: string; args: string[] }) {
|
||||
capturedTransportOpts = { command: opts.command, args: opts.args };
|
||||
}
|
||||
async close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: class FakeClient {
|
||||
constructor() {}
|
||||
async connect() {}
|
||||
async callTool() {
|
||||
return { content: [{ type: 'text', text: '{}' }] };
|
||||
}
|
||||
async close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
SettingsDefaultsManager: {
|
||||
get: (key: string) => currentSettings[key] ?? '',
|
||||
getInt: () => 0,
|
||||
loadFromFile: () => currentSettings,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../../../src/shared/paths.js', () => ({
|
||||
USER_SETTINGS_PATH: '/tmp/fake-settings.json',
|
||||
}));
|
||||
|
||||
mock.module('../../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
failure: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Now import the module under test ───────────────────────────────────
|
||||
import { ChromaMcpManager } from '../../../src/services/sync/ChromaMcpManager.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
async function assertSslFlag(sslSetting: string | undefined, expectedValue: string) {
|
||||
currentSettings = { CLAUDE_MEM_CHROMA_MODE: 'remote' };
|
||||
if (sslSetting !== undefined) currentSettings.CLAUDE_MEM_CHROMA_SSL = sslSetting;
|
||||
|
||||
await mgr.callTool('chroma_list_collections', {});
|
||||
|
||||
expect(capturedTransportOpts).not.toBeNull();
|
||||
const sslIdx = capturedTransportOpts!.args.indexOf('--ssl');
|
||||
expect(sslIdx).not.toBe(-1);
|
||||
expect(capturedTransportOpts!.args[sslIdx + 1]).toBe(expectedValue);
|
||||
}
|
||||
|
||||
let mgr: ChromaMcpManager;
|
||||
|
||||
// ── Test suite ─────────────────────────────────────────────────────────
|
||||
describe('ChromaMcpManager SSL flag regression (#1286)', () => {
|
||||
beforeEach(async () => {
|
||||
await ChromaMcpManager.reset();
|
||||
capturedTransportOpts = null;
|
||||
currentSettings = {};
|
||||
mgr = ChromaMcpManager.getInstance();
|
||||
});
|
||||
|
||||
it('emits --ssl false when CLAUDE_MEM_CHROMA_SSL=false', async () => {
|
||||
await assertSslFlag('false', 'false');
|
||||
});
|
||||
|
||||
it('emits --ssl true when CLAUDE_MEM_CHROMA_SSL=true', async () => {
|
||||
await assertSslFlag('true', 'true');
|
||||
});
|
||||
|
||||
it('defaults --ssl false when CLAUDE_MEM_CHROMA_SSL is not set', async () => {
|
||||
await assertSslFlag(undefined, 'false');
|
||||
});
|
||||
|
||||
it('omits --ssl entirely in local mode', async () => {
|
||||
currentSettings = {
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local',
|
||||
};
|
||||
|
||||
await mgr.callTool('chroma_list_collections', {});
|
||||
|
||||
expect(capturedTransportOpts).not.toBeNull();
|
||||
const args = capturedTransportOpts!.args;
|
||||
expect(args).not.toContain('--ssl');
|
||||
expect(args).toContain('--client-type');
|
||||
expect(args[args.indexOf('--client-type') + 1]).toBe('persistent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
/**
|
||||
* Session ID Usage Validation - Smoke Tests for Critical Invariants
|
||||
*
|
||||
* These tests validate the most critical behaviors of the dual session ID system:
|
||||
* - contentSessionId: User's Claude Code conversation session (immutable)
|
||||
* - memorySessionId: SDK agent's session ID for resume (captured from SDK response)
|
||||
*
|
||||
* CRITICAL INVARIANTS:
|
||||
* 1. Cross-contamination prevention: Observations from different sessions never mix
|
||||
* 2. Resume safety: Resume only allowed when memorySessionId is actually captured (non-NULL)
|
||||
* 3. 1:1 mapping: Each contentSessionId maps to exactly one memorySessionId
|
||||
*/
|
||||
describe('Session ID Critical Invariants', () => {
|
||||
let store: SessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new SessionStore(':memory:');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe('Cross-Contamination Prevention', () => {
|
||||
it('should never mix observations from different content sessions', () => {
|
||||
// Create two independent sessions
|
||||
const content1 = 'user-session-A';
|
||||
const content2 = 'user-session-B';
|
||||
const memory1 = 'memory-session-A';
|
||||
const memory2 = 'memory-session-B';
|
||||
|
||||
const id1 = store.createSDKSession(content1, 'project-a', 'Prompt A');
|
||||
const id2 = store.createSDKSession(content2, 'project-b', 'Prompt B');
|
||||
store.updateMemorySessionId(id1, memory1);
|
||||
store.updateMemorySessionId(id2, memory2);
|
||||
|
||||
// Store observations in each session
|
||||
store.storeObservation(memory1, 'project-a', {
|
||||
type: 'discovery',
|
||||
title: 'Observation A',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}, 1);
|
||||
|
||||
store.storeObservation(memory2, 'project-b', {
|
||||
type: 'discovery',
|
||||
title: 'Observation B',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}, 1);
|
||||
|
||||
// CRITICAL: Each session's observations must be isolated
|
||||
const obsA = store.getObservationsForSession(memory1);
|
||||
const obsB = store.getObservationsForSession(memory2);
|
||||
|
||||
expect(obsA.length).toBe(1);
|
||||
expect(obsB.length).toBe(1);
|
||||
expect(obsA[0].title).toBe('Observation A');
|
||||
expect(obsB[0].title).toBe('Observation B');
|
||||
|
||||
// Verify no cross-contamination: A's query doesn't return B's data
|
||||
expect(obsA.some(o => o.title === 'Observation B')).toBe(false);
|
||||
expect(obsB.some(o => o.title === 'Observation A')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resume Safety', () => {
|
||||
it('should prevent resume when memorySessionId is NULL (not yet captured)', () => {
|
||||
const contentSessionId = 'new-session-123';
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'First prompt');
|
||||
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
|
||||
// CRITICAL: Before SDK returns real session ID, memory_session_id must be NULL
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// hasRealMemorySessionId check: only resume when non-NULL
|
||||
const hasRealMemorySessionId = session?.memory_session_id !== null;
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
|
||||
// Resume options should be empty (no resume parameter)
|
||||
const resumeOptions = hasRealMemorySessionId ? { resume: session?.memory_session_id } : {};
|
||||
expect(resumeOptions).toEqual({});
|
||||
});
|
||||
|
||||
it('should allow resume only after memorySessionId is captured', () => {
|
||||
const contentSessionId = 'resume-ready-session';
|
||||
const capturedMemoryId = 'sdk-returned-session-xyz';
|
||||
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt');
|
||||
|
||||
// Before capture
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Capture memory session ID (simulates SDK response)
|
||||
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
||||
|
||||
// After capture
|
||||
session = store.getSessionById(sessionDbId);
|
||||
const hasRealMemorySessionId = session?.memory_session_id !== null;
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true);
|
||||
expect(session?.memory_session_id).toBe(capturedMemoryId);
|
||||
expect(session?.memory_session_id).not.toBe(contentSessionId);
|
||||
});
|
||||
|
||||
it('should preserve memorySessionId across createSDKSession calls (pure get-or-create)', () => {
|
||||
// createSDKSession is a pure get-or-create: it never modifies memory_session_id.
|
||||
// Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level,
|
||||
// and ensureMemorySessionIdRegistered updates the ID when a new generator captures one.
|
||||
const contentSessionId = 'multi-prompt-session';
|
||||
const firstMemoryId = 'first-generator-memory-id';
|
||||
|
||||
// First generator creates session and captures memory ID
|
||||
let sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
store.updateMemorySessionId(sessionDbId, firstMemoryId);
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId);
|
||||
|
||||
// Second createSDKSession call preserves memory_session_id (no reset)
|
||||
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId); // Preserved, not reset
|
||||
|
||||
// ensureMemorySessionIdRegistered can update to a new ID (ON UPDATE CASCADE handles FK)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, 'second-generator-memory-id');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe('second-generator-memory-id');
|
||||
});
|
||||
|
||||
it('should NOT reset memorySessionId when it is still NULL (first prompt scenario)', () => {
|
||||
// When memory_session_id is NULL, createSDKSession should NOT reset it
|
||||
// This is the normal first-prompt scenario where SDKAgent hasn't captured the ID yet
|
||||
const contentSessionId = 'new-session';
|
||||
|
||||
// First createSDKSession - creates row with NULL memory_session_id
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Second createSDKSession (before SDK has returned) - should still be NULL, no reset needed
|
||||
store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNIQUE Constraint Enforcement', () => {
|
||||
it('should prevent duplicate memorySessionId (protects against multiple transcripts)', () => {
|
||||
const content1 = 'content-session-1';
|
||||
const content2 = 'content-session-2';
|
||||
const sharedMemoryId = 'shared-memory-id';
|
||||
|
||||
const id1 = store.createSDKSession(content1, 'project', 'Prompt 1');
|
||||
const id2 = store.createSDKSession(content2, 'project', 'Prompt 2');
|
||||
|
||||
// First session captures memory ID - should succeed
|
||||
store.updateMemorySessionId(id1, sharedMemoryId);
|
||||
|
||||
// Second session tries to use SAME memory ID - should FAIL
|
||||
expect(() => {
|
||||
store.updateMemorySessionId(id2, sharedMemoryId);
|
||||
}).toThrow(); // UNIQUE constraint violation
|
||||
|
||||
// First session still has the ID
|
||||
const session1 = store.getSessionById(id1);
|
||||
expect(session1?.memory_session_id).toBe(sharedMemoryId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Foreign Key Integrity', () => {
|
||||
it('should reject observations for non-existent sessions', () => {
|
||||
expect(() => {
|
||||
store.storeObservation('nonexistent-session-id', 'test-project', {
|
||||
type: 'discovery',
|
||||
title: 'Invalid FK',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: null,
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}, 1);
|
||||
}).toThrow(); // FK constraint violation
|
||||
});
|
||||
});
|
||||
});
|
||||
121
.agent/services/claude-mem/tests/session_store.test.ts
Normal file
121
.agent/services/claude-mem/tests/session_store.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for SessionStore in-memory database operations
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with ':memory:' - tests actual SQL and schema
|
||||
* - All CRUD operations are tested against real database behavior
|
||||
* - Timestamp handling and FK relationships are validated
|
||||
*
|
||||
* Value: Validates core persistence layer without filesystem dependencies
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
describe('SessionStore', () => {
|
||||
let store: SessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new SessionStore(':memory:');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
it('should correctly count user prompts', () => {
|
||||
const claudeId = 'claude-session-1';
|
||||
store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Should be 0 initially
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(0);
|
||||
|
||||
// Save prompt 1
|
||||
store.saveUserPrompt(claudeId, 1, 'First prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(1);
|
||||
|
||||
// Save prompt 2
|
||||
store.saveUserPrompt(claudeId, 2, 'Second prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(2);
|
||||
|
||||
// Save prompt for another session
|
||||
store.createSDKSession('claude-session-2', 'test-project', 'initial prompt');
|
||||
store.saveUserPrompt('claude-session-2', 1, 'Other prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(2);
|
||||
});
|
||||
|
||||
it('should store observation with timestamp override', () => {
|
||||
const claudeId = 'claude-sess-obs';
|
||||
const memoryId = 'memory-sess-obs';
|
||||
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Set the memory_session_id before storing observations
|
||||
// createSDKSession now initializes memory_session_id = NULL
|
||||
store.updateMemorySessionId(sdkId, memoryId);
|
||||
|
||||
const obs = {
|
||||
type: 'discovery',
|
||||
title: 'Test Obs',
|
||||
subtitle: null,
|
||||
facts: [],
|
||||
narrative: 'Testing',
|
||||
concepts: [],
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
};
|
||||
|
||||
const pastTimestamp = 1600000000000; // Some time in the past
|
||||
|
||||
const result = store.storeObservation(
|
||||
memoryId, // Use memorySessionId for FK reference
|
||||
'test-project',
|
||||
obs,
|
||||
1,
|
||||
0,
|
||||
pastTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(pastTimestamp);
|
||||
|
||||
const stored = store.getObservationById(result.id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
|
||||
// Verify ISO string matches
|
||||
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
|
||||
});
|
||||
|
||||
it('should store summary with timestamp override', () => {
|
||||
const claudeId = 'claude-sess-sum';
|
||||
const memoryId = 'memory-sess-sum';
|
||||
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Set the memory_session_id before storing summaries
|
||||
store.updateMemorySessionId(sdkId, memoryId);
|
||||
|
||||
const summary = {
|
||||
request: 'Do something',
|
||||
investigated: 'Stuff',
|
||||
learned: 'Things',
|
||||
completed: 'Done',
|
||||
next_steps: 'More',
|
||||
notes: null
|
||||
};
|
||||
|
||||
const pastTimestamp = 1650000000000;
|
||||
|
||||
const result = store.storeSummary(
|
||||
memoryId, // Use memorySessionId for FK reference
|
||||
'test-project',
|
||||
summary,
|
||||
1,
|
||||
0,
|
||||
pastTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(pastTimestamp);
|
||||
|
||||
const stored = store.getSummaryForSession(memoryId);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* SettingsDefaultsManager Tests
|
||||
*
|
||||
* Tests for the settings file auto-creation feature in loadFromFile().
|
||||
* Uses temp directories for file system isolation.
|
||||
*
|
||||
* Test cases:
|
||||
* 1. File doesn't exist - should create file with defaults and return defaults
|
||||
* 2. File exists with valid content - should return parsed content
|
||||
* 3. File exists but is empty/corrupt - should return defaults
|
||||
* 4. Directory doesn't exist - should create directory and file
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { SettingsDefaultsManager } from '../../src/shared/SettingsDefaultsManager.js';
|
||||
|
||||
describe('SettingsDefaultsManager', () => {
|
||||
let tempDir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `settings-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
settingsPath = join(tempDir, 'settings.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadFromFile', () => {
|
||||
describe('file does not exist', () => {
|
||||
it('should create file with defaults when file does not exist', () => {
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(existsSync(settingsPath)).toBe(true);
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should write valid JSON to the created file', () => {
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should write pretty-printed JSON (2-space indent)', () => {
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' "CLAUDE_MEM_MODEL"');
|
||||
});
|
||||
|
||||
it('should write all default keys to the file', () => {
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
for (const key of Object.keys(defaults)) {
|
||||
expect(parsed).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory does not exist', () => {
|
||||
it('should create directory and file when parent directory does not exist', () => {
|
||||
const nestedPath = join(tempDir, 'nested', 'deep', 'settings.json');
|
||||
expect(existsSync(join(tempDir, 'nested'))).toBe(false);
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(nestedPath);
|
||||
|
||||
expect(existsSync(join(tempDir, 'nested', 'deep'))).toBe(true);
|
||||
expect(existsSync(nestedPath)).toBe(true);
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should create deeply nested directories recursively', () => {
|
||||
const deepPath = join(tempDir, 'a', 'b', 'c', 'd', 'e', 'settings.json');
|
||||
|
||||
SettingsDefaultsManager.loadFromFile(deepPath);
|
||||
|
||||
expect(existsSync(join(tempDir, 'a', 'b', 'c', 'd', 'e'))).toBe(true);
|
||||
expect(existsSync(deepPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file exists with valid content', () => {
|
||||
it('should return parsed content when file has valid JSON', () => {
|
||||
const customSettings = {
|
||||
CLAUDE_MEM_MODEL: 'custom-model',
|
||||
CLAUDE_MEM_WORKER_PORT: '12345',
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(customSettings));
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('custom-model');
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('12345');
|
||||
});
|
||||
|
||||
it('should merge file settings with defaults for missing keys', () => {
|
||||
// Only set one value, defaults should fill the rest
|
||||
const partialSettings = {
|
||||
CLAUDE_MEM_MODEL: 'partial-model',
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(partialSettings));
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('partial-model');
|
||||
// Other values should come from defaults
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe(defaults.CLAUDE_MEM_WORKER_PORT);
|
||||
expect(result.CLAUDE_MEM_WORKER_HOST).toBe(defaults.CLAUDE_MEM_WORKER_HOST);
|
||||
expect(result.CLAUDE_MEM_LOG_LEVEL).toBe(defaults.CLAUDE_MEM_LOG_LEVEL);
|
||||
});
|
||||
|
||||
it('should not modify existing file when loading', () => {
|
||||
const customSettings = {
|
||||
CLAUDE_MEM_MODEL: 'do-not-change',
|
||||
CUSTOM_KEY: 'should-persist', // Extra key not in defaults
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2));
|
||||
const originalContent = readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
const afterContent = readFileSync(settingsPath, 'utf-8');
|
||||
expect(afterContent).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('should handle all settings keys correctly', () => {
|
||||
const fullSettings = SettingsDefaultsManager.getAllDefaults();
|
||||
fullSettings.CLAUDE_MEM_MODEL = 'all-keys-model';
|
||||
fullSettings.CLAUDE_MEM_PROVIDER = 'gemini';
|
||||
writeFileSync(settingsPath, JSON.stringify(fullSettings));
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('all-keys-model');
|
||||
expect(result.CLAUDE_MEM_PROVIDER).toBe('gemini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file exists but is empty or corrupt', () => {
|
||||
it('should return defaults when file is empty', () => {
|
||||
writeFileSync(settingsPath, '');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should return defaults when file contains invalid JSON', () => {
|
||||
writeFileSync(settingsPath, 'not valid json {{{{');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should return defaults when file contains only whitespace', () => {
|
||||
writeFileSync(settingsPath, ' \n\t ');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should return defaults when file contains null', () => {
|
||||
writeFileSync(settingsPath, 'null');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should return defaults when file contains array instead of object', () => {
|
||||
writeFileSync(settingsPath, '["array", "not", "object"]');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should return defaults when file contains primitive value', () => {
|
||||
writeFileSync(settingsPath, '"just a string"');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested schema migration', () => {
|
||||
it('should migrate old nested { env: {...} } schema to flat schema', () => {
|
||||
const nestedSettings = {
|
||||
env: {
|
||||
CLAUDE_MEM_MODEL: 'nested-model',
|
||||
CLAUDE_MEM_WORKER_PORT: '54321',
|
||||
},
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(nestedSettings));
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('nested-model');
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('54321');
|
||||
});
|
||||
|
||||
it('should auto-migrate file from nested to flat schema', () => {
|
||||
const nestedSettings = {
|
||||
env: {
|
||||
CLAUDE_MEM_MODEL: 'migrated-model',
|
||||
},
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(nestedSettings));
|
||||
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// File should now be flat schema
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.env).toBeUndefined();
|
||||
expect(parsed.CLAUDE_MEM_MODEL).toBe('migrated-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty object in file', () => {
|
||||
writeFileSync(settingsPath, '{}');
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result).toEqual(SettingsDefaultsManager.getAllDefaults());
|
||||
});
|
||||
|
||||
it('should ignore unknown keys in file', () => {
|
||||
const settingsWithUnknown = {
|
||||
CLAUDE_MEM_MODEL: 'known-model',
|
||||
UNKNOWN_KEY: 'should-be-ignored',
|
||||
ANOTHER_UNKNOWN: 12345,
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(settingsWithUnknown));
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('known-model');
|
||||
expect((result as Record<string, unknown>).UNKNOWN_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle file with BOM', () => {
|
||||
const bom = '\uFEFF';
|
||||
const settings = { CLAUDE_MEM_MODEL: 'bom-model' };
|
||||
writeFileSync(settingsPath, bom + JSON.stringify(settings));
|
||||
|
||||
// JSON.parse handles BOM, but let's verify behavior
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// If it fails to parse due to BOM, it should return defaults
|
||||
// If it succeeds, it should return the parsed value
|
||||
// Either way, should not throw
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllDefaults', () => {
|
||||
it('should return a copy of defaults', () => {
|
||||
const defaults1 = SettingsDefaultsManager.getAllDefaults();
|
||||
const defaults2 = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
expect(defaults1).toEqual(defaults2);
|
||||
expect(defaults1).not.toBe(defaults2); // Different object references
|
||||
});
|
||||
|
||||
it('should include all expected keys', () => {
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
// Core settings
|
||||
expect(defaults.CLAUDE_MEM_MODEL).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_WORKER_PORT).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_WORKER_HOST).toBeDefined();
|
||||
|
||||
// Provider settings
|
||||
expect(defaults.CLAUDE_MEM_PROVIDER).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_GEMINI_API_KEY).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_OPENROUTER_API_KEY).toBeDefined();
|
||||
|
||||
// System settings
|
||||
expect(defaults.CLAUDE_MEM_DATA_DIR).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_LOG_LEVEL).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return default value for key', () => {
|
||||
expect(SettingsDefaultsManager.get('CLAUDE_MEM_MODEL')).toBe('claude-sonnet-4-5');
|
||||
expect(SettingsDefaultsManager.get('CLAUDE_MEM_WORKER_PORT')).toBe('37777');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInt', () => {
|
||||
it('should return integer value for numeric string', () => {
|
||||
expect(SettingsDefaultsManager.getInt('CLAUDE_MEM_WORKER_PORT')).toBe(37777);
|
||||
expect(SettingsDefaultsManager.getInt('CLAUDE_MEM_CONTEXT_OBSERVATIONS')).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBool', () => {
|
||||
it('should return true for "true" string', () => {
|
||||
expect(SettingsDefaultsManager.getBool('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-"true" string', () => {
|
||||
expect(SettingsDefaultsManager.getBool('CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable overrides', () => {
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original env values
|
||||
originalEnv.CLAUDE_MEM_WORKER_PORT = process.env.CLAUDE_MEM_WORKER_PORT;
|
||||
originalEnv.CLAUDE_MEM_MODEL = process.env.CLAUDE_MEM_MODEL;
|
||||
originalEnv.CLAUDE_MEM_LOG_LEVEL = process.env.CLAUDE_MEM_LOG_LEVEL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env values
|
||||
if (originalEnv.CLAUDE_MEM_WORKER_PORT === undefined) {
|
||||
delete process.env.CLAUDE_MEM_WORKER_PORT;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = originalEnv.CLAUDE_MEM_WORKER_PORT;
|
||||
}
|
||||
if (originalEnv.CLAUDE_MEM_MODEL === undefined) {
|
||||
delete process.env.CLAUDE_MEM_MODEL;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_MODEL = originalEnv.CLAUDE_MEM_MODEL;
|
||||
}
|
||||
if (originalEnv.CLAUDE_MEM_LOG_LEVEL === undefined) {
|
||||
delete process.env.CLAUDE_MEM_LOG_LEVEL;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_LOG_LEVEL = originalEnv.CLAUDE_MEM_LOG_LEVEL;
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize env var over file setting', () => {
|
||||
// File has port 12345, env var has 54321
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '12345',
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(fileSettings));
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '54321';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('54321');
|
||||
});
|
||||
|
||||
it('should prioritize env var over default', () => {
|
||||
// No file, env var set
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '99999';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('99999');
|
||||
});
|
||||
|
||||
it('should use file setting when env var is not set', () => {
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '11111',
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(fileSettings));
|
||||
delete process.env.CLAUDE_MEM_WORKER_PORT;
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('11111');
|
||||
});
|
||||
|
||||
it('should apply env var override even on file parse error', () => {
|
||||
writeFileSync(settingsPath, 'invalid json {{{');
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '88888';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('88888');
|
||||
});
|
||||
|
||||
it('should apply multiple env var overrides', () => {
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '12345',
|
||||
CLAUDE_MEM_MODEL: 'file-model',
|
||||
CLAUDE_MEM_LOG_LEVEL: 'DEBUG',
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(fileSettings));
|
||||
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '54321';
|
||||
process.env.CLAUDE_MEM_MODEL = 'env-model';
|
||||
// LOG_LEVEL not set in env, should use file value
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('54321');
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('env-model');
|
||||
expect(result.CLAUDE_MEM_LOG_LEVEL).toBe('DEBUG'); // From file
|
||||
});
|
||||
|
||||
it('should document priority: env > file > defaults', () => {
|
||||
// This test documents the expected priority order
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
// Set file to something different from default
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '22222', // Different from default 37777
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(fileSettings));
|
||||
|
||||
// Set env to something different from both
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '33333';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// Priority check:
|
||||
// Default is 37777, file is 22222, env is 33333
|
||||
// Result should be env (33333) because env > file > default
|
||||
expect(defaults.CLAUDE_MEM_WORKER_PORT).toBe('37777'); // Confirm default
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('33333'); // Env wins
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect, mock, afterEach } from 'bun:test';
|
||||
|
||||
// Mock logger BEFORE imports (required pattern)
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
formatTool: (toolName: string, toolInput?: any) => toolInput ? `${toolName}(...)` : toolName,
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { extractFirstFile, groupByDate } from '../../src/shared/timeline-formatting.js';
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('extractFirstFile', () => {
|
||||
const cwd = '/Users/test/project';
|
||||
|
||||
it('should return first modified file as relative path', () => {
|
||||
const filesModified = JSON.stringify(['/Users/test/project/src/app.ts', '/Users/test/project/src/utils.ts']);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd);
|
||||
|
||||
expect(result).toBe('src/app.ts');
|
||||
});
|
||||
|
||||
it('should fall back to files_read when modified is empty', () => {
|
||||
const filesModified = JSON.stringify([]);
|
||||
const filesRead = JSON.stringify(['/Users/test/project/README.md']);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd, filesRead);
|
||||
|
||||
expect(result).toBe('README.md');
|
||||
});
|
||||
|
||||
it('should return General when both are empty arrays', () => {
|
||||
const filesModified = JSON.stringify([]);
|
||||
const filesRead = JSON.stringify([]);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd, filesRead);
|
||||
|
||||
expect(result).toBe('General');
|
||||
});
|
||||
|
||||
it('should return General when both are null', () => {
|
||||
const result = extractFirstFile(null, cwd, null);
|
||||
|
||||
expect(result).toBe('General');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in modified and fall back to read', () => {
|
||||
const filesModified = 'invalid json {]';
|
||||
const filesRead = JSON.stringify(['/Users/test/project/config.json']);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd, filesRead);
|
||||
|
||||
expect(result).toBe('config.json');
|
||||
});
|
||||
|
||||
it('should return relative path (not absolute) for files inside cwd', () => {
|
||||
const filesModified = JSON.stringify(['/Users/test/project/deeply/nested/file.ts']);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd);
|
||||
|
||||
expect(result).toBe('deeply/nested/file.ts');
|
||||
expect(result).not.toContain('/Users/test/project');
|
||||
});
|
||||
|
||||
it('should handle files that are already relative paths', () => {
|
||||
const filesModified = JSON.stringify(['src/component.tsx']);
|
||||
|
||||
const result = extractFirstFile(filesModified, cwd);
|
||||
|
||||
expect(result).toBe('src/component.tsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByDate', () => {
|
||||
interface TestItem {
|
||||
id: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
it('should return empty map for empty array', () => {
|
||||
const items: TestItem[] = [];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should group items by formatted date', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, date: '2025-01-04T10:00:00Z' },
|
||||
{ id: 2, date: '2025-01-04T14:00:00Z' },
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
const dayItems = Array.from(result.values())[0];
|
||||
expect(dayItems).toHaveLength(2);
|
||||
expect(dayItems[0].id).toBe(1);
|
||||
expect(dayItems[1].id).toBe(2);
|
||||
});
|
||||
|
||||
it('should sort dates chronologically', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, date: '2025-01-06T10:00:00Z' },
|
||||
{ id: 2, date: '2025-01-04T10:00:00Z' },
|
||||
{ id: 3, date: '2025-01-05T10:00:00Z' },
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
const dates = Array.from(result.keys());
|
||||
expect(dates).toHaveLength(3);
|
||||
// Dates should be in chronological order (oldest first)
|
||||
expect(dates[0]).toContain('Jan 4');
|
||||
expect(dates[1]).toContain('Jan 5');
|
||||
expect(dates[2]).toContain('Jan 6');
|
||||
});
|
||||
|
||||
it('should group multiple items on same date together', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, date: '2025-01-04T08:00:00Z' },
|
||||
{ id: 2, date: '2025-01-04T12:00:00Z' },
|
||||
{ id: 3, date: '2025-01-04T18:00:00Z' },
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
const dayItems = Array.from(result.values())[0];
|
||||
expect(dayItems).toHaveLength(3);
|
||||
expect(dayItems.map(i => i.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle items from different days correctly', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 1, date: '2025-01-04T10:00:00Z' },
|
||||
{ id: 2, date: '2025-01-05T10:00:00Z' },
|
||||
{ id: 3, date: '2025-01-04T15:00:00Z' },
|
||||
{ id: 4, date: '2025-01-05T20:00:00Z' },
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
|
||||
const dates = Array.from(result.keys());
|
||||
expect(dates[0]).toContain('Jan 4');
|
||||
expect(dates[1]).toContain('Jan 5');
|
||||
|
||||
const jan4Items = result.get(dates[0])!;
|
||||
const jan5Items = result.get(dates[1])!;
|
||||
|
||||
expect(jan4Items).toHaveLength(2);
|
||||
expect(jan5Items).toHaveLength(2);
|
||||
expect(jan4Items.map(i => i.id)).toEqual([1, 3]);
|
||||
expect(jan5Items.map(i => i.id)).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it('should handle numeric timestamps as date input', () => {
|
||||
// Use clearly different dates (24+ hours apart to avoid timezone issues)
|
||||
const items = [
|
||||
{ id: 1, date: '2025-01-04T00:00:00Z' },
|
||||
{ id: 2, date: '2025-01-06T00:00:00Z' }, // 2 days later
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
const dates = Array.from(result.keys());
|
||||
expect(dates).toHaveLength(2);
|
||||
expect(dates[0]).toContain('Jan 4');
|
||||
expect(dates[1]).toContain('Jan 6');
|
||||
});
|
||||
|
||||
it('should preserve item order within each date group', () => {
|
||||
const items: TestItem[] = [
|
||||
{ id: 3, date: '2025-01-04T08:00:00Z' },
|
||||
{ id: 1, date: '2025-01-04T09:00:00Z' },
|
||||
{ id: 2, date: '2025-01-04T10:00:00Z' },
|
||||
];
|
||||
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
const dayItems = Array.from(result.values())[0];
|
||||
// Items should maintain their insertion order
|
||||
expect(dayItems.map(i => i.id)).toEqual([3, 1, 2]);
|
||||
});
|
||||
});
|
||||
239
.agent/services/claude-mem/tests/smart-install.test.ts
Normal file
239
.agent/services/claude-mem/tests/smart-install.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Smart Install Script Tests
|
||||
*
|
||||
* Tests the resolveRoot() and verifyCriticalModules() logic used by
|
||||
* plugin/scripts/smart-install.js to find the correct install directory
|
||||
* for cache-based and marketplace installs.
|
||||
*
|
||||
* These are unit tests that exercise the resolution logic in isolation
|
||||
* using temp directories, without running actual bun/npm install.
|
||||
*/
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `claude-mem-smart-install-test-${process.pid}`);
|
||||
|
||||
function createDir(relativePath: string): string {
|
||||
const fullPath = join(TEST_DIR, relativePath);
|
||||
mkdirSync(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function createPackageJson(dir: string, version = '10.0.0', deps: Record<string, string> = {}): void {
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'claude-mem-plugin',
|
||||
version,
|
||||
dependencies: deps
|
||||
}));
|
||||
}
|
||||
|
||||
describe('smart-install resolveRoot logic', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should prefer CLAUDE_PLUGIN_ROOT when it contains package.json', () => {
|
||||
const cacheDir = createDir('cache/thedotmack/claude-mem/10.0.0');
|
||||
createPackageJson(cacheDir);
|
||||
|
||||
// Simulate what resolveRoot does
|
||||
const root = cacheDir;
|
||||
expect(existsSync(join(root, 'package.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect cache-based install paths', () => {
|
||||
// Cache installs have paths like ~/.claude/plugins/cache/thedotmack/claude-mem/<version>/
|
||||
const cacheDir = createDir('plugins/cache/thedotmack/claude-mem/10.3.0');
|
||||
createPackageJson(cacheDir);
|
||||
|
||||
// Marketplace dir does NOT exist (fresh cache install, no marketplace)
|
||||
const pluginRoot = cacheDir;
|
||||
expect(existsSync(join(pluginRoot, 'package.json'))).toBe(true);
|
||||
// The cache dir is valid — resolveRoot should use it, not try to navigate to marketplace
|
||||
});
|
||||
|
||||
it('should fall back to script-relative path when CLAUDE_PLUGIN_ROOT is unset', () => {
|
||||
// Simulate: scripts/smart-install.js lives in <root>/scripts/
|
||||
const pluginRoot = createDir('marketplace-plugin');
|
||||
createPackageJson(pluginRoot);
|
||||
const scriptsDir = createDir('marketplace-plugin/scripts');
|
||||
|
||||
// dirname(scripts/) = marketplace-plugin/ which has package.json
|
||||
const candidate = join(scriptsDir, '..');
|
||||
expect(existsSync(join(candidate, 'package.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing package.json in CLAUDE_PLUGIN_ROOT gracefully', () => {
|
||||
// CLAUDE_PLUGIN_ROOT points to a dir without package.json
|
||||
const badDir = createDir('empty-cache-dir');
|
||||
expect(existsSync(join(badDir, 'package.json'))).toBe(false);
|
||||
// resolveRoot should fall through to next candidate
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-install verifyCriticalModules logic', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should pass when all dependencies exist in node_modules', () => {
|
||||
const root = createDir('plugin-root');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
});
|
||||
|
||||
// Create the module directory
|
||||
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
|
||||
|
||||
// Simulate verifyCriticalModules
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect missing dependencies in node_modules', () => {
|
||||
const root = createDir('plugin-root-missing');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
});
|
||||
|
||||
// Do NOT create node_modules — simulate a failed install
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual(['@chroma-core/default-embed']);
|
||||
});
|
||||
|
||||
it('should handle packages with no dependencies gracefully', () => {
|
||||
const root = createDir('plugin-root-no-deps');
|
||||
createPackageJson(root, '10.0.0', {});
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
|
||||
expect(dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect partially installed scoped packages', () => {
|
||||
const root = createDir('plugin-root-partial');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9',
|
||||
'@chroma-core/other-pkg': '^1.0.0'
|
||||
});
|
||||
|
||||
// Only install one of the two packages
|
||||
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual(['@chroma-core/other-pkg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-install stdout JSON output (#1253)', () => {
|
||||
const SCRIPT_PATH = join(__dirname, '..', 'plugin', 'scripts', 'smart-install.js');
|
||||
|
||||
it('should not have any execSync with stdio: inherit (prevents stdout leak)', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// stdio: 'inherit' would leak non-JSON output to stdout, breaking Claude Code hooks
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout on success path', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// The script must print JSON to stdout for the Claude Code hook contract
|
||||
expect(content).toContain('console.log(JSON.stringify(');
|
||||
expect(content).toContain('continue');
|
||||
expect(content).toContain('suppressOutput');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout even in error catch block', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// Find the catch block and verify it also outputs JSON
|
||||
const catchIndex = content.lastIndexOf('catch (e)');
|
||||
expect(catchIndex).toBeGreaterThan(0);
|
||||
const catchBlock = content.slice(catchIndex, catchIndex + 300);
|
||||
expect(catchBlock).toContain('console.log(JSON.stringify(');
|
||||
});
|
||||
|
||||
it('should use piped stdout for all execSync calls', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// All execSync calls should pipe stdout to prevent leaking to the hook output.
|
||||
// Match execSync calls that have a stdio option — they should all use array form.
|
||||
// All execSync calls should either use 'ignore', array form, or the installStdio variable
|
||||
// — never bare 'inherit' which leaks non-JSON output to stdout
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
// Verify the installStdio variable is defined with the correct pipe config
|
||||
expect(content).toContain("const installStdio = ['pipe', 'pipe', 'inherit']");
|
||||
});
|
||||
|
||||
it('should produce valid JSON when run with plugin disabled', () => {
|
||||
// Run the actual script with the plugin forcefully disabled via settings
|
||||
// This exercises the early exit path
|
||||
const settingsDir = join(tmpdir(), `claude-mem-test-settings-${process.pid}`);
|
||||
const settingsFile = join(settingsDir, 'settings.json');
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(settingsFile, JSON.stringify({
|
||||
enabledPlugins: { 'claude-mem@thedotmack': false }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = spawnSync('node', [SCRIPT_PATH], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: settingsDir,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// When plugin is disabled, script exits with 0 and produces no stdout
|
||||
// (the early exit at line 31-33 calls process.exit(0) before any output)
|
||||
expect(result.status).toBe(0);
|
||||
// stdout should be empty or valid JSON (not plain text install messages)
|
||||
const stdout = (result.stdout || '').trim();
|
||||
if (stdout.length > 0) {
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
}
|
||||
} finally {
|
||||
rmSync(settingsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
199
.agent/services/claude-mem/tests/sqlite/data-integrity.test.ts
Normal file
199
.agent/services/claude-mem/tests/sqlite/data-integrity.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Data integrity tests for TRIAGE-03
|
||||
* Tests: content-hash deduplication, project name collision, empty project guard, stuck isProcessing
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
storeObservation,
|
||||
computeObservationContentHash,
|
||||
findDuplicateObservation,
|
||||
} from '../../src/services/sqlite/observations/store.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
updateMemorySessionId,
|
||||
} from '../../src/services/sqlite/Sessions.js';
|
||||
import { storeObservations } from '../../src/services/sqlite/transactions.js';
|
||||
import { PendingMessageStore } from '../../src/services/sqlite/PendingMessageStore.js';
|
||||
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
||||
return {
|
||||
type: 'discovery',
|
||||
title: 'Test Observation',
|
||||
subtitle: 'Test Subtitle',
|
||||
facts: ['fact1', 'fact2'],
|
||||
narrative: 'Test narrative content',
|
||||
concepts: ['concept1', 'concept2'],
|
||||
files_read: ['/path/to/file1.ts'],
|
||||
files_modified: ['/path/to/file2.ts'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionWithMemoryId(db: Database, contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
describe('TRIAGE-03: Data Integrity', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('Content-hash deduplication', () => {
|
||||
it('computeObservationContentHash produces consistent hashes', () => {
|
||||
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
|
||||
const hash2 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1.length).toBe(16);
|
||||
});
|
||||
|
||||
it('computeObservationContentHash produces different hashes for different content', () => {
|
||||
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
|
||||
const hash2 = computeObservationContentHash('session-1', 'Title B', 'Narrative B');
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('computeObservationContentHash handles nulls', () => {
|
||||
const hash = computeObservationContentHash('session-1', null, null);
|
||||
expect(hash.length).toBe(16);
|
||||
});
|
||||
|
||||
it('storeObservation deduplicates identical observations within 30s window', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-dedup-1', 'mem-dedup-1');
|
||||
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
|
||||
|
||||
const now = Date.now();
|
||||
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
|
||||
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 1000);
|
||||
|
||||
// Second call should return the same id as the first (deduped)
|
||||
expect(result2.id).toBe(result1.id);
|
||||
});
|
||||
|
||||
it('storeObservation allows same content after dedup window expires', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-dedup-2', 'mem-dedup-2');
|
||||
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
|
||||
|
||||
const now = Date.now();
|
||||
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
|
||||
// 31 seconds later — outside the 30s window
|
||||
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 31_000);
|
||||
|
||||
expect(result2.id).not.toBe(result1.id);
|
||||
});
|
||||
|
||||
it('storeObservation allows different content at same time', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-dedup-3', 'mem-dedup-3');
|
||||
const obs1 = createObservationInput({ title: 'Title A', narrative: 'Narrative A' });
|
||||
const obs2 = createObservationInput({ title: 'Title B', narrative: 'Narrative B' });
|
||||
|
||||
const now = Date.now();
|
||||
const result1 = storeObservation(db, memId, 'test-project', obs1, 1, 0, now);
|
||||
const result2 = storeObservation(db, memId, 'test-project', obs2, 1, 0, now);
|
||||
|
||||
expect(result2.id).not.toBe(result1.id);
|
||||
});
|
||||
|
||||
it('content_hash column is populated on new observations', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-hash-col', 'mem-hash-col');
|
||||
const obs = createObservationInput();
|
||||
|
||||
storeObservation(db, memId, 'test-project', obs);
|
||||
|
||||
const row = db.prepare('SELECT content_hash FROM observations LIMIT 1').get() as { content_hash: string };
|
||||
expect(row.content_hash).toBeTruthy();
|
||||
expect(row.content_hash.length).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction-level deduplication', () => {
|
||||
it('storeObservations deduplicates within a batch', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-tx-1', 'mem-tx-1');
|
||||
const obs = createObservationInput({ title: 'Duplicate', narrative: 'Same content' });
|
||||
|
||||
const result = storeObservations(db, memId, 'test-project', [obs, obs, obs], null);
|
||||
|
||||
// First is inserted, second and third are deduped to the first
|
||||
expect(result.observationIds.length).toBe(3);
|
||||
expect(result.observationIds[1]).toBe(result.observationIds[0]);
|
||||
expect(result.observationIds[2]).toBe(result.observationIds[0]);
|
||||
|
||||
// Only 1 row in the database
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
expect(count.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty project string guard', () => {
|
||||
it('storeObservation replaces empty project with cwd-derived name', () => {
|
||||
const memId = createSessionWithMemoryId(db, 'content-empty-proj', 'mem-empty-proj');
|
||||
const obs = createObservationInput();
|
||||
|
||||
const result = storeObservation(db, memId, '', obs);
|
||||
const row = db.prepare('SELECT project FROM observations WHERE id = ?').get(result.id) as { project: string };
|
||||
|
||||
// Should not be empty — will be derived from cwd
|
||||
expect(row.project).toBeTruthy();
|
||||
expect(row.project.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stuck isProcessing flag', () => {
|
||||
it('hasAnyPendingWork resets stuck processing messages older than 5 minutes', () => {
|
||||
// Create a pending_messages table entry that's stuck in 'processing'
|
||||
const sessionId = createSDKSession(db, 'content-stuck', 'stuck-project', 'test');
|
||||
|
||||
// Insert a processing message stuck for 6 minutes
|
||||
const sixMinutesAgo = Date.now() - (6 * 60 * 1000);
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
|
||||
VALUES (?, 'content-stuck', 'observation', 'processing', 0, ?, ?)
|
||||
`).run(sessionId, sixMinutesAgo, sixMinutesAgo);
|
||||
|
||||
const pendingStore = new PendingMessageStore(db);
|
||||
|
||||
// hasAnyPendingWork should reset the stuck message and still return true (it's now pending again)
|
||||
const hasPending = pendingStore.hasAnyPendingWork();
|
||||
expect(hasPending).toBe(true);
|
||||
|
||||
// Verify the message was reset to 'pending'
|
||||
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-stuck') as { status: string };
|
||||
expect(msg.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('hasAnyPendingWork does NOT reset recently-started processing messages', () => {
|
||||
const sessionId = createSDKSession(db, 'content-recent', 'recent-project', 'test');
|
||||
|
||||
// Insert a processing message started 1 minute ago (well within 5-minute threshold)
|
||||
const oneMinuteAgo = Date.now() - (1 * 60 * 1000);
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
|
||||
VALUES (?, 'content-recent', 'observation', 'processing', 0, ?, ?)
|
||||
`).run(sessionId, oneMinuteAgo, oneMinuteAgo);
|
||||
|
||||
const pendingStore = new PendingMessageStore(db);
|
||||
const hasPending = pendingStore.hasAnyPendingWork();
|
||||
expect(hasPending).toBe(true);
|
||||
|
||||
// Verify the message is still 'processing' (not reset)
|
||||
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-recent') as { status: string };
|
||||
expect(msg.status).toBe('processing');
|
||||
});
|
||||
|
||||
it('hasAnyPendingWork returns false when no pending or processing messages exist', () => {
|
||||
const pendingStore = new PendingMessageStore(db);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
.agent/services/claude-mem/tests/sqlite/observations.test.ts
Normal file
231
.agent/services/claude-mem/tests/sqlite/observations.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Observations module tests
|
||||
* Tests modular observation functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/observations/store.ts
|
||||
* - API patterns from src/services/sqlite/observations/get.ts
|
||||
* - API patterns from src/services/sqlite/observations/recent.ts
|
||||
* - Type definitions from src/services/sqlite/observations/types.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
storeObservation,
|
||||
getObservationById,
|
||||
getRecentObservations,
|
||||
} from '../../src/services/sqlite/Observations.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
updateMemorySessionId,
|
||||
} from '../../src/services/sqlite/Sessions.js';
|
||||
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Observations Module', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid observation input
|
||||
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
||||
return {
|
||||
type: 'discovery',
|
||||
title: 'Test Observation',
|
||||
subtitle: 'Test Subtitle',
|
||||
facts: ['fact1', 'fact2'],
|
||||
narrative: 'Test narrative content',
|
||||
concepts: ['concept1', 'concept2'],
|
||||
files_read: ['/path/to/file1.ts'],
|
||||
files_modified: ['/path/to/file2.ts'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
describe('storeObservation', () => {
|
||||
it('should store observation and return id and createdAtEpoch', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-123', 'mem-session-123');
|
||||
const project = 'test-project';
|
||||
const observation = createObservationInput();
|
||||
|
||||
const result = storeObservation(db, memorySessionId, project, observation);
|
||||
|
||||
expect(typeof result.id).toBe('number');
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
expect(typeof result.createdAtEpoch).toBe('number');
|
||||
expect(result.createdAtEpoch).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should store all observation fields correctly', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-456', 'mem-session-456');
|
||||
const project = 'test-project';
|
||||
const observation = createObservationInput({
|
||||
type: 'bugfix',
|
||||
title: 'Fixed critical bug',
|
||||
subtitle: 'Memory leak',
|
||||
facts: ['leak found', 'patched'],
|
||||
narrative: 'Fixed memory leak in parser',
|
||||
concepts: ['memory', 'gc'],
|
||||
files_read: ['/src/parser.ts'],
|
||||
files_modified: ['/src/parser.ts', '/tests/parser.test.ts'],
|
||||
});
|
||||
|
||||
const result = storeObservation(db, memorySessionId, project, observation, 1, 100);
|
||||
|
||||
const stored = getObservationById(db, result.id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.type).toBe('bugfix');
|
||||
expect(stored?.title).toBe('Fixed critical bug');
|
||||
expect(stored?.memory_session_id).toBe(memorySessionId);
|
||||
expect(stored?.project).toBe(project);
|
||||
});
|
||||
|
||||
it('should respect overrideTimestampEpoch', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-789', 'mem-session-789');
|
||||
const project = 'test-project';
|
||||
const observation = createObservationInput();
|
||||
const pastTimestamp = 1600000000000; // Sep 13, 2020
|
||||
|
||||
const result = storeObservation(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observation,
|
||||
1,
|
||||
0,
|
||||
pastTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(pastTimestamp);
|
||||
|
||||
const stored = getObservationById(db, result.id);
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
// Verify ISO string matches epoch
|
||||
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
|
||||
});
|
||||
|
||||
it('should use current time when overrideTimestampEpoch not provided', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-now', 'session-now');
|
||||
const before = Date.now();
|
||||
const result = storeObservation(
|
||||
db,
|
||||
memorySessionId,
|
||||
'project',
|
||||
createObservationInput()
|
||||
);
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
|
||||
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should handle null subtitle and narrative', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-null', 'session-null');
|
||||
const observation = createObservationInput({
|
||||
subtitle: null,
|
||||
narrative: null,
|
||||
});
|
||||
|
||||
const result = storeObservation(db, memorySessionId, 'project', observation);
|
||||
const stored = getObservationById(db, result.id);
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.id).toBe(result.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getObservationById', () => {
|
||||
it('should retrieve observation by ID', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-get', 'session-get');
|
||||
const observation = createObservationInput({ title: 'Unique Title' });
|
||||
const result = storeObservation(db, memorySessionId, 'project', observation);
|
||||
|
||||
const retrieved = getObservationById(db, result.id);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.id).toBe(result.id);
|
||||
expect(retrieved?.title).toBe('Unique Title');
|
||||
});
|
||||
|
||||
it('should return null for non-existent observation', () => {
|
||||
const retrieved = getObservationById(db, 99999);
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentObservations', () => {
|
||||
it('should return observations ordered by date DESC', () => {
|
||||
const project = 'test-project';
|
||||
|
||||
// Create sessions and store observations with different timestamps (oldest first)
|
||||
const mem1 = createSessionWithMemoryId('content-1', 'session1', project);
|
||||
const mem2 = createSessionWithMemoryId('content-2', 'session2', project);
|
||||
const mem3 = createSessionWithMemoryId('content-3', 'session3', project);
|
||||
|
||||
storeObservation(db, mem1, project, createObservationInput(), 1, 0, 1000000000000);
|
||||
storeObservation(db, mem2, project, createObservationInput(), 2, 0, 2000000000000);
|
||||
storeObservation(db, mem3, project, createObservationInput(), 3, 0, 3000000000000);
|
||||
|
||||
const recent = getRecentObservations(db, project, 10);
|
||||
|
||||
expect(recent.length).toBe(3);
|
||||
// Most recent first (DESC order)
|
||||
expect(recent[0].prompt_number).toBe(3);
|
||||
expect(recent[1].prompt_number).toBe(2);
|
||||
expect(recent[2].prompt_number).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', () => {
|
||||
const project = 'test-project';
|
||||
|
||||
const mem1 = createSessionWithMemoryId('content-lim1', 'session-lim1', project);
|
||||
const mem2 = createSessionWithMemoryId('content-lim2', 'session-lim2', project);
|
||||
const mem3 = createSessionWithMemoryId('content-lim3', 'session-lim3', project);
|
||||
|
||||
storeObservation(db, mem1, project, createObservationInput(), 1, 0, 1000000000000);
|
||||
storeObservation(db, mem2, project, createObservationInput(), 2, 0, 2000000000000);
|
||||
storeObservation(db, mem3, project, createObservationInput(), 3, 0, 3000000000000);
|
||||
|
||||
const recent = getRecentObservations(db, project, 2);
|
||||
|
||||
expect(recent.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by project', () => {
|
||||
const memA1 = createSessionWithMemoryId('content-a1', 'session-a1', 'project-a');
|
||||
const memB1 = createSessionWithMemoryId('content-b1', 'session-b1', 'project-b');
|
||||
const memA2 = createSessionWithMemoryId('content-a2', 'session-a2', 'project-a');
|
||||
|
||||
storeObservation(db, memA1, 'project-a', createObservationInput());
|
||||
storeObservation(db, memB1, 'project-b', createObservationInput());
|
||||
storeObservation(db, memA2, 'project-a', createObservationInput());
|
||||
|
||||
const recentA = getRecentObservations(db, 'project-a', 10);
|
||||
const recentB = getRecentObservations(db, 'project-b', 10);
|
||||
|
||||
expect(recentA.length).toBe(2);
|
||||
expect(recentB.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty array for project with no observations', () => {
|
||||
const recent = getRecentObservations(db, 'nonexistent-project', 10);
|
||||
|
||||
expect(recent).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
.agent/services/claude-mem/tests/sqlite/prompts.test.ts
Normal file
129
.agent/services/claude-mem/tests/sqlite/prompts.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Prompts module tests
|
||||
* Tests modular prompt functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/prompts/store.ts
|
||||
* - API patterns from src/services/sqlite/prompts/get.ts
|
||||
* - Test pattern from tests/session_store.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
saveUserPrompt,
|
||||
getPromptNumberFromUserPrompts,
|
||||
} from '../../src/services/sqlite/Prompts.js';
|
||||
import { createSDKSession } from '../../src/services/sqlite/Sessions.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Prompts Module', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a session (for FK constraint on user_prompts.content_session_id)
|
||||
function createSession(contentSessionId: string, project: string = 'test-project'): string {
|
||||
createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
return contentSessionId;
|
||||
}
|
||||
|
||||
describe('saveUserPrompt', () => {
|
||||
it('should store prompt and return numeric ID', () => {
|
||||
const contentSessionId = createSession('content-session-prompt-1');
|
||||
const promptNumber = 1;
|
||||
const promptText = 'First user prompt';
|
||||
|
||||
const id = saveUserPrompt(db, contentSessionId, promptNumber, promptText);
|
||||
|
||||
expect(typeof id).toBe('number');
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should store multiple prompts with incrementing IDs', () => {
|
||||
const contentSessionId = createSession('content-session-prompt-2');
|
||||
|
||||
const id1 = saveUserPrompt(db, contentSessionId, 1, 'First prompt');
|
||||
const id2 = saveUserPrompt(db, contentSessionId, 2, 'Second prompt');
|
||||
const id3 = saveUserPrompt(db, contentSessionId, 3, 'Third prompt');
|
||||
|
||||
expect(id1).toBeGreaterThan(0);
|
||||
expect(id2).toBeGreaterThan(id1);
|
||||
expect(id3).toBeGreaterThan(id2);
|
||||
});
|
||||
|
||||
it('should allow prompts from different sessions', () => {
|
||||
const sessionA = createSession('session-a');
|
||||
const sessionB = createSession('session-b');
|
||||
|
||||
const id1 = saveUserPrompt(db, sessionA, 1, 'Prompt A1');
|
||||
const id2 = saveUserPrompt(db, sessionB, 1, 'Prompt B1');
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPromptNumberFromUserPrompts', () => {
|
||||
it('should return 0 when no prompts exist', () => {
|
||||
const count = getPromptNumberFromUserPrompts(db, 'nonexistent-session');
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should return count of prompts for session', () => {
|
||||
const contentSessionId = createSession('count-test-session');
|
||||
|
||||
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(0);
|
||||
|
||||
saveUserPrompt(db, contentSessionId, 1, 'First prompt');
|
||||
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(1);
|
||||
|
||||
saveUserPrompt(db, contentSessionId, 2, 'Second prompt');
|
||||
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(2);
|
||||
|
||||
saveUserPrompt(db, contentSessionId, 3, 'Third prompt');
|
||||
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(3);
|
||||
});
|
||||
|
||||
it('should maintain session isolation', () => {
|
||||
const sessionA = createSession('isolation-session-a');
|
||||
const sessionB = createSession('isolation-session-b');
|
||||
|
||||
// Add prompts to session A
|
||||
saveUserPrompt(db, sessionA, 1, 'A1');
|
||||
saveUserPrompt(db, sessionA, 2, 'A2');
|
||||
|
||||
// Add prompts to session B
|
||||
saveUserPrompt(db, sessionB, 1, 'B1');
|
||||
|
||||
// Session A should have 2 prompts
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionA)).toBe(2);
|
||||
|
||||
// Session B should have 1 prompt
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionB)).toBe(1);
|
||||
|
||||
// Adding to session B shouldn't affect session A
|
||||
saveUserPrompt(db, sessionB, 2, 'B2');
|
||||
saveUserPrompt(db, sessionB, 3, 'B3');
|
||||
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionA)).toBe(2);
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionB)).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle edge case of many prompts', () => {
|
||||
const contentSessionId = createSession('many-prompts-session');
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
saveUserPrompt(db, contentSessionId, i, `Prompt ${i}`);
|
||||
}
|
||||
|
||||
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
.agent/services/claude-mem/tests/sqlite/sessions.test.ts
Normal file
166
.agent/services/claude-mem/tests/sqlite/sessions.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Session module tests
|
||||
* Tests modular session functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/sessions/create.ts
|
||||
* - API patterns from src/services/sqlite/sessions/get.ts
|
||||
* - Test pattern from tests/session_store.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
getSessionById,
|
||||
updateMemorySessionId,
|
||||
} from '../../src/services/sqlite/Sessions.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Sessions Module', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('createSDKSession', () => {
|
||||
it('should create a new session and return numeric ID', () => {
|
||||
const contentSessionId = 'content-session-123';
|
||||
const project = 'test-project';
|
||||
const userPrompt = 'Initial user prompt';
|
||||
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
|
||||
|
||||
expect(typeof sessionId).toBe('number');
|
||||
expect(sessionId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be idempotent - return same ID for same content_session_id', () => {
|
||||
const contentSessionId = 'content-session-456';
|
||||
const project = 'test-project';
|
||||
const userPrompt = 'Initial user prompt';
|
||||
|
||||
const sessionId1 = createSDKSession(db, contentSessionId, project, userPrompt);
|
||||
const sessionId2 = createSDKSession(db, contentSessionId, project, 'Different prompt');
|
||||
|
||||
expect(sessionId1).toBe(sessionId2);
|
||||
});
|
||||
|
||||
it('should create different sessions for different content_session_ids', () => {
|
||||
const sessionId1 = createSDKSession(db, 'session-a', 'project', 'prompt');
|
||||
const sessionId2 = createSDKSession(db, 'session-b', 'project', 'prompt');
|
||||
|
||||
expect(sessionId1).not.toBe(sessionId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionById', () => {
|
||||
it('should retrieve session by ID', () => {
|
||||
const contentSessionId = 'content-session-get';
|
||||
const project = 'test-project';
|
||||
const userPrompt = 'Test prompt';
|
||||
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
|
||||
const session = getSessionById(db, sessionId);
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.id).toBe(sessionId);
|
||||
expect(session?.content_session_id).toBe(contentSessionId);
|
||||
expect(session?.project).toBe(project);
|
||||
expect(session?.user_prompt).toBe(userPrompt);
|
||||
// memory_session_id should be null initially (set via updateMemorySessionId)
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', () => {
|
||||
const session = getSessionById(db, 99999);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom_title', () => {
|
||||
it('should store custom_title when provided at creation', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-1', 'project', 'prompt', 'My Agent');
|
||||
const session = getSessionById(db, sessionId);
|
||||
|
||||
expect(session?.custom_title).toBe('My Agent');
|
||||
});
|
||||
|
||||
it('should default custom_title to null when not provided', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-2', 'project', 'prompt');
|
||||
const session = getSessionById(db, sessionId);
|
||||
|
||||
expect(session?.custom_title).toBeNull();
|
||||
});
|
||||
|
||||
it('should backfill custom_title on idempotent call if not already set', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-3', 'project', 'prompt');
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBeNull();
|
||||
|
||||
// Second call with custom_title should backfill
|
||||
createSDKSession(db, 'session-title-3', 'project', 'prompt', 'Backfilled Title');
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Backfilled Title');
|
||||
});
|
||||
|
||||
it('should not overwrite existing custom_title on idempotent call', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Original');
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Original');
|
||||
|
||||
// Second call should NOT overwrite
|
||||
createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Attempted Override');
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Original');
|
||||
});
|
||||
|
||||
it('should handle empty string custom_title as no title', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-5', 'project', 'prompt', '');
|
||||
const session = getSessionById(db, sessionId);
|
||||
|
||||
// Empty string becomes null via the || null conversion
|
||||
expect(session?.custom_title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemorySessionId', () => {
|
||||
it('should update memory_session_id for existing session', () => {
|
||||
const contentSessionId = 'content-session-update';
|
||||
const project = 'test-project';
|
||||
const userPrompt = 'Test prompt';
|
||||
const memorySessionId = 'memory-session-abc123';
|
||||
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
|
||||
|
||||
// Verify memory_session_id is null initially
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Update memory session ID
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
|
||||
// Verify update
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBe(memorySessionId);
|
||||
});
|
||||
|
||||
it('should allow updating to different memory_session_id', () => {
|
||||
const sessionId = createSDKSession(db, 'session-x', 'project', 'prompt');
|
||||
|
||||
updateMemorySessionId(db, sessionId, 'memory-1');
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBe('memory-1');
|
||||
|
||||
updateMemorySessionId(db, sessionId, 'memory-2');
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBe('memory-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
214
.agent/services/claude-mem/tests/sqlite/summaries.test.ts
Normal file
214
.agent/services/claude-mem/tests/sqlite/summaries.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Summaries module tests
|
||||
* Tests modular summary functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/summaries/store.ts
|
||||
* - API patterns from src/services/sqlite/summaries/get.ts
|
||||
* - Type definitions from src/services/sqlite/summaries/types.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
storeSummary,
|
||||
getSummaryForSession,
|
||||
} from '../../src/services/sqlite/Summaries.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
updateMemorySessionId,
|
||||
} from '../../src/services/sqlite/Sessions.js';
|
||||
import type { SummaryInput } from '../../src/services/sqlite/summaries/types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Summaries Module', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid summary input
|
||||
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
|
||||
return {
|
||||
request: 'User requested feature X',
|
||||
investigated: 'Explored the codebase',
|
||||
learned: 'Discovered pattern Y',
|
||||
completed: 'Implemented feature X',
|
||||
next_steps: 'Add tests and documentation',
|
||||
notes: 'Consider edge case Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
describe('storeSummary', () => {
|
||||
it('should store summary and return id and createdAtEpoch', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-123', 'mem-session-sum-123');
|
||||
const project = 'test-project';
|
||||
const summary = createSummaryInput();
|
||||
|
||||
const result = storeSummary(db, memorySessionId, project, summary);
|
||||
|
||||
expect(typeof result.id).toBe('number');
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
expect(typeof result.createdAtEpoch).toBe('number');
|
||||
expect(result.createdAtEpoch).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should store all summary fields correctly', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-456', 'mem-session-sum-456');
|
||||
const project = 'test-project';
|
||||
const summary = createSummaryInput({
|
||||
request: 'Refactor the database layer',
|
||||
investigated: 'Analyzed current schema',
|
||||
learned: 'Found N+1 query issues',
|
||||
completed: 'Optimized queries',
|
||||
next_steps: 'Monitor performance',
|
||||
notes: 'May need caching',
|
||||
});
|
||||
|
||||
const result = storeSummary(db, memorySessionId, project, summary, 1, 500);
|
||||
|
||||
const stored = getSummaryForSession(db, memorySessionId);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.request).toBe('Refactor the database layer');
|
||||
expect(stored?.investigated).toBe('Analyzed current schema');
|
||||
expect(stored?.learned).toBe('Found N+1 query issues');
|
||||
expect(stored?.completed).toBe('Optimized queries');
|
||||
expect(stored?.next_steps).toBe('Monitor performance');
|
||||
expect(stored?.notes).toBe('May need caching');
|
||||
expect(stored?.prompt_number).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect overrideTimestampEpoch', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-789', 'mem-session-sum-789');
|
||||
const project = 'test-project';
|
||||
const summary = createSummaryInput();
|
||||
const pastTimestamp = 1650000000000; // Apr 15, 2022
|
||||
|
||||
const result = storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
summary,
|
||||
1,
|
||||
0,
|
||||
pastTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(pastTimestamp);
|
||||
|
||||
const stored = getSummaryForSession(db, memorySessionId);
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
});
|
||||
|
||||
it('should use current time when overrideTimestampEpoch not provided', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-now', 'session-sum-now');
|
||||
const before = Date.now();
|
||||
const result = storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
'project',
|
||||
createSummaryInput()
|
||||
);
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
|
||||
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should handle null notes', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-null', 'session-sum-null');
|
||||
const summary = createSummaryInput({ notes: null });
|
||||
|
||||
const result = storeSummary(db, memorySessionId, 'project', summary);
|
||||
const stored = getSummaryForSession(db, memorySessionId);
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.notes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummaryForSession', () => {
|
||||
it('should retrieve summary by memory_session_id', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-unique', 'unique-mem-session');
|
||||
const summary = createSummaryInput({ request: 'Unique request' });
|
||||
|
||||
storeSummary(db, memorySessionId, 'project', summary);
|
||||
|
||||
const retrieved = getSummaryForSession(db, memorySessionId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.request).toBe('Unique request');
|
||||
});
|
||||
|
||||
it('should return null for session with no summary', () => {
|
||||
const retrieved = getSummaryForSession(db, 'nonexistent-session');
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it('should return most recent summary when multiple exist', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-multi', 'multi-summary-session');
|
||||
|
||||
// Store older summary
|
||||
storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
'project',
|
||||
createSummaryInput({ request: 'First request' }),
|
||||
1,
|
||||
0,
|
||||
1000000000000
|
||||
);
|
||||
|
||||
// Store newer summary
|
||||
storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
'project',
|
||||
createSummaryInput({ request: 'Second request' }),
|
||||
2,
|
||||
0,
|
||||
2000000000000
|
||||
);
|
||||
|
||||
const retrieved = getSummaryForSession(db, memorySessionId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.request).toBe('Second request');
|
||||
expect(retrieved?.prompt_number).toBe(2);
|
||||
});
|
||||
|
||||
it('should return summary with all expected fields', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-fields', 'fields-check-session');
|
||||
const summary = createSummaryInput();
|
||||
|
||||
storeSummary(db, memorySessionId, 'project', summary, 1, 100, 1500000000000);
|
||||
|
||||
const retrieved = getSummaryForSession(db, memorySessionId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved).toHaveProperty('request');
|
||||
expect(retrieved).toHaveProperty('investigated');
|
||||
expect(retrieved).toHaveProperty('learned');
|
||||
expect(retrieved).toHaveProperty('completed');
|
||||
expect(retrieved).toHaveProperty('next_steps');
|
||||
expect(retrieved).toHaveProperty('notes');
|
||||
expect(retrieved).toHaveProperty('prompt_number');
|
||||
expect(retrieved).toHaveProperty('created_at');
|
||||
expect(retrieved).toHaveProperty('created_at_epoch');
|
||||
});
|
||||
});
|
||||
});
|
||||
309
.agent/services/claude-mem/tests/sqlite/transactions.test.ts
Normal file
309
.agent/services/claude-mem/tests/sqlite/transactions.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Transactions module tests
|
||||
* Tests atomic transaction functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/transactions.ts
|
||||
* - Type definitions from src/services/sqlite/transactions.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
import {
|
||||
storeObservations,
|
||||
storeObservationsAndMarkComplete,
|
||||
} from '../../src/services/sqlite/transactions.js';
|
||||
import { getObservationById } from '../../src/services/sqlite/Observations.js';
|
||||
import { getSummaryForSession } from '../../src/services/sqlite/Summaries.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
updateMemorySessionId,
|
||||
} from '../../src/services/sqlite/Sessions.js';
|
||||
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
|
||||
import type { SummaryInput } from '../../src/services/sqlite/summaries/types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Transactions Module', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid observation input
|
||||
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
||||
return {
|
||||
type: 'discovery',
|
||||
title: 'Test Observation',
|
||||
subtitle: 'Test Subtitle',
|
||||
facts: ['fact1', 'fact2'],
|
||||
narrative: 'Test narrative content',
|
||||
concepts: ['concept1', 'concept2'],
|
||||
files_read: ['/path/to/file1.ts'],
|
||||
files_modified: ['/path/to/file2.ts'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a valid summary input
|
||||
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
|
||||
return {
|
||||
request: 'User requested feature X',
|
||||
investigated: 'Explored the codebase',
|
||||
learned: 'Discovered pattern Y',
|
||||
completed: 'Implemented feature X',
|
||||
next_steps: 'Add tests and documentation',
|
||||
notes: 'Consider edge case Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): { memorySessionId: string; sessionDbId: number } {
|
||||
const sessionDbId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionDbId, memorySessionId);
|
||||
return { memorySessionId, sessionDbId };
|
||||
}
|
||||
|
||||
describe('storeObservations', () => {
|
||||
it('should store multiple observations atomically and return result', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-atomic-123', 'atomic-session-123');
|
||||
const project = 'test-project';
|
||||
const observations = [
|
||||
createObservationInput({ title: 'Obs 1' }),
|
||||
createObservationInput({ title: 'Obs 2' }),
|
||||
createObservationInput({ title: 'Obs 3' }),
|
||||
];
|
||||
|
||||
const result = storeObservations(db, memorySessionId, project, observations, null);
|
||||
|
||||
expect(result.observationIds).toHaveLength(3);
|
||||
expect(result.observationIds.every((id) => typeof id === 'number')).toBe(true);
|
||||
expect(result.summaryId).toBeNull();
|
||||
expect(typeof result.createdAtEpoch).toBe('number');
|
||||
});
|
||||
|
||||
it('should store all observations with same timestamp', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-ts', 'timestamp-session');
|
||||
const project = 'test-project';
|
||||
const observations = [
|
||||
createObservationInput({ title: 'Obs A' }),
|
||||
createObservationInput({ title: 'Obs B' }),
|
||||
];
|
||||
const fixedTimestamp = 1600000000000;
|
||||
|
||||
const result = storeObservations(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
null,
|
||||
1,
|
||||
0,
|
||||
fixedTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(fixedTimestamp);
|
||||
|
||||
// Verify each observation has the same timestamp
|
||||
for (const id of result.observationIds) {
|
||||
const obs = getObservationById(db, id);
|
||||
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
|
||||
}
|
||||
});
|
||||
|
||||
it('should store observations with summary', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-with-sum', 'with-summary-session');
|
||||
const project = 'test-project';
|
||||
const observations = [createObservationInput({ title: 'Main Obs' })];
|
||||
const summary = createSummaryInput({ request: 'Test request' });
|
||||
|
||||
const result = storeObservations(db, memorySessionId, project, observations, summary);
|
||||
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
expect(typeof result.summaryId).toBe('number');
|
||||
|
||||
// Verify summary was stored
|
||||
const storedSummary = getSummaryForSession(db, memorySessionId);
|
||||
expect(storedSummary).not.toBeNull();
|
||||
expect(storedSummary?.request).toBe('Test request');
|
||||
});
|
||||
|
||||
it('should handle empty observations array', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-empty', 'empty-obs-session');
|
||||
const project = 'test-project';
|
||||
const observations: ObservationInput[] = [];
|
||||
|
||||
const result = storeObservations(db, memorySessionId, project, observations, null);
|
||||
|
||||
expect(result.observationIds).toHaveLength(0);
|
||||
expect(result.summaryId).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle summary-only (no observations)', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-sum-only', 'summary-only-session');
|
||||
const project = 'test-project';
|
||||
const summary = createSummaryInput({ request: 'Summary-only request' });
|
||||
|
||||
const result = storeObservations(db, memorySessionId, project, [], summary);
|
||||
|
||||
expect(result.observationIds).toHaveLength(0);
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
|
||||
const storedSummary = getSummaryForSession(db, memorySessionId);
|
||||
expect(storedSummary?.request).toBe('Summary-only request');
|
||||
});
|
||||
|
||||
it('should return correct createdAtEpoch', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-epoch', 'session-epoch');
|
||||
const before = Date.now();
|
||||
const result = storeObservations(
|
||||
db,
|
||||
memorySessionId,
|
||||
'project',
|
||||
[createObservationInput()],
|
||||
null
|
||||
);
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
|
||||
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should apply promptNumber to all observations', () => {
|
||||
const { memorySessionId } = createSessionWithMemoryId('content-pn', 'prompt-num-session');
|
||||
const project = 'test-project';
|
||||
const observations = [
|
||||
createObservationInput({ title: 'Obs 1' }),
|
||||
createObservationInput({ title: 'Obs 2' }),
|
||||
];
|
||||
const promptNumber = 5;
|
||||
|
||||
const result = storeObservations(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
null,
|
||||
promptNumber
|
||||
);
|
||||
|
||||
for (const id of result.observationIds) {
|
||||
const obs = getObservationById(db, id);
|
||||
expect(obs?.prompt_number).toBe(promptNumber);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeObservationsAndMarkComplete', () => {
|
||||
// Note: This function also marks a pending message as processed.
|
||||
// For testing, we need a pending_messages row to exist first.
|
||||
|
||||
it('should store observations, summary, and mark message complete', () => {
|
||||
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-complete', 'complete-session');
|
||||
const project = 'test-project';
|
||||
const observations = [createObservationInput({ title: 'Complete Obs' })];
|
||||
const summary = createSummaryInput({ request: 'Complete request' });
|
||||
|
||||
// First, insert a pending message to mark as complete
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
VALUES (?, ?, 'observation', ?, 'processing')
|
||||
`);
|
||||
const msgResult = insertStmt.run(sessionDbId, 'content-complete', Date.now());
|
||||
const messageId = Number(msgResult.lastInsertRowid);
|
||||
|
||||
const result = storeObservationsAndMarkComplete(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
summary,
|
||||
messageId
|
||||
);
|
||||
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
|
||||
// Verify message was marked as processed
|
||||
const msgStmt = db.prepare('SELECT status FROM pending_messages WHERE id = ?');
|
||||
const msg = msgStmt.get(messageId) as { status: string } | undefined;
|
||||
expect(msg?.status).toBe('processed');
|
||||
});
|
||||
|
||||
it('should maintain atomicity - all operations share same timestamp', () => {
|
||||
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-atomic-ts', 'atomic-timestamp-session');
|
||||
const project = 'test-project';
|
||||
const observations = [
|
||||
createObservationInput({ title: 'Obs 1' }),
|
||||
createObservationInput({ title: 'Obs 2' }),
|
||||
];
|
||||
const summary = createSummaryInput();
|
||||
const fixedTimestamp = 1700000000000;
|
||||
|
||||
// Create pending message
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
VALUES (?, ?, 'observation', ?, 'processing')
|
||||
`).run(sessionDbId, 'content-atomic-ts', Date.now());
|
||||
const messageId = db.prepare('SELECT last_insert_rowid() as id').get() as { id: number };
|
||||
|
||||
const result = storeObservationsAndMarkComplete(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
summary,
|
||||
messageId.id,
|
||||
1,
|
||||
0,
|
||||
fixedTimestamp
|
||||
);
|
||||
|
||||
expect(result.createdAtEpoch).toBe(fixedTimestamp);
|
||||
|
||||
// All observations should have same timestamp
|
||||
for (const id of result.observationIds) {
|
||||
const obs = getObservationById(db, id);
|
||||
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
|
||||
}
|
||||
|
||||
// Summary should have same timestamp
|
||||
const storedSummary = getSummaryForSession(db, memorySessionId);
|
||||
expect(storedSummary?.created_at_epoch).toBe(fixedTimestamp);
|
||||
});
|
||||
|
||||
it('should handle null summary', () => {
|
||||
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-no-sum', 'no-summary-session');
|
||||
const project = 'test-project';
|
||||
const observations = [createObservationInput({ title: 'Only Obs' })];
|
||||
|
||||
// Create pending message
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
VALUES (?, ?, 'observation', ?, 'processing')
|
||||
`).run(sessionDbId, 'content-no-sum', Date.now());
|
||||
const messageId = db.prepare('SELECT last_insert_rowid() as id').get() as { id: number };
|
||||
|
||||
const result = storeObservationsAndMarkComplete(
|
||||
db,
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
null,
|
||||
messageId.id
|
||||
);
|
||||
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { sanitizeEnv } from '../../src/supervisor/env-sanitizer.js';
|
||||
|
||||
describe('sanitizeEnv', () => {
|
||||
it('strips variables with CLAUDECODE_ prefix', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
CLAUDECODE_SOMETHING: 'value',
|
||||
PATH: '/usr/bin'
|
||||
});
|
||||
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.CLAUDECODE_SOMETHING).toBeUndefined();
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('strips variables with CLAUDE_CODE_ prefix', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDE_CODE_BAR: 'baz',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token',
|
||||
HOME: '/home/user'
|
||||
});
|
||||
|
||||
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(result.HOME).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('strips exact-match variables (CLAUDECODE, CLAUDE_CODE_SESSION, CLAUDE_CODE_ENTRYPOINT, MCP_SESSION_ID)', () => {
|
||||
const result = sanitizeEnv({
|
||||
CLAUDECODE: '1',
|
||||
CLAUDE_CODE_SESSION: 'session-123',
|
||||
CLAUDE_CODE_ENTRYPOINT: 'hook',
|
||||
MCP_SESSION_ID: 'mcp-abc',
|
||||
NODE_PATH: '/usr/local/lib'
|
||||
});
|
||||
|
||||
expect(result.CLAUDECODE).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(result.MCP_SESSION_ID).toBeUndefined();
|
||||
expect(result.NODE_PATH).toBe('/usr/local/lib');
|
||||
});
|
||||
|
||||
it('preserves allowed variables like PATH, HOME, NODE_PATH', () => {
|
||||
const result = sanitizeEnv({
|
||||
PATH: '/usr/bin:/usr/local/bin',
|
||||
HOME: '/home/user',
|
||||
NODE_PATH: '/usr/local/lib/node_modules',
|
||||
SHELL: '/bin/zsh',
|
||||
USER: 'developer',
|
||||
LANG: 'en_US.UTF-8'
|
||||
});
|
||||
|
||||
expect(result.PATH).toBe('/usr/bin:/usr/local/bin');
|
||||
expect(result.HOME).toBe('/home/user');
|
||||
expect(result.NODE_PATH).toBe('/usr/local/lib/node_modules');
|
||||
expect(result.SHELL).toBe('/bin/zsh');
|
||||
expect(result.USER).toBe('developer');
|
||||
expect(result.LANG).toBe('en_US.UTF-8');
|
||||
});
|
||||
|
||||
it('returns a new object and does not mutate the original', () => {
|
||||
const original: NodeJS.ProcessEnv = {
|
||||
PATH: '/usr/bin',
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
KEEP: 'yes'
|
||||
};
|
||||
const originalCopy = { ...original };
|
||||
|
||||
const result = sanitizeEnv(original);
|
||||
|
||||
// Result should be a different object
|
||||
expect(result).not.toBe(original);
|
||||
|
||||
// Original should be unchanged
|
||||
expect(original).toEqual(originalCopy);
|
||||
|
||||
// Result should not contain stripped vars
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('handles empty env gracefully', () => {
|
||||
const result = sanitizeEnv({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('skips entries with undefined values', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
DEFINED: 'value',
|
||||
UNDEFINED_KEY: undefined
|
||||
};
|
||||
|
||||
const result = sanitizeEnv(env);
|
||||
expect(result.DEFINED).toBe('value');
|
||||
expect('UNDEFINED_KEY' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('combines prefix and exact match removal in a single pass', () => {
|
||||
const result = sanitizeEnv({
|
||||
PATH: '/usr/bin',
|
||||
CLAUDECODE: '1',
|
||||
CLAUDECODE_FOO: 'bar',
|
||||
CLAUDE_CODE_BAR: 'baz',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token',
|
||||
CLAUDE_CODE_SESSION: 'session',
|
||||
CLAUDE_CODE_ENTRYPOINT: 'entry',
|
||||
MCP_SESSION_ID: 'mcp',
|
||||
KEEP_ME: 'yes'
|
||||
});
|
||||
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
expect(result.KEEP_ME).toBe('yes');
|
||||
expect(result.CLAUDECODE).toBeUndefined();
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_BAR).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_SESSION).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(result.MCP_SESSION_ID).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { startHealthChecker, stopHealthChecker } from '../../src/supervisor/health-checker.js';
|
||||
|
||||
describe('health-checker', () => {
|
||||
afterEach(() => {
|
||||
// Always stop the checker to avoid leaking intervals between tests
|
||||
stopHealthChecker();
|
||||
});
|
||||
|
||||
it('startHealthChecker sets up an interval without throwing', () => {
|
||||
expect(() => startHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('stopHealthChecker clears the interval without throwing', () => {
|
||||
startHealthChecker();
|
||||
expect(() => stopHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('stopHealthChecker is safe to call when no checker is running', () => {
|
||||
expect(() => stopHealthChecker()).not.toThrow();
|
||||
});
|
||||
|
||||
it('multiple startHealthChecker calls do not create multiple intervals', () => {
|
||||
// Track setInterval calls
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let setIntervalCallCount = 0;
|
||||
|
||||
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
|
||||
setIntervalCallCount++;
|
||||
return originalSetInterval(...args);
|
||||
}) as typeof setInterval;
|
||||
|
||||
try {
|
||||
// Stop any existing checker first to ensure clean state
|
||||
stopHealthChecker();
|
||||
setIntervalCallCount = 0;
|
||||
|
||||
startHealthChecker();
|
||||
startHealthChecker();
|
||||
startHealthChecker();
|
||||
|
||||
// Only one interval should have been created due to the guard
|
||||
expect(setIntervalCallCount).toBe(1);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
}
|
||||
});
|
||||
|
||||
it('stopHealthChecker after start allows restarting', () => {
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let setIntervalCallCount = 0;
|
||||
|
||||
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
|
||||
setIntervalCallCount++;
|
||||
return originalSetInterval(...args);
|
||||
}) as typeof setInterval;
|
||||
|
||||
try {
|
||||
stopHealthChecker();
|
||||
setIntervalCallCount = 0;
|
||||
|
||||
startHealthChecker();
|
||||
expect(setIntervalCallCount).toBe(1);
|
||||
|
||||
stopHealthChecker();
|
||||
|
||||
startHealthChecker();
|
||||
expect(setIntervalCallCount).toBe(2);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
}
|
||||
});
|
||||
});
|
||||
111
.agent/services/claude-mem/tests/supervisor/index.test.ts
Normal file
111
.agent/services/claude-mem/tests/supervisor/index.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { validateWorkerPidFile, type ValidateWorkerPidStatus } from '../../src/supervisor/index.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = path.join(tmpdir(), `claude-mem-index-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('validateWorkerPidFile', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "missing" when PID file does not exist', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('missing');
|
||||
});
|
||||
|
||||
it('returns "invalid" when PID file contains bad JSON', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, 'not-json!!!');
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('invalid');
|
||||
});
|
||||
|
||||
it('returns "stale" when PID file references a dead process', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('stale');
|
||||
});
|
||||
|
||||
it('returns "alive" when PID file references the current process', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const status = validateWorkerPidFile({ logAlive: false, pidFilePath });
|
||||
expect(status).toBe('alive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supervisor assertCanSpawn behavior', () => {
|
||||
it('assertCanSpawn throws when stopPromise is active (shutdown in progress)', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const supervisor = getSupervisor();
|
||||
|
||||
// When not shutting down, assertCanSpawn should not throw
|
||||
expect(() => supervisor.assertCanSpawn('test')).not.toThrow();
|
||||
});
|
||||
|
||||
it('registerProcess and unregisterProcess delegate to the registry', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const supervisor = getSupervisor();
|
||||
const registry = supervisor.getRegistry();
|
||||
|
||||
const testId = `test-${Date.now()}`;
|
||||
supervisor.registerProcess(testId, {
|
||||
pid: process.pid,
|
||||
type: 'test',
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
const found = registry.getAll().find((r: { id: string }) => r.id === testId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.type).toBe('test');
|
||||
|
||||
supervisor.unregisterProcess(testId);
|
||||
const afterUnregister = registry.getAll().find((r: { id: string }) => r.id === testId);
|
||||
expect(afterUnregister).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Supervisor start idempotency', () => {
|
||||
it('getSupervisor returns the same instance', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const s1 = getSupervisor();
|
||||
const s2 = getSupervisor();
|
||||
expect(s1).toBe(s2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { createProcessRegistry, isPidAlive } from '../../src/supervisor/process-registry.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return path.join(tmpdir(), `claude-mem-supervisor-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('supervisor ProcessRegistry', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('isPidAlive', () => {
|
||||
it('treats current process as alive', () => {
|
||||
expect(isPidAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an impossibly high PID as dead', () => {
|
||||
expect(isPidAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats negative PID as dead', () => {
|
||||
expect(isPidAlive(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats non-integer PID as dead', () => {
|
||||
expect(isPidAlive(3.14)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('persists entries to disk and reloads them on initialize', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
// Create a registry, register an entry, and let it persist
|
||||
const registry1 = createProcessRegistry(registryPath);
|
||||
registry1.register('worker:1', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Verify file exists on disk
|
||||
expect(existsSync(registryPath)).toBe(true);
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(diskData.processes['worker:1']).toBeDefined();
|
||||
|
||||
// Create a second registry from the same path — it should load the persisted entry
|
||||
const registry2 = createProcessRegistry(registryPath);
|
||||
registry2.initialize();
|
||||
const records = registry2.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('worker:1');
|
||||
expect(records[0]?.pid).toBe(process.pid);
|
||||
});
|
||||
|
||||
it('prunes dead processes on initialize', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
writeFileSync(registryPath, JSON.stringify({
|
||||
processes: {
|
||||
alive: {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
},
|
||||
dead: {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.initialize();
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('alive');
|
||||
expect(existsSync(registryPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles corrupted registry file gracefully', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
writeFileSync(registryPath, '{ not valid json!!!');
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.initialize();
|
||||
|
||||
// Should recover with an empty registry
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register and unregister', () => {
|
||||
it('register adds an entry retrievable by getAll', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('sdk:1');
|
||||
expect(records[0]?.type).toBe('sdk');
|
||||
});
|
||||
|
||||
it('unregister removes an entry', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
|
||||
registry.unregister('sdk:1');
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unregister is a no-op for unknown IDs', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
registry.unregister('nonexistent');
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns records sorted by startedAt ascending', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('newest', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
registry.register('oldest', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('middle', {
|
||||
pid: process.pid,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getAll();
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]?.id).toBe('oldest');
|
||||
expect(records[1]?.id).toBe('middle');
|
||||
expect(records[2]?.id).toBe('newest');
|
||||
});
|
||||
|
||||
it('returns empty array when no entries exist', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
expect(registry.getAll()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBySession', () => {
|
||||
it('filters records by session id', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('sdk:2', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 'other',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const records = registry.getBySession(42);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.id).toBe('sdk:1');
|
||||
});
|
||||
|
||||
it('returns empty array when no processes match the session', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
expect(registry.getBySession(999)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches string and numeric session IDs by string comparison', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: '42',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Querying with number should find string "42"
|
||||
expect(registry.getBySession(42)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneDeadEntries', () => {
|
||||
it('removes entries with dead PIDs and preserves live ones', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('alive', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
const removed = registry.pruneDeadEntries();
|
||||
expect(removed).toBe(1);
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
expect(registry.getAll()[0]?.id).toBe('alive');
|
||||
});
|
||||
|
||||
it('returns 0 when all entries are alive', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('alive', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const removed = registry.pruneDeadEntries();
|
||||
expect(removed).toBe(0);
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('persists changes to disk after pruning', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('dead', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
registry.pruneDeadEntries();
|
||||
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('sdk:2', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
|
||||
registry.clear();
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
|
||||
// Verify persisted to disk
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProcessRegistry', () => {
|
||||
it('creates an isolated instance with a custom path', () => {
|
||||
const tempDir1 = makeTempDir();
|
||||
const tempDir2 = makeTempDir();
|
||||
tempDirs.push(tempDir1, tempDir2);
|
||||
|
||||
const registry1 = createProcessRegistry(path.join(tempDir1, 'supervisor.json'));
|
||||
const registry2 = createProcessRegistry(path.join(tempDir2, 'supervisor.json'));
|
||||
|
||||
registry1.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// registry2 should be independent
|
||||
expect(registry1.getAll()).toHaveLength(1);
|
||||
expect(registry2.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reapSession', () => {
|
||||
it('unregisters dead processes for the given session', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:99:50001', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
sessionId: 99,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('mcp:99:50002', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
sessionId: 99,
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Register a process for a different session (should survive)
|
||||
registry.register('sdk:100:50003', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 100,
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
const reaped = await registry.reapSession(99);
|
||||
expect(reaped).toBe(2);
|
||||
|
||||
expect(registry.getBySession(99)).toHaveLength(0);
|
||||
expect(registry.getBySession(100)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 0 when no processes match the session', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
|
||||
registry.register('sdk:1', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
sessionId: 42,
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
const reaped = await registry.reapSession(999);
|
||||
expect(reaped).toBe(0);
|
||||
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
.agent/services/claude-mem/tests/supervisor/shutdown.test.ts
Normal file
186
.agent/services/claude-mem/tests/supervisor/shutdown.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import { createProcessRegistry } from '../../src/supervisor/process-registry.js';
|
||||
import { runShutdownCascade } from '../../src/supervisor/shutdown.js';
|
||||
|
||||
function makeTempDir(): string {
|
||||
return path.join(tmpdir(), `claude-mem-shutdown-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('supervisor shutdown cascade', () => {
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('removes child records and pid file', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const pidFilePath = path.join(tempDir, 'worker.pid');
|
||||
|
||||
writeFileSync(pidFilePath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.register('worker', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead-child', {
|
||||
pid: 2147483647,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(persisted.processes)).toHaveLength(0);
|
||||
expect(() => readFileSync(pidFilePath, 'utf-8')).toThrow();
|
||||
});
|
||||
|
||||
it('terminates tracked children in reverse spawn order', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registry = createProcessRegistry(path.join(tempDir, 'supervisor.json'));
|
||||
registry.register('oldest', {
|
||||
pid: 41001,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('middle', {
|
||||
pid: 41002,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
registry.register('newest', {
|
||||
pid: 41003,
|
||||
type: 'chroma',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
const originalKill = process.kill;
|
||||
const alive = new Set([41001, 41002, 41003]);
|
||||
const calls: Array<{ pid: number; signal: NodeJS.Signals | number }> = [];
|
||||
|
||||
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
|
||||
const normalizedSignal = signal ?? 'SIGTERM';
|
||||
if (normalizedSignal === 0) {
|
||||
if (!alive.has(pid)) {
|
||||
const error = new Error(`kill ESRCH ${pid}`) as NodeJS.ErrnoException;
|
||||
error.code = 'ESRCH';
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
calls.push({ pid, signal: normalizedSignal });
|
||||
alive.delete(pid);
|
||||
return true;
|
||||
}) as typeof process.kill;
|
||||
|
||||
try {
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
}
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ pid: 41003, signal: 'SIGTERM' },
|
||||
{ pid: 41002, signal: 'SIGTERM' },
|
||||
{ pid: 41001, signal: 'SIGTERM' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles already-dead processes gracefully without throwing', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
// Register processes with PIDs that are definitely dead
|
||||
registry.register('dead:1', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('dead:2', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All entries should be unregistered
|
||||
const persisted = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(persisted.processes)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unregisters all children from registry after cascade', async () => {
|
||||
const tempDir = makeTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
registry.register('worker', {
|
||||
pid: process.pid,
|
||||
type: 'worker',
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
registry.register('child:1', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
registry.register('child:2', {
|
||||
pid: 2147483641,
|
||||
type: 'mcp',
|
||||
startedAt: '2026-03-15T00:00:02.000Z'
|
||||
});
|
||||
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All records (including the current process one) should be removed
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
3
.agent/services/claude-mem/tests/utils/CLAUDE.md
Normal file
3
.agent/services/claude-mem/tests/utils/CLAUDE.md
Normal file
@@ -0,0 +1,3 @@
|
||||
<claude-mem-context>
|
||||
|
||||
</claude-mem-context>
|
||||
1004
.agent/services/claude-mem/tests/utils/claude-md-utils.test.ts
Normal file
1004
.agent/services/claude-mem/tests/utils/claude-md-utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Direct implementation of formatTool for testing
|
||||
* This avoids Bun's mock.module() pollution from parallel tests
|
||||
* The logic is identical to Logger.formatTool in src/utils/logger.ts
|
||||
*/
|
||||
function formatTool(toolName: string, toolInput?: any): string {
|
||||
if (!toolInput) return toolName;
|
||||
|
||||
let input = toolInput;
|
||||
if (typeof toolInput === 'string') {
|
||||
try {
|
||||
input = JSON.parse(toolInput);
|
||||
} catch {
|
||||
// Input is a raw string (e.g., Bash command), use as-is
|
||||
input = toolInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Bash: show full command
|
||||
if (toolName === 'Bash' && input.command) {
|
||||
return `${toolName}(${input.command})`;
|
||||
}
|
||||
|
||||
// File operations: show full path
|
||||
if (input.file_path) {
|
||||
return `${toolName}(${input.file_path})`;
|
||||
}
|
||||
|
||||
// NotebookEdit: show full notebook path
|
||||
if (input.notebook_path) {
|
||||
return `${toolName}(${input.notebook_path})`;
|
||||
}
|
||||
|
||||
// Glob: show full pattern
|
||||
if (toolName === 'Glob' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// Grep: show full pattern
|
||||
if (toolName === 'Grep' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// WebFetch/WebSearch: show full URL or query
|
||||
if (input.url) {
|
||||
return `${toolName}(${input.url})`;
|
||||
}
|
||||
|
||||
if (input.query) {
|
||||
return `${toolName}(${input.query})`;
|
||||
}
|
||||
|
||||
// Task: show subagent_type or full description
|
||||
if (toolName === 'Task') {
|
||||
if (input.subagent_type) {
|
||||
return `${toolName}(${input.subagent_type})`;
|
||||
}
|
||||
if (input.description) {
|
||||
return `${toolName}(${input.description})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Skill: show skill name
|
||||
if (toolName === 'Skill' && input.skill) {
|
||||
return `${toolName}(${input.skill})`;
|
||||
}
|
||||
|
||||
// LSP: show operation type
|
||||
if (toolName === 'LSP' && input.operation) {
|
||||
return `${toolName}(${input.operation})`;
|
||||
}
|
||||
|
||||
// Default: just show tool name
|
||||
return toolName;
|
||||
}
|
||||
|
||||
describe('logger.formatTool()', () => {
|
||||
describe('Valid JSON string input', () => {
|
||||
it('should parse JSON string and extract command for Bash', () => {
|
||||
const result = formatTool('Bash', '{"command": "ls -la"}');
|
||||
expect(result).toBe('Bash(ls -la)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract file_path', () => {
|
||||
const result = formatTool('Read', '{"file_path": "/path/to/file.ts"}');
|
||||
expect(result).toBe('Read(/path/to/file.ts)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract pattern for Glob', () => {
|
||||
const result = formatTool('Glob', '{"pattern": "**/*.ts"}');
|
||||
expect(result).toBe('Glob(**/*.ts)');
|
||||
});
|
||||
|
||||
it('should parse JSON string and extract pattern for Grep', () => {
|
||||
const result = formatTool('Grep', '{"pattern": "TODO|FIXME"}');
|
||||
expect(result).toBe('Grep(TODO|FIXME)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Raw non-JSON string input (Issue #545 bug fix)', () => {
|
||||
it('should handle raw command string without crashing', () => {
|
||||
// This was the bug: raw strings caused JSON.parse to throw
|
||||
const result = formatTool('Bash', 'raw command string');
|
||||
// Since it's not JSON, it should just return the tool name
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
const result = formatTool('Read', '{file_path: broken}');
|
||||
expect(result).toBe('Read');
|
||||
});
|
||||
|
||||
it('should handle partial JSON gracefully', () => {
|
||||
const result = formatTool('Write', '{"file_path":');
|
||||
expect(result).toBe('Write');
|
||||
});
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
const result = formatTool('Bash', '');
|
||||
// Empty string is falsy, so returns just the tool name early
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle string with special characters', () => {
|
||||
const result = formatTool('Bash', 'echo "hello world" && ls');
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should handle numeric string input', () => {
|
||||
const result = formatTool('Task', '12345');
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Already-parsed object input', () => {
|
||||
it('should extract command from Bash object input', () => {
|
||||
const result = formatTool('Bash', { command: 'echo hello' });
|
||||
expect(result).toBe('Bash(echo hello)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Read object input', () => {
|
||||
const result = formatTool('Read', { file_path: '/src/index.ts' });
|
||||
expect(result).toBe('Read(/src/index.ts)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Write object input', () => {
|
||||
const result = formatTool('Write', { file_path: '/output/result.json', content: 'data' });
|
||||
expect(result).toBe('Write(/output/result.json)');
|
||||
});
|
||||
|
||||
it('should extract file_path from Edit object input', () => {
|
||||
const result = formatTool('Edit', { file_path: '/src/utils.ts', old_string: 'foo', new_string: 'bar' });
|
||||
expect(result).toBe('Edit(/src/utils.ts)');
|
||||
});
|
||||
|
||||
it('should extract pattern from Glob object input', () => {
|
||||
const result = formatTool('Glob', { pattern: 'src/**/*.test.ts' });
|
||||
expect(result).toBe('Glob(src/**/*.test.ts)');
|
||||
});
|
||||
|
||||
it('should extract pattern from Grep object input', () => {
|
||||
const result = formatTool('Grep', { pattern: 'function\\s+\\w+', path: '/src' });
|
||||
expect(result).toBe('Grep(function\\s+\\w+)');
|
||||
});
|
||||
|
||||
it('should extract notebook_path from NotebookEdit object input', () => {
|
||||
const result = formatTool('NotebookEdit', { notebook_path: '/notebooks/analysis.ipynb' });
|
||||
expect(result).toBe('NotebookEdit(/notebooks/analysis.ipynb)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty/null/undefined inputs', () => {
|
||||
it('should return just tool name when toolInput is undefined', () => {
|
||||
const result = formatTool('Bash');
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is null', () => {
|
||||
const result = formatTool('Bash', null);
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is undefined explicitly', () => {
|
||||
const result = formatTool('Bash', undefined);
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is empty object', () => {
|
||||
const result = formatTool('Bash', {});
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is 0', () => {
|
||||
// 0 is falsy
|
||||
const result = formatTool('Task', 0);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is false', () => {
|
||||
// false is falsy
|
||||
const result = formatTool('Task', false);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Various tool types', () => {
|
||||
describe('Bash tool', () => {
|
||||
it('should extract command from object', () => {
|
||||
const result = formatTool('Bash', { command: 'npm install' });
|
||||
expect(result).toBe('Bash(npm install)');
|
||||
});
|
||||
|
||||
it('should extract command from JSON string', () => {
|
||||
const result = formatTool('Bash', '{"command":"git status"}');
|
||||
expect(result).toBe('Bash(git status)');
|
||||
});
|
||||
|
||||
it('should return just Bash when command is missing', () => {
|
||||
const result = formatTool('Bash', { description: 'some action' });
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = formatTool('Read', { file_path: '/Users/test/file.ts' });
|
||||
expect(result).toBe('Read(/Users/test/file.ts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Write tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = formatTool('Write', { file_path: '/tmp/output.txt', content: 'hello' });
|
||||
expect(result).toBe('Write(/tmp/output.txt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit tool', () => {
|
||||
it('should extract file_path', () => {
|
||||
const result = formatTool('Edit', { file_path: '/src/main.ts', old_string: 'a', new_string: 'b' });
|
||||
expect(result).toBe('Edit(/src/main.ts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grep tool', () => {
|
||||
it('should extract pattern', () => {
|
||||
const result = formatTool('Grep', { pattern: 'import.*from' });
|
||||
expect(result).toBe('Grep(import.*from)');
|
||||
});
|
||||
|
||||
it('should prioritize pattern over other fields', () => {
|
||||
const result = formatTool('Grep', { pattern: 'search', path: '/src', type: 'ts' });
|
||||
expect(result).toBe('Grep(search)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glob tool', () => {
|
||||
it('should extract pattern', () => {
|
||||
const result = formatTool('Glob', { pattern: '**/*.md' });
|
||||
expect(result).toBe('Glob(**/*.md)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task tool', () => {
|
||||
it('should extract subagent_type when present', () => {
|
||||
const result = formatTool('Task', { subagent_type: 'code_review' });
|
||||
expect(result).toBe('Task(code_review)');
|
||||
});
|
||||
|
||||
it('should extract description when subagent_type is missing', () => {
|
||||
const result = formatTool('Task', { description: 'Analyze the codebase structure' });
|
||||
expect(result).toBe('Task(Analyze the codebase structure)');
|
||||
});
|
||||
|
||||
it('should prefer subagent_type over description', () => {
|
||||
const result = formatTool('Task', { subagent_type: 'research', description: 'Find docs' });
|
||||
expect(result).toBe('Task(research)');
|
||||
});
|
||||
|
||||
it('should return just Task when neither field is present', () => {
|
||||
const result = formatTool('Task', { timeout: 5000 });
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebFetch tool', () => {
|
||||
it('should extract url', () => {
|
||||
const result = formatTool('WebFetch', { url: 'https://example.com/api' });
|
||||
expect(result).toBe('WebFetch(https://example.com/api)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSearch tool', () => {
|
||||
it('should extract query', () => {
|
||||
const result = formatTool('WebSearch', { query: 'typescript best practices' });
|
||||
expect(result).toBe('WebSearch(typescript best practices)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skill tool', () => {
|
||||
it('should extract skill name', () => {
|
||||
const result = formatTool('Skill', { skill: 'commit' });
|
||||
expect(result).toBe('Skill(commit)');
|
||||
});
|
||||
|
||||
it('should return just Skill when skill is missing', () => {
|
||||
const result = formatTool('Skill', { args: '--help' });
|
||||
expect(result).toBe('Skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LSP tool', () => {
|
||||
it('should extract operation', () => {
|
||||
const result = formatTool('LSP', { operation: 'goToDefinition', filePath: '/src/main.ts' });
|
||||
expect(result).toBe('LSP(goToDefinition)');
|
||||
});
|
||||
|
||||
it('should return just LSP when operation is missing', () => {
|
||||
const result = formatTool('LSP', { filePath: '/src/main.ts', line: 10 });
|
||||
expect(result).toBe('LSP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotebookEdit tool', () => {
|
||||
it('should extract notebook_path', () => {
|
||||
const result = formatTool('NotebookEdit', { notebook_path: '/docs/demo.ipynb', cell_number: 3 });
|
||||
expect(result).toBe('NotebookEdit(/docs/demo.ipynb)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown tools', () => {
|
||||
it('should return just tool name for unknown tools with unrecognized fields', () => {
|
||||
const result = formatTool('CustomTool', { foo: 'bar', baz: 123 });
|
||||
expect(result).toBe('CustomTool');
|
||||
});
|
||||
|
||||
it('should extract url from unknown tools if present', () => {
|
||||
// url is a generic extractor
|
||||
const result = formatTool('CustomFetch', { url: 'https://api.custom.com' });
|
||||
expect(result).toBe('CustomFetch(https://api.custom.com)');
|
||||
});
|
||||
|
||||
it('should extract query from unknown tools if present', () => {
|
||||
// query is a generic extractor
|
||||
const result = formatTool('CustomSearch', { query: 'find something' });
|
||||
expect(result).toBe('CustomSearch(find something)');
|
||||
});
|
||||
|
||||
it('should extract file_path from unknown tools if present', () => {
|
||||
// file_path is a generic extractor
|
||||
const result = formatTool('CustomFileTool', { file_path: '/some/path.txt' });
|
||||
expect(result).toBe('CustomFileTool(/some/path.txt)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle JSON string with nested objects', () => {
|
||||
const input = JSON.stringify({ command: 'echo test', options: { verbose: true } });
|
||||
const result = formatTool('Bash', input);
|
||||
expect(result).toBe('Bash(echo test)');
|
||||
});
|
||||
|
||||
it('should handle very long command strings', () => {
|
||||
const longCommand = 'npm run build && npm run test && npm run lint && npm run format';
|
||||
const result = formatTool('Bash', { command: longCommand });
|
||||
expect(result).toBe(`Bash(${longCommand})`);
|
||||
});
|
||||
|
||||
it('should handle file paths with spaces', () => {
|
||||
const result = formatTool('Read', { file_path: '/Users/test/My Documents/file.ts' });
|
||||
expect(result).toBe('Read(/Users/test/My Documents/file.ts)');
|
||||
});
|
||||
|
||||
it('should handle file paths with special characters', () => {
|
||||
const result = formatTool('Write', { file_path: '/tmp/test-file_v2.0.ts' });
|
||||
expect(result).toBe('Write(/tmp/test-file_v2.0.ts)');
|
||||
});
|
||||
|
||||
it('should handle patterns with regex special characters', () => {
|
||||
const result = formatTool('Grep', { pattern: '\\[.*\\]|\\(.*\\)' });
|
||||
expect(result).toBe('Grep(\\[.*\\]|\\(.*\\))');
|
||||
});
|
||||
|
||||
it('should handle unicode in strings', () => {
|
||||
const result = formatTool('Bash', { command: 'echo "Hello, World!"' });
|
||||
expect(result).toBe('Bash(echo "Hello, World!")');
|
||||
});
|
||||
|
||||
it('should handle number values in fields correctly', () => {
|
||||
// If command is a number, it gets stringified
|
||||
const result = formatTool('Bash', { command: 123 });
|
||||
expect(result).toBe('Bash(123)');
|
||||
});
|
||||
|
||||
it('should handle JSON array as input', () => {
|
||||
// Arrays don't have command/file_path/etc fields
|
||||
const result = formatTool('Unknown', ['item1', 'item2']);
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle JSON string that parses to a primitive', () => {
|
||||
// JSON.parse("123") = 123 (number)
|
||||
const result = formatTool('Task', '"a plain string"');
|
||||
// After parsing, input becomes "a plain string" which has no recognized fields
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Project Filter Tests
|
||||
*
|
||||
* Tests glob-based path matching for project exclusion.
|
||||
* Source: src/utils/project-filter.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isProjectExcluded } from '../../src/utils/project-filter.js';
|
||||
import { homedir } from 'os';
|
||||
|
||||
describe('Project Filter', () => {
|
||||
describe('isProjectExcluded', () => {
|
||||
describe('with empty patterns', () => {
|
||||
it('returns false for empty pattern string', () => {
|
||||
expect(isProjectExcluded('/Users/test/project', '')).toBe(false);
|
||||
expect(isProjectExcluded('/Users/test/project', ' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with exact path matching', () => {
|
||||
it('matches exact paths', () => {
|
||||
expect(isProjectExcluded('/tmp/secret', '/tmp/secret')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/public', '/tmp/secret')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with * wildcard (single directory level)', () => {
|
||||
it('matches any directory name', () => {
|
||||
expect(isProjectExcluded('/tmp/secret', '/tmp/*')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/anything', '/tmp/*')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match across directory boundaries', () => {
|
||||
expect(isProjectExcluded('/tmp/a/b', '/tmp/*')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ** wildcard (any path depth)', () => {
|
||||
it('matches any path depth', () => {
|
||||
expect(isProjectExcluded('/Users/test/kunden/client1/project', '/Users/*/kunden/**')).toBe(true);
|
||||
expect(isProjectExcluded('/Users/test/kunden/deep/nested/project', '/Users/*/kunden/**')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ? wildcard (single character)', () => {
|
||||
it('matches single character', () => {
|
||||
expect(isProjectExcluded('/tmp/a', '/tmp/?')).toBe(true);
|
||||
expect(isProjectExcluded('/tmp/ab', '/tmp/?')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ~ home directory expansion', () => {
|
||||
it('expands ~ to home directory', () => {
|
||||
const home = homedir();
|
||||
expect(isProjectExcluded(`${home}/secret`, '~/secret')).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/projects/secret`, '~/projects/*')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple patterns', () => {
|
||||
it('returns true if any pattern matches', () => {
|
||||
const patterns = '/tmp/*,~/kunden/*,/var/secret';
|
||||
expect(isProjectExcluded('/tmp/test', patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${homedir()}/kunden/client`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/var/secret', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/home/user/public', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with Windows-style paths', () => {
|
||||
it('normalizes backslashes to forward slashes', () => {
|
||||
expect(isProjectExcluded('C:\\Users\\test\\secret', 'C:/Users/*/secret')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world patterns', () => {
|
||||
it('excludes customer projects', () => {
|
||||
const patterns = '~/kunden/*,~/customers/**';
|
||||
const home = homedir();
|
||||
|
||||
expect(isProjectExcluded(`${home}/kunden/acme-corp`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/customers/bigco/project1`, patterns)).toBe(true);
|
||||
expect(isProjectExcluded(`${home}/projects/opensource`, patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes temporary directories', () => {
|
||||
const patterns = '/tmp/*,/var/tmp/*';
|
||||
|
||||
expect(isProjectExcluded('/tmp/scratch', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/var/tmp/test', patterns)).toBe(true);
|
||||
expect(isProjectExcluded('/home/user/tmp', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
348
.agent/services/claude-mem/tests/utils/tag-stripping.test.ts
Normal file
348
.agent/services/claude-mem/tests/utils/tag-stripping.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Tag Stripping Utility Tests
|
||||
*
|
||||
* Tests the tag privacy system for <private>, <claude-mem-context>, and <system_instruction> tags.
|
||||
* These tags enable users and the system to exclude content from memory storage.
|
||||
*
|
||||
* Sources:
|
||||
* - Implementation from src/utils/tag-stripping.ts
|
||||
* - Privacy patterns from src/services/worker/http/routes/SessionRoutes.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { stripMemoryTagsFromPrompt, stripMemoryTagsFromJson } from '../../src/utils/tag-stripping.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Tag Stripping Utilities', () => {
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
});
|
||||
|
||||
describe('stripMemoryTagsFromPrompt', () => {
|
||||
describe('basic tag removal', () => {
|
||||
it('should strip single <private> tag and preserve surrounding content', () => {
|
||||
const input = 'public content <private>secret stuff</private> more public';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public content more public');
|
||||
});
|
||||
|
||||
it('should strip single <claude-mem-context> tag', () => {
|
||||
const input = 'public content <claude-mem-context>injected context</claude-mem-context> more public';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public content more public');
|
||||
});
|
||||
|
||||
it('should strip both tag types in mixed content', () => {
|
||||
const input = '<private>secret</private> public <claude-mem-context>context</claude-mem-context> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple tags handling', () => {
|
||||
it('should strip multiple <private> blocks', () => {
|
||||
const input = '<private>first secret</private> middle <private>second secret</private> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('middle end');
|
||||
});
|
||||
|
||||
it('should strip multiple <claude-mem-context> blocks', () => {
|
||||
const input = '<claude-mem-context>ctx1</claude-mem-context><claude-mem-context>ctx2</claude-mem-context> content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('content');
|
||||
});
|
||||
|
||||
it('should handle many interleaved tags', () => {
|
||||
let input = 'start';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
input += ` <private>p${i}</private> <claude-mem-context>c${i}</claude-mem-context>`;
|
||||
}
|
||||
input += ' end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
// Tags are stripped but spaces between them remain
|
||||
expect(result).not.toContain('<private>');
|
||||
expect(result).not.toContain('<claude-mem-context>');
|
||||
expect(result).toContain('start');
|
||||
expect(result).toContain('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty and private-only prompts', () => {
|
||||
it('should return empty string for entirely private prompt', () => {
|
||||
const input = '<private>entire prompt is private</private>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for entirely context-tagged prompt', () => {
|
||||
const input = '<claude-mem-context>all is context</claude-mem-context>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve content with no tags', () => {
|
||||
const input = 'no tags here at all';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('no tags here at all');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const result = stripMemoryTagsFromPrompt('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle whitespace-only after stripping', () => {
|
||||
const input = '<private>content</private> <claude-mem-context>more</claude-mem-context>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content preservation', () => {
|
||||
it('should preserve non-tagged content exactly', () => {
|
||||
const input = 'keep this <private>remove this</private> and this';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('keep this and this');
|
||||
});
|
||||
|
||||
it('should preserve special characters in non-tagged content', () => {
|
||||
const input = 'code: const x = 1; <private>secret</private> more: { "key": "value" }';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('code: const x = 1; more: { "key": "value" }');
|
||||
});
|
||||
|
||||
it('should preserve newlines in non-tagged content', () => {
|
||||
const input = 'line1\n<private>secret</private>\nline2';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('line1\n\nline2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline content in tags', () => {
|
||||
it('should strip multiline content within <private> tags', () => {
|
||||
const input = `public
|
||||
<private>
|
||||
multi
|
||||
line
|
||||
secret
|
||||
</private>
|
||||
end`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public\n\nend');
|
||||
});
|
||||
|
||||
it('should strip multiline content within <claude-mem-context> tags', () => {
|
||||
const input = `start
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
- Item 1
|
||||
- Item 2
|
||||
</claude-mem-context>
|
||||
finish`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('start\n\nfinish');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReDoS protection', () => {
|
||||
it('should handle content with many tags without hanging (< 1 second)', async () => {
|
||||
// Generate content with many tags
|
||||
let content = '';
|
||||
for (let i = 0; i < 150; i++) {
|
||||
content += `<private>secret${i}</private> text${i} `;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = stripMemoryTagsFromPrompt(content);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should complete quickly despite many tags
|
||||
expect(duration).toBeLessThan(1000);
|
||||
// Should not contain any private content
|
||||
expect(result).not.toContain('<private>');
|
||||
// Should warn about exceeding tag limit
|
||||
expect(loggerSpies[2]).toHaveBeenCalled(); // warn spy
|
||||
});
|
||||
|
||||
it('should process within reasonable time with nested-looking patterns', () => {
|
||||
// Content that looks like it could cause backtracking
|
||||
const content = '<private>' + 'x'.repeat(10000) + '</private> keep this';
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = stripMemoryTagsFromPrompt(content);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(duration).toBeLessThan(1000);
|
||||
expect(result).toBe('keep this');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMemoryTagsFromJson', () => {
|
||||
describe('JSON content stripping', () => {
|
||||
it('should strip tags from stringified JSON', () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
file_path: '/path/to/file',
|
||||
content: '<private>secret</private> public'
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(jsonContent);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.content).toBe(' public');
|
||||
});
|
||||
|
||||
it('should strip claude-mem-context tags from JSON', () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
data: '<claude-mem-context>injected</claude-mem-context> real data'
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(jsonContent);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.data).toBe(' real data');
|
||||
});
|
||||
|
||||
it('should handle tool_input with tags', () => {
|
||||
const toolInput = {
|
||||
command: 'echo hello',
|
||||
args: '<private>secret args</private>'
|
||||
};
|
||||
const result = stripMemoryTagsFromJson(JSON.stringify(toolInput));
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.args).toBe('');
|
||||
});
|
||||
|
||||
it('should handle tool_response with tags', () => {
|
||||
const toolResponse = {
|
||||
output: 'result <claude-mem-context>context data</claude-mem-context>',
|
||||
status: 'success'
|
||||
};
|
||||
const result = stripMemoryTagsFromJson(JSON.stringify(toolResponse));
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.output).toBe('result ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty JSON object', () => {
|
||||
const result = stripMemoryTagsFromJson('{}');
|
||||
expect(result).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle JSON with no tags', () => {
|
||||
const input = JSON.stringify({ key: 'value' });
|
||||
const result = stripMemoryTagsFromJson(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle nested JSON structures', () => {
|
||||
const input = JSON.stringify({
|
||||
outer: {
|
||||
inner: '<private>secret</private> visible'
|
||||
}
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.outer.inner).toBe(' visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('system_instruction tag stripping', () => {
|
||||
describe('basic system_instruction removal', () => {
|
||||
it('should strip single <system_instruction> tag from prompt', () => {
|
||||
const input = 'user content <system_instruction>injected instructions</system_instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> mixed with <private> tags', () => {
|
||||
const input = '<system_instruction>instructions</system_instruction> public <private>secret</private> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('public end');
|
||||
});
|
||||
|
||||
it('should return empty string for entirely <system_instruction> content', () => {
|
||||
const input = '<system_instruction>entire prompt is system instructions</system_instruction>';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should strip <system_instruction> tags from JSON content', () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
data: '<system_instruction>injected</system_instruction> real data'
|
||||
});
|
||||
const result = stripMemoryTagsFromJson(jsonContent);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.data).toBe(' real data');
|
||||
});
|
||||
|
||||
it('should strip multiline content within <system_instruction> tags', () => {
|
||||
const input = `before
|
||||
<system_instruction>
|
||||
line one
|
||||
line two
|
||||
line three
|
||||
</system_instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('system-instruction (hyphen variant) tag stripping', () => {
|
||||
it('should strip single <system-instruction> tag from prompt', () => {
|
||||
const input = 'user content <system-instruction>injected instructions</system-instruction> more content';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('user content more content');
|
||||
});
|
||||
|
||||
it('should strip both underscore and hyphen variants in same prompt', () => {
|
||||
const input = '<system_instruction>underscore</system_instruction> middle <system-instruction>hyphen</system-instruction> end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('middle end');
|
||||
});
|
||||
|
||||
it('should strip multiline <system-instruction> content', () => {
|
||||
const input = `before
|
||||
<system-instruction>
|
||||
line one
|
||||
line two
|
||||
</system-instruction>
|
||||
after`;
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
expect(result).toBe('before\n\nafter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy enforcement integration', () => {
|
||||
it('should allow empty result to trigger privacy skip', () => {
|
||||
// Simulates what SessionRoutes does with private-only prompts
|
||||
const prompt = '<private>entirely private prompt</private>';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(prompt);
|
||||
|
||||
// Empty/whitespace prompts should trigger skip
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow partial content when not entirely private', () => {
|
||||
const prompt = '<private>password123</private> Please help me with my code';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(prompt);
|
||||
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(false);
|
||||
expect(cleanedPrompt.trim()).toBe('Please help me with my code');
|
||||
});
|
||||
});
|
||||
});
|
||||
220
.agent/services/claude-mem/tests/worker-spawn.test.ts
Normal file
220
.agent/services/claude-mem/tests/worker-spawn.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'bun:test';
|
||||
import { execSync, ChildProcess } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Worker Self-Spawn Integration Tests
|
||||
*
|
||||
* Tests actual integration points:
|
||||
* - Health check utilities (real network behavior)
|
||||
* - PID file management (real filesystem)
|
||||
* - Status command output format
|
||||
* - Windows-specific behavior detection
|
||||
*
|
||||
* Removed: JSON.parse tests, CLI command parsing (tests language built-ins)
|
||||
*/
|
||||
|
||||
const TEST_PORT = 37877;
|
||||
const TEST_DATA_DIR = path.join(homedir(), '.claude-mem-test');
|
||||
const TEST_PID_FILE = path.join(TEST_DATA_DIR, 'worker.pid');
|
||||
const WORKER_SCRIPT = path.join(__dirname, '../plugin/scripts/worker-service.cjs');
|
||||
|
||||
interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if port is in use by attempting a health check
|
||||
*/
|
||||
async function isPortInUse(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for port to be healthy
|
||||
*/
|
||||
async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await isPortInUse(port)) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout
|
||||
*/
|
||||
function runWorkerCommand(command: string, env: Record<string, string> = {}): string {
|
||||
const result = execSync(`bun "${WORKER_SCRIPT}" ${command}`, {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
describe('Worker Self-Spawn CLI', () => {
|
||||
beforeAll(async () => {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
it('should report worker status in expected format', async () => {
|
||||
const output = runWorkerCommand('status');
|
||||
// Should contain either "running" or "not running"
|
||||
expect(output.includes('running')).toBe(true);
|
||||
});
|
||||
|
||||
it('should include PID and port when running', async () => {
|
||||
const output = runWorkerCommand('status');
|
||||
if (output.includes('Worker running')) {
|
||||
expect(output).toMatch(/PID: \d+/);
|
||||
expect(output).toMatch(/Port: \d+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PID file management', () => {
|
||||
it('should create and read PID file with correct structure', () => {
|
||||
mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
|
||||
const testPidInfo: PidInfo = {
|
||||
pid: 12345,
|
||||
port: TEST_PORT,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
writeFileSync(TEST_PID_FILE, JSON.stringify(testPidInfo, null, 2));
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(true);
|
||||
|
||||
const readInfo = JSON.parse(readFileSync(TEST_PID_FILE, 'utf-8')) as PidInfo;
|
||||
expect(readInfo.pid).toBe(12345);
|
||||
expect(readInfo.port).toBe(TEST_PORT);
|
||||
expect(readInfo.startedAt).toBe(testPidInfo.startedAt);
|
||||
|
||||
// Cleanup
|
||||
unlinkSync(TEST_PID_FILE);
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check utilities', () => {
|
||||
it('should return false for non-existent server', async () => {
|
||||
const unusedPort = 39999;
|
||||
const result = await isPortInUse(unusedPort);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should timeout appropriately for unreachable server', async () => {
|
||||
const start = Date.now();
|
||||
const result = await isPortInUse(39998);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should not wait longer than the timeout (2s) + small buffer
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Health Endpoints', () => {
|
||||
let workerProcess: ChildProcess | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping worker health tests - worker script not built');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workerProcess) {
|
||||
workerProcess.kill('SIGTERM');
|
||||
workerProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('health endpoint contract', () => {
|
||||
it('should expect /api/health to return status ok with expected fields', async () => {
|
||||
// Contract validation: verify expected response structure
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
build: 'TEST-008-wrapper-ipc',
|
||||
managed: false,
|
||||
hasIpc: false,
|
||||
platform: 'darwin',
|
||||
pid: 12345,
|
||||
initialized: true,
|
||||
mcpReady: true
|
||||
};
|
||||
|
||||
expect(mockResponse.status).toBe('ok');
|
||||
expect(typeof mockResponse.build).toBe('string');
|
||||
expect(typeof mockResponse.pid).toBe('number');
|
||||
expect(typeof mockResponse.managed).toBe('boolean');
|
||||
expect(typeof mockResponse.initialized).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should expect /api/readiness to distinguish ready vs initializing states', async () => {
|
||||
const readyResponse = { status: 'ready', mcpReady: true };
|
||||
const initializingResponse = { status: 'initializing', message: 'Worker is still initializing, please retry' };
|
||||
|
||||
expect(readyResponse.status).toBe('ready');
|
||||
expect(initializingResponse.status).toBe('initializing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows-specific behavior', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
delete process.env.CLAUDE_MEM_MANAGED;
|
||||
});
|
||||
|
||||
it('should detect Windows managed worker mode correctly', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
process.env.CLAUDE_MEM_MANAGED = 'true';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isManaged = process.env.CLAUDE_MEM_MANAGED === 'true';
|
||||
|
||||
expect(isWindows).toBe(true);
|
||||
expect(isManaged).toBe(true);
|
||||
|
||||
// In non-managed mode (without process.send), IPC messages won't work
|
||||
const hasProcessSend = typeof process.send === 'function';
|
||||
const isWindowsManaged = isWindows && isManaged && hasProcessSend;
|
||||
expect(isWindowsManaged).toBe(false); // No process.send in test context
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for fallback error classification logic
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Tests pure functions directly with no external dependencies
|
||||
* - shouldFallbackToClaude: Pattern matching on error messages
|
||||
* - isAbortError: Simple type checking
|
||||
*
|
||||
* High-value tests: Ensure correct provider fallback behavior for transient errors
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// Import directly from specific files to avoid worker-service import chain
|
||||
import { shouldFallbackToClaude, isAbortError } from '../../../src/services/worker/agents/FallbackErrorHandler.js';
|
||||
import { FALLBACK_ERROR_PATTERNS } from '../../../src/services/worker/agents/types.js';
|
||||
|
||||
describe('FallbackErrorHandler', () => {
|
||||
describe('FALLBACK_ERROR_PATTERNS', () => {
|
||||
it('should contain all 7 expected patterns', () => {
|
||||
expect(FALLBACK_ERROR_PATTERNS).toHaveLength(7);
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('429');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('500');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('502');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('503');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('ECONNREFUSED');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('ETIMEDOUT');
|
||||
expect(FALLBACK_ERROR_PATTERNS).toContain('fetch failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFallbackToClaude', () => {
|
||||
describe('returns true for fallback patterns', () => {
|
||||
it('should return true for 429 rate limit errors', () => {
|
||||
expect(shouldFallbackToClaude('Rate limit exceeded: 429')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('429 Too Many Requests'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for 500 internal server errors', () => {
|
||||
expect(shouldFallbackToClaude('500 Internal Server Error')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('Server returned 500'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for 502 bad gateway errors', () => {
|
||||
expect(shouldFallbackToClaude('502 Bad Gateway')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('Upstream returned 502'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for 503 service unavailable errors', () => {
|
||||
expect(shouldFallbackToClaude('503 Service Unavailable')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('Server is 503'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for ECONNREFUSED errors', () => {
|
||||
expect(shouldFallbackToClaude('connect ECONNREFUSED 127.0.0.1:8080')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('ECONNREFUSED'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for ETIMEDOUT errors', () => {
|
||||
expect(shouldFallbackToClaude('connect ETIMEDOUT')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('Request ETIMEDOUT'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for fetch failed errors', () => {
|
||||
expect(shouldFallbackToClaude('fetch failed')).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('fetch failed: network error'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns false for non-fallback errors', () => {
|
||||
it('should return false for 400 Bad Request', () => {
|
||||
expect(shouldFallbackToClaude('400 Bad Request')).toBe(false);
|
||||
expect(shouldFallbackToClaude(new Error('400 Invalid argument'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 401 Unauthorized', () => {
|
||||
expect(shouldFallbackToClaude('401 Unauthorized')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 403 Forbidden', () => {
|
||||
expect(shouldFallbackToClaude('403 Forbidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 404 Not Found', () => {
|
||||
expect(shouldFallbackToClaude('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for generic errors', () => {
|
||||
expect(shouldFallbackToClaude('Something went wrong')).toBe(false);
|
||||
expect(shouldFallbackToClaude(new Error('Unknown error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles various error types', () => {
|
||||
it('should handle string errors', () => {
|
||||
expect(shouldFallbackToClaude('429 rate limited')).toBe(true);
|
||||
expect(shouldFallbackToClaude('invalid input')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle Error objects', () => {
|
||||
expect(shouldFallbackToClaude(new Error('429 Too Many Requests'))).toBe(true);
|
||||
expect(shouldFallbackToClaude(new Error('Bad Request'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle objects with message property', () => {
|
||||
expect(shouldFallbackToClaude({ message: '503 unavailable' })).toBe(true);
|
||||
expect(shouldFallbackToClaude({ message: 'ok' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null and undefined', () => {
|
||||
expect(shouldFallbackToClaude(null)).toBe(false);
|
||||
expect(shouldFallbackToClaude(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-error objects by stringifying', () => {
|
||||
expect(shouldFallbackToClaude({ code: 429 })).toBe(false); // toString won't include 429
|
||||
expect(shouldFallbackToClaude(429)).toBe(true); // number 429 stringifies to "429"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('should return true for Error with name "AbortError"', () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
expect(isAbortError(abortError)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for objects with name "AbortError"', () => {
|
||||
expect(isAbortError({ name: 'AbortError', message: 'aborted' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular Error objects', () => {
|
||||
expect(isAbortError(new Error('Some error'))).toBe(false);
|
||||
expect(isAbortError(new TypeError('Type error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for errors with other names', () => {
|
||||
const error = new Error('timeout');
|
||||
error.name = 'TimeoutError';
|
||||
expect(isAbortError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null and undefined', () => {
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for strings', () => {
|
||||
expect(isAbortError('AbortError')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for objects without name property', () => {
|
||||
expect(isAbortError({ message: 'error' })).toBe(false);
|
||||
expect(isAbortError({})).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,656 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { logger } from '../../../src/utils/logger.js';
|
||||
|
||||
// Mock modules that cause import chain issues - MUST be before imports
|
||||
// Use full paths from test file location
|
||||
mock.module('../../../src/services/worker-service.js', () => ({
|
||||
updateCursorContextForProject: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
mock.module('../../../src/shared/worker-utils.js', () => ({
|
||||
getWorkerPort: () => 37777,
|
||||
}));
|
||||
|
||||
// Mock the ModeManager
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
getActiveMode: () => ({
|
||||
name: 'code',
|
||||
prompts: {
|
||||
init: 'init prompt',
|
||||
observation: 'obs prompt',
|
||||
summary: 'summary prompt',
|
||||
},
|
||||
observation_types: [{ id: 'discovery' }, { id: 'bugfix' }, { id: 'refactor' }],
|
||||
observation_concepts: [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { processAgentResponse } from '../../../src/services/worker/agents/ResponseProcessor.js';
|
||||
import type { WorkerRef, StorageResult } from '../../../src/services/worker/agents/types.js';
|
||||
import type { ActiveSession } from '../../../src/services/worker-types.js';
|
||||
import type { DatabaseManager } from '../../../src/services/worker/DatabaseManager.js';
|
||||
import type { SessionManager } from '../../../src/services/worker/SessionManager.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ResponseProcessor', () => {
|
||||
// Mocks
|
||||
let mockStoreObservations: ReturnType<typeof mock>;
|
||||
let mockChromaSyncObservation: ReturnType<typeof mock>;
|
||||
let mockChromaSyncSummary: ReturnType<typeof mock>;
|
||||
let mockBroadcast: ReturnType<typeof mock>;
|
||||
let mockBroadcastProcessingStatus: ReturnType<typeof mock>;
|
||||
let mockDbManager: DatabaseManager;
|
||||
let mockSessionManager: SessionManager;
|
||||
let mockWorker: WorkerRef;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on logger to suppress output
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
// Create fresh mocks for each test
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1, 2],
|
||||
summaryId: 1,
|
||||
createdAtEpoch: 1700000000000,
|
||||
} as StorageResult));
|
||||
|
||||
mockChromaSyncObservation = mock(() => Promise.resolve());
|
||||
mockChromaSyncSummary = mock(() => Promise.resolve());
|
||||
|
||||
mockDbManager = {
|
||||
getSessionStore: () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}), // FK fix (Issue #846)
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), // FK fix (Issue #846)
|
||||
}),
|
||||
getChromaSync: () => ({
|
||||
syncObservation: mockChromaSyncObservation,
|
||||
syncSummary: mockChromaSyncSummary,
|
||||
}),
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
mockSessionManager = {
|
||||
getMessageIterator: async function* () {
|
||||
yield* [];
|
||||
},
|
||||
getPendingMessageStore: () => ({
|
||||
markProcessed: mock(() => {}),
|
||||
confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage
|
||||
cleanupProcessed: mock(() => 0),
|
||||
resetStuckMessages: mock(() => 0),
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
mockBroadcast = mock(() => {});
|
||||
mockBroadcastProcessingStatus = mock(() => {});
|
||||
|
||||
mockWorker = {
|
||||
sseBroadcaster: {
|
||||
broadcast: mockBroadcast,
|
||||
},
|
||||
broadcastProcessingStatus: mockBroadcastProcessingStatus,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// Helper to create mock session
|
||||
function createMockSession(
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
return {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'content-session-123',
|
||||
memorySessionId: 'memory-session-456',
|
||||
project: 'test-project',
|
||||
userPrompt: 'Test prompt',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 5,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 100,
|
||||
cumulativeOutputTokens: 50,
|
||||
earliestPendingTimestamp: Date.now() - 10000,
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parsing observations from XML response', () => {
|
||||
it('should parse single observation from response', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Found important pattern</title>
|
||||
<subtitle>In auth module</subtitle>
|
||||
<narrative>Discovered reusable authentication pattern.</narrative>
|
||||
<facts><fact>Uses JWT</fact></facts>
|
||||
<concepts><concept>authentication</concept></concepts>
|
||||
<files_read><file>src/auth.ts</file></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
|
||||
const [memorySessionId, project, observations, summary] =
|
||||
mockStoreObservations.mock.calls[0];
|
||||
expect(memorySessionId).toBe('memory-session-456');
|
||||
expect(project).toBe('test-project');
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0].type).toBe('discovery');
|
||||
expect(observations[0].title).toBe('Found important pattern');
|
||||
});
|
||||
|
||||
it('should parse multiple observations from response', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>First discovery</title>
|
||||
<narrative>First narrative</narrative>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
<observation>
|
||||
<type>bugfix</type>
|
||||
<title>Fixed null pointer</title>
|
||||
<narrative>Second narrative</narrative>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
const [, , observations] = mockStoreObservations.mock.calls[0];
|
||||
expect(observations).toHaveLength(2);
|
||||
expect(observations[0].type).toBe('discovery');
|
||||
expect(observations[1].type).toBe('bugfix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsing summary from XML response', () => {
|
||||
it('should parse summary from response', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
<summary>
|
||||
<request>Build login form</request>
|
||||
<investigated>Reviewed existing forms</investigated>
|
||||
<learned>React Hook Form works well</learned>
|
||||
<completed>Form skeleton created</completed>
|
||||
<next_steps>Add validation</next_steps>
|
||||
<notes>Some notes</notes>
|
||||
</summary>
|
||||
`;
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
const [, , , summary] = mockStoreObservations.mock.calls[0];
|
||||
expect(summary).not.toBeNull();
|
||||
expect(summary.request).toBe('Build login form');
|
||||
expect(summary.investigated).toBe('Reviewed existing forms');
|
||||
expect(summary.learned).toBe('React Hook Form works well');
|
||||
});
|
||||
|
||||
it('should handle response without summary', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
// Mock to return result without summary
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
const [, , , summary] = mockStoreObservations.mock.calls[0];
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic database transactions', () => {
|
||||
it('should call storeObservations atomically', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
<summary>
|
||||
<request>Test request</request>
|
||||
<investigated>Test investigated</investigated>
|
||||
<learned>Test learned</learned>
|
||||
<completed>Test completed</completed>
|
||||
<next_steps>Test next steps</next_steps>
|
||||
</summary>
|
||||
`;
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
1700000000000,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Verify storeObservations was called exactly once (atomic)
|
||||
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify all parameters passed correctly
|
||||
const [
|
||||
memorySessionId,
|
||||
project,
|
||||
observations,
|
||||
summary,
|
||||
promptNumber,
|
||||
tokens,
|
||||
timestamp,
|
||||
] = mockStoreObservations.mock.calls[0];
|
||||
|
||||
expect(memorySessionId).toBe('memory-session-456');
|
||||
expect(project).toBe('test-project');
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(summary).not.toBeNull();
|
||||
expect(promptNumber).toBe(5);
|
||||
expect(tokens).toBe(100);
|
||||
expect(timestamp).toBe(1700000000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE broadcasting', () => {
|
||||
it('should broadcast observations via SSE', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Broadcast Test</title>
|
||||
<subtitle>Testing broadcast</subtitle>
|
||||
<narrative>Testing SSE broadcast</narrative>
|
||||
<facts><fact>Fact 1</fact></facts>
|
||||
<concepts><concept>testing</concept></concepts>
|
||||
<files_read><file>test.ts</file></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
// Mock returning single observation ID
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [42],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Should broadcast observation
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
|
||||
// Find the observation broadcast call
|
||||
const observationCall = mockBroadcast.mock.calls.find(
|
||||
(call: any[]) => call[0].type === 'new_observation'
|
||||
);
|
||||
expect(observationCall).toBeDefined();
|
||||
expect(observationCall[0].observation.id).toBe(42);
|
||||
expect(observationCall[0].observation.title).toBe('Broadcast Test');
|
||||
expect(observationCall[0].observation.type).toBe('discovery');
|
||||
});
|
||||
|
||||
it('should broadcast summary via SSE', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
<summary>
|
||||
<request>Build feature</request>
|
||||
<investigated>Reviewed code</investigated>
|
||||
<learned>Found patterns</learned>
|
||||
<completed>Feature built</completed>
|
||||
<next_steps>Add tests</next_steps>
|
||||
</summary>
|
||||
`;
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Find the summary broadcast call
|
||||
const summaryCall = mockBroadcast.mock.calls.find(
|
||||
(call: any[]) => call[0].type === 'new_summary'
|
||||
);
|
||||
expect(summaryCall).toBeDefined();
|
||||
expect(summaryCall[0].summary.request).toBe('Build feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handling empty response', () => {
|
||||
it('should handle empty response gracefully', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = '';
|
||||
|
||||
// Mock to handle empty observations
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Should still call storeObservations with empty arrays
|
||||
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
|
||||
const [, , observations, summary] = mockStoreObservations.mock.calls[0];
|
||||
expect(observations).toHaveLength(0);
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle response with only text (no XML)', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = 'This is just plain text without any XML tags.';
|
||||
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
|
||||
const [, , observations] = mockStoreObservations.mock.calls[0];
|
||||
expect(observations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session cleanup', () => {
|
||||
it('should reset earliestPendingTimestamp after processing', async () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should call broadcastProcessingStatus after processing', async () => {
|
||||
const session = createMockSession();
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(mockBroadcastProcessingStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation history', () => {
|
||||
it('should add assistant response to conversation history', async () => {
|
||||
const session = createMockSession({
|
||||
conversationHistory: [],
|
||||
});
|
||||
const responseText = `
|
||||
<observation>
|
||||
<type>discovery</type>
|
||||
<title>Test</title>
|
||||
<facts></facts>
|
||||
<concepts></concepts>
|
||||
<files_read></files_read>
|
||||
<files_modified></files_modified>
|
||||
</observation>
|
||||
`;
|
||||
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
createdAtEpoch: 1700000000000,
|
||||
}));
|
||||
(mockDbManager.getSessionStore as any) = () => ({
|
||||
storeObservations: mockStoreObservations,
|
||||
ensureMemorySessionIdRegistered: mock(() => {}),
|
||||
getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })),
|
||||
});
|
||||
|
||||
await processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
expect(session.conversationHistory).toHaveLength(1);
|
||||
expect(session.conversationHistory[0].role).toBe('assistant');
|
||||
expect(session.conversationHistory[0].content).toBe(responseText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error if memorySessionId is missing from session', async () => {
|
||||
const session = createMockSession({
|
||||
memorySessionId: null, // Missing memory session ID
|
||||
});
|
||||
const responseText = '<observation><type>discovery</type></observation>';
|
||||
|
||||
await expect(
|
||||
processAgentResponse(
|
||||
responseText,
|
||||
session,
|
||||
mockDbManager,
|
||||
mockSessionManager,
|
||||
mockWorker,
|
||||
100,
|
||||
null,
|
||||
'TestAgent'
|
||||
)
|
||||
).rejects.toThrow('Cannot store observations: memorySessionId not yet captured');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for session cleanup helper functionality
|
||||
*
|
||||
* Mock Justification (~19% mock code):
|
||||
* - Session fixtures: Required to create valid ActiveSession objects with
|
||||
* all required fields - tests the actual cleanup logic
|
||||
* - Worker mocks: Verify broadcast notification calls - the actual
|
||||
* cleanupProcessedMessages logic is tested against real session mutation
|
||||
*
|
||||
* What's NOT mocked: Session state mutation, null/undefined handling
|
||||
*/
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
|
||||
// Import directly from specific files to avoid worker-service import chain
|
||||
import { cleanupProcessedMessages } from '../../../src/services/worker/agents/SessionCleanupHelper.js';
|
||||
import type { WorkerRef } from '../../../src/services/worker/agents/types.js';
|
||||
import type { ActiveSession } from '../../../src/services/worker-types.js';
|
||||
|
||||
describe('SessionCleanupHelper', () => {
|
||||
// Helper to create a minimal mock session
|
||||
function createMockSession(
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
return {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'content-session-123',
|
||||
memorySessionId: 'memory-session-456',
|
||||
project: 'test-project',
|
||||
userPrompt: 'Test prompt',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 5,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 100,
|
||||
cumulativeOutputTokens: 50,
|
||||
earliestPendingTimestamp: Date.now() - 10000, // 10 seconds ago
|
||||
conversationHistory: [],
|
||||
currentProvider: 'claude',
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock worker
|
||||
function createMockWorker() {
|
||||
const broadcastProcessingStatusMock = mock(() => {});
|
||||
const worker: WorkerRef = {
|
||||
sseBroadcaster: {
|
||||
broadcast: mock(() => {}),
|
||||
},
|
||||
broadcastProcessingStatus: broadcastProcessingStatusMock,
|
||||
};
|
||||
return { worker, broadcastProcessingStatusMock };
|
||||
}
|
||||
|
||||
describe('cleanupProcessedMessages', () => {
|
||||
it('should reset session.earliestPendingTimestamp to null', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
const { worker } = createMockWorker();
|
||||
|
||||
expect(session.earliestPendingTimestamp).toBe(1700000000000);
|
||||
|
||||
cleanupProcessedMessages(session, worker);
|
||||
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should reset earliestPendingTimestamp even when already null', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: null,
|
||||
});
|
||||
const { worker } = createMockWorker();
|
||||
|
||||
cleanupProcessedMessages(session, worker);
|
||||
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should call worker.broadcastProcessingStatus() if available', () => {
|
||||
const session = createMockSession();
|
||||
const { worker, broadcastProcessingStatusMock } = createMockWorker();
|
||||
|
||||
cleanupProcessedMessages(session, worker);
|
||||
|
||||
expect(broadcastProcessingStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle missing worker gracefully (no crash)', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, undefined);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle worker without broadcastProcessingStatus', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
const worker: WorkerRef = {
|
||||
sseBroadcaster: {
|
||||
broadcast: mock(() => {}),
|
||||
},
|
||||
// No broadcastProcessingStatus
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty worker object', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
const worker: WorkerRef = {};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle worker with null broadcastProcessingStatus', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
const worker: WorkerRef = {
|
||||
broadcastProcessingStatus: undefined,
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it('should not modify other session properties', () => {
|
||||
const session = createMockSession({
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
lastPromptNumber: 10,
|
||||
cumulativeInputTokens: 500,
|
||||
cumulativeOutputTokens: 250,
|
||||
project: 'my-project',
|
||||
});
|
||||
const { worker } = createMockWorker();
|
||||
|
||||
cleanupProcessedMessages(session, worker);
|
||||
|
||||
// Only earliestPendingTimestamp should change
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
expect(session.lastPromptNumber).toBe(10);
|
||||
expect(session.cumulativeInputTokens).toBe(500);
|
||||
expect(session.cumulativeOutputTokens).toBe(250);
|
||||
expect(session.project).toBe('my-project');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* DataRoutes Type Coercion Tests
|
||||
*
|
||||
* Tests that MCP clients sending string-encoded arrays for `ids` and
|
||||
* `memorySessionIds` are properly coerced before validation.
|
||||
*
|
||||
* Mock Justification:
|
||||
* - Express req/res mocks: Required because route handlers expect Express objects
|
||||
* - DatabaseManager/SessionStore: Avoids database setup; we test coercion logic, not queries
|
||||
* - Logger spies: Suppress console output during tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logger } from '../../../../src/utils/logger.js';
|
||||
|
||||
// Mock dependencies before importing DataRoutes
|
||||
mock.module('../../../../src/shared/paths.js', () => ({
|
||||
getPackageRoot: () => '/tmp/test',
|
||||
}));
|
||||
mock.module('../../../../src/shared/worker-utils.js', () => ({
|
||||
getWorkerPort: () => 37777,
|
||||
}));
|
||||
|
||||
import { DataRoutes } from '../../../../src/services/worker/http/routes/DataRoutes.js';
|
||||
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
// Helper to create mock req/res
|
||||
function createMockReqRes(body: any): { req: Partial<Request>; res: Partial<Response>; jsonSpy: ReturnType<typeof mock>; statusSpy: ReturnType<typeof mock> } {
|
||||
const jsonSpy = mock(() => {});
|
||||
const statusSpy = mock(() => ({ json: jsonSpy }));
|
||||
return {
|
||||
req: { body, path: '/test', query: {} } as Partial<Request>,
|
||||
res: { json: jsonSpy, status: statusSpy } as unknown as Partial<Response>,
|
||||
jsonSpy,
|
||||
statusSpy,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataRoutes Type Coercion', () => {
|
||||
let routes: DataRoutes;
|
||||
let mockGetObservationsByIds: ReturnType<typeof mock>;
|
||||
let mockGetSdkSessionsBySessionIds: ReturnType<typeof mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
spyOn(logger, 'failure').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
mockGetObservationsByIds = mock(() => [{ id: 1 }, { id: 2 }]);
|
||||
mockGetSdkSessionsBySessionIds = mock(() => [{ id: 'abc' }]);
|
||||
|
||||
const mockDbManager = {
|
||||
getSessionStore: () => ({
|
||||
getObservationsByIds: mockGetObservationsByIds,
|
||||
getSdkSessionsBySessionIds: mockGetSdkSessionsBySessionIds,
|
||||
}),
|
||||
};
|
||||
|
||||
routes = new DataRoutes(
|
||||
{} as any, // paginationHelper
|
||||
mockDbManager as any,
|
||||
{} as any, // sessionManager
|
||||
{} as any, // sseBroadcaster
|
||||
{} as any, // workerService
|
||||
Date.now()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('handleGetObservationsByIds — ids coercion', () => {
|
||||
// Access the handler via setupRoutes
|
||||
let handler: (req: Request, res: Response) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockApp = {
|
||||
get: mock(() => {}),
|
||||
post: mock((path: string, fn: any) => {
|
||||
if (path === '/api/observations/batch') handler = fn;
|
||||
}),
|
||||
delete: mock(() => {}),
|
||||
};
|
||||
routes.setupRoutes(mockApp as any);
|
||||
});
|
||||
|
||||
it('should accept a native array of numbers', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ ids: [1, 2, 3] });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should coerce a JSON-encoded string array "[1,2,3]" to native array', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ ids: '[1,2,3]' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should coerce a comma-separated string "1,2,3" to native array', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ ids: '1,2,3' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetObservationsByIds).toHaveBeenCalledWith([1, 2, 3], expect.anything());
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-integer values after coercion', () => {
|
||||
const { req, res, statusSpy } = createMockReqRes({ ids: 'foo,bar' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
// NaN values should fail the Number.isInteger check
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should reject missing ids', () => {
|
||||
const { req, res, statusSpy } = createMockReqRes({});
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should return empty array for empty ids array', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ ids: [] });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(jsonSpy).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetSdkSessionsByIds — memorySessionIds coercion', () => {
|
||||
let handler: (req: Request, res: Response) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockApp = {
|
||||
get: mock(() => {}),
|
||||
post: mock((path: string, fn: any) => {
|
||||
if (path === '/api/sdk-sessions/batch') handler = fn;
|
||||
}),
|
||||
delete: mock(() => {}),
|
||||
};
|
||||
routes.setupRoutes(mockApp as any);
|
||||
});
|
||||
|
||||
it('should accept a native array of strings', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: ['abc', 'def'] });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should coerce a JSON-encoded string array to native array', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: '["abc","def"]' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should coerce a comma-separated string to native array', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc,def' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim whitespace from comma-separated values', () => {
|
||||
const { req, res, jsonSpy } = createMockReqRes({ memorySessionIds: 'abc, def , ghi' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(mockGetSdkSessionsBySessionIds).toHaveBeenCalledWith(['abc', 'def', 'ghi']);
|
||||
expect(jsonSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject non-array, non-string values', () => {
|
||||
const { req, res, statusSpy } = createMockReqRes({ memorySessionIds: 42 });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* CORS Restriction Tests
|
||||
*
|
||||
* Verifies that CORS is properly restricted to localhost origins only,
|
||||
* and that preflight responses include the correct methods and headers (#1029).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
|
||||
// Test the CORS origin validation logic directly
|
||||
function isAllowedOrigin(origin: string | undefined): boolean {
|
||||
if (!origin) return true; // No origin = hooks, curl, CLI
|
||||
if (origin.startsWith('http://localhost:')) return true;
|
||||
if (origin.startsWith('http://127.0.0.1:')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the same CORS config used in production middleware.ts.
|
||||
* Duplicated here to avoid module-mock interference from other test files.
|
||||
*/
|
||||
function buildProductionCorsMiddleware() {
|
||||
return cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin ||
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('CORS not allowed'));
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||
credentials: false
|
||||
});
|
||||
}
|
||||
|
||||
describe('CORS Restriction', () => {
|
||||
describe('allowed origins', () => {
|
||||
it('allows requests without Origin header (hooks, curl, CLI)', () => {
|
||||
expect(isAllowedOrigin(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows localhost with port', () => {
|
||||
expect(isAllowedOrigin('http://localhost:37777')).toBe(true);
|
||||
expect(isAllowedOrigin('http://localhost:3000')).toBe(true);
|
||||
expect(isAllowedOrigin('http://localhost:8080')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows 127.0.0.1 with port', () => {
|
||||
expect(isAllowedOrigin('http://127.0.0.1:37777')).toBe(true);
|
||||
expect(isAllowedOrigin('http://127.0.0.1:3000')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked origins', () => {
|
||||
it('blocks external domains', () => {
|
||||
expect(isAllowedOrigin('http://evil.com')).toBe(false);
|
||||
expect(isAllowedOrigin('https://attacker.io')).toBe(false);
|
||||
expect(isAllowedOrigin('http://malicious-site.net:8080')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks HTTPS localhost (not typically used for local dev)', () => {
|
||||
// HTTPS localhost is unusual and could indicate a proxy attack
|
||||
expect(isAllowedOrigin('https://localhost:37777')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks localhost-like domains (subdomain attacks)', () => {
|
||||
expect(isAllowedOrigin('http://localhost.evil.com')).toBe(false);
|
||||
expect(isAllowedOrigin('http://localhost.attacker.io:8080')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks file:// origins', () => {
|
||||
expect(isAllowedOrigin('file://')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks null origin', () => {
|
||||
// null origin can come from sandboxed iframes
|
||||
expect(isAllowedOrigin('null')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight CORS headers (#1029)', () => {
|
||||
let app: express.Application;
|
||||
let server: http.Server;
|
||||
let testPort: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(buildProductionCorsMiddleware());
|
||||
|
||||
// Add a test endpoint that supports all methods
|
||||
app.all('/api/settings', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
testPort = 41000 + Math.floor(Math.random() * 10000);
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(testPort, '127.0.0.1', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(err => err ? reject(err) : resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('preflight response includes PUT in allowed methods', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'PUT',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('PUT');
|
||||
});
|
||||
|
||||
it('preflight response includes PATCH in allowed methods', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'PATCH',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('PATCH');
|
||||
});
|
||||
|
||||
it('preflight response includes DELETE in allowed methods', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'DELETE',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const allowedMethods = response.headers.get('access-control-allow-methods');
|
||||
expect(allowedMethods).toContain('DELETE');
|
||||
});
|
||||
|
||||
it('preflight response includes Content-Type in allowed headers', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const allowedHeaders = response.headers.get('access-control-allow-headers');
|
||||
expect(allowedHeaders).toContain('Content-Type');
|
||||
});
|
||||
|
||||
it('preflight from localhost includes allow-origin header', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:37777',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
const origin = response.headers.get('access-control-allow-origin');
|
||||
expect(origin).toBe('http://localhost:37777');
|
||||
});
|
||||
|
||||
it('preflight from external origin omits allow-origin header', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/settings`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://evil.com',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
},
|
||||
});
|
||||
|
||||
// cors middleware rejects disallowed origins — browser enforces the block
|
||||
const origin = response.headers.get('access-control-allow-origin');
|
||||
expect(origin).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
204
.agent/services/claude-mem/tests/worker/process-registry.test.ts
Normal file
204
.agent/services/claude-mem/tests/worker/process-registry.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
registerProcess,
|
||||
unregisterProcess,
|
||||
getProcessBySession,
|
||||
getActiveCount,
|
||||
getActiveProcesses,
|
||||
waitForSlot,
|
||||
ensureProcessExit,
|
||||
} from '../../src/services/worker/ProcessRegistry.js';
|
||||
|
||||
/**
|
||||
* Create a mock ChildProcess that behaves like a real one for testing.
|
||||
* Supports exitCode, killed, kill(), and event emission.
|
||||
*/
|
||||
function createMockProcess(overrides: { exitCode?: number | null; killed?: boolean } = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const mock = Object.assign(emitter, {
|
||||
pid: Math.floor(Math.random() * 100000) + 1000,
|
||||
exitCode: overrides.exitCode ?? null,
|
||||
killed: overrides.killed ?? false,
|
||||
kill(signal?: string) {
|
||||
mock.killed = true;
|
||||
// Simulate async exit after kill
|
||||
setTimeout(() => {
|
||||
mock.exitCode = signal === 'SIGKILL' ? null : 0;
|
||||
mock.emit('exit', mock.exitCode, signal || 'SIGTERM');
|
||||
}, 10);
|
||||
return true;
|
||||
},
|
||||
stdin: null,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Helper to clear registry between tests by unregistering all
|
||||
function clearRegistry() {
|
||||
for (const p of getActiveProcesses()) {
|
||||
unregisterProcess(p.pid);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProcessRegistry', () => {
|
||||
beforeEach(() => {
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
describe('registerProcess / unregisterProcess', () => {
|
||||
it('should register and track a process', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
expect(getActiveCount()).toBe(1);
|
||||
expect(getProcessBySession(1)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should unregister a process and free the slot', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
unregisterProcess(proc.pid);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
expect(getProcessBySession(1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessBySession', () => {
|
||||
it('should return undefined for unknown session', () => {
|
||||
expect(getProcessBySession(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find process by session ID', () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 42, proc as any);
|
||||
const found = getProcessBySession(42);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.pid).toBe(proc.pid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForSlot', () => {
|
||||
it('should resolve immediately when under limit', async () => {
|
||||
await waitForSlot(2); // 0 processes, limit 2
|
||||
});
|
||||
|
||||
it('should wait until a slot opens', async () => {
|
||||
const proc1 = createMockProcess();
|
||||
const proc2 = createMockProcess();
|
||||
registerProcess(proc1.pid, 1, proc1 as any);
|
||||
registerProcess(proc2.pid, 2, proc2 as any);
|
||||
|
||||
// Start waiting for slot (limit=2, both slots full)
|
||||
const waitPromise = waitForSlot(2, 5000);
|
||||
|
||||
// Free a slot after 50ms
|
||||
setTimeout(() => unregisterProcess(proc1.pid), 50);
|
||||
|
||||
await waitPromise; // Should resolve once slot freed
|
||||
expect(getActiveCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw on timeout when no slot opens', async () => {
|
||||
const proc1 = createMockProcess();
|
||||
const proc2 = createMockProcess();
|
||||
registerProcess(proc1.pid, 1, proc1 as any);
|
||||
registerProcess(proc2.pid, 2, proc2 as any);
|
||||
|
||||
await expect(waitForSlot(2, 100)).rejects.toThrow('Timed out waiting for agent pool slot');
|
||||
});
|
||||
|
||||
it('should throw when hard cap (10) is exceeded', async () => {
|
||||
// Register 10 processes to hit the hard cap
|
||||
const procs = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, i + 100, proc as any);
|
||||
procs.push(proc);
|
||||
}
|
||||
|
||||
await expect(waitForSlot(20)).rejects.toThrow('Hard cap exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureProcessExit', () => {
|
||||
it('should unregister immediately if exitCode is set', async () => {
|
||||
const proc = createMockProcess({ exitCode: 0 });
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any });
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should NOT treat proc.killed as exited — must wait for actual exit', async () => {
|
||||
// This is the core bug fix: proc.killed=true but exitCode=null means NOT dead
|
||||
const proc = createMockProcess({ killed: true, exitCode: null });
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill to simulate SIGKILL + delayed exit
|
||||
proc.kill = (signal?: string) => {
|
||||
proc.killed = true;
|
||||
setTimeout(() => {
|
||||
proc.exitCode = 0;
|
||||
proc.emit('exit', 0, signal);
|
||||
}, 20);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ensureProcessExit should NOT short-circuit on proc.killed
|
||||
// It should wait for exit event or timeout, then escalate to SIGKILL
|
||||
const start = Date.now();
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should escalate to SIGKILL after timeout', async () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill: only respond to SIGKILL
|
||||
let sigkillSent = false;
|
||||
proc.kill = (signal?: string) => {
|
||||
proc.killed = true;
|
||||
if (signal === 'SIGKILL') {
|
||||
sigkillSent = true;
|
||||
setTimeout(() => {
|
||||
proc.exitCode = -1;
|
||||
proc.emit('exit', -1, 'SIGKILL');
|
||||
}, 10);
|
||||
}
|
||||
// Don't emit exit for non-SIGKILL signals (simulates stuck process)
|
||||
return true;
|
||||
};
|
||||
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
expect(sigkillSent).toBe(true);
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should unregister even if process ignores SIGKILL (after 1s timeout)', async () => {
|
||||
const proc = createMockProcess();
|
||||
registerProcess(proc.pid, 1, proc as any);
|
||||
|
||||
// Override kill to never emit exit (completely stuck process)
|
||||
proc.kill = () => {
|
||||
proc.killed = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await ensureProcessExit({ pid: proc.pid, sessionDbId: 1, spawnedAt: Date.now(), process: proc as any }, 100);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Should have waited ~100ms for graceful + ~1000ms for SIGKILL timeout
|
||||
expect(elapsed).toBeGreaterThan(90);
|
||||
// Process is unregistered regardless (safety net)
|
||||
expect(getActiveCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before imports
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
getActiveMode: () => ({
|
||||
name: 'code',
|
||||
prompts: {},
|
||||
observation_types: [
|
||||
{ id: 'decision', icon: 'D' },
|
||||
{ id: 'bugfix', icon: 'B' },
|
||||
{ id: 'feature', icon: 'F' },
|
||||
{ id: 'refactor', icon: 'R' },
|
||||
{ id: 'discovery', icon: 'I' },
|
||||
{ id: 'change', icon: 'C' }
|
||||
],
|
||||
observation_concepts: [],
|
||||
}),
|
||||
getObservationTypes: () => [
|
||||
{ id: 'decision', icon: 'D' },
|
||||
{ id: 'bugfix', icon: 'B' },
|
||||
{ id: 'feature', icon: 'F' },
|
||||
{ id: 'refactor', icon: 'R' },
|
||||
{ id: 'discovery', icon: 'I' },
|
||||
{ id: 'change', icon: 'C' }
|
||||
],
|
||||
getTypeIcon: (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
decision: 'D',
|
||||
bugfix: 'B',
|
||||
feature: 'F',
|
||||
refactor: 'R',
|
||||
discovery: 'I',
|
||||
change: 'C'
|
||||
};
|
||||
return icons[type] || '?';
|
||||
},
|
||||
getWorkEmoji: () => 'W',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { ResultFormatter } from '../../../src/services/worker/search/ResultFormatter.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult, SearchResults } from '../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation text',
|
||||
type: 'decision',
|
||||
title: 'Test Decision Title',
|
||||
subtitle: 'A descriptive subtitle',
|
||||
facts: '["fact1", "fact2"]',
|
||||
narrative: 'This is the narrative description',
|
||||
concepts: '["concept1", "concept2"]',
|
||||
files_read: '["src/file1.ts"]',
|
||||
files_modified: '["src/file2.ts"]',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
const mockSession: SessionSummarySearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
request: 'Implement feature X',
|
||||
investigated: 'Looked at code structure',
|
||||
learned: 'Learned about the architecture',
|
||||
completed: 'Added new feature',
|
||||
next_steps: 'Write tests',
|
||||
files_read: '["src/index.ts"]',
|
||||
files_edited: '["src/feature.ts"]',
|
||||
notes: 'Additional notes',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 500,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
const mockPrompt: UserPromptSearchResult = {
|
||||
id: 1,
|
||||
content_session_id: 'content-123',
|
||||
prompt_number: 1,
|
||||
prompt_text: 'Can you help me implement feature X?',
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
describe('ResultFormatter', () => {
|
||||
let formatter: ResultFormatter;
|
||||
|
||||
beforeEach(() => {
|
||||
formatter = new ResultFormatter();
|
||||
});
|
||||
|
||||
describe('formatSearchResults', () => {
|
||||
it('should format observations as markdown', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'test query');
|
||||
|
||||
expect(formatted).toContain('test query');
|
||||
expect(formatted).toContain('1 result');
|
||||
expect(formatted).toContain('1 obs');
|
||||
expect(formatted).toContain('#1'); // ID
|
||||
expect(formatted).toContain('Test Decision Title');
|
||||
});
|
||||
|
||||
it('should format sessions as markdown', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [],
|
||||
sessions: [mockSession],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'session query');
|
||||
|
||||
expect(formatted).toContain('1 session');
|
||||
expect(formatted).toContain('#S1'); // Session ID format
|
||||
expect(formatted).toContain('Implement feature X');
|
||||
});
|
||||
|
||||
it('should format prompts as markdown', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: [mockPrompt]
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'prompt query');
|
||||
|
||||
expect(formatted).toContain('1 prompt');
|
||||
expect(formatted).toContain('#P1'); // Prompt ID format
|
||||
expect(formatted).toContain('Can you help me implement');
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'no matches');
|
||||
|
||||
expect(formatted).toContain('No results found');
|
||||
expect(formatted).toContain('no matches');
|
||||
});
|
||||
|
||||
it('should show combined count for multiple types', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [mockSession],
|
||||
prompts: [mockPrompt]
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'mixed query');
|
||||
|
||||
expect(formatted).toContain('3 result(s)');
|
||||
expect(formatted).toContain('1 obs');
|
||||
expect(formatted).toContain('1 sessions');
|
||||
expect(formatted).toContain('1 prompts');
|
||||
});
|
||||
|
||||
it('should escape special characters in query', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'query with "quotes"');
|
||||
|
||||
expect(formatted).toContain('query with "quotes"');
|
||||
});
|
||||
|
||||
it('should include table headers', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'test');
|
||||
|
||||
expect(formatted).toContain('| ID |');
|
||||
expect(formatted).toContain('| Time |');
|
||||
expect(formatted).toContain('| T |');
|
||||
expect(formatted).toContain('| Title |');
|
||||
});
|
||||
|
||||
it('should indicate Chroma failure when chromaFailed is true', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = formatter.formatSearchResults(results, 'test', true);
|
||||
|
||||
expect(formatted).toContain('Vector search failed');
|
||||
expect(formatted).toContain('semantic search unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineResults', () => {
|
||||
it('should combine all result types into unified format', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [mockSession],
|
||||
prompts: [mockPrompt]
|
||||
};
|
||||
|
||||
const combined = formatter.combineResults(results);
|
||||
|
||||
expect(combined).toHaveLength(3);
|
||||
expect(combined.some(r => r.type === 'observation')).toBe(true);
|
||||
expect(combined.some(r => r.type === 'session')).toBe(true);
|
||||
expect(combined.some(r => r.type === 'prompt')).toBe(true);
|
||||
});
|
||||
|
||||
it('should include epoch for sorting', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const combined = formatter.combineResults(results);
|
||||
|
||||
expect(combined[0].epoch).toBe(mockObservation.created_at_epoch);
|
||||
});
|
||||
|
||||
it('should include created_at for display', () => {
|
||||
const results: SearchResults = {
|
||||
observations: [mockObservation],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const combined = formatter.combineResults(results);
|
||||
|
||||
expect(combined[0].created_at).toBe(mockObservation.created_at);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTableHeader', () => {
|
||||
it('should include Work column', () => {
|
||||
const header = formatter.formatTableHeader();
|
||||
|
||||
expect(header).toContain('| Work |');
|
||||
expect(header).toContain('| ID |');
|
||||
expect(header).toContain('| Time |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSearchTableHeader', () => {
|
||||
it('should not include Work column', () => {
|
||||
const header = formatter.formatSearchTableHeader();
|
||||
|
||||
expect(header).not.toContain('| Work |');
|
||||
expect(header).toContain('| Read |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatObservationSearchRow', () => {
|
||||
it('should format observation as table row', () => {
|
||||
const result = formatter.formatObservationSearchRow(mockObservation, '');
|
||||
|
||||
expect(result.row).toContain('#1');
|
||||
expect(result.row).toContain('Test Decision Title');
|
||||
expect(result.row).toContain('~'); // Token estimate
|
||||
});
|
||||
|
||||
it('should use quote mark for repeated time', () => {
|
||||
// First get the actual time format for this observation
|
||||
const firstResult = formatter.formatObservationSearchRow(mockObservation, '');
|
||||
// Now pass that same time as lastTime
|
||||
const result = formatter.formatObservationSearchRow(mockObservation, firstResult.time);
|
||||
|
||||
// When time matches lastTime, the row should show quote mark
|
||||
expect(result.row).toContain('"');
|
||||
expect(result.time).toBe(firstResult.time);
|
||||
});
|
||||
|
||||
it('should return the time for tracking', () => {
|
||||
const result = formatter.formatObservationSearchRow(mockObservation, '');
|
||||
|
||||
expect(typeof result.time).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSessionSearchRow', () => {
|
||||
it('should format session as table row', () => {
|
||||
const result = formatter.formatSessionSearchRow(mockSession, '');
|
||||
|
||||
expect(result.row).toContain('#S1');
|
||||
expect(result.row).toContain('Implement feature X');
|
||||
});
|
||||
|
||||
it('should fallback to session ID prefix when no request', () => {
|
||||
const sessionNoRequest = { ...mockSession, request: null };
|
||||
const result = formatter.formatSessionSearchRow(sessionNoRequest, '');
|
||||
|
||||
expect(result.row).toContain('Session session-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPromptSearchRow', () => {
|
||||
it('should format prompt as table row', () => {
|
||||
const result = formatter.formatPromptSearchRow(mockPrompt, '');
|
||||
|
||||
expect(result.row).toContain('#P1');
|
||||
expect(result.row).toContain('Can you help me implement');
|
||||
});
|
||||
|
||||
it('should truncate long prompts', () => {
|
||||
const longPrompt = {
|
||||
...mockPrompt,
|
||||
prompt_text: 'A'.repeat(100)
|
||||
};
|
||||
|
||||
const result = formatter.formatPromptSearchRow(longPrompt, '');
|
||||
|
||||
expect(result.row).toContain('...');
|
||||
expect(result.row.length).toBeLessThan(longPrompt.prompt_text.length + 50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatObservationIndex', () => {
|
||||
it('should include Work column in index format', () => {
|
||||
const row = formatter.formatObservationIndex(mockObservation, 0);
|
||||
|
||||
expect(row).toContain('#1');
|
||||
// Should have more columns than search row
|
||||
expect(row.split('|').length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('should show discovery tokens as work', () => {
|
||||
const obsWithTokens = { ...mockObservation, discovery_tokens: 250 };
|
||||
const row = formatter.formatObservationIndex(obsWithTokens, 0);
|
||||
|
||||
expect(row).toContain('250');
|
||||
});
|
||||
|
||||
it('should show dash when no discovery tokens', () => {
|
||||
const obsNoTokens = { ...mockObservation, discovery_tokens: 0 };
|
||||
const row = formatter.formatObservationIndex(obsNoTokens, 0);
|
||||
|
||||
expect(row).toContain('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSessionIndex', () => {
|
||||
it('should include session ID prefix', () => {
|
||||
const row = formatter.formatSessionIndex(mockSession, 0);
|
||||
|
||||
expect(row).toContain('#S1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPromptIndex', () => {
|
||||
it('should include prompt ID prefix', () => {
|
||||
const row = formatter.formatPromptIndex(mockPrompt, 0);
|
||||
|
||||
expect(row).toContain('#P1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSearchTips', () => {
|
||||
it('should include search strategy tips', () => {
|
||||
const tips = formatter.formatSearchTips();
|
||||
|
||||
expect(tips).toContain('Search Strategy');
|
||||
expect(tips).toContain('timeline');
|
||||
expect(tips).toContain('get_observations');
|
||||
});
|
||||
|
||||
it('should include filter examples', () => {
|
||||
const tips = formatter.formatSearchTips();
|
||||
|
||||
expect(tips).toContain('obs_type');
|
||||
expect(tips).toContain('dateStart');
|
||||
expect(tips).toContain('orderBy');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before imports
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
getActiveMode: () => ({
|
||||
name: 'code',
|
||||
prompts: {},
|
||||
observation_types: [
|
||||
{ id: 'decision', icon: 'D' },
|
||||
{ id: 'bugfix', icon: 'B' },
|
||||
{ id: 'feature', icon: 'F' },
|
||||
{ id: 'refactor', icon: 'R' },
|
||||
{ id: 'discovery', icon: 'I' },
|
||||
{ id: 'change', icon: 'C' }
|
||||
],
|
||||
observation_concepts: [],
|
||||
}),
|
||||
getObservationTypes: () => [
|
||||
{ id: 'decision', icon: 'D' },
|
||||
{ id: 'bugfix', icon: 'B' },
|
||||
{ id: 'feature', icon: 'F' },
|
||||
{ id: 'refactor', icon: 'R' },
|
||||
{ id: 'discovery', icon: 'I' },
|
||||
{ id: 'change', icon: 'C' }
|
||||
],
|
||||
getTypeIcon: (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
decision: 'D',
|
||||
bugfix: 'B',
|
||||
feature: 'F',
|
||||
refactor: 'R',
|
||||
discovery: 'I',
|
||||
change: 'C'
|
||||
};
|
||||
return icons[type] || '?';
|
||||
},
|
||||
getWorkEmoji: () => 'W',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SearchOrchestrator } from '../../../src/services/worker/search/SearchOrchestrator.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation',
|
||||
type: 'decision',
|
||||
title: 'Test Decision',
|
||||
subtitle: 'Subtitle',
|
||||
facts: '["fact1"]',
|
||||
narrative: 'Narrative',
|
||||
concepts: '["concept1"]',
|
||||
files_read: '["file1.ts"]',
|
||||
files_modified: '["file2.ts"]',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
const mockSession: SessionSummarySearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
request: 'Test request',
|
||||
investigated: 'Investigated',
|
||||
learned: 'Learned',
|
||||
completed: 'Completed',
|
||||
next_steps: 'Next steps',
|
||||
files_read: '["file1.ts"]',
|
||||
files_edited: '["file2.ts"]',
|
||||
notes: 'Notes',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 500,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
const mockPrompt: UserPromptSearchResult = {
|
||||
id: 1,
|
||||
content_session_id: 'content-123',
|
||||
prompt_number: 1,
|
||||
prompt_text: 'Test prompt',
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
describe('SearchOrchestrator', () => {
|
||||
let orchestrator: SearchOrchestrator;
|
||||
let mockSessionSearch: any;
|
||||
let mockSessionStore: any;
|
||||
let mockChromaSync: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionSearch = {
|
||||
searchObservations: mock(() => [mockObservation]),
|
||||
searchSessions: mock(() => [mockSession]),
|
||||
searchUserPrompts: mock(() => [mockPrompt]),
|
||||
findByConcept: mock(() => [mockObservation]),
|
||||
findByType: mock(() => [mockObservation]),
|
||||
findByFile: mock(() => ({ observations: [mockObservation], sessions: [mockSession] }))
|
||||
};
|
||||
|
||||
mockSessionStore = {
|
||||
getObservationsByIds: mock(() => [mockObservation]),
|
||||
getSessionSummariesByIds: mock(() => [mockSession]),
|
||||
getUserPromptsByIds: mock(() => [mockPrompt])
|
||||
};
|
||||
|
||||
mockChromaSync = {
|
||||
queryChroma: mock(() => Promise.resolve({
|
||||
ids: [1],
|
||||
distances: [0.1],
|
||||
metadatas: [{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: Date.now() - 1000 }]
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
describe('with Chroma available', () => {
|
||||
beforeEach(() => {
|
||||
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, mockChromaSync);
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should select SQLite strategy for filter-only queries (no query text)', async () => {
|
||||
const result = await orchestrator.search({
|
||||
project: 'test-project',
|
||||
limit: 10
|
||||
});
|
||||
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
|
||||
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should select Chroma strategy for query-only', async () => {
|
||||
const result = await orchestrator.search({
|
||||
query: 'semantic search query'
|
||||
});
|
||||
|
||||
expect(result.strategy).toBe('chroma');
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to SQLite when Chroma fails', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
|
||||
|
||||
const result = await orchestrator.search({
|
||||
query: 'test query'
|
||||
});
|
||||
|
||||
// Chroma failed, should have fallen back
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.usedChroma).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalize comma-separated concepts', async () => {
|
||||
await orchestrator.search({
|
||||
concepts: 'concept1, concept2, concept3',
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Should be parsed into array internally
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].concepts).toEqual(['concept1', 'concept2', 'concept3']);
|
||||
});
|
||||
|
||||
it('should normalize comma-separated files', async () => {
|
||||
await orchestrator.search({
|
||||
files: 'file1.ts, file2.ts',
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].files).toEqual(['file1.ts', 'file2.ts']);
|
||||
});
|
||||
|
||||
it('should normalize dateStart/dateEnd into dateRange object', async () => {
|
||||
await orchestrator.search({
|
||||
dateStart: '2025-01-01',
|
||||
dateEnd: '2025-01-31'
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].dateRange).toEqual({
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('should map type to searchType for observations/sessions/prompts', async () => {
|
||||
await orchestrator.search({
|
||||
type: 'observations'
|
||||
});
|
||||
|
||||
// Should search only observations
|
||||
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchSessions).not.toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchUserPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByConcept', () => {
|
||||
it('should use hybrid strategy when Chroma available', async () => {
|
||||
const result = await orchestrator.findByConcept('test-concept', {
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Hybrid strategy should be used
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalled();
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return observations matching concept', async () => {
|
||||
const result = await orchestrator.findByConcept('test-concept', {});
|
||||
|
||||
expect(result.results.observations.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByType', () => {
|
||||
it('should use hybrid strategy', async () => {
|
||||
const result = await orchestrator.findByType('decision', {});
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle array of types', async () => {
|
||||
await orchestrator.findByType(['decision', 'bugfix'], {});
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByFile', () => {
|
||||
it('should return observations and sessions for file', async () => {
|
||||
const result = await orchestrator.findByFile('/path/to/file.ts', {});
|
||||
|
||||
expect(result.observations.length).toBeGreaterThanOrEqual(0);
|
||||
expect(mockSessionSearch.findByFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include usedChroma in result', async () => {
|
||||
const result = await orchestrator.findByFile('/path/to/file.ts', {});
|
||||
|
||||
expect(typeof result.usedChroma).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChromaAvailable', () => {
|
||||
it('should return true when Chroma is available', () => {
|
||||
expect(orchestrator.isChromaAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSearchResults', () => {
|
||||
it('should format results as markdown', () => {
|
||||
const results = {
|
||||
observations: [mockObservation],
|
||||
sessions: [mockSession],
|
||||
prompts: [mockPrompt]
|
||||
};
|
||||
|
||||
const formatted = orchestrator.formatSearchResults(results, 'test query');
|
||||
|
||||
expect(formatted).toContain('test query');
|
||||
expect(formatted).toContain('result');
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const results = {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = orchestrator.formatSearchResults(results, 'no matches');
|
||||
|
||||
expect(formatted).toContain('No results found');
|
||||
});
|
||||
|
||||
it('should indicate Chroma failure when chromaFailed is true', () => {
|
||||
const results = {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const formatted = orchestrator.formatSearchResults(results, 'test', true);
|
||||
|
||||
expect(formatted).toContain('Vector search failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without Chroma (null)', () => {
|
||||
beforeEach(() => {
|
||||
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, null);
|
||||
});
|
||||
|
||||
describe('isChromaAvailable', () => {
|
||||
it('should return false when Chroma is null', () => {
|
||||
expect(orchestrator.isChromaAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return empty results for query search without Chroma', async () => {
|
||||
const result = await orchestrator.search({
|
||||
query: 'semantic query'
|
||||
});
|
||||
|
||||
// No Chroma available, can't do semantic search
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.usedChroma).toBe(false);
|
||||
});
|
||||
|
||||
it('should still work for filter-only queries', async () => {
|
||||
const result = await orchestrator.search({
|
||||
project: 'test-project'
|
||||
});
|
||||
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByConcept', () => {
|
||||
it('should fall back to SQLite-only', async () => {
|
||||
const result = await orchestrator.findByConcept('test-concept', {});
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByType', () => {
|
||||
it('should fall back to SQLite-only', async () => {
|
||||
const result = await orchestrator.findByType('decision', {});
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByFile', () => {
|
||||
it('should fall back to SQLite-only', async () => {
|
||||
const result = await orchestrator.findByFile('/path/to/file.ts', {});
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(mockSessionSearch.findByFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter normalization', () => {
|
||||
beforeEach(() => {
|
||||
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, null);
|
||||
});
|
||||
|
||||
it('should parse obs_type into obsType array', async () => {
|
||||
await orchestrator.search({
|
||||
obs_type: 'decision, bugfix'
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].type).toEqual(['decision', 'bugfix']);
|
||||
});
|
||||
|
||||
it('should handle already-array concepts', async () => {
|
||||
await orchestrator.search({
|
||||
concepts: ['concept1', 'concept2']
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].concepts).toEqual(['concept1', 'concept2']);
|
||||
});
|
||||
|
||||
it('should handle empty string filters', async () => {
|
||||
await orchestrator.search({
|
||||
concepts: '',
|
||||
files: ''
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
// Empty strings are falsy, so the normalization doesn't process them
|
||||
// They stay as empty strings (the underlying search functions handle this)
|
||||
expect(callArgs[1].concepts).toEqual('');
|
||||
expect(callArgs[1].files).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import { ChromaSearchStrategy } from '../../../../src/services/worker/search/strategies/ChromaSearchStrategy.js';
|
||||
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock observation data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation text',
|
||||
type: 'decision',
|
||||
title: 'Test Decision',
|
||||
subtitle: 'A test subtitle',
|
||||
facts: '["fact1", "fact2"]',
|
||||
narrative: 'Test narrative',
|
||||
concepts: '["concept1", "concept2"]',
|
||||
files_read: '["file1.ts"]',
|
||||
files_modified: '["file2.ts"]',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 // 1 day ago
|
||||
};
|
||||
|
||||
const mockSession: SessionSummarySearchResult = {
|
||||
id: 2,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
request: 'Test request',
|
||||
investigated: 'Test investigated',
|
||||
learned: 'Test learned',
|
||||
completed: 'Test completed',
|
||||
next_steps: 'Test next steps',
|
||||
files_read: '["file1.ts"]',
|
||||
files_edited: '["file2.ts"]',
|
||||
notes: 'Test notes',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 500,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
const mockPrompt: UserPromptSearchResult = {
|
||||
id: 3,
|
||||
content_session_id: 'content-session-123',
|
||||
prompt_number: 1,
|
||||
prompt_text: 'Test prompt text',
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
describe('ChromaSearchStrategy', () => {
|
||||
let strategy: ChromaSearchStrategy;
|
||||
let mockChromaSync: any;
|
||||
let mockSessionStore: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const recentEpoch = Date.now() - 1000 * 60 * 60 * 24; // 1 day ago (within 90-day window)
|
||||
|
||||
mockChromaSync = {
|
||||
queryChroma: mock(() => Promise.resolve({
|
||||
ids: [1, 2, 3],
|
||||
distances: [0.1, 0.2, 0.3],
|
||||
metadatas: [
|
||||
{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 2, doc_type: 'session_summary', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 3, doc_type: 'user_prompt', created_at_epoch: recentEpoch }
|
||||
]
|
||||
}))
|
||||
};
|
||||
|
||||
mockSessionStore = {
|
||||
getObservationsByIds: mock(() => [mockObservation]),
|
||||
getSessionSummariesByIds: mock(() => [mockSession]),
|
||||
getUserPromptsByIds: mock(() => [mockPrompt])
|
||||
};
|
||||
|
||||
strategy = new ChromaSearchStrategy(mockChromaSync, mockSessionStore);
|
||||
});
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should return true when query text is present', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'semantic search query'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for filter-only (no query)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
project: 'test-project'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when query is empty string', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: ''
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when query is undefined', () => {
|
||||
const options: StrategySearchOptions = {};
|
||||
expect(strategy.canHandle(options)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should call Chroma with query text', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100, // CHROMA_BATCH_SIZE
|
||||
undefined // no where filter for 'all'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return usedChroma: true on success', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('chroma');
|
||||
});
|
||||
|
||||
it('should hydrate observations from SQLite', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(mockSessionStore.getObservationsByIds).toHaveBeenCalled();
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should hydrate sessions from SQLite', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'sessions'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockSessionStore.getSessionSummariesByIds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hydrate prompts from SQLite', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'prompts'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockSessionStore.getUserPromptsByIds).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by doc_type when searchType is observations', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ doc_type: 'observation' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by doc_type when searchType is sessions', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'sessions'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ doc_type: 'session_summary' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by doc_type when searchType is prompts', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'prompts'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ doc_type: 'user_prompt' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should include project in Chroma where clause when specified', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
project: 'my-project'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ project: 'my-project' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should combine doc_type and project with $and when both specified', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations',
|
||||
project: 'my-project'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ $and: [{ doc_type: 'observation' }, { project: 'my-project' }] }
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include project filter when project is not specified', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
100,
|
||||
{ doc_type: 'observation' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty result when no query provided', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: undefined
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty result when Chroma returns no matches', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [],
|
||||
distances: [],
|
||||
metadatas: []
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'no matches query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.usedChroma).toBe(true); // Still used Chroma, just no results
|
||||
});
|
||||
|
||||
it('should filter out old results (beyond 90-day window)', async () => {
|
||||
const oldEpoch = Date.now() - 1000 * 60 * 60 * 24 * 100; // 100 days ago
|
||||
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [1],
|
||||
distances: [0.1],
|
||||
metadatas: [
|
||||
{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: oldEpoch }
|
||||
]
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'old data query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
// Old results should be filtered out
|
||||
expect(mockSessionStore.getObservationsByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Chroma errors gracefully (returns usedChroma: false)', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma connection failed')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle SQLite hydration errors gracefully', async () => {
|
||||
mockSessionStore.getObservationsByIds = mock(() => {
|
||||
throw new Error('SQLite error');
|
||||
});
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false); // Error occurred
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should correctly align IDs with metadatas when Chroma returns duplicate sqlite_ids (multiple docs per observation)', async () => {
|
||||
// BUG SCENARIO: One observation (id=100) has 3 documents in Chroma (narrative + 2 facts)
|
||||
// Another observation (id=200) has 1 document
|
||||
// Chroma returns 4 metadatas but after deduplication we have 2 unique IDs
|
||||
// The metadatas MUST be deduplicated/aligned to match the unique IDs
|
||||
const recentEpoch = Date.now() - 1000 * 60 * 60 * 24; // 1 day ago
|
||||
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
// After deduplication in ChromaSync.queryChroma, ids should be [100, 200]
|
||||
// But metadatas array has 4 elements - THIS IS THE BUG
|
||||
ids: [100, 200], // Deduplicated
|
||||
distances: [0.3, 0.4, 0.5, 0.6], // Original 4 distances
|
||||
metadatas: [
|
||||
// Original 4 metadatas - not aligned with deduplicated ids!
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 200, doc_type: 'observation', created_at_epoch: recentEpoch }
|
||||
]
|
||||
}));
|
||||
|
||||
// Mock that returns observations when called with correct IDs
|
||||
const mockObs100 = { ...mockObservation, id: 100 };
|
||||
const mockObs200 = { ...mockObservation, id: 200, title: 'Second observation' };
|
||||
mockSessionStore.getObservationsByIds = mock((ids: number[]) => {
|
||||
// Should receive [100, 200]
|
||||
return ids.map(id => id === 100 ? mockObs100 : mockObs200);
|
||||
});
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
// The strategy should correctly identify BOTH observations
|
||||
// Before the fix: idx=2 and idx=3 would access ids[2] and ids[3] which are undefined
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(mockSessionStore.getObservationsByIds).toHaveBeenCalled();
|
||||
|
||||
// Verify the correct IDs were passed to SQLite hydration
|
||||
const calledWith = mockSessionStore.getObservationsByIds.mock.calls[0][0];
|
||||
expect(calledWith).toContain(100);
|
||||
expect(calledWith).toContain(200);
|
||||
expect(calledWith.length).toBe(2); // Should have exactly 2 unique IDs
|
||||
});
|
||||
|
||||
it('should handle misaligned arrays gracefully without undefined access', async () => {
|
||||
// Edge case: metadatas array longer than ids array
|
||||
// This simulates the actual bug condition
|
||||
const recentEpoch = Date.now() - 1000 * 60 * 60 * 24;
|
||||
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [100], // Only 1 ID after deduplication
|
||||
distances: [0.3, 0.4, 0.5], // 3 distances
|
||||
metadatas: [
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch },
|
||||
{ sqlite_id: 100, doc_type: 'observation', created_at_epoch: recentEpoch }
|
||||
] // 3 metadatas for same observation
|
||||
}));
|
||||
|
||||
mockSessionStore.getObservationsByIds = mock(() => [mockObservation]);
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query',
|
||||
searchType: 'observations'
|
||||
};
|
||||
|
||||
// Before fix: This would try to access ids[1], ids[2] which are undefined
|
||||
// causing incorrect filtering or crashes
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(true);
|
||||
// Should still find the one observation correctly
|
||||
expect(mockSessionStore.getObservationsByIds).toHaveBeenCalled();
|
||||
const calledWith = mockSessionStore.getObservationsByIds.mock.calls[0][0];
|
||||
expect(calledWith).toEqual([100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy name', () => {
|
||||
it('should have name "chroma"', () => {
|
||||
expect(strategy.name).toBe('chroma');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import { HybridSearchStrategy } from '../../../../src/services/worker/search/strategies/HybridSearchStrategy.js';
|
||||
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult } from '../../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock observation data
|
||||
const mockObservation1: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation 1',
|
||||
type: 'decision',
|
||||
title: 'First Decision',
|
||||
subtitle: 'Subtitle 1',
|
||||
facts: '["fact1"]',
|
||||
narrative: 'Narrative 1',
|
||||
concepts: '["concept1"]',
|
||||
files_read: '["file1.ts"]',
|
||||
files_modified: '["file2.ts"]',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
const mockObservation2: ObservationSearchResult = {
|
||||
id: 2,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation 2',
|
||||
type: 'bugfix',
|
||||
title: 'Second Bugfix',
|
||||
subtitle: 'Subtitle 2',
|
||||
facts: '["fact2"]',
|
||||
narrative: 'Narrative 2',
|
||||
concepts: '["concept2"]',
|
||||
files_read: '["file3.ts"]',
|
||||
files_modified: '["file4.ts"]',
|
||||
prompt_number: 2,
|
||||
discovery_tokens: 150,
|
||||
created_at: '2025-01-02T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 * 2
|
||||
};
|
||||
|
||||
const mockObservation3: ObservationSearchResult = {
|
||||
id: 3,
|
||||
memory_session_id: 'session-456',
|
||||
project: 'test-project',
|
||||
text: 'Test observation 3',
|
||||
type: 'feature',
|
||||
title: 'Third Feature',
|
||||
subtitle: 'Subtitle 3',
|
||||
facts: '["fact3"]',
|
||||
narrative: 'Narrative 3',
|
||||
concepts: '["concept3"]',
|
||||
files_read: '["file5.ts"]',
|
||||
files_modified: '["file6.ts"]',
|
||||
prompt_number: 3,
|
||||
discovery_tokens: 200,
|
||||
created_at: '2025-01-03T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 * 3
|
||||
};
|
||||
|
||||
const mockSession: SessionSummarySearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
request: 'Test request',
|
||||
investigated: 'Test investigated',
|
||||
learned: 'Test learned',
|
||||
completed: 'Test completed',
|
||||
next_steps: 'Test next steps',
|
||||
files_read: '["file1.ts"]',
|
||||
files_edited: '["file2.ts"]',
|
||||
notes: 'Test notes',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 500,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
|
||||
};
|
||||
|
||||
describe('HybridSearchStrategy', () => {
|
||||
let strategy: HybridSearchStrategy;
|
||||
let mockChromaSync: any;
|
||||
let mockSessionStore: any;
|
||||
let mockSessionSearch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromaSync = {
|
||||
queryChroma: mock(() => Promise.resolve({
|
||||
ids: [2, 1, 3], // Chroma returns in semantic relevance order
|
||||
distances: [0.1, 0.2, 0.3],
|
||||
metadatas: []
|
||||
}))
|
||||
};
|
||||
|
||||
mockSessionStore = {
|
||||
getObservationsByIds: mock((ids: number[]) => {
|
||||
// Return in the order we stored them (not Chroma order)
|
||||
const allObs = [mockObservation1, mockObservation2, mockObservation3];
|
||||
return allObs.filter(obs => ids.includes(obs.id));
|
||||
}),
|
||||
getSessionSummariesByIds: mock(() => [mockSession]),
|
||||
getUserPromptsByIds: mock(() => [])
|
||||
};
|
||||
|
||||
mockSessionSearch = {
|
||||
findByConcept: mock(() => [mockObservation1, mockObservation2, mockObservation3]),
|
||||
findByType: mock(() => [mockObservation1, mockObservation2]),
|
||||
findByFile: mock(() => ({
|
||||
observations: [mockObservation1, mockObservation2],
|
||||
sessions: [mockSession]
|
||||
}))
|
||||
};
|
||||
|
||||
strategy = new HybridSearchStrategy(mockChromaSync, mockSessionStore, mockSessionSearch);
|
||||
});
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should return true when concepts filter is present', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
concepts: ['test-concept']
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when files filter is present', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
files: ['/path/to/file.ts']
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when type and query are present', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
type: 'decision',
|
||||
query: 'semantic query'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when strategyHint is hybrid', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
strategyHint: 'hybrid'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for query-only (no filters)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'semantic query'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for filter-only without Chroma', () => {
|
||||
// Create strategy without Chroma
|
||||
const strategyNoChroma = new HybridSearchStrategy(null as any, mockSessionStore, mockSessionSearch);
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
concepts: ['test-concept']
|
||||
};
|
||||
expect(strategyNoChroma.canHandle(options)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should return empty result for generic hybrid search without query', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
concepts: ['test-concept']
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.strategy).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('should return empty result for generic hybrid search (use specific methods)', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'test query'
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
// Generic search returns empty - use findByConcept/findByType/findByFile instead
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByConcept', () => {
|
||||
it('should combine metadata + semantic results', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', expect.any(Object));
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith('test-concept', expect.any(Number));
|
||||
expect(result.usedChroma).toBe(true);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('should preserve semantic ranking order from Chroma', async () => {
|
||||
// Chroma returns: [2, 1, 3] (obs 2 is most relevant)
|
||||
// SQLite returns: [1, 2, 3] (by date or however)
|
||||
// Result should be in Chroma order: [2, 1, 3]
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(result.results.observations.length).toBeGreaterThan(0);
|
||||
// The first result should be id=2 (Chroma's top result)
|
||||
expect(result.results.observations[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it('should only include observations that match both metadata and Chroma', async () => {
|
||||
// Metadata returns ids [1, 2, 3]
|
||||
// Chroma returns ids [2, 4, 5] (4 and 5 don't exist in metadata results)
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [2, 4, 5],
|
||||
distances: [0.1, 0.2, 0.3],
|
||||
metadatas: []
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('test-concept', options);
|
||||
|
||||
// Only id=2 should be in both sets
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
expect(result.results.observations[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty when no metadata matches', async () => {
|
||||
mockSessionSearch.findByConcept = mock(() => []);
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('nonexistent-concept', options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled(); // Should short-circuit
|
||||
});
|
||||
|
||||
it('should fall back to metadata-only on Chroma error', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma failed')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.results.observations).toHaveLength(3); // All metadata results
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByType', () => {
|
||||
it('should find observations by type with semantic ranking', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByType('decision', options);
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('decision', expect.any(Object));
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
|
||||
expect(result.usedChroma).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle array of types', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
await strategy.findByType(['decision', 'bugfix'], options);
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
|
||||
// Chroma query should use joined type string
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith('decision, bugfix', expect.any(Number));
|
||||
});
|
||||
|
||||
it('should preserve Chroma ranking order for types', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [2, 1], // Chroma order
|
||||
distances: [0.1, 0.2],
|
||||
metadatas: []
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByType('decision', options);
|
||||
|
||||
expect(result.results.observations[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it('should fall back on Chroma error', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByType('bugfix', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.results.observations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty when no metadata matches', async () => {
|
||||
mockSessionSearch.findByType = mock(() => []);
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByType('nonexistent', options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByFile', () => {
|
||||
it('should find observations and sessions by file path', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/path/to/file.ts', expect.any(Object));
|
||||
expect(result.observations.length).toBeGreaterThanOrEqual(0);
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return sessions without semantic ranking', async () => {
|
||||
// Sessions are already summarized, no need for semantic ranking
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
// Sessions should come directly from metadata search
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('should apply semantic ranking only to observations', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.resolve({
|
||||
ids: [2, 1], // Chroma ranking for observations
|
||||
distances: [0.1, 0.2],
|
||||
metadatas: []
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
// Observations should be in Chroma order
|
||||
expect(result.observations[0].id).toBe(2);
|
||||
expect(result.usedChroma).toBe(true);
|
||||
});
|
||||
|
||||
it('should return usedChroma: false when no observations to rank', async () => {
|
||||
mockSessionSearch.findByFile = mock(() => ({
|
||||
observations: [],
|
||||
sessions: [mockSession]
|
||||
}));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should fall back on Chroma error', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma down')));
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.observations.length).toBeGreaterThan(0);
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy name', () => {
|
||||
it('should have name "hybrid"', () => {
|
||||
expect(strategy.name).toBe('hybrid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import { SQLiteSearchStrategy } from '../../../../src/services/worker/search/strategies/SQLiteSearchStrategy.js';
|
||||
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock observation data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
text: 'Test observation text',
|
||||
type: 'decision',
|
||||
title: 'Test Decision',
|
||||
subtitle: 'A test subtitle',
|
||||
facts: '["fact1", "fact2"]',
|
||||
narrative: 'Test narrative',
|
||||
concepts: '["concept1", "concept2"]',
|
||||
files_read: '["file1.ts"]',
|
||||
files_modified: '["file2.ts"]',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 100,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
const mockSession: SessionSummarySearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
project: 'test-project',
|
||||
request: 'Test request',
|
||||
investigated: 'Test investigated',
|
||||
learned: 'Test learned',
|
||||
completed: 'Test completed',
|
||||
next_steps: 'Test next steps',
|
||||
files_read: '["file1.ts"]',
|
||||
files_edited: '["file2.ts"]',
|
||||
notes: 'Test notes',
|
||||
prompt_number: 1,
|
||||
discovery_tokens: 500,
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
const mockPrompt: UserPromptSearchResult = {
|
||||
id: 1,
|
||||
content_session_id: 'content-session-123',
|
||||
prompt_number: 1,
|
||||
prompt_text: 'Test prompt text',
|
||||
created_at: '2025-01-01T12:00:00.000Z',
|
||||
created_at_epoch: 1735732800000
|
||||
};
|
||||
|
||||
describe('SQLiteSearchStrategy', () => {
|
||||
let strategy: SQLiteSearchStrategy;
|
||||
let mockSessionSearch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionSearch = {
|
||||
searchObservations: mock(() => [mockObservation]),
|
||||
searchSessions: mock(() => [mockSession]),
|
||||
searchUserPrompts: mock(() => [mockPrompt]),
|
||||
findByConcept: mock(() => [mockObservation]),
|
||||
findByType: mock(() => [mockObservation]),
|
||||
findByFile: mock(() => ({ observations: [mockObservation], sessions: [mockSession] }))
|
||||
};
|
||||
strategy = new SQLiteSearchStrategy(mockSessionSearch);
|
||||
});
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should return true when no query text (filter-only)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
project: 'test-project'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when query is empty string', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: '',
|
||||
project: 'test-project'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when query text is present', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'semantic search query'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when strategyHint is sqlite (even with query)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
query: 'semantic search query',
|
||||
strategyHint: 'sqlite'
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for date range filter only', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
dateRange: {
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31'
|
||||
}
|
||||
};
|
||||
expect(strategy.canHandle(options)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should search all types by default', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.usedChroma).toBe(false);
|
||||
expect(result.fellBack).toBe(false);
|
||||
expect(result.strategy).toBe('sqlite');
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
expect(result.results.sessions).toHaveLength(1);
|
||||
expect(result.results.prompts).toHaveLength(1);
|
||||
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchSessions).toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchUserPrompts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search only observations when searchType is observations', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
searchType: 'observations',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(1);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchSessions).not.toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchUserPrompts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search only sessions when searchType is sessions', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
searchType: 'sessions',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(1);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should search only prompts when searchType is prompts', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
searchType: 'prompts',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should pass date range filter to search methods', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
dateRange: {
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31'
|
||||
},
|
||||
limit: 10
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].dateRange).toEqual({
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31'
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass project filter to search methods', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
project: 'my-project',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].project).toBe('my-project');
|
||||
});
|
||||
|
||||
it('should pass orderBy to search methods', async () => {
|
||||
const options: StrategySearchOptions = {
|
||||
orderBy: 'date_asc',
|
||||
limit: 10
|
||||
};
|
||||
|
||||
await strategy.search(options);
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].orderBy).toBe('date_asc');
|
||||
});
|
||||
|
||||
it('should handle search errors gracefully', async () => {
|
||||
mockSessionSearch.searchObservations = mock(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = await strategy.search(options);
|
||||
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.results.sessions).toHaveLength(0);
|
||||
expect(result.results.prompts).toHaveLength(0);
|
||||
expect(result.usedChroma).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByConcept', () => {
|
||||
it('should return matching observations (sync)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const results = strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe(1);
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should pass all filter options to findByConcept', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 20,
|
||||
project: 'my-project',
|
||||
dateRange: { start: '2025-01-01' },
|
||||
orderBy: 'date_desc'
|
||||
};
|
||||
|
||||
strategy.findByConcept('test-concept', options);
|
||||
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', {
|
||||
limit: 20,
|
||||
project: 'my-project',
|
||||
dateRange: { start: '2025-01-01' },
|
||||
orderBy: 'date_desc'
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default limit when not specified', () => {
|
||||
const options: StrategySearchOptions = {};
|
||||
|
||||
strategy.findByConcept('test-concept', options);
|
||||
|
||||
const callArgs = mockSessionSearch.findByConcept.mock.calls[0];
|
||||
expect(callArgs[1].limit).toBe(20); // SEARCH_CONSTANTS.DEFAULT_LIMIT
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByType', () => {
|
||||
it('should return typed observations (sync)', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const results = strategy.findByType('decision', options);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].type).toBe('decision');
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('decision', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should handle array of types', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
strategy.findByType(['decision', 'bugfix'], options);
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
|
||||
});
|
||||
|
||||
it('should pass filter options to findByType', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 15,
|
||||
project: 'test-project',
|
||||
orderBy: 'date_asc'
|
||||
};
|
||||
|
||||
strategy.findByType('feature', options);
|
||||
|
||||
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('feature', {
|
||||
limit: 15,
|
||||
project: 'test-project',
|
||||
orderBy: 'date_asc'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByFile', () => {
|
||||
it('should return observations and sessions for file path', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 10
|
||||
};
|
||||
|
||||
const result = strategy.findByFile('/path/to/file.ts', options);
|
||||
|
||||
expect(result.observations).toHaveLength(1);
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/path/to/file.ts', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should pass filter options to findByFile', () => {
|
||||
const options: StrategySearchOptions = {
|
||||
limit: 25,
|
||||
project: 'file-project',
|
||||
dateRange: { end: '2025-12-31' },
|
||||
orderBy: 'date_desc'
|
||||
};
|
||||
|
||||
strategy.findByFile('/src/index.ts', options);
|
||||
|
||||
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/src/index.ts', {
|
||||
limit: 25,
|
||||
project: 'file-project',
|
||||
dateRange: { end: '2025-12-31' },
|
||||
orderBy: 'date_desc'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy name', () => {
|
||||
it('should have name "sqlite"', () => {
|
||||
expect(strategy.name).toBe('sqlite');
|
||||
});
|
||||
});
|
||||
});
|
||||
477
.agent/services/claude-mem/tests/zombie-prevention.test.ts
Normal file
477
.agent/services/claude-mem/tests/zombie-prevention.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Zombie Agent Prevention Tests
|
||||
*
|
||||
* Tests the mechanisms that prevent zombie/duplicate SDK agent spawning:
|
||||
* 1. Concurrent spawn prevention - generatorPromise guards against duplicate spawns
|
||||
* 2. Crash recovery gate - processPendingQueues skips active sessions
|
||||
* 3. queueDepth accuracy - database-backed pending count tracking
|
||||
*
|
||||
* These tests verify the fix for Issue #737 (zombie process accumulation).
|
||||
*
|
||||
* Mock Justification (~25% mock code):
|
||||
* - Session fixtures: Required to create valid ActiveSession objects with
|
||||
* all required fields - tests actual guard logic
|
||||
* - Database: In-memory SQLite for isolation - tests real query behavior
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../src/services/sqlite/Database.js';
|
||||
import { PendingMessageStore } from '../src/services/sqlite/PendingMessageStore.js';
|
||||
import { createSDKSession } from '../src/services/sqlite/Sessions.js';
|
||||
import type { ActiveSession, PendingMessage } from '../src/services/worker-types.js';
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Zombie Agent Prevention', () => {
|
||||
let db: Database;
|
||||
let pendingStore: PendingMessageStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new ClaudeMemDatabase(':memory:').db;
|
||||
pendingStore = new PendingMessageStore(db, 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a minimal mock session
|
||||
*/
|
||||
function createMockSession(
|
||||
sessionDbId: number,
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
return {
|
||||
sessionDbId,
|
||||
contentSessionId: `content-session-${sessionDbId}`,
|
||||
memorySessionId: null,
|
||||
project: 'test-project',
|
||||
userPrompt: 'Test prompt',
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 1,
|
||||
startTime: Date.now(),
|
||||
cumulativeInputTokens: 0,
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [],
|
||||
currentProvider: null,
|
||||
processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a session in the database and return its ID
|
||||
*/
|
||||
function createDbSession(contentSessionId: string, project: string = 'test-project'): number {
|
||||
return createSDKSession(db, contentSessionId, project, 'Test user prompt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to enqueue a test message
|
||||
*/
|
||||
function enqueueTestMessage(sessionDbId: number, contentSessionId: string): number {
|
||||
const message: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
tool_input: { test: 'input' },
|
||||
tool_response: { test: 'response' },
|
||||
prompt_number: 1,
|
||||
};
|
||||
return pendingStore.enqueue(sessionDbId, contentSessionId, message);
|
||||
}
|
||||
|
||||
// Test 1: Concurrent spawn prevention
|
||||
test('should prevent concurrent spawns for same session', async () => {
|
||||
// Create a session with an active generator
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Simulate an active generator by setting generatorPromise
|
||||
// This is the guard that prevents duplicate spawns
|
||||
session.generatorPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
|
||||
// Verify the guard is in place
|
||||
expect(session.generatorPromise).not.toBeNull();
|
||||
|
||||
// The pattern used in worker-service.ts:
|
||||
// if (existingSession?.generatorPromise) { skip }
|
||||
const shouldSkip = session.generatorPromise !== null;
|
||||
expect(shouldSkip).toBe(true);
|
||||
|
||||
// Wait for the promise to resolve
|
||||
await session.generatorPromise;
|
||||
|
||||
// After generator completes, promise is set to null
|
||||
session.generatorPromise = null;
|
||||
|
||||
// Now spawning should be allowed
|
||||
const canSpawnNow = session.generatorPromise === null;
|
||||
expect(canSpawnNow).toBe(true);
|
||||
});
|
||||
|
||||
// Test 2: Crash recovery gate
|
||||
test('should prevent duplicate crash recovery spawns', async () => {
|
||||
// Create sessions in the database
|
||||
const sessionId1 = createDbSession('content-1');
|
||||
const sessionId2 = createDbSession('content-2');
|
||||
|
||||
// Enqueue messages to simulate pending work
|
||||
enqueueTestMessage(sessionId1, 'content-1');
|
||||
enqueueTestMessage(sessionId2, 'content-2');
|
||||
|
||||
// Verify both sessions have pending work
|
||||
const orphanedSessions = pendingStore.getSessionsWithPendingMessages();
|
||||
expect(orphanedSessions).toContain(sessionId1);
|
||||
expect(orphanedSessions).toContain(sessionId2);
|
||||
|
||||
// Create in-memory sessions
|
||||
const session1 = createMockSession(sessionId1, {
|
||||
contentSessionId: 'content-1',
|
||||
generatorPromise: new Promise<void>(() => {}), // Active generator
|
||||
});
|
||||
const session2 = createMockSession(sessionId2, {
|
||||
contentSessionId: 'content-2',
|
||||
generatorPromise: null, // No active generator
|
||||
});
|
||||
|
||||
// Simulate the recovery logic from processPendingQueues
|
||||
const sessions = new Map<number, ActiveSession>();
|
||||
sessions.set(sessionId1, session1);
|
||||
sessions.set(sessionId2, session2);
|
||||
|
||||
const result = {
|
||||
sessionsStarted: 0,
|
||||
sessionsSkipped: 0,
|
||||
startedSessionIds: [] as number[],
|
||||
};
|
||||
|
||||
for (const sessionDbId of orphanedSessions) {
|
||||
const existingSession = sessions.get(sessionDbId);
|
||||
|
||||
// The key guard: skip if generatorPromise is active
|
||||
if (existingSession?.generatorPromise) {
|
||||
result.sessionsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.sessionsStarted++;
|
||||
result.startedSessionIds.push(sessionDbId);
|
||||
}
|
||||
|
||||
// Session 1 should be skipped (has active generator)
|
||||
// Session 2 should be started (no active generator)
|
||||
expect(result.sessionsSkipped).toBe(1);
|
||||
expect(result.sessionsStarted).toBe(1);
|
||||
expect(result.startedSessionIds).toContain(sessionId2);
|
||||
expect(result.startedSessionIds).not.toContain(sessionId1);
|
||||
});
|
||||
|
||||
// Test 3: queueDepth accuracy with CLAIM-CONFIRM pattern
|
||||
test('should report accurate queueDepth from database', async () => {
|
||||
// Create a session
|
||||
const sessionId = createDbSession('content-queue-test');
|
||||
|
||||
// Initially no pending messages
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
|
||||
// Enqueue 3 messages
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1);
|
||||
|
||||
const msgId2 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
const msgId3 = enqueueTestMessage(sessionId, 'content-queue-test');
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// hasAnyPendingWork should return true
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// CLAIM-CONFIRM pattern: claimNextMessage marks as 'processing' (not deleted)
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed?.id).toBe(msgId1);
|
||||
|
||||
// Count stays at 3 because 'processing' messages are still counted
|
||||
// (they need to be confirmed after successful storage)
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// After confirmProcessed, the message is actually deleted
|
||||
pendingStore.confirmProcessed(msgId1);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
// Claim and confirm remaining messages
|
||||
const msg2 = pendingStore.claimNextMessage(sessionId);
|
||||
pendingStore.confirmProcessed(msg2!.id);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1);
|
||||
|
||||
const msg3 = pendingStore.claimNextMessage(sessionId);
|
||||
pendingStore.confirmProcessed(msg3!.id);
|
||||
|
||||
// Should be empty now
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
// Additional test: Multiple sessions with pending work
|
||||
test('should track pending work across multiple sessions', async () => {
|
||||
// Create 3 sessions
|
||||
const session1Id = createDbSession('content-multi-1');
|
||||
const session2Id = createDbSession('content-multi-2');
|
||||
const session3Id = createDbSession('content-multi-3');
|
||||
|
||||
// Enqueue different numbers of messages
|
||||
enqueueTestMessage(session1Id, 'content-multi-1');
|
||||
enqueueTestMessage(session1Id, 'content-multi-1'); // 2 messages
|
||||
|
||||
enqueueTestMessage(session2Id, 'content-multi-2'); // 1 message
|
||||
|
||||
// Session 3 has no messages
|
||||
|
||||
// Verify counts
|
||||
expect(pendingStore.getPendingCount(session1Id)).toBe(2);
|
||||
expect(pendingStore.getPendingCount(session2Id)).toBe(1);
|
||||
expect(pendingStore.getPendingCount(session3Id)).toBe(0);
|
||||
|
||||
// getSessionsWithPendingMessages should return session 1 and 2
|
||||
const sessionsWithPending = pendingStore.getSessionsWithPendingMessages();
|
||||
expect(sessionsWithPending).toContain(session1Id);
|
||||
expect(sessionsWithPending).toContain(session2Id);
|
||||
expect(sessionsWithPending).not.toContain(session3Id);
|
||||
expect(sessionsWithPending.length).toBe(2);
|
||||
});
|
||||
|
||||
// Test: AbortController reset before restart
|
||||
test('should reset AbortController when restarting after abort', async () => {
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Abort the controller (simulating a cancelled operation)
|
||||
session.abortController.abort();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
|
||||
// The pattern used in worker-service.ts before starting generator:
|
||||
// if (session.abortController.signal.aborted) {
|
||||
// session.abortController = new AbortController();
|
||||
// }
|
||||
if (session.abortController.signal.aborted) {
|
||||
session.abortController = new AbortController();
|
||||
}
|
||||
|
||||
// New controller should not be aborted
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
// Test: Stuck processing messages are recovered by claimNextMessage self-healing
|
||||
test('should recover stuck processing messages via claimNextMessage self-healing', async () => {
|
||||
const sessionId = createDbSession('content-stuck-recovery');
|
||||
|
||||
// Enqueue and claim a message (transitions to 'processing')
|
||||
const msgId = enqueueTestMessage(sessionId, 'content-stuck-recovery');
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId);
|
||||
|
||||
// Simulate crash: message stuck in 'processing' with stale timestamp
|
||||
const staleTimestamp = Date.now() - 120_000; // 2 minutes ago
|
||||
db.run(
|
||||
`UPDATE pending_messages SET started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[staleTimestamp, msgId]
|
||||
);
|
||||
|
||||
// Verify it's stuck
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(1); // processing counts as pending work
|
||||
|
||||
// Next claimNextMessage should self-heal: reset stuck message and re-claim it
|
||||
const recovered = pendingStore.claimNextMessage(sessionId);
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(recovered!.id).toBe(msgId);
|
||||
|
||||
// Confirm it can be processed successfully
|
||||
pendingStore.confirmProcessed(msgId);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
|
||||
// Test: Generator cleanup on session delete
|
||||
test('should properly cleanup generator promise on session delete', async () => {
|
||||
const session = createMockSession(1);
|
||||
|
||||
// Track whether generator was awaited
|
||||
let generatorCompleted = false;
|
||||
|
||||
// Simulate an active generator
|
||||
session.generatorPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
generatorCompleted = true;
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Simulate the deleteSession logic:
|
||||
// 1. Abort the controller
|
||||
session.abortController.abort();
|
||||
|
||||
// 2. Wait for generator to finish
|
||||
if (session.generatorPromise) {
|
||||
await session.generatorPromise.catch(() => {});
|
||||
}
|
||||
|
||||
expect(generatorCompleted).toBe(true);
|
||||
|
||||
// 3. Clear the promise
|
||||
session.generatorPromise = null;
|
||||
expect(session.generatorPromise).toBeNull();
|
||||
});
|
||||
|
||||
describe('Session Termination Invariant', () => {
|
||||
// Tests the restart-or-terminate invariant:
|
||||
// When a generator exits without restarting, its messages must be
|
||||
// marked abandoned and the session removed from the active Map.
|
||||
|
||||
test('should mark messages abandoned when session is terminated', () => {
|
||||
const sessionId = createDbSession('content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-1');
|
||||
|
||||
// Verify messages exist
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate: mark abandoned (same as terminateSession does)
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
|
||||
// Spinner should stop: no pending work remains
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle terminate with zero pending messages', () => {
|
||||
const sessionId = createDbSession('content-terminate-empty');
|
||||
|
||||
// No messages enqueued
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
|
||||
// Terminate with nothing to abandon
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(0);
|
||||
|
||||
// Still no pending work
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should be idempotent — double terminate marks zero on second call', () => {
|
||||
const sessionId = createDbSession('content-terminate-idempotent');
|
||||
enqueueTestMessage(sessionId, 'content-terminate-idempotent');
|
||||
|
||||
// First terminate
|
||||
const first = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(first).toBe(1);
|
||||
|
||||
// Second terminate — already failed, nothing to mark
|
||||
const second = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(second).toBe(0);
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should remove session from Map via removeSessionImmediate', () => {
|
||||
const sessionId = createDbSession('content-terminate-map');
|
||||
const session = createMockSession(sessionId, {
|
||||
contentSessionId: 'content-terminate-map',
|
||||
});
|
||||
|
||||
// Simulate the in-memory sessions Map
|
||||
const sessions = new Map<number, ActiveSession>();
|
||||
sessions.set(sessionId, session);
|
||||
expect(sessions.has(sessionId)).toBe(true);
|
||||
|
||||
// Simulate removeSessionImmediate behavior
|
||||
sessions.delete(sessionId);
|
||||
expect(sessions.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
test('should return hasAnyPendingWork false after all sessions terminated', () => {
|
||||
// Create multiple sessions with messages
|
||||
const sid1 = createDbSession('content-multi-term-1');
|
||||
const sid2 = createDbSession('content-multi-term-2');
|
||||
const sid3 = createDbSession('content-multi-term-3');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid1, 'content-multi-term-1');
|
||||
enqueueTestMessage(sid2, 'content-multi-term-2');
|
||||
enqueueTestMessage(sid3, 'content-multi-term-3');
|
||||
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
|
||||
// Terminate all sessions
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid2);
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid3);
|
||||
|
||||
// Spinner must stop
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should not affect other sessions when terminating one', () => {
|
||||
const sid1 = createDbSession('content-isolate-1');
|
||||
const sid2 = createDbSession('content-isolate-2');
|
||||
|
||||
enqueueTestMessage(sid1, 'content-isolate-1');
|
||||
enqueueTestMessage(sid2, 'content-isolate-2');
|
||||
|
||||
// Terminate only session 1
|
||||
pendingStore.markAllSessionMessagesAbandoned(sid1);
|
||||
|
||||
// Session 2 still has work
|
||||
expect(pendingStore.getPendingCount(sid1)).toBe(0);
|
||||
expect(pendingStore.getPendingCount(sid2)).toBe(1);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test('should mark both pending and processing messages as abandoned', () => {
|
||||
const sessionId = createDbSession('content-mixed-status');
|
||||
|
||||
// Enqueue two messages
|
||||
const msgId1 = enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
enqueueTestMessage(sessionId, 'content-mixed-status');
|
||||
|
||||
// Claim first message (transitions to 'processing')
|
||||
const claimed = pendingStore.claimNextMessage(sessionId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId1);
|
||||
|
||||
// Now we have 1 processing + 1 pending
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(2);
|
||||
|
||||
// Terminate should mark BOTH as failed
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(abandoned).toBe(2);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test('should enforce invariant: no pending work after terminate regardless of initial state', () => {
|
||||
const sessionId = createDbSession('content-invariant');
|
||||
|
||||
// Create a complex initial state: some pending, some processing, some with stale timestamps
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
enqueueTestMessage(sessionId, 'content-invariant');
|
||||
|
||||
// Claim one (processing)
|
||||
pendingStore.claimNextMessage(sessionId);
|
||||
|
||||
// Verify complex state
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(3);
|
||||
|
||||
// THE INVARIANT: after terminate, hasAnyPendingWork MUST be false
|
||||
pendingStore.markAllSessionMessagesAbandoned(sessionId);
|
||||
expect(pendingStore.hasAnyPendingWork()).toBe(false);
|
||||
expect(pendingStore.getPendingCount(sessionId)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user