wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation

This commit is contained in:
2026-03-29 22:08:40 +09:00
parent aca7bf592a
commit 2507de45d3
4289 changed files with 732689 additions and 28672 deletions

View 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>

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View 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');
});
});
});

View 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)');
});
});
});

View 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);
});
});
});

View 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');
});
});
});

View 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);
});
});

View 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();
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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:");
});
});

View File

@@ -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);
});
});
});

View 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>

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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}"`');
});
});

View File

@@ -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([]);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});
});

View 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']);
});
});
});

View 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');
});
});
});

View File

@@ -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));
}
});
});

View File

@@ -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
});
});
});
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
}
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});

View File

@@ -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
});
});
});

View 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);
});
});

View File

@@ -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
});
});
});

View File

@@ -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]);
});
});

View 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 });
}
});
});

View 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);
});
});
});

View 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([]);
});
});
});

View 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);
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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();
});
});
});

View File

@@ -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();
});
});

View File

@@ -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;
}
});
});

View 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);
});
});

View File

@@ -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);
});
});
});

View 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);
});
});

View File

@@ -0,0 +1,3 @@
<claude-mem-context>
</claude-mem-context>

File diff suppressed because it is too large Load Diff

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});
});

View 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');
});
});
});

View 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
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View 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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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('');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View 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);
});
});
});