Files
claude-mem/tests/integration/full-lifecycle.test.ts
T
Alex Newman ded9671a82 Refactor worker port handling and improve logging
- Replaced hardcoded migration port with dynamic port retrieval using `getWorkerPort()` in worker-cli.ts.
- Updated context generator to clarify error handling comments.
- Introduced timeout constants in ProcessManager for better maintainability.
- Configured SQLite settings using constants for mmap size and cache size in DatabaseManager.
- Added timeout constants for Git and NPM commands in BranchManager.
- Enhanced error logging in FormattingService and SearchManager to provide more context on failures.
- Removed deprecated silentDebug function and replaced its usage with logger.debug.
- Updated tests to use dynamic worker port retrieval instead of hardcoded values.
2025-12-11 14:49:47 -05:00

354 lines
11 KiB
TypeScript

/**
* 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);
});
});