diff --git a/claudia-server/src/services/__tests__/claude.service.spec.ts b/claudia-server/src/services/__tests__/claude.service.spec.ts new file mode 100644 index 000000000..4c71d3860 --- /dev/null +++ b/claudia-server/src/services/__tests__/claude.service.spec.ts @@ -0,0 +1,505 @@ +import { EventEmitter } from 'events'; + +// We mock child_process.spawn, fs.promises.access, and os.homedir to ensure deterministic behavior +import * as childProcess from 'child_process'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Import the class under test +// The provided file is named `claude.test.ts` but contains the service implementation. +// We import it directly as that's where the ClaudeService is exported. +import { ClaudeService } from '../claude.test'; + +// Test helpers to craft controllable fake ChildProcess instances +class FakeChildProcess extends EventEmitter { + public pid: number | undefined; + public stdout?: EventEmitter; + public stderr?: EventEmitter; + public killed = false; + + constructor(withPid = true) { + super(); + this.pid = withPid ? Math.floor(Math.random() * 10000) + 1000 : undefined; + this.stdout = new EventEmitter(); + this.stderr = new EventEmitter(); + } + + kill = (signal?: NodeJS.Signals | number) => { + this.killed = true; + // No-op; tests will assert on flags or trigger "close" as needed + return true; + }; +} + +// Jest-compatible mocks (works in Vitest with vi.mock as well) +jest.mock('child_process', () => { + const actual = jest.requireActual('child_process'); + return { + ...actual, + spawn: jest.fn(), + }; +}); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + promises: { + ...(actual.promises || {}), + access: jest.fn(), + }, + }; +}); + +jest.mock('os', () => { + const actual = jest.requireActual('os'); + return { + ...actual, + homedir: jest.fn(), + }; +}); + +describe('ClaudeService', () => { + const mockedSpawn = childProcess.spawn as unknown as jest.Mock; + const mockedFsAccess = fs.access as unknown as jest.Mock; + const mockedHomedir = os.homedir as unknown as jest.Mock; + + const CLAUDE_BIN = '/fake/path/claude'; + + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + + mockedHomedir.mockReturnValue('/home/testuser'); + mockedFsAccess.mockResolvedValue(undefined); + mockedSpawn.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + function setupSpawnForVersion(output: string, exitCode: number) { + const fake = new FakeChildProcess(true); + // For --version call invoked by findClaudeBinary().testClaudeBinary() and by runCommand + mockedSpawn.mockImplementation((_cmd: string, args: string[]) => { + // args may include --version or other CLI flags + // simulate emission of output to stdout + setImmediate(() => { + if (output) { + fake.stdout?.emit('data', Buffer.from(output)); + } + // stderr is unused for success path; keep empty + fake.emit('close', exitCode); + }); + return fake as unknown as childProcess.ChildProcess; + }); + return fake; + } + + function setupSpawnForRunCommand(stdoutOutput: string, stderrOutput: string, exitCode: number) { + const fake = new FakeChildProcess(true); + mockedSpawn.mockImplementation((_cmd: string, _args: string[]) => { + setImmediate(() => { + if (stdoutOutput) fake.stdout?.emit('data', Buffer.from(stdoutOutput)); + if (stderrOutput) fake.stderr?.emit('data', Buffer.from(stderrOutput)); + fake.emit('close', exitCode); + }); + return fake as unknown as childProcess.ChildProcess; + }); + return fake; + } + + function setupSpawnForStreaming(argsMatcher: (args: string[]) => boolean, events: Array<{ type: 'stdout' | 'stderr' | 'close' | 'error'; data?: string; code?: number; error?: Error }>) { + const fake = new FakeChildProcess(true); + mockedSpawn.mockImplementation((_cmd: string, args: string[]) => { + // Only stream for the matching command invocation; other spawns (e.g. --version) need separate stubs in tests + if (argsMatcher(args)) { + setImmediate(() => { + for (const ev of events) { + if (ev.type === 'stdout' && ev.data !== undefined) { + fake.stdout?.emit('data', Buffer.from(ev.data)); + } else if (ev.type === 'stderr' && ev.data !== undefined) { + fake.stderr?.emit('data', Buffer.from(ev.data)); + } else if (ev.type === 'close') { + fake.emit('close', ev.code ?? 0); + } else if (ev.type === 'error') { + fake.emit('error', ev.error ?? new Error('spawn error')); + } + } + }); + } + return fake as unknown as childProcess.ChildProcess; + }); + return fake; + } + + describe('checkClaudeVersion', () => { + it('returns installed=true with parsed semver when binary is valid and outputs version', async () => { + // findClaudeBinary -> testClaudeBinary uses spawn(..., ['--version']) + setupSpawnForVersion('claude version 1.2.3\n', 0); + + // runCommand(..., ['--version']) is also used; provide output there too + setupSpawnForRunCommand('claude version 1.2.3\n', '', 0); + + const svc = new ClaudeService(CLAUDE_BIN); + const res = await svc.checkClaudeVersion(); + + expect(res.is_installed).toBe(true); + expect(res.version).toBe('1.2.3'); + expect(res.output).toContain('claude'); + }); + + it('returns installed=true but undefined version if format lacks semver', async () => { + setupSpawnForVersion('claude build main\n', 0); + setupSpawnForRunCommand('claude build main\n', '', 0); + + const svc = new ClaudeService(CLAUDE_BIN); + const res = await svc.checkClaudeVersion(); + + expect(res.is_installed).toBe(true); + expect(res.version).toBeUndefined(); + expect(res.output).toContain('claude'); + }); + + it('returns installed=false when binary missing or invalid', async () => { + // fs.access ok, but testClaudeBinary fails via non-zero exit or non-matching output + setupSpawnForVersion('something else\n', 1); + + const svc = new ClaudeService(CLAUDE_BIN); + const res = await svc.checkClaudeVersion(); + + expect(res.is_installed).toBe(false); + expect(res.output).toMatch(/Invalid Claude binary|not found|Unknown error/i); + }); + }); + + describe('execute/continue/resume stream handling and registry', () => { + it('executeClaudeCode: streams JSON and non-JSON lines, emits events, and registers process', async () => { + // testClaudeBinary path when finding claude binary + setupSpawnForVersion('claude 1.0.0', 0); + + const json1 = JSON.stringify({ type: 'token', text: 'Hello' }); + const json2 = JSON.stringify({ type: 'done' }); + + // stream-json invocation args matcher + const isExecuteArgs = (args: string[]) => + args.includes('-p') && + args.includes('--model') && + args.includes('--output-format') && + args.includes('stream-json') && + !args.includes('-c') && + !args.includes('--resume'); + + // streaming events: stdout lines include JSON and a non-JSON line + const streamEvents = [ + { type: 'stdout' as const, data: json1 + '\n' }, + { type: 'stdout' as const, data: 'raw text line\n' }, + { type: 'stdout' as const, data: json2 + '\n' }, + { type: 'stderr' as const, data: 'warning: something\n' }, + { type: 'close' as const, code: 0 }, + ]; + + setupSpawnForStreaming(isExecuteArgs, streamEvents); + + const svc = new ClaudeService(CLAUDE_BIN); + + const streamEventsCaptured: any[] = []; + const outputEventsCaptured: any[] = []; + const errorEventsCaptured: any[] = []; + const exitEventsCaptured: any[] = []; + + svc.on('claude_stream', (e) => streamEventsCaptured.push(e)); + svc.on('claude_output', (e) => outputEventsCaptured.push(e)); + svc.on('claude_error', (e) => errorEventsCaptured.push(e)); + svc.on('claude_exit', (e) => exitEventsCaptured.push(e)); + + const sessionId = await svc.executeClaudeCode({ + prompt: 'Do something', + model: 'claude-3', + project_path: '/tmp/project', + }); + + expect(typeof sessionId).toBe('string'); + // wait microtasks to flush stream events + await Promise.resolve(); + + // Validate stream parsing and event enrichment with session_id/timestamp + expect(streamEventsCaptured.length).toBe(2); + for (const ev of streamEventsCaptured) { + expect(ev.session_id).toBe(sessionId); + expect(ev.message).toHaveProperty('timestamp'); + expect(ev.message).toHaveProperty('session_id', sessionId); + } + + expect(outputEventsCaptured.length).toBe(1); + expect(outputEventsCaptured[0].session_id).toBe(sessionId); + expect(outputEventsCaptured[0].data).toBe('raw text line'); + + expect(errorEventsCaptured.length).toBe(1); + expect(errorEventsCaptured[0].session_id).toBe(sessionId); + expect(errorEventsCaptured[0].error).toContain('warning'); + + // exit event fired and registries cleared + // wait for close handling + await Promise.resolve(); + expect(exitEventsCaptured.length).toBe(1); + expect(exitEventsCaptured[0].session_id).toBe(sessionId); + expect(typeof exitEventsCaptured[0].code).toBe('number'); + + // After exit, process should be removed from registries + expect(svc.getSessionInfo(sessionId)).toBeUndefined(); + expect(svc.getRunningClaudeSessions().find(s => (s as any).process_type?.ClaudeSession?.session_id === sessionId)).toBeUndefined(); + }); + + it('continueClaudeCode: uses -c flag and registers until exit', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + const isContinueArgs = (args: string[]) => args.includes('-c'); + setupSpawnForStreaming(isContinueArgs, [ + { type: 'stdout', data: JSON.stringify({ type: 'token', text: 'cont' }) + '\n' }, + { type: 'close', code: 0 }, + ]); + + const svc = new ClaudeService(CLAUDE_BIN); + const exitEvents: any[] = []; + svc.on('claude_exit', (e) => exitEvents.push(e)); + + const sessionId = await svc.continueClaudeCode({ + prompt: 'Continue', + model: 'claude-3', + project_path: '/tmp/project', + }); + + expect(typeof sessionId).toBe('string'); + await Promise.resolve(); + expect(svc.getSessionInfo(sessionId)).toBeUndefined(); + expect(exitEvents.length).toBe(1); + }); + + it('resumeClaudeCode: uses --resume and given session_id', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + const isResumeArgs = (args: string[]) => args.includes('--resume') && args.some((a) => a === 'existing-session'); + setupSpawnForStreaming(isResumeArgs, [ + { type: 'stdout', data: JSON.stringify({ type: 'token', text: 'resumed' }) + '\n' }, + { type: 'close', code: 0 }, + ]); + + const svc = new ClaudeService(CLAUDE_BIN); + const exitEvents: any[] = []; + svc.on('claude_exit', (e) => exitEvents.push(e)); + + const sessionId = await svc.resumeClaudeCode({ + prompt: 'Resume please', + model: 'claude-3', + project_path: '/tmp/project', + session_id: 'existing-session', + }); + + expect(sessionId).toBe('existing-session'); + await Promise.resolve(); + expect(exitEvents.length).toBe(1); + }); + + it('emits claude_error and clears registries on spawn error', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + const isExecuteArgs = (args: string[]) => args.includes('-p') && args.includes('Fail'); + const fake = setupSpawnForStreaming(isExecuteArgs, [ + { type: 'error', error: new Error('spawn failure') }, + ]); + + const svc = new ClaudeService(CLAUDE_BIN); + const errors: any[] = []; + svc.on('claude_error', (e) => errors.push(e)); + + const sessionId = await svc.executeClaudeCode({ + prompt: 'Fail', + model: 'claude-3', + project_path: '/tmp/project', + }); + + // Ensure error was emitted with session_id and message + await Promise.resolve(); + expect(errors.length).toBe(1); + expect(errors[0].session_id).toBe(sessionId); + expect(errors[0].error).toContain('spawn failure'); + + // Make sure registries are cleared on error + expect(svc.getSessionInfo(sessionId)).toBeUndefined(); + expect(svc.getRunningClaudeSessions().find(s => (s as any).process_type?.ClaudeSession?.session_id === sessionId)).toBeUndefined(); + + // Avoid eslint 'unused' warning for fake + expect(fake).toBeTruthy(); + }); + }); + + describe('cancelClaudeExecution', () => { + it('returns true and sends SIGTERM; SIGKILL after 5s if not killed', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + // The process that will be stored/registered; we need it to not exit immediately + const isExecuteArgs = (args: string[]) => args.includes('-p') && args.includes('cancel test'); + const fake = new FakeChildProcess(true); + (childProcess.spawn as jest.Mock).mockImplementation((_cmd: string, args: string[]) => { + if (isExecuteArgs(args)) { + // keep it alive; do not emit close immediately + return fake as unknown as childProcess.ChildProcess; + } + // For binary validation + const ver = new FakeChildProcess(true); + setImmediate(() => ver.emit('close', 0)); + return ver as unknown as childProcess.ChildProcess; + }); + + const svc = new ClaudeService(CLAUDE_BIN); + const id = await svc.executeClaudeCode({ + prompt: 'cancel test', + model: 'claude-3', + project_path: '/tmp/project', + }); + + const result = await svc.cancelClaudeExecution(id); + expect(result).toBe(true); + + // Force the delayed SIGKILL path by leaving fake.killed=false + jest.advanceTimersByTime(5000); + expect(setTimeout).toHaveBeenCalled(); + // We can only assert that kill was invoked; our FakeChildProcess.kill toggles .killed=true on any call + expect(fake.killed).toBe(true); + }); + + it('returns false when no such session exists', async () => { + const svc = new ClaudeService(CLAUDE_BIN); + const res = await svc.cancelClaudeExecution('non-existent'); + expect(res).toBe(false); + }); + }); + + describe('registry and helpers', () => { + it('getRunningClaudeSessions and getSessionInfo reflect active process; cleared on close', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + const isExecuteArgs = (args: string[]) => args.includes('-p') && args.includes('track me'); + const events = [ + { type: 'stdout' as const, data: JSON.stringify({ type: 'token', text: 'hi' }) + '\n' }, + { type: 'close' as const, code: 0 }, + ]; + setupSpawnForStreaming(isExecuteArgs, events); + + const svc = new ClaudeService(CLAUDE_BIN); + const id = await svc.executeClaudeCode({ + prompt: 'track me', + model: 'claude-3', + project_path: '/tmp/prj', + }); + + // Immediately after spawn, registry should have the session + const running = svc.getRunningClaudeSessions(); + expect(running.length).toBe(1); + const info = svc.getSessionInfo(id); + expect(info).toBeDefined(); + expect(info?.model).toBe('claude-3'); + expect(info?.project_path).toBe('/tmp/prj'); + expect(info?.process_type).toEqual({ ClaudeSession: { session_id: id } }); + + // After close, cleared + await Promise.resolve(); + expect(svc.getSessionInfo(id)).toBeUndefined(); + expect(svc.getRunningClaudeSessions().length).toBe(0); + }); + + it('getClaudeHomeDir returns ~/.claude', () => { + mockedHomedir.mockReturnValue('/home/userx'); + const svc = new ClaudeService(CLAUDE_BIN); + expect(svc.getClaudeHomeDir()).toBe(path.join('/home/userx', '.claude')); + }); + + it('cleanup kills all tracked processes and clears registries', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + // Keep the process running so cleanup has something to kill + const isExecuteArgs = (args: string[]) => args.includes('-p') && args.includes('cleanup'); + const fake = new FakeChildProcess(true); + mockedSpawn.mockImplementation((_cmd: string, args: string[]) => { + if (isExecuteArgs(args)) { + return fake as unknown as childProcess.ChildProcess; + } + const ver = new FakeChildProcess(true); + setImmediate(() => ver.emit('close', 0)); + return ver as unknown as childProcess.ChildProcess; + }); + + const svc = new ClaudeService(CLAUDE_BIN); + const id = await svc.executeClaudeCode({ + prompt: 'cleanup', + model: 'claude-3', + project_path: '/tmp/p', + }); + + expect(svc.getSessionInfo(id)).toBeDefined(); + + // Invoke cleanup + svc.cleanup(); + + // Process array cleared + expect(svc.getSessionInfo(id)).toBeUndefined(); + expect(svc.getRunningClaudeSessions().length).toBe(0); + // Fake process kill called + expect(fake.killed).toBe(true); + }); + }); + + describe('runCommand failure path (indirectly via checkClaudeVersion)', () => { + it('bubbles up non-zero exit errors from runCommand', async () => { + // testClaudeBinary ok + setupSpawnForVersion('claude 1.0.0', 0); + + // Next spawn for runCommand returns non-zero with stderr message + mockedSpawn.mockImplementationOnce((_cmd: string, _args: string[]) => { + // This first call already consumed by setupSpawnForVersion. We need second for runCommand: + // To ensure we target runCommand, we return a process that emits stderr and close 2. + const fake = new FakeChildProcess(true); + setImmediate(() => { + fake.stderr?.emit('data', Buffer.from('boom')); + fake.emit('close', 2); + }); + return fake as unknown as childProcess.ChildProcess; + }); + + const svc = new ClaudeService(CLAUDE_BIN); + const res = await svc.checkClaudeVersion(); + expect(res.is_installed).toBe(false); + expect(res.output).toContain('boom'); + }); + }); + + describe('spawn failure to start (no pid)', () => { + it('throws if spawn returns process without pid', async () => { + setupSpawnForVersion('claude 1.0.0', 0); + + // Create a child process without pid to trigger the guard + const badChild = new FakeChildProcess(false); + mockedSpawn.mockImplementation((_cmd: string, args: string[]) => { + // for the execution spawn (not version check), return bad child + if (args.includes('--output-format')) { + return badChild as unknown as childProcess.ChildProcess; + } + // for version checks, return normal that closes immediately + const ver = new FakeChildProcess(true); + setImmediate(() => ver.emit('close', 0)); + return ver as unknown as childProcess.ChildProcess; + }); + + const svc = new ClaudeService(CLAUDE_BIN); + await expect(svc.executeClaudeCode({ + prompt: 'start please', + model: 'claude-3', + project_path: '/tmp/pp', + })).rejects.toThrow(/Failed to start Claude process/); + }); + }); +}); \ No newline at end of file diff --git a/claudia-server/src/services/project.test.ts b/claudia-server/src/services/project.test.ts new file mode 100644 index 000000000..c8f478a0c --- /dev/null +++ b/claudia-server/src/services/project.test.ts @@ -0,0 +1,614 @@ +/* + Tests for ProjectService + + Testing framework and runner: Jest (TypeScript) with ts-jest assumed based on typical setup. + If this repository uses Vitest instead, replace jest.mock/jest.fn with vi.mock/vi.fn and update imports accordingly. + + Focus: Validate behaviors across happy paths, edge cases, and failure conditions for: + - listProjects + - createProject + - getProjectSessions + - loadSessionHistory + - findClaudeMdFiles + findClaudeMdFilesRecursive + - readClaudeMdFile + - saveClaudeMdFile + - listDirectoryContents + + Strategy: + - Mock fs.promises: access, readdir, stat, readFile, writeFile, mkdir + - Use a custom claudeHomeDir so we can assert generated paths deterministically + - Avoid real filesystem IO +*/ + +import { join, basename } from 'path'; + +// Import the service under test. Adjust the import path if the actual service file differs. +// The provided snippet indicates this class is exported as 'ProjectService' from claudia-server/src/services/project.ts +// If the implementation resides elsewhere, update this import accordingly. +import { ProjectService } from './project'; // <-- if this path differs in repo, update accordingly + +// Types used in assertions (optional). If types module path differs, you can remove the import and assert using runtime shapes. +type Project = { + id: string; + path: string; + sessions: string[]; + created_at: number; + most_recent_session?: number; +}; + +type Session = { + id: string; + project_id: string; + project_path: string; + created_at: number; + first_message?: string; + message_timestamp?: string; + todo_data?: any; +}; + +// We will mock fs.promises interface only. +type DirentLike = { + name: string; + isFile: () => boolean; + isDirectory: () => boolean; +}; + +// Build a manual mock for fs to control fs.promises methods precisely. +jest.mock('fs', () => { + const original = jest.requireActual('fs'); + const mem: Record = {}; + + // Not using memfs dependency; we'll stub readFile/writeFile via handlers configured per test + + return { + ...original, + promises: { + access: jest.fn(), + readdir: jest.fn(), + stat: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), + mkdir: jest.fn(), + }, + }; +}); + +const fsPromises = (require('fs').promises) as { + access: jest.Mock; + readdir: jest.Mock; + stat: jest.Mock; + readFile: jest.Mock; + writeFile: jest.Mock; + mkdir: jest.Mock; +}; + +// Helper to create a Dirent-like object +function dirent(name: string, kind: 'file' | 'dir'): DirentLike { + return { + name, + isFile: () => kind === 'file', + isDirectory: () => kind === 'dir', + }; +} + +// Common test variables +const HOME = '/tmp/claude-home'; +const PROJECTS_DIR = join(HOME, 'projects'); +const TODOS_DIR = join(HOME, 'todos'); + +// Date helpers +function fakeDate(epochSecs: number) { + return new Date(epochSecs * 1000); +} + +function statWithTimes({ ctime, mtime, birthtime } : { ctime: number; mtime?: number; birthtime?: number }) { + return { + ctime: fakeDate(ctime), + mtime: fakeDate(mtime ?? ctime), + birthtime: birthtime ? fakeDate(birthtime) : fakeDate(0), + }; +} + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('ProjectService', () => { + describe('listProjects', () => { + it('returns empty array if projects directory does not exist', async () => { + const svc = new ProjectService(HOME); + fsPromises.access.mockRejectedValueOnce(Object.assign(new Error('no dir'), { code: 'ENOENT' })); + + const projects = await svc.listProjects(); + + expect(projects).toEqual([]); + expect(fsPromises.access).toHaveBeenCalledWith(PROJECTS_DIR); + }); + + it('lists projects, reading sessions and sorting by most recent session then creation time', async () => { + const svc = new ProjectService(HOME); + // access ok + fsPromises.access.mockResolvedValueOnce(undefined); + + // Projects directory contains two projects: a-foo-bar and z-bar + fsPromises.readdir.mockResolvedValueOnce([ + dirent('a-foo-bar', 'dir'), + dirent('z-bar', 'dir'), + dirent('ignore.txt', 'file'), + ]); + + // For getProjectFromDirectory('a-foo-bar'): + fsPromises.stat + // stat(projectDir) + .mockResolvedValueOnce(statWithTimes({ ctime: 1000 })) + // stat(session1) for a-foo-bar + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 2000 })) + // stat(session2) for a-foo-bar + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 1500 })) + + // For getProjectFromDirectory('z-bar'): + // stat(projectDir) + .mockResolvedValueOnce(statWithTimes({ ctime: 900 })) + // stat(session for z-bar) + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 1200 })); + + // readdir(projectDir) calls inside getProjectFromDirectory: + fsPromises.readdir + // entries in a-foo-bar + .mockResolvedValueOnce([ + dirent('123.jsonl', 'file'), + dirent('456.jsonl', 'file'), + dirent('ignore.md', 'file'), + ]) + // entries in z-bar + .mockResolvedValueOnce([ + dirent('789.jsonl', 'file'), + ]); + + // getProjectPathFromSessions logic: will read session files and pick first with cwd + // For a-foo-bar: read 123.jsonl -> has cwd, so used + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/a/foo/bar' }) + '\n') + // For z-bar: read 789.jsonl -> throws parse or no cwd, then fallback decode of projectId (z-bar -> z/bar) + .mockResolvedValueOnce(JSON.stringify({ something: 'else' }) + '\n'); + + const projects = await svc.listProjects(); + + // Sorting: a-foo-bar most recent session mtime=2000, z-bar most recent session mtime=1200 + expect(projects.map(p => p.id)).toEqual(['a-foo-bar', 'z-bar']); + + const a = projects[0]; + expect(a.path).toBe('/work/a/foo/bar'); + expect(a.sessions.sort()).toEqual(['123', '456']); + expect(a.created_at).toBe(1000); + expect(a.most_recent_session).toBe(2000); + + const z = projects[1]; + expect(z.path).toBe('z/bar'); // fallback decodeProjectPath + expect(z.sessions).toEqual(['789']); + expect(z.created_at).toBe(900); + expect(z.most_recent_session).toBe(1200); + }); + + it('ignores unreadable projects and continues', async () => { + const svc = new ProjectService(HOME); + fsPromises.access.mockResolvedValueOnce(undefined); + fsPromises.readdir.mockResolvedValueOnce([ + dirent('good', 'dir'), + dirent('bad', 'dir'), + ]); + + // Good project + fsPromises.stat + .mockResolvedValueOnce(statWithTimes({ ctime: 100 })) + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 120 })); // session + + fsPromises.readdir + .mockResolvedValueOnce([dirent('s1.jsonl', 'file')]); + + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/good' }) + '\n'); + + // Bad project: throw while reading project dir stat + fsPromises.stat.mockRejectedValueOnce(new Error('stat error for bad')); + + const projects = await svc.listProjects(); + expect(projects).toHaveLength(1); + expect(projects[0].id).toBe('good'); + expect(projects[0].path).toBe('/work/good'); + }); + }); + + describe('createProject', () => { + it('creates project directory, writes metadata, and returns project data', async () => { + const svc = new ProjectService(HOME); + const path = '/repo/foo'; + const projectId = ' - repo - foo - '.trim().replace(/\//g, '-'); // Just a note. Actual code uses path.replace(/\//g, '-') + // Our expected id: + const expectedProjectId = path.replace(/\//g, '-'); + const projectDir = join(PROJECTS_DIR, expectedProjectId); + + // mkdir projects dir, then project dir + fsPromises.mkdir.mockResolvedValue(undefined); + + // stat(projectDir) for timestamps - prefer birthtime if available, else mtime + fsPromises.stat.mockResolvedValueOnce({ + ctime: fakeDate(0), // not used directly for createProject + mtime: fakeDate(2000), + birthtime: fakeDate(1500), + }); + + // write metadata.json + fsPromises.writeFile.mockResolvedValue(undefined); + + const result = await svc.createProject(path); + + expect(fsPromises.mkdir).toHaveBeenCalledWith(PROJECTS_DIR, { recursive: true }); + expect(fsPromises.mkdir).toHaveBeenCalledWith(projectDir, { recursive: true }); + // uses birthtime first then falls back + expect(result.created_at).toBe(1500); + expect(result).toEqual({ + id: expectedProjectId, + path, + sessions: [], + created_at: 1500, + }); + // verify metadata contents + const metadataPath = join(projectDir, 'metadata.json'); + const writeCall = fsPromises.writeFile.mock.calls.find(c => c[0] === metadataPath); + expect(writeCall).toBeTruthy(); + const json = JSON.parse(writeCall![1]); + expect(json).toEqual({ path, created_at: 1500, version: 1 }); + }); + + it('falls back to mtime when birthtime is unavailable', async () => { + const svc = new ProjectService(HOME); + fsPromises.mkdir.mockResolvedValue(undefined); + fsPromises.stat.mockResolvedValueOnce({ + ctime: fakeDate(0), + mtime: fakeDate(7777), + birthtime: undefined, + } as any); + fsPromises.writeFile.mockResolvedValue(undefined); + + const result = await svc.createProject('/x/y'); + expect(result.created_at).toBe(7777); + }); + }); + + describe('getProjectSessions', () => { + it('throws if project directory does not exist', async () => { + const svc = new ProjectService(HOME); + const projectId = 'x-y'; + const projectDir = join(PROJECTS_DIR, projectId); + fsPromises.access.mockRejectedValueOnce(Object.assign(new Error('no dir'), { code: 'ENOENT' })); + + await expect(svc.getProjectSessions(projectId)).rejects.toThrow(`Project directory not found: ${projectId}`); + expect(fsPromises.access).toHaveBeenCalledWith(projectDir); + }); + + it('returns sessions sorted by created_at (newest first) and extracts first user message and todos', async () => { + const svc = new ProjectService(HOME); + const projectId = 'foo-bar'; + const projectDir = join(PROJECTS_DIR, projectId); + + // access ok + fsPromises.access.mockResolvedValueOnce(undefined); + + // getProjectPathFromSessions: read first .jsonl file; include cwd + // readdir(projectDir) - for determining path + fsPromises.readdir + .mockResolvedValueOnce([dirent('100.jsonl', 'file'), dirent('50.jsonl', 'file')]) // for getProjectPathFromSessions + .mockResolvedValueOnce([dirent('100.jsonl', 'file'), dirent('50.jsonl', 'file')]); // for main sessions loop + + fsPromises.readFile + // For getProjectPathFromSessions -> pick first and read + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/foo/bar' }) + '\n') + + // For getSessionFromFile('100.jsonl') + .mockResolvedValueOnce([ + JSON.stringify({ message: { role: 'assistant', content: 'hi' }, timestamp: 't1' }), + JSON.stringify({ message: { role: 'user', content: 'User 100 message' }, timestamp: 't2' }), + '', + ].join('\n')) + + // For todos 100 + .mockResolvedValueOnce(JSON.stringify({ tasks: ['a', 'b'] })) + + // For getSessionFromFile('50.jsonl') + .mockResolvedValueOnce([ + JSON.stringify({ message: { role: 'user', content: [{ type: 'text', text: 'Array user message' }] }, timestamp: 't3' }), + '', + ].join('\n')) + + // For todos 50 -> simulate not found + .mockRejectedValueOnce(Object.assign(new Error('no todo'), { code: 'ENOENT' })); + + // stat for sessions: ctime defines created_at + fsPromises.stat + .mockResolvedValueOnce(statWithTimes({ ctime: 100 })) // for 100.jsonl + .mockResolvedValueOnce(statWithTimes({ ctime: 50 })); // for 50.jsonl + + const sessions = await svc.getProjectSessions(projectId); + + // Sorted newest first (100 then 50) + expect(sessions.map(s => s.id)).toEqual(['100', '50']); + + const s100 = sessions[0]; + expect(s100.project_id).toBe(projectId); + expect(s100.project_path).toBe('/work/foo/bar'); + expect(s100.created_at).toBe(100); + expect(s100.first_message).toBe('User 100 message'); + expect(s100.message_timestamp).toBe('t2'); + expect(s100.todo_data).toEqual({ tasks: ['a', 'b'] }); + + const s50 = sessions[1]; + expect(s50.created_at).toBe(50); + expect(s50.first_message).toBe('Array user message'); + expect(s50.todo_data).toBeUndefined(); + + // Verify readFile was attempted for todos in HOME/todos/.json + const todosPath100 = join(HOME, 'todos', '100.json'); + const todosReadCall = fsPromises.readFile.mock.calls.find(c => c[0] === todosPath100); + expect(todosReadCall).toBeTruthy(); + }); + + it('falls back to decodeProjectPath when getProjectPathFromSessions fails', async () => { + const svc = new ProjectService(HOME); + const projectId = 'a-b-c'; + fsPromises.access.mockResolvedValueOnce(undefined); + // readdir for getProjectPathFromSessions + fsPromises.readdir + .mockResolvedValueOnce([dirent('1.jsonl', 'file')]) // for getProjectPathFromSessions + .mockResolvedValueOnce([dirent('1.jsonl', 'file')]); // main + + fsPromises.readFile + // fail on reading session for path extraction + .mockRejectedValueOnce(new Error('parse fail')) + // for getSessionFromFile + .mockResolvedValueOnce(JSON.stringify({ message: { role: 'user', content: 'hello' }, timestamp: 't' })); + + fsPromises.stat.mockResolvedValueOnce(statWithTimes({ ctime: 1 })); + + const sessions = await svc.getProjectSessions(projectId); + expect(sessions).toHaveLength(1); + expect(sessions[0].project_path).toBe('a/b/c'); + }); + }); + + describe('loadSessionHistory', () => { + it('returns file content for found session across projects', async () => { + const svc = new ProjectService(HOME); + + // listProjects -> access ok + fsPromises.access.mockResolvedValueOnce(undefined); + + // readdir projects list: two projects + fsPromises.readdir + .mockResolvedValueOnce([dirent('p1', 'dir'), dirent('p2', 'dir')]) // listProjects + // getProjectFromDirectory('p1') entries + .mockResolvedValueOnce([dirent('111.jsonl', 'file')]) + // getProjectFromDirectory('p2') entries + .mockResolvedValueOnce([dirent('222.jsonl', 'file'), dirent('target.jsonl', 'file')]); + + // getProjectPathFromSessions for p1, p2 + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/p1' }) + '\n') + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/p2' }) + '\n'); + + // stats: project dirs and sessions to compute most_recent_session + fsPromises.stat + .mockResolvedValueOnce(statWithTimes({ ctime: 10 })) // p1 + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 100 })) // p1/111 + .mockResolvedValueOnce(statWithTimes({ ctime: 20 })) // p2 + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 90 })) // p2/222 + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 200 })); // p2/target + + // Once listProjects done, loadSessionHistory reads the target + fsPromises.readFile.mockResolvedValueOnce('HISTORY CONTENT'); + + const content = await svc.loadSessionHistory('target'); + expect(content).toBe('HISTORY CONTENT'); + const expectedPath = join(PROJECTS_DIR, 'p2', 'target.jsonl'); + expect(fsPromises.readFile).toHaveBeenCalledWith(expectedPath, 'utf-8'); + }); + + it('throws if session not found in any project', async () => { + const svc = new ProjectService(HOME); + + fsPromises.access.mockResolvedValueOnce(undefined); + fsPromises.readdir + .mockResolvedValueOnce([dirent('p', 'dir')]); + fsPromises.readdir + .mockResolvedValueOnce([dirent('111.jsonl', 'file')]); // getProjectFromDirectory + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/p' }) + '\n'); + + fsPromises.stat + .mockResolvedValueOnce(statWithTimes({ ctime: 1 })) // project dir + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 2 })); // session + + await expect(svc.loadSessionHistory('nope')).rejects.toThrow('Session not found: nope'); + }); + }); + + describe('findClaudeMdFiles', () => { + it('recursively finds CLAUDE.md files ignoring hidden directories', async () => { + const svc = new ProjectService(HOME); + const root = '/r'; + + // Directory tree: + // /r + // /sub1 + // CLAUDE.md + // file.txt + // /.hidden + // CLAUDE.md (should be ignored) + // /sub2 + // /nested + // CLAUDE.md + // CLAUDE.md + // + // Simulate traversal order with readdir per directory encountered + fsPromises.readdir + .mockResolvedValueOnce([dirent('sub1', 'dir'), dirent('.hidden', 'dir'), dirent('sub2', 'dir'), dirent('CLAUDE.md', 'file')]) // /r + .mockResolvedValueOnce([dirent('CLAUDE.md', 'file'), dirent('file.txt', 'file')]) // /r/sub1 + // .hidden should be attempted but ignored because name startsWith('.'); still a readdir call is made only if code enters it, but code avoids recursion into hidden dirs + .mockResolvedValueOnce([dirent('nested', 'dir')]) // /r/sub2 + .mockResolvedValueOnce([dirent('CLAUDE.md', 'file')]); // /r/sub2/nested + + // stat for each discovered CLAUDE.md + fsPromises.stat + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 100 })) // /r/CLAUDE.md + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 110 })) // /r/sub1/CLAUDE.md + .mockResolvedValueOnce(statWithTimes({ ctime: 0, mtime: 150 })); // /r/sub2/nested/CLAUDE.md + + const files = await svc.findClaudeMdFiles(root); + + // Expect 3 files (root, sub1, sub2/nested). Ensure properties filled. + const rel = files.map(f => f.relative_path).sort(); + expect(rel).toEqual(['CLAUDE.md', 'sub1/CLAUDE.md', 'sub2/nested/CLAUDE.md']); + + const rootFile = files.find(f => f.relative_path === 'CLAUDE.md')!; + expect(rootFile.absolute_path).toBe(join(root, 'CLAUDE.md')); + expect(rootFile.size).toBeDefined(); + expect(rootFile.modified).toBe(100); + + const nestedFile = files.find(f => f.relative_path === 'sub2/nested/CLAUDE.md')!; + expect(nestedFile.modified).toBe(150); + }); + + it('logs and continues when encountering unreadable directories', async () => { + const svc = new ProjectService(HOME); + const root = '/r'; + + // First call throws, to simulate unreadable root (function should catch and warn, then return []) + fsPromises.readdir.mockRejectedValueOnce(new Error('permission denied')); + + const files = await svc.findClaudeMdFiles(root); + expect(files).toEqual([]); + }); + }); + + describe('readClaudeMdFile', () => { + it('reads CLAUDE.md content', async () => { + const svc = new ProjectService(HOME); + fsPromises.readFile.mockResolvedValueOnce('# Hello'); + + const content = await svc.readClaudeMdFile('/p/CLAUDE.md'); + expect(content).toBe('# Hello'); + expect(fsPromises.readFile).toHaveBeenCalledWith('/p/CLAUDE.md', 'utf-8'); + }); + }); + + describe('saveClaudeMdFile', () => { + it('writes CLAUDE.md content', async () => { + const svc = new ProjectService(HOME); + fsPromises.writeFile.mockResolvedValueOnce(undefined); + + await svc.saveClaudeMdFile('/p/CLAUDE.md', 'Body'); + expect(fsPromises.writeFile).toHaveBeenCalledWith('/p/CLAUDE.md', 'Body', 'utf-8'); + }); + }); + + describe('listDirectoryContents', () => { + it('lists files and directories with details and sorts dirs first then by name', async () => { + const svc = new ProjectService(HOME); + const path = '/code'; + + fsPromises.readdir.mockResolvedValueOnce([ + dirent('b.txt', 'file'), + dirent('a', 'dir'), + dirent('z', 'dir'), + dirent('a.txt', 'file'), + ]); + + // stat for each full path in iteration order + fsPromises.stat + .mockResolvedValueOnce({ size: 22, isDirectory: () => false }) // b.txt + .mockResolvedValueOnce({ size: 0, isDirectory: () => true }) // a (dir) - ignored since entry.isDirectory() used + .mockResolvedValueOnce({ size: 0, isDirectory: () => true }) // z (dir) - ignored + .mockResolvedValueOnce({ size: 11, isDirectory: () => false }); // a.txt + + const result = await svc.listDirectoryContents(path); + + // Sorted: directories first (a, z), then files (a.txt, b.txt) alphabetically + expect(result.map(e => e.name)).toEqual(['a', 'z', 'a.txt', 'b.txt']); + + const aTxt = result.find(e => e.name === 'a.txt')!; + expect(aTxt.extension).toBe('txt'); + expect(aTxt.size).toBe(11); + expect(aTxt.is_directory).toBe(false); + + const aDir = result.find(e => e.name === 'a')!; + expect(aDir.is_directory).toBe(true); + expect(aDir.size).toBe(0); + expect(aDir.extension).toBeUndefined(); + }); + + it('skips files that cannot be stat-ed', async () => { + const svc = new ProjectService(HOME); + const path = '/code'; + + fsPromises.readdir.mockResolvedValueOnce([ + dirent('ok.txt', 'file'), + dirent('bad.txt', 'file'), + ]); + + fsPromises.stat + .mockResolvedValueOnce({ size: 5 }) // ok.txt + .mockRejectedValueOnce(new Error('stat failed')); // bad.txt + + const result = await svc.listDirectoryContents(path); + + expect(result.map(e => e.name)).toEqual(['ok.txt']); + }); + }); + + describe('internal behaviors', () => { + it('getProjectPathFromSessions selects first JSONL with cwd and throws when none provide it', async () => { + const svc = new ProjectService(HOME); + const dir = '/p'; + + // Entries: two jsonl files + fsPromises.readdir.mockResolvedValueOnce([ + dirent('1.jsonl', 'file'), + dirent('2.jsonl', 'file'), + dirent('readme.md', 'file'), + ]); + + // read 1 -> no cwd; read 2 -> has cwd + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({}) + '\n') + .mockResolvedValueOnce(JSON.stringify({ cwd: '/work/p' }) + '\n'); + + // @ts-ignore accessing private method by casting to any for unit testing purposes + const path = await (svc as any).getProjectPathFromSessions(dir); + expect(path).toBe('/work/p'); + }); + + it('getProjectPathFromSessions throws if no sessions have cwd or files are unreadable', async () => { + const svc = new ProjectService(HOME); + const dir = '/p'; + + fsPromises.readdir.mockResolvedValueOnce([ + dirent('1.jsonl', 'file'), + dirent('2.jsonl', 'file'), + ]); + + fsPromises.readFile + .mockResolvedValueOnce(JSON.stringify({}) + '\n') + .mockResolvedValueOnce(JSON.stringify({ notcwd: true }) + '\n'); + + await expect((svc as any).getProjectPathFromSessions(dir)).rejects.toThrow('Could not determine project path from session files'); + }); + + it('decodeProjectPath decodes hyphens to slashes', () => { + const svc = new ProjectService(HOME); + // @ts-ignore + const decoded = (svc as any).decodeProjectPath('a-b-c'); + expect(decoded).toBe('a/b/c'); + }); + }); + +}); \ No newline at end of file