diff --git a/src/__tests__/main/claude-session-storage.test.ts b/src/__tests__/main/claude-session-storage.test.ts new file mode 100644 index 00000000..339cad8d --- /dev/null +++ b/src/__tests__/main/claude-session-storage.test.ts @@ -0,0 +1,960 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================================ +// Mocks (must be declared before imports) +// ============================================================================ + +vi.mock('../../main/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../../main/constants', () => ({ + CLAUDE_SESSION_PARSE_LIMITS: { + FIRST_MESSAGE_SCAN_LINES: 50, + FIRST_MESSAGE_PREVIEW_LENGTH: 200, + LAST_TIMESTAMP_SCAN_LINES: 10, + }, +})); + +vi.mock('../../main/utils/pricing', () => ({ + calculateClaudeCost: vi.fn((input: number, output: number, cacheRead: number, cacheCreation: number) => { + return (input * 3 + output * 15 + cacheRead * 0.3 + cacheCreation * 3.75) / 1_000_000; + }), +})); + +vi.mock('../../main/utils/statsCache', () => ({ + encodeClaudeProjectPath: vi.fn((p: string) => p.replace(/\//g, '-')), +})); + +vi.mock('../../main/utils/remote-fs', () => ({ + readDirRemote: vi.fn(), + readFileRemote: vi.fn(), + statRemote: vi.fn(), +})); + +// Mock electron-store: each instantiation gets its own isolated in-memory store +vi.mock('electron-store', () => { + const MockStore = function (this: Record) { + const data: Record = { origins: {} }; + this.get = vi.fn((key: string, defaultVal?: unknown) => data[key] ?? defaultVal); + this.set = vi.fn((key: string, value: unknown) => { + data[key] = value; + }); + }; + return { default: MockStore }; +}); + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + access: vi.fn(), + writeFile: vi.fn(), + }, +})); + +// ============================================================================ +// Imports (after mocks) +// ============================================================================ + +import { ClaudeSessionStorage } from '../../main/storage/claude-session-storage'; +import { calculateClaudeCost } from '../../main/utils/pricing'; +import Store from 'electron-store'; +import fs from 'fs/promises'; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Build a single JSONL line */ +function jsonl(...entries: Record[]): string { + return entries.map((e) => JSON.stringify(e)).join('\n'); +} + +/** Convenience: create a user message entry */ +function userMsg(content: string | unknown[], ts = '2025-06-01T10:00:00Z', uuid = 'u1') { + return { type: 'user', timestamp: ts, uuid, message: { role: 'user', content } }; +} + +/** Convenience: create an assistant message entry */ +function assistantMsg(content: string | unknown[], ts = '2025-06-01T10:01:00Z', uuid = 'a1') { + return { type: 'assistant', timestamp: ts, uuid, message: { role: 'assistant', content } }; +} + +/** Convenience: create a result entry with token usage */ +function resultEntry( + inputTokens: number, + outputTokens: number, + cacheRead = 0, + cacheCreation = 0, + ts = '2025-06-01T10:02:00Z' +) { + return { + type: 'result', + timestamp: ts, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheCreation, + }, + }; +} + +/** Default stats object for parseSessionContent calls via listSessions */ +const DEFAULT_STATS = { size: 1024, mtimeMs: new Date('2025-06-01T12:00:00Z').getTime() }; + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ClaudeSessionStorage', () => { + let storage: ClaudeSessionStorage; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new ClaudeSessionStorage(); + }); + + // ========================================================================== + // extractTextFromContent (tested indirectly via parseSessionContent / listSessions) + // ========================================================================== + + describe('extractTextFromContent (via session parsing)', () => { + it('should extract plain string content as the preview message', async () => { + const content = jsonl( + userMsg('Hello, how are you?'), + assistantMsg('I am doing well, thank you!') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-1.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // Assistant message is preferred as preview + expect(sessions[0].firstMessage).toBe('I am doing well, thank you!'); + }); + + it('should extract text from array content with type=text blocks', async () => { + const content = jsonl( + userMsg([ + { type: 'text', text: 'First part' }, + { type: 'image', source: {} }, + { type: 'text', text: 'Second part' }, + ]), + assistantMsg([ + { type: 'text', text: 'Response here' }, + { type: 'tool_use', id: 'tool-1', name: 'read_file' }, + ]) + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-2.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // Should have extracted the assistant text block only + expect(sessions[0].firstMessage).toBe('Response here'); + }); + + it('should return empty string for non-string, non-array content', async () => { + // Content that is neither string nor array (e.g., number, null, object) should yield '' + const content = jsonl( + { type: 'user', timestamp: '2025-06-01T10:00:00Z', uuid: 'u1', message: { role: 'user', content: 12345 } }, + { type: 'assistant', timestamp: '2025-06-01T10:01:00Z', uuid: 'a1', message: { role: 'assistant', content: null } } + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-3.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 200, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // No meaningful text extracted, so firstMessage should be empty + expect(sessions[0].firstMessage).toBe(''); + }); + + it('should skip text blocks that are whitespace-only', async () => { + const content = jsonl( + userMsg([ + { type: 'text', text: ' ' }, + { type: 'text', text: '' }, + ]), + assistantMsg('Actual response') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-4.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 300, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // Should fall through to assistant message since user text blocks are whitespace-only + expect(sessions[0].firstMessage).toBe('Actual response'); + }); + }); + + // ========================================================================== + // parseSessionContent (tested indirectly via listSessions) + // ========================================================================== + + describe('parseSessionContent (via listSessions)', () => { + it('should count user and assistant messages via regex', async () => { + const content = jsonl( + userMsg('msg 1'), + assistantMsg('reply 1'), + userMsg('msg 2', '2025-06-01T10:03:00Z', 'u2'), + assistantMsg('reply 2', '2025-06-01T10:04:00Z', 'a2'), + userMsg('msg 3', '2025-06-01T10:05:00Z', 'u3') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-count.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 2048, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // 3 user + 2 assistant = 5 + expect(sessions[0].messageCount).toBe(5); + }); + + it('should prefer first assistant message as preview over user message', async () => { + const content = jsonl( + userMsg('User says hello'), + assistantMsg('Assistant responds helpfully') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-preview.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 500, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].firstMessage).toBe('Assistant responds helpfully'); + }); + + it('should fall back to first user message when no assistant message exists', async () => { + const content = jsonl(userMsg('Only user message here')); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-fallback.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 300, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].firstMessage).toBe('Only user message here'); + }); + + it('should sum token counts using regex extraction', async () => { + const content = jsonl( + userMsg('Hello'), + assistantMsg('World'), + resultEntry(100, 50, 20, 10), + resultEntry(200, 75, 30, 15) + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-tokens.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + expect(sessions[0].inputTokens).toBe(300); // 100 + 200 + expect(sessions[0].outputTokens).toBe(125); // 50 + 75 + expect(sessions[0].cacheReadTokens).toBe(50); // 20 + 30 + expect(sessions[0].cacheCreationTokens).toBe(25); // 10 + 15 + }); + + it('should calculate cost via calculateClaudeCost', async () => { + const content = jsonl( + userMsg('Hello'), + assistantMsg('World'), + resultEntry(1000, 500, 200, 100) + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-cost.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(calculateClaudeCost).toHaveBeenCalledWith(1000, 500, 200, 100); + expect(sessions[0].costUsd).toBeDefined(); + // Using mock formula: (1000*3 + 500*15 + 200*0.3 + 100*3.75) / 1000000 + const expectedCost = (1000 * 3 + 500 * 15 + 200 * 0.3 + 100 * 3.75) / 1_000_000; + expect(sessions[0].costUsd).toBeCloseTo(expectedCost, 10); + }); + + it('should extract last timestamp for duration calculation', async () => { + const content = jsonl( + userMsg('Start', '2025-06-01T10:00:00Z'), + assistantMsg('Middle', '2025-06-01T10:05:00Z'), + resultEntry(10, 5, 0, 0, '2025-06-01T10:10:00Z') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-dur.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 800, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + // First timestamp comes from first user message: 10:00:00 + // Last timestamp comes from result entry: 10:10:00 + // Duration = 10 minutes = 600 seconds + expect(sessions[0].durationSeconds).toBe(600); + }); + + it('should truncate firstMessage to FIRST_MESSAGE_PREVIEW_LENGTH (200)', async () => { + const longMessage = 'A'.repeat(300); + const content = jsonl(assistantMsg(longMessage)); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-trunc.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 500, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].firstMessage).toHaveLength(200); + }); + + it('should set sizeBytes and modifiedAt from stats', async () => { + const content = jsonl(userMsg('Test')); + const mtimeMs = new Date('2025-07-15T08:30:00Z').getTime(); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-meta.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 4096, mtimeMs, mtime: new Date(mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].sizeBytes).toBe(4096); + expect(sessions[0].modifiedAt).toBe(new Date(mtimeMs).toISOString()); + }); + + it('should skip malformed JSONL lines gracefully', async () => { + const content = [ + JSON.stringify(userMsg('Valid message')), + 'this is not valid json', + JSON.stringify(assistantMsg('Valid reply')), + ].join('\n'); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-malformed.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + // Should still parse valid lines; 1 user + 1 assistant = 2 + expect(sessions[0].messageCount).toBe(2); + }); + + it('should filter empty lines from content', async () => { + const content = [ + JSON.stringify(userMsg('Hello')), + '', + ' ', + JSON.stringify(assistantMsg('World')), + ].join('\n'); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-empty.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 400, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions).toHaveLength(1); + expect(sessions[0].messageCount).toBe(2); + }); + + it('should filter out zero-byte sessions', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['empty.jsonl', 'valid.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockImplementation((filePath: unknown) => { + const fp = filePath as string; + if (fp.includes('empty')) { + return Promise.resolve({ size: 0, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) }) as unknown as ReturnType; + } + return Promise.resolve({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) }) as unknown as ReturnType; + }); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Content'))); + + const sessions = await storage.listSessions('/test/project'); + // Only valid.jsonl should remain (empty.jsonl has size 0) + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe('valid'); + }); + + it('should handle session with zero tokens', async () => { + const content = jsonl( + userMsg('Question'), + assistantMsg('Answer') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-notokens.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].inputTokens).toBe(0); + expect(sessions[0].outputTokens).toBe(0); + expect(sessions[0].cacheReadTokens).toBe(0); + expect(sessions[0].cacheCreationTokens).toBe(0); + }); + + it('should return empty array when project directory does not exist', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + const sessions = await storage.listSessions('/nonexistent/path'); + expect(sessions).toEqual([]); + }); + }); + + // ========================================================================== + // Origin Management + // ========================================================================== + + describe('registerSessionOrigin', () => { + it('should store origin as a plain string when no sessionName provided', () => { + storage.registerSessionOrigin('/test/project', 'sess-abc', 'user'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-abc']).toEqual({ origin: 'user' }); + }); + + it('should store origin with sessionName as an object', () => { + storage.registerSessionOrigin('/test/project', 'sess-abc', 'auto', 'My Session'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-abc']).toEqual({ + origin: 'auto', + sessionName: 'My Session', + }); + }); + + it('should handle multiple sessions in the same project', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user'); + storage.registerSessionOrigin('/test/project', 'sess-2', 'auto', 'Auto Session'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ origin: 'user' }); + expect(origins['sess-2']).toEqual({ origin: 'auto', sessionName: 'Auto Session' }); + }); + + it('should handle sessions across different projects', () => { + storage.registerSessionOrigin('/project-a', 'sess-1', 'user'); + storage.registerSessionOrigin('/project-b', 'sess-2', 'auto'); + + expect(storage.getSessionOrigins('/project-a')['sess-1']).toEqual({ origin: 'user' }); + expect(storage.getSessionOrigins('/project-b')['sess-2']).toEqual({ origin: 'auto' }); + }); + + it('should overwrite existing origin when re-registered', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user'); + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ origin: 'auto' }); + }); + }); + + describe('updateSessionName', () => { + it('should create entry with default origin "user" if no existing entry', () => { + storage.updateSessionName('/test/project', 'sess-new', 'Brand New'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-new']).toEqual({ + origin: 'user', + sessionName: 'Brand New', + }); + }); + + it('should upgrade a string origin to an object with sessionName', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto'); + storage.updateSessionName('/test/project', 'sess-1', 'Named Session'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'auto', + sessionName: 'Named Session', + }); + }); + + it('should update sessionName on an existing object origin', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user', 'Old Name'); + storage.updateSessionName('/test/project', 'sess-1', 'New Name'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'user', + sessionName: 'New Name', + }); + }); + + it('should preserve starred when updating sessionName on an existing object', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user'); + storage.updateSessionStarred('/test/project', 'sess-1', true); + storage.updateSessionName('/test/project', 'sess-1', 'Named'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'user', + sessionName: 'Named', + starred: true, + }); + }); + }); + + describe('updateSessionStarred', () => { + it('should create entry with default origin "user" if no existing entry', () => { + storage.updateSessionStarred('/test/project', 'sess-new', true); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-new']).toEqual({ + origin: 'user', + starred: true, + }); + }); + + it('should upgrade a string origin to an object with starred', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto'); + storage.updateSessionStarred('/test/project', 'sess-1', true); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'auto', + starred: true, + }); + }); + + it('should update starred on an existing object origin', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user', 'My Session'); + storage.updateSessionStarred('/test/project', 'sess-1', true); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'user', + sessionName: 'My Session', + starred: true, + }); + }); + + it('should be able to un-star a session', () => { + storage.updateSessionStarred('/test/project', 'sess-1', true); + storage.updateSessionStarred('/test/project', 'sess-1', false); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1'].starred).toBe(false); + }); + + it('should preserve sessionName when updating starred', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto', 'Important'); + storage.updateSessionStarred('/test/project', 'sess-1', true); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'auto', + sessionName: 'Important', + starred: true, + }); + }); + }); + + describe('updateSessionContextUsage', () => { + it('should create entry with default origin "user" if no existing entry', () => { + storage.updateSessionContextUsage('/test/project', 'sess-new', 75); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-new']).toEqual({ + origin: 'user', + contextUsage: 75, + }); + }); + + it('should upgrade a string origin to an object with contextUsage', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto'); + storage.updateSessionContextUsage('/test/project', 'sess-1', 50); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'auto', + contextUsage: 50, + }); + }); + + it('should update contextUsage on an existing object origin', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'user', 'Session'); + storage.updateSessionContextUsage('/test/project', 'sess-1', 85); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'user', + sessionName: 'Session', + contextUsage: 85, + }); + }); + + it('should preserve other fields when updating contextUsage', () => { + storage.registerSessionOrigin('/test/project', 'sess-1', 'auto', 'Named'); + storage.updateSessionStarred('/test/project', 'sess-1', true); + storage.updateSessionContextUsage('/test/project', 'sess-1', 42); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1']).toEqual({ + origin: 'auto', + sessionName: 'Named', + starred: true, + contextUsage: 42, + }); + }); + + it('should overwrite previous contextUsage value', () => { + storage.updateSessionContextUsage('/test/project', 'sess-1', 30); + storage.updateSessionContextUsage('/test/project', 'sess-1', 90); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-1'].contextUsage).toBe(90); + }); + }); + + // ========================================================================== + // getSessionOrigins + // ========================================================================== + + describe('getSessionOrigins', () => { + it('should normalize string origins to SessionOriginInfo objects', () => { + // Register a plain string origin + storage.registerSessionOrigin('/test/project', 'sess-plain', 'user'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-plain']).toEqual({ origin: 'user' }); + expect(origins['sess-plain'].sessionName).toBeUndefined(); + expect(origins['sess-plain'].starred).toBeUndefined(); + expect(origins['sess-plain'].contextUsage).toBeUndefined(); + }); + + it('should return full object origins with all fields', () => { + storage.registerSessionOrigin('/test/project', 'sess-full', 'auto', 'My Session'); + storage.updateSessionStarred('/test/project', 'sess-full', true); + storage.updateSessionContextUsage('/test/project', 'sess-full', 60); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-full']).toEqual({ + origin: 'auto', + sessionName: 'My Session', + starred: true, + contextUsage: 60, + }); + }); + + it('should return empty object for unknown project path', () => { + const origins = storage.getSessionOrigins('/unknown/project'); + expect(origins).toEqual({}); + }); + + it('should return origins for the correct project only', () => { + storage.registerSessionOrigin('/project-a', 'sess-1', 'user'); + storage.registerSessionOrigin('/project-b', 'sess-2', 'auto'); + + const originsA = storage.getSessionOrigins('/project-a'); + const originsB = storage.getSessionOrigins('/project-b'); + + expect(Object.keys(originsA)).toEqual(['sess-1']); + expect(Object.keys(originsB)).toEqual(['sess-2']); + }); + + it('should handle mixed string and object origins in the same project', () => { + storage.registerSessionOrigin('/test/project', 'sess-string', 'user'); + storage.registerSessionOrigin('/test/project', 'sess-object', 'auto', 'Named'); + + const origins = storage.getSessionOrigins('/test/project'); + expect(origins['sess-string']).toEqual({ origin: 'user' }); + expect(origins['sess-object']).toEqual({ origin: 'auto', sessionName: 'Named' }); + }); + }); + + // ========================================================================== + // getSessionPath + // ========================================================================== + + describe('getSessionPath', () => { + it('should return correct local file path', () => { + const result = storage.getSessionPath('/Users/test/my-project', 'sess-abc123'); + expect(result).not.toBeNull(); + // The path should contain the encoded project path and session id + expect(result).toContain('sess-abc123.jsonl'); + expect(result).toContain('.claude'); + expect(result).toContain('projects'); + }); + + it('should return remote POSIX path when sshConfig is provided', () => { + const sshConfig = { enabled: true, host: 'remote-host', user: 'testuser' }; + const result = storage.getSessionPath('/home/user/project', 'sess-remote', sshConfig as any); + + expect(result).not.toBeNull(); + expect(result).toContain('sess-remote.jsonl'); + expect(result).toContain('~/.claude/projects'); + // Remote paths use forward slashes (POSIX) + expect(result).not.toContain('\\'); + }); + + it('should use encodeClaudeProjectPath for the directory', async () => { + const { encodeClaudeProjectPath } = await import('../../main/utils/statsCache'); + storage.getSessionPath('/my/project', 'sess-1'); + expect(encodeClaudeProjectPath).toHaveBeenCalledWith('/my/project'); + }); + }); + + // ========================================================================== + // Constructor + // ========================================================================== + + describe('constructor', () => { + it('should use provided store when passed', () => { + const customStore = new Store({ name: 'custom', defaults: { origins: {} } }); + const customStorage = new ClaudeSessionStorage(customStore as any); + expect(customStorage).toBeDefined(); + }); + + it('should create a default store when none is provided', () => { + const defaultStorage = new ClaudeSessionStorage(); + expect(defaultStorage).toBeDefined(); + }); + + it('should have agentId set to claude-code', () => { + expect(storage.agentId).toBe('claude-code'); + }); + }); + + // ========================================================================== + // listSessions - directory and file handling + // ========================================================================== + + describe('listSessions', () => { + it('should only process .jsonl files', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + 'session-1.jsonl', + 'notes.txt', + 'readme.md', + 'session-2.jsonl', + '.hidden', + ] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Test'))); + + const sessions = await storage.listSessions('/test/project'); + // Should only have parsed the two .jsonl files + expect(sessions).toHaveLength(2); + const ids = sessions.map((s) => s.sessionId); + expect(ids).toContain('session-1'); + expect(ids).toContain('session-2'); + }); + + it('should sort sessions by modified date descending', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + 'old.jsonl', + 'new.jsonl', + ] as unknown as Awaited>); + vi.mocked(fs.stat).mockImplementation((filePath: unknown) => { + const fp = filePath as string; + if (fp.includes('old')) { + return Promise.resolve({ + size: 256, + mtimeMs: new Date('2025-01-01T00:00:00Z').getTime(), + mtime: new Date('2025-01-01T00:00:00Z'), + }) as unknown as ReturnType; + } + return Promise.resolve({ + size: 256, + mtimeMs: new Date('2025-06-15T00:00:00Z').getTime(), + mtime: new Date('2025-06-15T00:00:00Z'), + }) as unknown as ReturnType; + }); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Test'))); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].sessionId).toBe('new'); + expect(sessions[1].sessionId).toBe('old'); + }); + + it('should attach origin info to sessions', async () => { + storage.registerSessionOrigin('/test/project', 'sess-with-origin', 'auto', 'My Auto Session'); + storage.updateSessionStarred('/test/project', 'sess-with-origin', true); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-with-origin.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Hello'))); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].origin).toBe('auto'); + expect(sessions[0].sessionName).toBe('My Auto Session'); + expect(sessions[0].starred).toBe(true); + }); + + it('should set sessionId from filename without extension', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['abc-123-def.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Test'))); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].sessionId).toBe('abc-123-def'); + }); + + it('should set projectPath on each session', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['s1.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(jsonl(userMsg('Test'))); + + const sessions = await storage.listSessions('/my/special/project'); + expect(sessions[0].projectPath).toBe('/my/special/project'); + }); + }); + + // ========================================================================== + // Edge cases for token regex extraction + // ========================================================================== + + describe('token regex extraction edge cases', () => { + it('should handle multiple token entries scattered throughout content', async () => { + const content = jsonl( + userMsg('Hello'), + { type: 'result', timestamp: '2025-06-01T10:01:00Z', usage: { input_tokens: 50, output_tokens: 25 } }, + assistantMsg('Reply'), + { type: 'result', timestamp: '2025-06-01T10:02:00Z', usage: { input_tokens: 75, output_tokens: 50, cache_read_input_tokens: 10 } }, + userMsg('Follow up', '2025-06-01T10:03:00Z', 'u2'), + { type: 'result', timestamp: '2025-06-01T10:04:00Z', usage: { input_tokens: 100, output_tokens: 30, cache_creation_input_tokens: 5 } } + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-multi.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 2048, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].inputTokens).toBe(225); // 50 + 75 + 100 + expect(sessions[0].outputTokens).toBe(105); // 25 + 50 + 30 + expect(sessions[0].cacheReadTokens).toBe(10); + expect(sessions[0].cacheCreationTokens).toBe(5); + }); + + it('should handle content with no token information at all', async () => { + const content = jsonl( + userMsg('Just a message'), + assistantMsg('Just a reply') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-notoken.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].inputTokens).toBe(0); + expect(sessions[0].outputTokens).toBe(0); + }); + }); + + // ========================================================================== + // Duration calculation edge cases + // ========================================================================== + + describe('duration calculation', () => { + it('should return 0 duration when only one timestamp exists', async () => { + const content = jsonl( + userMsg('Single message', '2025-06-01T10:00:00Z') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-single.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 128, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].durationSeconds).toBe(0); + }); + + it('should never return negative duration', async () => { + // If last timestamp is somehow before first timestamp, + // Math.max(0, ...) ensures non-negative + const content = jsonl( + userMsg('Later message', '2025-06-01T12:00:00Z'), + assistantMsg('Earlier response', '2025-06-01T10:00:00Z') + ); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue(['sess-neg.jsonl'] as unknown as Awaited>); + vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited>); + vi.mocked(fs.readFile).mockResolvedValue(content); + + const sessions = await storage.listSessions('/test/project'); + expect(sessions[0].durationSeconds).toBeGreaterThanOrEqual(0); + }); + }); + + // ========================================================================== + // Combined origin operations (integration-style) + // ========================================================================== + + describe('combined origin operations', () => { + it('should support full lifecycle: register, name, star, contextUsage, read', () => { + // Register + storage.registerSessionOrigin('/proj', 'sess-lc', 'auto'); + expect(storage.getSessionOrigins('/proj')['sess-lc']).toEqual({ origin: 'auto' }); + + // Name + storage.updateSessionName('/proj', 'sess-lc', 'Lifecycle Test'); + expect(storage.getSessionOrigins('/proj')['sess-lc']).toEqual({ + origin: 'auto', + sessionName: 'Lifecycle Test', + }); + + // Star + storage.updateSessionStarred('/proj', 'sess-lc', true); + expect(storage.getSessionOrigins('/proj')['sess-lc']).toEqual({ + origin: 'auto', + sessionName: 'Lifecycle Test', + starred: true, + }); + + // Context usage + storage.updateSessionContextUsage('/proj', 'sess-lc', 95); + expect(storage.getSessionOrigins('/proj')['sess-lc']).toEqual({ + origin: 'auto', + sessionName: 'Lifecycle Test', + starred: true, + contextUsage: 95, + }); + }); + + it('should handle multiple sessions in different projects independently', () => { + storage.registerSessionOrigin('/proj-a', 'sess-1', 'user', 'Alpha'); + storage.registerSessionOrigin('/proj-b', 'sess-1', 'auto', 'Beta'); + + storage.updateSessionStarred('/proj-a', 'sess-1', true); + + const originsA = storage.getSessionOrigins('/proj-a'); + const originsB = storage.getSessionOrigins('/proj-b'); + + expect(originsA['sess-1'].starred).toBe(true); + expect(originsB['sess-1'].starred).toBeUndefined(); + expect(originsA['sess-1'].origin).toBe('user'); + expect(originsB['sess-1'].origin).toBe('auto'); + }); + }); +}); diff --git a/src/__tests__/main/group-chat/session-recovery.test.ts b/src/__tests__/main/group-chat/session-recovery.test.ts new file mode 100644 index 00000000..cd13dfbb --- /dev/null +++ b/src/__tests__/main/group-chat/session-recovery.test.ts @@ -0,0 +1,694 @@ +/** + * @file session-recovery.test.ts + * @description Unit tests for the Group Chat session recovery module. + * + * Tests cover: + * - detectSessionNotFoundError: detecting session-not-found errors from agent output + * - needsSessionRecovery: delegation to detectSessionNotFoundError + * - buildRecoveryContext: constructing recovery context with chat history + * - initiateSessionRecovery: clearing agentSessionId for re-spawn + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock dependencies before importing the module under test +vi.mock('../../../main/parsers/error-patterns', () => ({ + getErrorPatterns: vi.fn(() => ({})), + matchErrorPattern: vi.fn(() => null), +})); + +vi.mock('../../../main/group-chat/group-chat-log', () => ({ + readLog: vi.fn(async () => []), +})); + +vi.mock('../../../main/group-chat/group-chat-storage', () => ({ + loadGroupChat: vi.fn(async () => null), + updateParticipant: vi.fn(async () => ({})), + getGroupChatDir: vi.fn(() => '/tmp/gc'), +})); + +vi.mock('../../../main/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + detectSessionNotFoundError, + needsSessionRecovery, + buildRecoveryContext, + initiateSessionRecovery, +} from '../../../main/group-chat/session-recovery'; + +import { getErrorPatterns, matchErrorPattern } from '../../../main/parsers/error-patterns'; +import { readLog } from '../../../main/group-chat/group-chat-log'; +import { + loadGroupChat, + updateParticipant, +} from '../../../main/group-chat/group-chat-storage'; + +const mockedGetErrorPatterns = vi.mocked(getErrorPatterns); +const mockedMatchErrorPattern = vi.mocked(matchErrorPattern); +const mockedReadLog = vi.mocked(readLog); +const mockedLoadGroupChat = vi.mocked(loadGroupChat); +const mockedUpdateParticipant = vi.mocked(updateParticipant); + +describe('group-chat/session-recovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ======================================================================== + // detectSessionNotFoundError + // ======================================================================== + + describe('detectSessionNotFoundError', () => { + it('should return false for empty string', () => { + expect(detectSessionNotFoundError('')).toBe(false); + }); + + it('should return false for undefined-ish empty output', () => { + // The function checks `if (!output)` so empty string returns false + expect(detectSessionNotFoundError('')).toBe(false); + }); + + it('should return false for normal output with no error patterns', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Hello, I am working on your task. Everything looks good.'; + expect(detectSessionNotFoundError(output)).toBe(false); + }); + + it('should call getErrorPatterns with provided agentId', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + detectSessionNotFoundError('some output', 'opencode'); + expect(mockedGetErrorPatterns).toHaveBeenCalledWith('opencode'); + }); + + it('should default to claude-code when agentId is not provided', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + detectSessionNotFoundError('some output'); + expect(mockedGetErrorPatterns).toHaveBeenCalledWith('claude-code'); + }); + + it('should return true when matchErrorPattern returns session_not_found type', () => { + const mockPatterns = { session_not_found: [] }; + mockedGetErrorPatterns.mockReturnValue(mockPatterns); + mockedMatchErrorPattern.mockReturnValue({ + type: 'session_not_found', + message: 'Session not found. The session may have been deleted.', + recoverable: true, + }); + + const output = 'Error: no conversation found with session id abc-123'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return false when matchErrorPattern returns a different error type', () => { + const mockPatterns = {}; + mockedGetErrorPatterns.mockReturnValue(mockPatterns); + mockedMatchErrorPattern.mockReturnValue({ + type: 'auth_expired', + message: 'Authentication failed.', + recoverable: true, + }); + + // The function only returns true for type === 'session_not_found' + // and the raw fallback patterns below won't match this output + const output = 'Authentication failed please login again'; + expect(detectSessionNotFoundError(output)).toBe(false); + }); + + it('should check each line of multi-line output against matchErrorPattern', () => { + const mockPatterns = {}; + mockedGetErrorPatterns.mockReturnValue(mockPatterns); + + // Return null for first line, session_not_found for second line + mockedMatchErrorPattern + .mockReturnValueOnce(null) + .mockReturnValueOnce({ + type: 'session_not_found', + message: 'Session not found.', + recoverable: true, + }); + + const output = 'First line of output\nSession not found for id xyz'; + expect(detectSessionNotFoundError(output)).toBe(true); + // Should have been called twice (once per line) before finding the match + expect(mockedMatchErrorPattern).toHaveBeenCalledTimes(2); + }); + + // Raw regex pattern tests (fallback patterns that run after matchErrorPattern) + + it('should return true for raw pattern "no conversation found with session id"', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Error: no conversation found with session id abc-123-def'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return true for raw pattern "no conversation found with session id" (case insensitive)', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'NO CONVERSATION FOUND WITH SESSION ID xyz'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return true for raw pattern "Session not found"', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Error: Session not found'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return true for raw pattern "session was not found"', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'The session was not found in the system'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return true for raw pattern "invalid session id"', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Error: invalid session id provided'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return true for raw pattern "Invalid Session ID" (case insensitive)', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Invalid Session ID: abc-123'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should return false when output contains unrelated errors', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + const output = 'Error: rate limit exceeded. Please try again later.'; + expect(detectSessionNotFoundError(output)).toBe(false); + }); + + it('should check raw patterns against the full output (not line-by-line)', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + // The raw patterns test against the full output string + const output = 'Line 1: some normal output\nLine 2: invalid session id found\nLine 3: done'; + expect(detectSessionNotFoundError(output)).toBe(true); + }); + + it('should prefer matchErrorPattern result over raw patterns', () => { + const mockPatterns = {}; + mockedGetErrorPatterns.mockReturnValue(mockPatterns); + mockedMatchErrorPattern.mockReturnValue({ + type: 'session_not_found', + message: 'Session not found.', + recoverable: true, + }); + + const output = 'session not found'; + const result = detectSessionNotFoundError(output); + expect(result).toBe(true); + // matchErrorPattern was called and returned a match, so function returns early + // The raw regex patterns would also match, but we expect the structured match first + expect(mockedMatchErrorPattern).toHaveBeenCalled(); + }); + }); + + // ======================================================================== + // needsSessionRecovery + // ======================================================================== + + describe('needsSessionRecovery', () => { + it('should delegate to detectSessionNotFoundError', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + expect(needsSessionRecovery('')).toBe(false); + expect(needsSessionRecovery('normal output')).toBe(false); + }); + + it('should return true when session error is detected', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + expect(needsSessionRecovery('Session not found')).toBe(true); + }); + + it('should pass agentId through to detectSessionNotFoundError', () => { + mockedGetErrorPatterns.mockReturnValue({}); + mockedMatchErrorPattern.mockReturnValue(null); + + needsSessionRecovery('some output', 'codex'); + expect(mockedGetErrorPatterns).toHaveBeenCalledWith('codex'); + }); + }); + + // ======================================================================== + // buildRecoveryContext + // ======================================================================== + + describe('buildRecoveryContext', () => { + it('should return empty string when chat is not found', async () => { + mockedLoadGroupChat.mockResolvedValue(null); + + const result = await buildRecoveryContext('non-existent-id', 'Alice'); + expect(result).toBe(''); + }); + + it('should return empty string when there are no messages', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toBe(''); + }); + + it('should include "Session Recovery Context" header', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Architecture Discussion', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: 'Hello everyone' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('## Session Recovery Context'); + }); + + it('should include the chat name in the context', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Architecture Discussion', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: 'Hello' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('Architecture Discussion'); + }); + + it('should include participant own statements section when they have messages', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Alice', content: 'I think we should use TypeScript' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Bob', content: 'Good idea' }, + { timestamp: '2025-01-15T10:02:00.000Z', from: 'Alice', content: 'Let me explain further' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('### Your Previous Statements (as Alice)'); + expect(result).toContain('You previously said the following'); + expect(result).toContain('I think we should use TypeScript'); + expect(result).toContain('Let me explain further'); + }); + + it('should not include "Your Previous Statements" section when participant has no messages', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: 'Hello' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Charlie', content: 'Hi there' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).not.toContain('### Your Previous Statements'); + // Should still have conversation history + expect(result).toContain('### Recent Conversation History'); + }); + + it('should include recent conversation history with all messages', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Alice', content: 'My message' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Bob', content: 'Bob reply' }, + { timestamp: '2025-01-15T10:02:00.000Z', from: 'Charlie', content: 'Charlie reply' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('### Recent Conversation History'); + expect(result).toContain('My message'); + expect(result).toContain('Bob reply'); + expect(result).toContain('Charlie reply'); + }); + + it('should mark participant own messages with **YOU** prefix in conversation history', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Alice', content: 'Hello from Alice' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Bob', content: 'Hello from Bob' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('**YOU (Alice):**'); + expect(result).toContain('[Bob]:'); + }); + + it('should respect lastMessages limit', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + const messages = []; + for (let i = 0; i < 10; i++) { + messages.push({ + timestamp: `2025-01-15T10:${String(i).padStart(2, '0')}:00.000Z`, + from: i % 2 === 0 ? 'Alice' : 'Bob', + content: `Message ${i}`, + }); + } + mockedReadLog.mockResolvedValue(messages); + + // Request only last 3 messages + const result = await buildRecoveryContext('chat-1', 'Alice', 3); + + // Should contain only the last 3 messages (index 7, 8, 9) + expect(result).toContain('Message 7'); + expect(result).toContain('Message 8'); + expect(result).toContain('Message 9'); + + // Should NOT contain earlier messages + expect(result).not.toContain('Message 0'); + expect(result).not.toContain('Message 6'); + }); + + it('should use default lastMessages of 30', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + const messages = []; + for (let i = 0; i < 40; i++) { + messages.push({ + timestamp: `2025-01-15T10:${String(i).padStart(2, '0')}:00.000Z`, + from: 'Bob', + content: `Message ${i}`, + }); + } + mockedReadLog.mockResolvedValue(messages); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + + // Default is 30, so messages 10-39 should be included + expect(result).toContain('Message 10'); + expect(result).toContain('Message 39'); + + // Messages 0-9 should NOT be included + expect(result).not.toContain('Message 0'); + expect(result).not.toContain('Message 9'); + }); + + it('should truncate own messages longer than 1000 characters', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + const longContent = 'A'.repeat(1500); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Alice', content: longContent }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + + // The "Your Previous Statements" section truncates at 1000 chars + // It should contain the first 1000 chars followed by "..." + const yourStatementsSection = result.split('### Recent Conversation History')[0]; + expect(yourStatementsSection).toContain('A'.repeat(1000)); + expect(yourStatementsSection).toContain('...'); + // The full 1500 chars should NOT be present in that section + expect(yourStatementsSection).not.toContain('A'.repeat(1001)); + }); + + it('should truncate conversation history messages longer than 500 characters', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + const longContent = 'B'.repeat(800); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: longContent }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + + // In "Recent Conversation History", all messages truncate at 500 chars + const historySection = result.split('### Recent Conversation History')[1]; + expect(historySection).toContain('B'.repeat(500)); + expect(historySection).toContain('...'); + expect(historySection).not.toContain('B'.repeat(501)); + }); + + it('should not add ellipsis to messages within truncation limits', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Alice', content: 'Short message from Alice' }, + { timestamp: '2025-01-15T10:01:00.000Z', from: 'Bob', content: 'Short message from Bob' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + + // Count occurrences of "..." - should not have any truncation ellipsis + // (Note: the context does include "..." in other places, but not from truncation) + expect(result).toContain('Short message from Alice'); + expect(result).toContain('Short message from Bob'); + }); + + it('should include continuity instruction at the end', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: 'Hello' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('Please continue from where you left off'); + expect(result).toContain('Maintain consistency with your previous statements'); + }); + + it('should call readLog with the chat logPath', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/custom/path/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([]); + + await buildRecoveryContext('chat-1', 'Alice'); + expect(mockedReadLog).toHaveBeenCalledWith('/custom/path/chat.log'); + }); + + it('should include session unavailability explanation', async () => { + mockedLoadGroupChat.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + mockedReadLog.mockResolvedValue([ + { timestamp: '2025-01-15T10:00:00.000Z', from: 'Bob', content: 'Hello' }, + ]); + + const result = await buildRecoveryContext('chat-1', 'Alice'); + expect(result).toContain('Your previous session was unavailable'); + expect(result).toContain('fresh session'); + }); + }); + + // ======================================================================== + // initiateSessionRecovery + // ======================================================================== + + describe('initiateSessionRecovery', () => { + it('should return true and call updateParticipant to clear agentSessionId', async () => { + mockedUpdateParticipant.mockResolvedValue({ + id: 'chat-1', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + const result = await initiateSessionRecovery('chat-1', 'Alice'); + expect(result).toBe(true); + expect(mockedUpdateParticipant).toHaveBeenCalledWith('chat-1', 'Alice', { + agentSessionId: undefined, + }); + }); + + it('should return false when updateParticipant throws an error', async () => { + mockedUpdateParticipant.mockRejectedValue(new Error('Group chat not found: chat-99')); + + const result = await initiateSessionRecovery('chat-99', 'Alice'); + expect(result).toBe(false); + }); + + it('should call updateParticipant with the correct groupChatId and participantName', async () => { + mockedUpdateParticipant.mockResolvedValue({ + id: 'my-chat', + name: 'Test Chat', + logPath: '/tmp/chat.log', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'mod-session-1', + participants: [], + imagesDir: '/tmp/images', + }); + + await initiateSessionRecovery('my-chat', 'Bob'); + expect(mockedUpdateParticipant).toHaveBeenCalledWith('my-chat', 'Bob', { + agentSessionId: undefined, + }); + }); + + it('should return false when updateParticipant throws a non-standard error', async () => { + mockedUpdateParticipant.mockRejectedValue('string error'); + + const result = await initiateSessionRecovery('chat-1', 'Alice'); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/__tests__/main/history-manager.test.ts b/src/__tests__/main/history-manager.test.ts new file mode 100644 index 00000000..125449b9 --- /dev/null +++ b/src/__tests__/main/history-manager.test.ts @@ -0,0 +1,1390 @@ +/** + * Tests for the HistoryManager class + * + * HistoryManager handles per-session history storage with automatic migration + * from a legacy single-file format. Each session gets its own JSON file in a + * dedicated history/ subdirectory. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as path from 'path'; + +// Mock electron +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +// Mock logger +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock fs module +vi.mock('fs', () => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + readdirSync: vi.fn(), + unlinkSync: vi.fn(), + watch: vi.fn(), +})); + +import * as fs from 'fs'; +import { app } from 'electron'; +import { logger } from '../../main/utils/logger'; +import { HistoryManager, getHistoryManager } from '../../main/history-manager'; +import { + HISTORY_VERSION, + MAX_ENTRIES_PER_SESSION, + sanitizeSessionId, +} from '../../shared/history'; +import type { HistoryEntry } from '../../shared/types'; + +// Type the mocked fs functions +const mockExistsSync = vi.mocked(fs.existsSync); +const mockMkdirSync = vi.mocked(fs.mkdirSync); +const mockReadFileSync = vi.mocked(fs.readFileSync); +const mockWriteFileSync = vi.mocked(fs.writeFileSync); +const mockReaddirSync = vi.mocked(fs.readdirSync); +const mockUnlinkSync = vi.mocked(fs.unlinkSync); +const mockWatch = vi.mocked(fs.watch); + +/** + * Helper to create a mock HistoryEntry + */ +function createMockEntry(overrides: Partial = {}): HistoryEntry { + return { + id: `entry-${Math.random().toString(36).slice(2, 8)}`, + type: 'USER', + timestamp: Date.now(), + summary: 'Test summary', + projectPath: '/test/project', + sessionId: 'session-1', + ...overrides, + }; +} + +/** + * Helper to create a serialized history file data string + */ +function createHistoryFileData( + sessionId: string, + entries: HistoryEntry[], + projectPath = '/test/project' +): string { + return JSON.stringify({ + version: HISTORY_VERSION, + sessionId, + projectPath, + entries, + }); +} + +describe('HistoryManager', () => { + let manager: HistoryManager; + + beforeEach(() => { + vi.resetAllMocks(); + // Default: nothing exists + mockExistsSync.mockReturnValue(false); + manager = new HistoryManager(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ---------------------------------------------------------------- + // Constructor + // ---------------------------------------------------------------- + describe('constructor', () => { + it('should set up paths based on app.getPath("userData")', () => { + expect(app.getPath).toHaveBeenCalledWith('userData'); + expect(manager.getHistoryDir()).toBe(path.join('/mock/userData', 'history')); + expect(manager.getLegacyFilePath()).toBe( + path.join('/mock/userData', 'maestro-history.json') + ); + }); + }); + + // ---------------------------------------------------------------- + // initialize() + // ---------------------------------------------------------------- + describe('initialize()', () => { + it('should create history directory if it does not exist', async () => { + mockExistsSync.mockReturnValue(false); + await manager.initialize(); + + expect(mockMkdirSync).toHaveBeenCalledWith( + path.join('/mock/userData', 'history'), + { recursive: true } + ); + }); + + it('should not recreate history directory if it already exists', async () => { + // history dir exists, marker exists (no migration) + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return true; + return false; + }); + + await manager.initialize(); + expect(mockMkdirSync).not.toHaveBeenCalled(); + }); + + it('should run migration if needed', async () => { + // history dir does not exist, marker does not exist, legacy file exists with entries + const legacyEntries = [createMockEntry({ sessionId: 'sess-1' })]; + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue( + JSON.stringify({ entries: legacyEntries }) + ); + + await manager.initialize(); + + // Should have written a session file and a migration marker + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it('should not run migration if marker already exists', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return true; + return false; + }); + + await manager.initialize(); + + // No session file writes expected + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // needsMigration() (tested indirectly through initialize) + // ---------------------------------------------------------------- + describe('needsMigration (via initialize)', () => { + it('should not need migration when marker exists', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return true; + return false; + }); + + await manager.initialize(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should need migration when legacy file has entries', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue( + JSON.stringify({ entries: [createMockEntry({ sessionId: 's1' })] }) + ); + + await manager.initialize(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it('should not need migration when legacy file is empty', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ entries: [] })); + + await manager.initialize(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should not need migration when legacy file does not exist', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return false; + return false; + }); + + await manager.initialize(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should not need migration when legacy file is malformed', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return true; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue('not-json{{{'); + + await manager.initialize(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // hasMigrated() + // ---------------------------------------------------------------- + describe('hasMigrated()', () => { + it('should return true when migration marker exists', () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + return p.toString().endsWith('history-migrated.json'); + }); + expect(manager.hasMigrated()).toBe(true); + }); + + it('should return false when migration marker does not exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.hasMigrated()).toBe(false); + }); + }); + + // ---------------------------------------------------------------- + // migrateFromLegacy() (tested via initialize) + // ---------------------------------------------------------------- + describe('migrateFromLegacy (via initialize)', () => { + it('should group entries by sessionId and write per-session files', async () => { + const entry1 = createMockEntry({ sessionId: 'sess-a', id: 'e1', projectPath: '/projA' }); + const entry2 = createMockEntry({ sessionId: 'sess-a', id: 'e2', projectPath: '/projA' }); + const entry3 = createMockEntry({ sessionId: 'sess-b', id: 'e3', projectPath: '/projB' }); + + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue( + JSON.stringify({ entries: [entry1, entry2, entry3] }) + ); + + await manager.initialize(); + + // Should write two session files + migration marker = 3 writes + // (mkdirSync for history dir also called) + const writeCalls = mockWriteFileSync.mock.calls; + expect(writeCalls.length).toBe(3); // sess-a.json, sess-b.json, migration marker + + // Check session file for sess-a + const sessACall = writeCalls.find((c) => + c[0].toString().includes(`sess-a.json`) + ); + expect(sessACall).toBeDefined(); + const sessAData = JSON.parse(sessACall![1] as string); + expect(sessAData.entries).toHaveLength(2); + expect(sessAData.sessionId).toBe('sess-a'); + + // Check session file for sess-b + const sessBCall = writeCalls.find((c) => + c[0].toString().includes(`sess-b.json`) + ); + expect(sessBCall).toBeDefined(); + const sessBData = JSON.parse(sessBCall![1] as string); + expect(sessBData.entries).toHaveLength(1); + expect(sessBData.sessionId).toBe('sess-b'); + }); + + it('should create migration marker with correct metadata', async () => { + const entries = [ + createMockEntry({ sessionId: 'sess-1' }), + createMockEntry({ sessionId: 'sess-2' }), + ]; + + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ entries })); + + await manager.initialize(); + + const markerCall = mockWriteFileSync.mock.calls.find((c) => + c[0].toString().endsWith('history-migrated.json') + ); + expect(markerCall).toBeDefined(); + const marker = JSON.parse(markerCall![1] as string); + expect(marker.version).toBe(HISTORY_VERSION); + expect(marker.legacyEntryCount).toBe(2); + expect(marker.sessionsMigrated).toBe(2); + expect(typeof marker.migratedAt).toBe('number'); + }); + + it('should skip orphaned entries (no sessionId)', async () => { + const goodEntry = createMockEntry({ sessionId: 'sess-1', id: 'good' }); + const orphanedEntry = createMockEntry({ id: 'orphan' }); + delete (orphanedEntry as Partial).sessionId; + + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue( + JSON.stringify({ entries: [goodEntry, orphanedEntry] }) + ); + + await manager.initialize(); + + // Should write 1 session file + migration marker + expect(mockWriteFileSync).toHaveBeenCalledTimes(2); + + // Marker should reflect total entry count (including orphaned) + const markerCall = mockWriteFileSync.mock.calls.find((c) => + c[0].toString().endsWith('history-migrated.json') + ); + const marker = JSON.parse(markerCall![1] as string); + expect(marker.legacyEntryCount).toBe(2); + expect(marker.sessionsMigrated).toBe(1); + + // Should log that orphaned entries were skipped + expect(vi.mocked(logger.info)).toHaveBeenCalledWith( + expect.stringContaining('Skipped 1 orphaned entries'), + expect.any(String) + ); + }); + + it('should trim entries to MAX_ENTRIES_PER_SESSION per session during migration', async () => { + // Create more entries than the limit for a single session + const entries: HistoryEntry[] = []; + for (let i = 0; i < MAX_ENTRIES_PER_SESSION + 50; i++) { + entries.push(createMockEntry({ sessionId: 'sess-big', id: `e-${i}` })); + } + + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ entries })); + + await manager.initialize(); + + const sessionCall = mockWriteFileSync.mock.calls.find((c) => + c[0].toString().includes('sess-big.json') + ); + const sessionData = JSON.parse(sessionCall![1] as string); + expect(sessionData.entries.length).toBe(MAX_ENTRIES_PER_SESSION); + }); + + it('should throw and log error if migration fails', async () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => { + const s = p.toString(); + if (s.endsWith('history')) return false; + if (s.endsWith('history-migrated.json')) return false; + if (s.endsWith('maestro-history.json')) return true; + return false; + }); + // First call (needsMigration) succeeds; second call (migrateFromLegacy) throws + mockReadFileSync + .mockReturnValueOnce(JSON.stringify({ entries: [createMockEntry({ sessionId: 's1' })] })) + .mockImplementationOnce(() => { + throw new Error('Disk read error'); + }); + + await expect(manager.initialize()).rejects.toThrow('Disk read error'); + expect(vi.mocked(logger.error)).toHaveBeenCalledWith( + expect.stringContaining('History migration failed'), + expect.any(String) + ); + }); + }); + + // ---------------------------------------------------------------- + // getEntries(sessionId) + // ---------------------------------------------------------------- + describe('getEntries()', () => { + it('should return entries from session file', () => { + const entries = [createMockEntry({ id: 'e1' }), createMockEntry({ id: 'e2' })]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + const result = manager.getEntries('session-1'); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('e1'); + }); + + it('should return empty array if session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + const result = manager.getEntries('nonexistent'); + expect(result).toEqual([]); + }); + + it('should return empty array on read error', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockImplementation(() => { + throw new Error('Read error'); + }); + + const result = manager.getEntries('session-1'); + expect(result).toEqual([]); + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + }); + + it('should return empty array when file contains malformed JSON', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue('not valid json'); + + const result = manager.getEntries('session-1'); + expect(result).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // addEntry(sessionId, projectPath, entry) + // ---------------------------------------------------------------- + describe('addEntry()', () => { + it('should create a new file when session does not exist', () => { + mockExistsSync.mockReturnValue(false); + const entry = createMockEntry({ id: 'new-entry' }); + + manager.addEntry('session-1', '/test/project', entry); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries).toHaveLength(1); + expect(written.entries[0].id).toBe('new-entry'); + expect(written.sessionId).toBe('session-1'); + expect(written.projectPath).toBe('/test/project'); + expect(written.version).toBe(HISTORY_VERSION); + }); + + it('should prepend entry to beginning of existing file', () => { + const existingEntry = createMockEntry({ id: 'old' }); + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue( + createHistoryFileData('session-1', [existingEntry]) + ); + + const newEntry = createMockEntry({ id: 'new' }); + manager.addEntry('session-1', '/test/project', newEntry); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries).toHaveLength(2); + expect(written.entries[0].id).toBe('new'); + expect(written.entries[1].id).toBe('old'); + }); + + it('should trim to MAX_ENTRIES_PER_SESSION', () => { + const existingEntries: HistoryEntry[] = []; + for (let i = 0; i < MAX_ENTRIES_PER_SESSION; i++) { + existingEntries.push(createMockEntry({ id: `e-${i}` })); + } + + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue( + createHistoryFileData('session-1', existingEntries) + ); + + const newEntry = createMockEntry({ id: 'overflow' }); + manager.addEntry('session-1', '/test/project', newEntry); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries).toHaveLength(MAX_ENTRIES_PER_SESSION); + expect(written.entries[0].id).toBe('overflow'); + }); + + it('should update projectPath on existing file', () => { + const existingEntry = createMockEntry({ id: 'e1' }); + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue( + createHistoryFileData('session-1', [existingEntry], '/old/path') + ); + + const newEntry = createMockEntry({ id: 'e2' }); + manager.addEntry('session-1', '/new/path', newEntry); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.projectPath).toBe('/new/path'); + }); + + it('should create fresh data when existing file is corrupted', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue('corrupted-json{{{'); + + const entry = createMockEntry({ id: 'new-entry' }); + manager.addEntry('session-1', '/test/project', entry); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries).toHaveLength(1); + expect(written.entries[0].id).toBe('new-entry'); + }); + + it('should log error on write failure', () => { + mockExistsSync.mockReturnValue(false); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write error'); + }); + + const entry = createMockEntry({ id: 'e1' }); + // Should not throw + manager.addEntry('session-1', '/test/project', entry); + + expect(vi.mocked(logger.error)).toHaveBeenCalledWith( + expect.stringContaining('Failed to write history'), + expect.any(String) + ); + }); + }); + + // ---------------------------------------------------------------- + // deleteEntry(sessionId, entryId) + // ---------------------------------------------------------------- + describe('deleteEntry()', () => { + it('should remove an entry by id and return true', () => { + const entries = [ + createMockEntry({ id: 'e1' }), + createMockEntry({ id: 'e2' }), + ]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + const result = manager.deleteEntry('session-1', 'e1'); + expect(result).toBe(true); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries).toHaveLength(1); + expect(written.entries[0].id).toBe('e2'); + }); + + it('should return false if session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.deleteEntry('nonexistent', 'e1')).toBe(false); + }); + + it('should return false if entry is not found', () => { + const entries = [createMockEntry({ id: 'e1' })]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + expect(manager.deleteEntry('session-1', 'nonexistent')).toBe(false); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should return false on read error (parse failure)', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue('bad json'); + + expect(manager.deleteEntry('session-1', 'e1')).toBe(false); + }); + + it('should return false on write error', () => { + const entries = [createMockEntry({ id: 'e1' })]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write error'); + }); + + expect(manager.deleteEntry('session-1', 'e1')).toBe(false); + expect(vi.mocked(logger.error)).toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // updateEntry(sessionId, entryId, updates) + // ---------------------------------------------------------------- + describe('updateEntry()', () => { + it('should update an entry by id and return true', () => { + const entries = [ + createMockEntry({ id: 'e1', summary: 'original' }), + ]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + const result = manager.updateEntry('session-1', 'e1', { summary: 'updated' }); + expect(result).toBe(true); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries[0].summary).toBe('updated'); + expect(written.entries[0].id).toBe('e1'); + }); + + it('should return false if session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.updateEntry('nonexistent', 'e1', { summary: 'x' })).toBe(false); + }); + + it('should return false if entry is not found', () => { + const entries = [createMockEntry({ id: 'e1' })]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + expect(manager.updateEntry('session-1', 'nonexistent', { summary: 'x' })).toBe(false); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should return false on parse error', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue('bad json'); + + expect(manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); + }); + + it('should return false on write error', () => { + const entries = [createMockEntry({ id: 'e1' })]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + mockWriteFileSync.mockImplementation(() => { + throw new Error('Write error'); + }); + + expect(manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); + expect(vi.mocked(logger.error)).toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // clearSession(sessionId) + // ---------------------------------------------------------------- + describe('clearSession()', () => { + it('should delete the session file if it exists', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + + manager.clearSession('session-1'); + + expect(mockUnlinkSync).toHaveBeenCalledWith(filePath); + }); + + it('should do nothing if session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + manager.clearSession('nonexistent'); + + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + + it('should log error on delete failure', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockUnlinkSync.mockImplementation(() => { + throw new Error('Delete error'); + }); + + manager.clearSession('session-1'); + + expect(vi.mocked(logger.error)).toHaveBeenCalledWith( + expect.stringContaining('Failed to clear history'), + expect.any(String) + ); + }); + }); + + // ---------------------------------------------------------------- + // listSessionsWithHistory() + // ---------------------------------------------------------------- + describe('listSessionsWithHistory()', () => { + it('should return session IDs from .json files in history dir', () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => + p.toString().endsWith('history') + ); + mockReaddirSync.mockReturnValue([ + 'session_1.json' as unknown as fs.Dirent, + 'session_2.json' as unknown as fs.Dirent, + 'readme.txt' as unknown as fs.Dirent, + ]); + + const result = manager.listSessionsWithHistory(); + expect(result).toEqual(['session_1', 'session_2']); + }); + + it('should return empty array if history directory does not exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.listSessionsWithHistory()).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // getHistoryFilePath(sessionId) + // ---------------------------------------------------------------- + describe('getHistoryFilePath()', () => { + it('should return file path if session file exists', () => { + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + + expect(manager.getHistoryFilePath('session-1')).toBe(filePath); + }); + + it('should return null if session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.getHistoryFilePath('nonexistent')).toBeNull(); + }); + }); + + // ---------------------------------------------------------------- + // getAllEntries(limit?) + // ---------------------------------------------------------------- + describe('getAllEntries()', () => { + it('should aggregate entries across all sessions sorted by timestamp', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + 'sess_b.json' as unknown as fs.Dirent, + ]); + + const entryA = createMockEntry({ id: 'a1', timestamp: 100 }); + const entryB = createMockEntry({ id: 'b1', timestamp: 200 }); + + mockReadFileSync.mockImplementation((p: string | fs.PathLike) => { + const s = p.toString(); + if (s.includes('sess_a.json')) { + return createHistoryFileData('sess_a', [entryA]); + } + if (s.includes('sess_b.json')) { + return createHistoryFileData('sess_b', [entryB]); + } + return '{}'; + }); + + const result = manager.getAllEntries(); + expect(result).toHaveLength(2); + // Sorted descending: 200, 100 + expect(result[0].id).toBe('b1'); + expect(result[1].id).toBe('a1'); + }); + + it('should respect limit parameter', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ id: 'e1', timestamp: 300 }), + createMockEntry({ id: 'e2', timestamp: 200 }), + createMockEntry({ id: 'e3', timestamp: 100 }), + ]; + mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); + + const result = manager.getAllEntries(2); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('e1'); + expect(result[1].id).toBe('e2'); + }); + + it('should return empty array when no sessions exist', () => { + mockExistsSync.mockReturnValue(false); + expect(manager.getAllEntries()).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // getAllEntriesPaginated(options?) + // ---------------------------------------------------------------- + describe('getAllEntriesPaginated()', () => { + it('should return paginated results with metadata', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ id: 'e1', timestamp: 300 }), + createMockEntry({ id: 'e2', timestamp: 200 }), + createMockEntry({ id: 'e3', timestamp: 100 }), + ]; + mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); + + const result = manager.getAllEntriesPaginated({ limit: 2, offset: 0 }); + expect(result.entries).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.limit).toBe(2); + expect(result.offset).toBe(0); + expect(result.hasMore).toBe(true); + }); + + it('should handle offset beyond total entries', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + mockReadFileSync.mockReturnValue( + createHistoryFileData('sess_a', [createMockEntry()]) + ); + + const result = manager.getAllEntriesPaginated({ limit: 10, offset: 100 }); + expect(result.entries).toHaveLength(0); + expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); + }); + }); + + // ---------------------------------------------------------------- + // getEntriesByProjectPath(projectPath) + // ---------------------------------------------------------------- + describe('getEntriesByProjectPath()', () => { + it('should return entries matching project path', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + 'sess_b.json' as unknown as fs.Dirent, + ]); + + const entryA = createMockEntry({ + id: 'a1', + projectPath: '/project/alpha', + timestamp: 100, + }); + const entryB = createMockEntry({ + id: 'b1', + projectPath: '/project/beta', + timestamp: 200, + }); + + mockReadFileSync.mockImplementation((p: string | fs.PathLike) => { + const s = p.toString(); + if (s.includes('sess_a.json')) { + return createHistoryFileData('sess_a', [entryA], '/project/alpha'); + } + if (s.includes('sess_b.json')) { + return createHistoryFileData('sess_b', [entryB], '/project/beta'); + } + return '{}'; + }); + + const result = manager.getEntriesByProjectPath('/project/alpha'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('a1'); + }); + + it('should return empty array when no matching sessions exist', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entry = createMockEntry({ projectPath: '/other/path' }); + mockReadFileSync.mockReturnValue( + createHistoryFileData('sess_a', [entry], '/other/path') + ); + + const result = manager.getEntriesByProjectPath('/no/match'); + expect(result).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // getEntriesByProjectPathPaginated(projectPath, options?) + // ---------------------------------------------------------------- + describe('getEntriesByProjectPathPaginated()', () => { + it('should return paginated results filtered by project path', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ id: 'e1', projectPath: '/proj', timestamp: 300 }), + createMockEntry({ id: 'e2', projectPath: '/proj', timestamp: 200 }), + createMockEntry({ id: 'e3', projectPath: '/proj', timestamp: 100 }), + ]; + mockReadFileSync.mockReturnValue( + createHistoryFileData('sess_a', entries, '/proj') + ); + + const result = manager.getEntriesByProjectPathPaginated('/proj', { + limit: 2, + offset: 0, + }); + expect(result.entries).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(true); + }); + }); + + // ---------------------------------------------------------------- + // getEntriesPaginated(sessionId, options?) + // ---------------------------------------------------------------- + describe('getEntriesPaginated()', () => { + it('should return paginated results for a single session', () => { + const entries = [ + createMockEntry({ id: 'e1' }), + createMockEntry({ id: 'e2' }), + createMockEntry({ id: 'e3' }), + ]; + const filePath = path.join( + '/mock/userData', + 'history', + `${sanitizeSessionId('session-1')}.json` + ); + + mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); + mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); + + const result = manager.getEntriesPaginated('session-1', { limit: 2, offset: 1 }); + expect(result.entries).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.offset).toBe(1); + expect(result.hasMore).toBe(false); + }); + + it('should return empty paginated result for nonexistent session', () => { + mockExistsSync.mockReturnValue(false); + + const result = manager.getEntriesPaginated('nonexistent'); + expect(result.entries).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + // ---------------------------------------------------------------- + // updateSessionNameByClaudeSessionId(agentSessionId, sessionName) + // ---------------------------------------------------------------- + describe('updateSessionNameByClaudeSessionId()', () => { + it('should update sessionName for matching entries and return count', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ + id: 'e1', + agentSessionId: 'agent-123', + sessionName: 'old-name', + }), + createMockEntry({ + id: 'e2', + agentSessionId: 'agent-123', + sessionName: 'old-name', + }), + createMockEntry({ + id: 'e3', + agentSessionId: 'agent-other', + sessionName: 'other', + }), + ]; + mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); + + const count = manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); + expect(count).toBe(2); + + const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(written.entries[0].sessionName).toBe('new-name'); + expect(written.entries[1].sessionName).toBe('new-name'); + expect(written.entries[2].sessionName).toBe('other'); + }); + + it('should return 0 when no entries match', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ id: 'e1', agentSessionId: 'agent-999' }), + ]; + mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); + + const count = manager.updateSessionNameByClaudeSessionId('no-match', 'new-name'); + expect(count).toBe(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should not update entries that already have the correct sessionName', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entries = [ + createMockEntry({ + id: 'e1', + agentSessionId: 'agent-123', + sessionName: 'already-correct', + }), + ]; + mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); + + const count = manager.updateSessionNameByClaudeSessionId( + 'agent-123', + 'already-correct' + ); + expect(count).toBe(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should handle read errors gracefully', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + mockReadFileSync.mockImplementation(() => { + throw new Error('Read error'); + }); + + const count = manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); + expect(count).toBe(0); + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // clearByProjectPath(projectPath) + // ---------------------------------------------------------------- + describe('clearByProjectPath()', () => { + it('should clear sessions matching the project path', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + 'sess_b.json' as unknown as fs.Dirent, + ]); + + const entryA = createMockEntry({ projectPath: '/target/project' }); + const entryB = createMockEntry({ projectPath: '/other/project' }); + + mockReadFileSync.mockImplementation((p: string | fs.PathLike) => { + const s = p.toString(); + if (s.includes('sess_a.json')) { + return createHistoryFileData('sess_a', [entryA], '/target/project'); + } + if (s.includes('sess_b.json')) { + return createHistoryFileData('sess_b', [entryB], '/other/project'); + } + return '{}'; + }); + + manager.clearByProjectPath('/target/project'); + + // Should only unlink sess_a + expect(mockUnlinkSync).toHaveBeenCalledTimes(1); + expect(mockUnlinkSync.mock.calls[0][0].toString()).toContain('sess_a.json'); + }); + + it('should do nothing when no sessions match', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + ]); + + const entry = createMockEntry({ projectPath: '/other' }); + mockReadFileSync.mockReturnValue( + createHistoryFileData('sess_a', [entry], '/other') + ); + + manager.clearByProjectPath('/no/match'); + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // clearAll() + // ---------------------------------------------------------------- + describe('clearAll()', () => { + it('should clear all session files', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([ + 'sess_a.json' as unknown as fs.Dirent, + 'sess_b.json' as unknown as fs.Dirent, + 'sess_c.json' as unknown as fs.Dirent, + ]); + + manager.clearAll(); + + expect(mockUnlinkSync).toHaveBeenCalledTimes(3); + }); + + it('should handle empty history directory', () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue([]); + + manager.clearAll(); + + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + }); + + // ---------------------------------------------------------------- + // startWatching / stopWatching + // ---------------------------------------------------------------- + describe('startWatching() / stopWatching()', () => { + it('should start watching history directory for changes', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + mockWatch.mockReturnValue(mockWatcher); + mockExistsSync.mockReturnValue(true); + + const callback = vi.fn(); + manager.startWatching(callback); + + expect(mockWatch).toHaveBeenCalledWith( + path.join('/mock/userData', 'history'), + expect.any(Function) + ); + }); + + it('should create directory if it does not exist before watching', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + mockWatch.mockReturnValue(mockWatcher); + mockExistsSync.mockReturnValue(false); + + manager.startWatching(vi.fn()); + + expect(mockMkdirSync).toHaveBeenCalledWith( + path.join('/mock/userData', 'history'), + { recursive: true } + ); + }); + + it('should invoke callback when a .json file changes', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + let watchCallback: (event: string, filename: string | null) => void = () => {}; + mockWatch.mockImplementation((_dir: string, cb: unknown) => { + watchCallback = cb as (event: string, filename: string | null) => void; + return mockWatcher; + }); + mockExistsSync.mockReturnValue(true); + + const callback = vi.fn(); + manager.startWatching(callback); + + // Simulate a file change event + watchCallback('change', 'session_1.json'); + + expect(callback).toHaveBeenCalledWith('session_1'); + }); + + it('should not invoke callback for non-json files', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + let watchCallback: (event: string, filename: string | null) => void = () => {}; + mockWatch.mockImplementation((_dir: string, cb: unknown) => { + watchCallback = cb as (event: string, filename: string | null) => void; + return mockWatcher; + }); + mockExistsSync.mockReturnValue(true); + + const callback = vi.fn(); + manager.startWatching(callback); + + watchCallback('change', 'readme.txt'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not invoke callback when filename is null', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + let watchCallback: (event: string, filename: string | null) => void = () => {}; + mockWatch.mockImplementation((_dir: string, cb: unknown) => { + watchCallback = cb as (event: string, filename: string | null) => void; + return mockWatcher; + }); + mockExistsSync.mockReturnValue(true); + + const callback = vi.fn(); + manager.startWatching(callback); + + watchCallback('change', null); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not start watching again if already watching', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + mockWatch.mockReturnValue(mockWatcher); + mockExistsSync.mockReturnValue(true); + + manager.startWatching(vi.fn()); + manager.startWatching(vi.fn()); + + expect(mockWatch).toHaveBeenCalledTimes(1); + }); + + it('should stop watching and close watcher', () => { + const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; + mockWatch.mockReturnValue(mockWatcher); + mockExistsSync.mockReturnValue(true); + + manager.startWatching(vi.fn()); + manager.stopWatching(); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('should allow re-watching after stop', () => { + const mockWatcher1 = { close: vi.fn() } as unknown as fs.FSWatcher; + const mockWatcher2 = { close: vi.fn() } as unknown as fs.FSWatcher; + mockWatch + .mockReturnValueOnce(mockWatcher1) + .mockReturnValueOnce(mockWatcher2); + mockExistsSync.mockReturnValue(true); + + manager.startWatching(vi.fn()); + manager.stopWatching(); + manager.startWatching(vi.fn()); + + expect(mockWatch).toHaveBeenCalledTimes(2); + }); + + it('should be safe to call stopWatching when not watching', () => { + // Should not throw + expect(() => manager.stopWatching()).not.toThrow(); + }); + }); + + // ---------------------------------------------------------------- + // getHistoryManager() singleton + // ---------------------------------------------------------------- + describe('getHistoryManager()', () => { + it('should return a HistoryManager instance', () => { + const instance = getHistoryManager(); + expect(instance).toBeInstanceOf(HistoryManager); + }); + + it('should return the same instance on subsequent calls', () => { + const instance1 = getHistoryManager(); + const instance2 = getHistoryManager(); + expect(instance1).toBe(instance2); + }); + }); + + // ---------------------------------------------------------------- + // sanitizeSessionId integration (uses real shared function) + // ---------------------------------------------------------------- + describe('session ID sanitization', () => { + it('should sanitize session IDs with special characters for file paths', () => { + mockExistsSync.mockReturnValue(false); + + const entry = createMockEntry({ id: 'e1' }); + manager.addEntry('session/with:special.chars!', '/test', entry); + + const writtenPath = mockWriteFileSync.mock.calls[0][0] as string; + // Should not contain /, :, ., or ! in the filename portion + const filename = path.basename(writtenPath); + expect(filename).toBe( + `${sanitizeSessionId('session/with:special.chars!')}.json` + ); + expect(filename).not.toContain('/'); + expect(filename).not.toContain(':'); + expect(filename).not.toContain('!'); + }); + }); +}); diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts new file mode 100644 index 00000000..8bd1ecc6 --- /dev/null +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -0,0 +1,1189 @@ +/** + * Tests for src/main/process-manager/handlers/StdoutHandler.ts + * + * Covers the StdoutHandler class and its internal normalizeUsageToDelta logic. + * normalizeUsageToDelta is a private module-level function, so it is tested + * indirectly through the StdoutHandler's stream-JSON processing paths. + * + * normalizeUsageToDelta behavior: + * - First call: stores totals in lastUsageTotals, returns stats as-is + * - Subsequent calls (cumulative): computes delta from previous totals + * - If values decrease (not monotonic): sets usageIsCumulative = false, returns raw stats + * - If usageIsCumulative is already false: returns raw stats, stores totals + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../main/process-manager/utils/bufferUtils', () => ({ + appendToBuffer: vi.fn((buf: string, data: string) => buf + data), +})); + +vi.mock('../../../../main/parsers/usage-aggregator', () => ({ + aggregateModelUsage: vi.fn(() => ({ + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.01, + contextWindow: 200000, + })), +})); + +vi.mock('../../../../main/parsers/error-patterns', () => ({ + matchSshErrorPattern: vi.fn(() => null), +})); + +// ── Imports (after mocks) ────────────────────────────────────────────────── + +import { StdoutHandler } from '../../../../main/process-manager/handlers/StdoutHandler'; +import type { ManagedProcess } from '../../../../main/process-manager/types'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function createMockProcess(overrides: Partial = {}): ManagedProcess { + return { + sessionId: 'test-session', + toolType: 'claude-code', + cwd: '/tmp', + pid: 1234, + isTerminal: false, + startTime: Date.now(), + isStreamJsonMode: false, + isBatchMode: false, + jsonBuffer: '', + stdoutBuffer: '', + contextWindow: 200000, + lastUsageTotals: undefined, + usageIsCumulative: undefined, + sessionIdEmitted: false, + resultEmitted: false, + errorEmitted: false, + outputParser: undefined, + sshRemoteId: undefined, + sshRemoteHost: undefined, + streamedText: '', + ...overrides, + } as ManagedProcess; +} + +function createMockBufferManager() { + return { + emitDataBuffered: vi.fn(), + flushDataBuffer: vi.fn(), + }; +} + +function createTestContext(processOverrides: Partial = {}) { + const processes = new Map(); + const emitter = new EventEmitter(); + const bufferManager = createMockBufferManager(); + const sessionId = 'test-session'; + const proc = createMockProcess({ sessionId, ...processOverrides }); + processes.set(sessionId, proc); + + const handler = new StdoutHandler({ processes, emitter, bufferManager: bufferManager as any }); + + return { processes, emitter, bufferManager, handler, sessionId, proc }; +} + +/** + * Send a complete JSON line through stream-JSON mode. + * Appends a newline so the handler parses it as a complete line. + */ +function sendJsonLine(handler: StdoutHandler, sessionId: string, obj: Record) { + handler.handleData(sessionId, JSON.stringify(obj) + '\n'); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('StdoutHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── handleData dispatch routing ──────────────────────────────────────── + + describe('handleData routing', () => { + it('should silently return when sessionId is not found in processes map', () => { + const { handler, bufferManager } = createTestContext(); + handler.handleData('nonexistent-session', 'some output'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + + it('should emit via bufferManager in plain text mode', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: false, + isBatchMode: false, + }); + + handler.handleData(sessionId, 'Hello, world!'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Hello, world!'); + }); + + it('should accumulate to jsonBuffer in batch mode', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isBatchMode: true, + isStreamJsonMode: false, + }); + + handler.handleData(sessionId, '{"partial":'); + handler.handleData(sessionId, '"data"}'); + + expect(proc.jsonBuffer).toBe('{"partial":"data"}'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + + it('should process complete lines in stream JSON mode', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + }); + + // Send non-JSON text as a complete line -- it should fall through + // to the catch block and be emitted via bufferManager + handler.handleData(sessionId, 'plain text output\n'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'plain text output'); + }); + + it('should buffer incomplete lines in stream JSON mode until newline arrives', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + }); + + // Send partial line (no newline) + handler.handleData(sessionId, '{"incomplete":'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + + // jsonBuffer should hold the partial data + expect(proc.jsonBuffer).toBe('{"incomplete":'); + }); + + it('should skip empty lines in stream JSON mode', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: true, + }); + + handler.handleData(sessionId, '\n\n\n'); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + }); + + // ── Legacy message handling (no outputParser) ────────────────────────── + + describe('legacy message handling (no outputParser)', () => { + it('should emit result data for type=result messages', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + type: 'result', + result: 'Here is the answer.', + }); + + expect(proc.resultEmitted).toBe(true); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Here is the answer.'); + }); + + it('should only emit result once (first result wins)', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + type: 'result', + result: 'First answer.', + }); + + sendJsonLine(handler, sessionId, { + type: 'result', + result: 'Second answer.', + }); + + const resultCalls = (bufferManager.emitDataBuffered as any).mock.calls.filter( + (call: any[]) => call[1] === 'First answer.' || call[1] === 'Second answer.' + ); + expect(resultCalls).toHaveLength(1); + expect(resultCalls[0][1]).toBe('First answer.'); + }); + + it('should extract session_id and emit session-id event', () => { + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + sendJsonLine(handler, sessionId, { + session_id: 'agent-session-xyz', + }); + + expect(proc.sessionIdEmitted).toBe(true); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'agent-session-xyz'); + }); + + it('should only emit session-id once', () => { + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + sendJsonLine(handler, sessionId, { session_id: 'first-id' }); + sendJsonLine(handler, sessionId, { session_id: 'second-id' }); + + expect(sessionIdSpy).toHaveBeenCalledTimes(1); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'first-id'); + }); + + it('should emit slash-commands for system init messages', () => { + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const slashSpy = vi.fn(); + emitter.on('slash-commands', slashSpy); + + sendJsonLine(handler, sessionId, { + type: 'system', + subtype: 'init', + slash_commands: ['/help', '/compact'], + }); + + expect(slashSpy).toHaveBeenCalledWith(sessionId, ['/help', '/compact']); + }); + + it('should skip error messages in legacy mode', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + type: 'error', + error: 'Something went wrong', + }); + + // Error messages should not be emitted via bufferManager + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + + it('should skip messages with error field in legacy mode', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + error: 'auth failed', + }); + + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + + it('should emit usage stats for messages with modelUsage', () => { + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { + modelUsage: { + 'claude-3-sonnet': { + inputTokens: 1000, + outputTokens: 500, + }, + }, + total_cost_usd: 0.05, + }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + // The mock aggregateModelUsage always returns the fixed value + expect(usageSpy).toHaveBeenCalledWith(sessionId, { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.01, + contextWindow: 200000, + }); + }); + + it('should emit usage stats for messages with usage field', () => { + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { + usage: { input_tokens: 500, output_tokens: 200 }, + }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit usage stats for messages with total_cost_usd only', () => { + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { + total_cost_usd: 0.03, + }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + }); + + it('should handle combined result and session_id in one message', () => { + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + sendJsonLine(handler, sessionId, { + type: 'result', + result: 'The answer is 42.', + session_id: 'sess-combined', + }); + + expect(proc.resultEmitted).toBe(true); + expect(proc.sessionIdEmitted).toBe(true); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'The answer is 42.'); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'sess-combined'); + }); + }); + + // ── normalizeUsageToDelta (tested via outputParser path) ─────────────── + + describe('normalizeUsageToDelta (via outputParser stream-JSON path)', () => { + /** + * These tests exercise the normalizeUsageToDelta function indirectly + * through the StdoutHandler's handleParsedEvent -> buildUsageStats -> + * normalizeUsageToDelta pipeline. We create a minimal outputParser mock + * that returns usage data, allowing us to observe the normalized result + * via the 'usage' event emitter. + */ + + function createOutputParserMock(usageReturn: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; + contextWindow?: number; + reasoningTokens?: number; + } | null) { + return { + agentId: 'claude-code', + parseJsonLine: vi.fn((line: string) => { + try { + const parsed = JSON.parse(line); + return { + type: parsed.type || 'message', + text: parsed.text, + isPartial: false, + }; + } catch { + return null; + } + }), + extractUsage: vi.fn(() => usageReturn), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn(() => false), + detectErrorFromLine: vi.fn(() => null), + }; + } + + it('should pass through usage stats on first call (no previous totals)', () => { + const parser = createOutputParserMock({ + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'hello' }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + const emittedUsage = usageSpy.mock.calls[0][1]; + expect(emittedUsage.inputTokens).toBe(1000); + expect(emittedUsage.outputTokens).toBe(500); + expect(emittedUsage.cacheReadInputTokens).toBe(200); + expect(emittedUsage.cacheCreationInputTokens).toBe(100); + expect(emittedUsage.totalCostUsd).toBe(0.05); + expect(emittedUsage.contextWindow).toBe(200000); + + // Should have stored totals for next call + expect(proc.lastUsageTotals).toEqual({ + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, + reasoningTokens: 0, + }); + }); + + it('should compute delta on second cumulative call (monotonically increasing)', () => { + // Start with a parser that returns increasing cumulative values + let callCount = 0; + const usageSequence = [ + { + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }, + { + inputTokens: 1800, + outputTokens: 900, + cacheReadTokens: 350, + cacheCreationTokens: 180, + costUsd: 0.09, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + // First call: returns raw values + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + expect(usageSpy.mock.calls[0][1].inputTokens).toBe(1000); + expect(usageSpy.mock.calls[0][1].outputTokens).toBe(500); + + // Second call: should return delta + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + const delta = usageSpy.mock.calls[1][1]; + expect(delta.inputTokens).toBe(800); // 1800 - 1000 + expect(delta.outputTokens).toBe(400); // 900 - 500 + expect(delta.cacheReadInputTokens).toBe(150); // 350 - 200 + expect(delta.cacheCreationInputTokens).toBe(80); // 180 - 100 + + // Cost and contextWindow should still be passed through from the raw stats + expect(delta.totalCostUsd).toBe(0.09); + expect(delta.contextWindow).toBe(200000); + + // usageIsCumulative should be set to true + expect(proc.usageIsCumulative).toBe(true); + }); + + it('should detect non-monotonic decrease and switch to raw mode', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }, + { + // inputTokens decreased: indicates per-turn reporting, not cumulative + inputTokens: 300, + outputTokens: 150, + cacheReadTokens: 50, + cacheCreationTokens: 20, + costUsd: 0.02, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + // First call: raw values + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + expect(usageSpy.mock.calls[0][1].inputTokens).toBe(1000); + + // Second call: decrease detected, returns raw values (not negative deltas) + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + const rawStats = usageSpy.mock.calls[1][1]; + expect(rawStats.inputTokens).toBe(300); + expect(rawStats.outputTokens).toBe(150); + expect(rawStats.cacheReadInputTokens).toBe(50); + expect(rawStats.cacheCreationInputTokens).toBe(20); + + // Should have flagged as non-cumulative + expect(proc.usageIsCumulative).toBe(false); + }); + + it('should continue returning raw stats once usageIsCumulative is false', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }, + { + // Decrease triggers non-cumulative detection + inputTokens: 300, + outputTokens: 150, + cacheReadTokens: 50, + cacheCreationTokens: 20, + costUsd: 0.02, + contextWindow: 200000, + }, + { + // Third call: even though this looks "cumulative" relative to 2nd, + // usageIsCumulative is false so it returns raw + inputTokens: 800, + outputTokens: 400, + cacheReadTokens: 100, + cacheCreationTokens: 50, + costUsd: 0.04, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + // Turn 1 + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + // Turn 2: triggers non-cumulative + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + expect(proc.usageIsCumulative).toBe(false); + + // Turn 3: should still return raw since flag is false + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 3' }); + + expect(usageSpy).toHaveBeenCalledTimes(3); + const thirdCallUsage = usageSpy.mock.calls[2][1]; + expect(thirdCallUsage.inputTokens).toBe(800); + expect(thirdCallUsage.outputTokens).toBe(400); + expect(thirdCallUsage.cacheReadInputTokens).toBe(100); + expect(thirdCallUsage.cacheCreationInputTokens).toBe(50); + + // Flag should remain false + expect(proc.usageIsCumulative).toBe(false); + }); + + it('should handle multiple consecutive cumulative turns correctly', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheCreationTokens: 50, + costUsd: 0.03, + contextWindow: 200000, + }, + { + inputTokens: 1200, + outputTokens: 600, + cacheReadTokens: 300, + cacheCreationTokens: 120, + costUsd: 0.07, + contextWindow: 200000, + }, + { + inputTokens: 2000, + outputTokens: 1000, + cacheReadTokens: 500, + cacheCreationTokens: 200, + costUsd: 0.12, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + // Turn 1: raw + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + expect(usageSpy.mock.calls[0][1].inputTokens).toBe(500); + + // Turn 2: delta from turn 1 + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + expect(usageSpy.mock.calls[1][1].inputTokens).toBe(700); // 1200 - 500 + expect(usageSpy.mock.calls[1][1].outputTokens).toBe(400); // 600 - 200 + + // Turn 3: delta from turn 2 + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 3' }); + expect(usageSpy.mock.calls[2][1].inputTokens).toBe(800); // 2000 - 1200 + expect(usageSpy.mock.calls[2][1].outputTokens).toBe(400); // 1000 - 600 + expect(usageSpy.mock.calls[2][1].cacheReadInputTokens).toBe(200); // 500 - 300 + expect(usageSpy.mock.calls[2][1].cacheCreationInputTokens).toBe(80); // 200 - 120 + + expect(proc.usageIsCumulative).toBe(true); + }); + + it('should handle zero deltas correctly (same cumulative values)', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }, + { + // Identical to first (no new tokens consumed) + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheCreationTokens: 100, + costUsd: 0.05, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + const delta = usageSpy.mock.calls[1][1]; + expect(delta.inputTokens).toBe(0); + expect(delta.outputTokens).toBe(0); + expect(delta.cacheReadInputTokens).toBe(0); + expect(delta.cacheCreationInputTokens).toBe(0); + + // Zero delta is still monotonic, so cumulative stays true + expect(proc.usageIsCumulative).toBe(true); + }); + + it('should handle reasoningTokens in cumulative delta calculations', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.03, + contextWindow: 200000, + reasoningTokens: 100, + }, + { + inputTokens: 1000, + outputTokens: 400, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.06, + contextWindow: 200000, + reasoningTokens: 250, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'codex', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + const delta = usageSpy.mock.calls[1][1]; + expect(delta.inputTokens).toBe(500); // 1000 - 500 + expect(delta.outputTokens).toBe(200); // 400 - 200 + expect(delta.reasoningTokens).toBe(150); // 250 - 100 + }); + + it('should detect decrease in reasoningTokens as non-monotonic', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.03, + contextWindow: 200000, + reasoningTokens: 300, + }, + { + // All fields increase except reasoningTokens decreases + inputTokens: 1000, + outputTokens: 400, + cacheReadTokens: 50, + cacheCreationTokens: 20, + costUsd: 0.06, + contextWindow: 200000, + reasoningTokens: 100, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'codex', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + // Non-monotonic detected -- raw values returned + const rawStats = usageSpy.mock.calls[1][1]; + expect(rawStats.inputTokens).toBe(1000); + expect(rawStats.outputTokens).toBe(400); + expect(rawStats.reasoningTokens).toBe(100); + + expect(proc.usageIsCumulative).toBe(false); + }); + + it('should NOT normalize usage for non-claude-code/codex toolTypes', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.03, + contextWindow: 200000, + }, + { + inputTokens: 1200, + outputTokens: 600, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.07, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'opencode', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + // opencode does NOT go through normalizeUsageToDelta, so raw values + const second = usageSpy.mock.calls[1][1]; + expect(second.inputTokens).toBe(1200); + expect(second.outputTokens).toBe(600); + + // lastUsageTotals should NOT be set since normalization was skipped + expect(proc.lastUsageTotals).toBeUndefined(); + }); + + it('should normalize usage for codex toolType (not just claude-code)', () => { + let callCount = 0; + const usageSequence = [ + { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.03, + contextWindow: 200000, + }, + { + inputTokens: 1200, + outputTokens: 600, + cacheReadTokens: 0, + cacheCreationTokens: 0, + costUsd: 0.07, + contextWindow: 200000, + }, + ]; + + const parser = createOutputParserMock(null); + parser.extractUsage.mockImplementation(() => { + return usageSequence[callCount++] || null; + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'codex', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 1' }); + sendJsonLine(handler, sessionId, { type: 'message', text: 'turn 2' }); + + expect(usageSpy).toHaveBeenCalledTimes(2); + const delta = usageSpy.mock.calls[1][1]; + expect(delta.inputTokens).toBe(700); // 1200 - 500 + expect(delta.outputTokens).toBe(400); // 600 - 200 + + expect(proc.usageIsCumulative).toBe(true); + }); + + it('should not emit usage when extractUsage returns null', () => { + const parser = createOutputParserMock(null); + // extractUsage always returns null (already the default from our null param) + + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'claude-code', + outputParser: parser as any, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'no usage' }); + + expect(usageSpy).not.toHaveBeenCalled(); + }); + }); + + // ── buildUsageStats (indirectly tested via defaults) ─────────────────── + + describe('buildUsageStats defaults', () => { + it('should default optional fields to 0 when not provided by parser', () => { + const parser = createMinimalOutputParser({ + inputTokens: 100, + outputTokens: 50, + // No cacheReadTokens, cacheCreationTokens, costUsd, contextWindow + }); + + const { handler, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'opencode', // avoid normalization + outputParser: parser as any, + contextWindow: 128000, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'hi' }); + + expect(usageSpy).toHaveBeenCalledTimes(1); + const stats = usageSpy.mock.calls[0][1]; + expect(stats.inputTokens).toBe(100); + expect(stats.outputTokens).toBe(50); + expect(stats.cacheReadInputTokens).toBe(0); + expect(stats.cacheCreationInputTokens).toBe(0); + expect(stats.totalCostUsd).toBe(0); + // Falls back to managedProcess.contextWindow + expect(stats.contextWindow).toBe(128000); + }); + + it('should use parser-reported contextWindow over managedProcess default', () => { + const parser = createMinimalOutputParser({ + inputTokens: 100, + outputTokens: 50, + contextWindow: 1000000, + }); + + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'opencode', + outputParser: parser as any, + contextWindow: 200000, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'hi' }); + + expect(usageSpy.mock.calls[0][1].contextWindow).toBe(1000000); + }); + + it('should fall back to 200000 when neither parser nor process has contextWindow', () => { + const parser = createMinimalOutputParser({ + inputTokens: 100, + outputTokens: 50, + // no contextWindow from parser + }); + + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'opencode', + outputParser: parser as any, + contextWindow: undefined, + }); + + const usageSpy = vi.fn(); + emitter.on('usage', usageSpy); + + sendJsonLine(handler, sessionId, { type: 'message', text: 'hi' }); + + expect(usageSpy.mock.calls[0][1].contextWindow).toBe(200000); + }); + }); + + // ── Stream JSON mode: multi-line handling ────────────────────────────── + + describe('stream JSON mode line splitting', () => { + it('should process multiple complete JSON lines in a single chunk', () => { + const { handler, emitter, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + const chunk = + JSON.stringify({ session_id: 'abc' }) + + '\n' + + JSON.stringify({ type: 'result', result: 'done' }) + + '\n'; + + handler.handleData(sessionId, chunk); + + expect(proc.sessionIdEmitted).toBe(true); + expect(proc.resultEmitted).toBe(true); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'abc'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'done'); + }); + + it('should reassemble split JSON lines across multiple chunks', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + const fullJson = JSON.stringify({ type: 'result', result: 'split-result' }); + const half1 = fullJson.substring(0, Math.floor(fullJson.length / 2)); + const half2 = fullJson.substring(Math.floor(fullJson.length / 2)); + + // Send first half (no newline, so it stays in buffer) + handler.handleData(sessionId, half1); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + + // Send second half with newline + handler.handleData(sessionId, half2 + '\n'); + expect(proc.resultEmitted).toBe(true); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'split-result'); + }); + }); + + // ── Edge cases ───────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('should emit non-JSON lines via bufferManager in stream JSON mode', () => { + const { handler, bufferManager, sessionId } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + handler.handleData(sessionId, 'This is not JSON\n'); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith( + sessionId, + 'This is not JSON' + ); + }); + + it('should append to stdoutBuffer for each processed line in stream JSON mode', () => { + const { handler, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + stdoutBuffer: '', + }); + + sendJsonLine(handler, sessionId, { session_id: 'x' }); + + // stdoutBuffer should contain the processed line + expect(proc.stdoutBuffer).toContain('session_id'); + }); + + it('should handle result with empty result string gracefully', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + type: 'result', + result: '', + }); + + // Empty string is falsy in JS, so the legacy handler's + // `msgRecord.result && ...` guard skips it entirely + expect(proc.resultEmitted).toBe(false); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + + it('should handle result with no result field gracefully', () => { + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + outputParser: undefined, + }); + + sendJsonLine(handler, sessionId, { + type: 'result', + // no result field + }); + + // msgRecord.result is undefined which is falsy, so resultEmitted stays false + expect(proc.resultEmitted).toBe(false); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + }); + }); +}); + +// ── Shared helper for minimal parser ─────────────────────────────────────── + +function createMinimalOutputParser(usageReturn: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; + contextWindow?: number; + reasoningTokens?: number; +}) { + return { + agentId: 'opencode', + parseJsonLine: vi.fn((line: string) => { + try { + const parsed = JSON.parse(line); + return { type: parsed.type || 'message', text: parsed.text, isPartial: false }; + } catch { + return null; + } + }), + extractUsage: vi.fn(() => usageReturn), + extractSessionId: vi.fn(() => null), + extractSlashCommands: vi.fn(() => null), + isResultMessage: vi.fn(() => false), + detectErrorFromLine: vi.fn(() => null), + }; +} diff --git a/src/__tests__/main/utils/agent-args.test.ts b/src/__tests__/main/utils/agent-args.test.ts new file mode 100644 index 00000000..10bd4d5b --- /dev/null +++ b/src/__tests__/main/utils/agent-args.test.ts @@ -0,0 +1,618 @@ +/** + * Tests for src/main/utils/agent-args.ts + * + * Covers buildAgentArgs, applyAgentConfigOverrides, and getContextWindowValue. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildAgentArgs, + applyAgentConfigOverrides, + getContextWindowValue, +} from '../../../main/utils/agent-args'; +import type { AgentConfig } from '../../../main/agent-detector'; + +/** + * Helper to create a minimal AgentConfig for testing. + * Only the fields relevant to agent-args are populated. + */ +function makeAgent(overrides: Partial = {}): AgentConfig { + return { + id: 'test-agent', + name: 'Test Agent', + binaryName: 'test', + command: 'test', + args: ['--default'], + available: true, + capabilities: {} as AgentConfig['capabilities'], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// buildAgentArgs +// --------------------------------------------------------------------------- +describe('buildAgentArgs', () => { + it('returns baseArgs when agent is null', () => { + const result = buildAgentArgs(null, { baseArgs: ['--foo', '--bar'] }); + expect(result).toEqual(['--foo', '--bar']); + }); + + it('returns baseArgs when agent is undefined', () => { + const result = buildAgentArgs(undefined, { baseArgs: ['--foo'] }); + expect(result).toEqual(['--foo']); + }); + + // -- batchModePrefix -- + it('adds batchModePrefix before baseArgs when prompt provided', () => { + const agent = makeAgent({ batchModePrefix: ['run'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + prompt: 'hello', + }); + expect(result[0]).toBe('run'); + expect(result).toEqual(['run', '--print']); + }); + + it('does not add batchModePrefix when no prompt', () => { + const agent = makeAgent({ batchModePrefix: ['run'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + // -- batchModeArgs -- + it('adds batchModeArgs when prompt provided', () => { + const agent = makeAgent({ batchModeArgs: ['--skip-git'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + prompt: 'hello', + }); + expect(result).toEqual(['--print', '--skip-git']); + }); + + it('does not add batchModeArgs when no prompt', () => { + const agent = makeAgent({ batchModeArgs: ['--skip-git'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + // -- jsonOutputArgs -- + it('adds jsonOutputArgs when not already present', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print', '--format', 'json']); + }); + + it('does not duplicate jsonOutputArgs when already present', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print', '--format', 'stream'], + }); + // '--format' is already in baseArgs, so jsonOutputArgs should not be added + expect(result).toEqual(['--print', '--format', 'stream']); + }); + + // -- workingDirArgs -- + it('adds workingDirArgs when cwd provided', () => { + const agent = makeAgent({ + workingDirArgs: (dir: string) => ['-C', dir], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + cwd: '/home/user/project', + }); + expect(result).toEqual(['--print', '-C', '/home/user/project']); + }); + + it('does not add workingDirArgs when cwd is not provided', () => { + const agent = makeAgent({ + workingDirArgs: (dir: string) => ['-C', dir], + }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + // -- readOnlyArgs -- + it('adds readOnlyArgs when readOnlyMode is true', () => { + const agent = makeAgent({ readOnlyArgs: ['--agent', 'plan'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + readOnlyMode: true, + }); + expect(result).toEqual(['--print', '--agent', 'plan']); + }); + + it('does not add readOnlyArgs when readOnlyMode is false', () => { + const agent = makeAgent({ readOnlyArgs: ['--agent', 'plan'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + readOnlyMode: false, + }); + expect(result).toEqual(['--print']); + }); + + // -- modelArgs -- + it('adds modelArgs when modelId provided', () => { + const agent = makeAgent({ + modelArgs: (model: string) => ['--model', model], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + modelId: 'claude-3-opus', + }); + expect(result).toEqual(['--print', '--model', 'claude-3-opus']); + }); + + it('does not add modelArgs when modelId is not provided', () => { + const agent = makeAgent({ + modelArgs: (model: string) => ['--model', model], + }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + // -- yoloModeArgs -- + it('adds yoloModeArgs when yoloMode is true', () => { + const agent = makeAgent({ yoloModeArgs: ['--dangerously-bypass'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + yoloMode: true, + }); + expect(result).toEqual(['--print', '--dangerously-bypass']); + }); + + it('does not add yoloModeArgs when yoloMode is false', () => { + const agent = makeAgent({ yoloModeArgs: ['--dangerously-bypass'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + yoloMode: false, + }); + expect(result).toEqual(['--print']); + }); + + // -- resumeArgs -- + it('adds resumeArgs when agentSessionId provided', () => { + const agent = makeAgent({ + resumeArgs: (sid: string) => ['--resume', sid], + }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + agentSessionId: 'sess-123', + }); + expect(result).toEqual(['--print', '--resume', 'sess-123']); + }); + + it('does not add resumeArgs when agentSessionId is not provided', () => { + const agent = makeAgent({ + resumeArgs: (sid: string) => ['--resume', sid], + }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + + // -- combined -- + it('combines multiple options together', () => { + const agent = makeAgent({ + batchModePrefix: ['run'], + batchModeArgs: ['--skip-git'], + jsonOutputArgs: ['--format', 'json'], + workingDirArgs: (dir: string) => ['-C', dir], + readOnlyArgs: ['--agent', 'plan'], + modelArgs: (model: string) => ['--model', model], + yoloModeArgs: ['--yolo'], + resumeArgs: (sid: string) => ['--resume', sid], + }); + + const result = buildAgentArgs(agent, { + baseArgs: ['--print'], + prompt: 'do stuff', + cwd: '/tmp', + readOnlyMode: true, + modelId: 'gpt-4', + yoloMode: true, + agentSessionId: 'abc', + }); + + expect(result).toEqual([ + 'run', + '--print', + '--skip-git', + '--format', + 'json', + '-C', + '/tmp', + '--agent', + 'plan', + '--model', + 'gpt-4', + '--yolo', + '--resume', + 'abc', + ]); + }); + + it('does not mutate the original baseArgs array', () => { + const baseArgs = ['--print']; + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + buildAgentArgs(agent, { baseArgs }); + expect(baseArgs).toEqual(['--print']); + }); +}); + +// --------------------------------------------------------------------------- +// applyAgentConfigOverrides +// --------------------------------------------------------------------------- +describe('applyAgentConfigOverrides', () => { + it('processes configOptions with argBuilder', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'maxTokens', + type: 'number', + label: 'Max Tokens', + description: 'Max tokens', + default: 1000, + argBuilder: (val: any) => ['--max-tokens', String(val)], + }, + ], + }); + + const result = applyAgentConfigOverrides(agent, ['--print'], {}); + expect(result.args).toEqual(['--print', '--max-tokens', '1000']); + }); + + it('skips configOptions without argBuilder', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'maxTokens', + type: 'number', + label: 'Max Tokens', + description: 'Max tokens', + default: 1000, + // no argBuilder + }, + ], + }); + + const result = applyAgentConfigOverrides(agent, ['--print'], {}); + expect(result.args).toEqual(['--print']); + }); + + // -- model precedence -- + it('model precedence: session overrides agent overrides default', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: 'Model', + default: 'default-model', + argBuilder: (val: any) => ['--model', String(val)], + }, + ], + }); + + // default + const r1 = applyAgentConfigOverrides(agent, [], {}); + expect(r1.args).toEqual(['--model', 'default-model']); + expect(r1.modelSource).toBe('default'); + + // agent overrides default + const r2 = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { model: 'agent-model' }, + }); + expect(r2.args).toEqual(['--model', 'agent-model']); + expect(r2.modelSource).toBe('agent'); + + // session overrides agent + const r3 = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { model: 'agent-model' }, + sessionCustomModel: 'session-model', + }); + expect(r3.args).toEqual(['--model', 'session-model']); + expect(r3.modelSource).toBe('session'); + }); + + it('uses agentConfigValues for non-model config options', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'temperature', + type: 'text', + label: 'Temperature', + description: 'Temperature', + default: '0.7', + argBuilder: (val: any) => ['--temp', String(val)], + }, + ], + }); + + const result = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { temperature: '0.9' }, + }); + expect(result.args).toEqual(['--temp', '0.9']); + }); + + // -- custom args -- + it('custom args from session override agent config', () => { + const agent = makeAgent(); + + const result = applyAgentConfigOverrides(agent, ['--print'], { + agentConfigValues: { customArgs: '--from-agent' }, + sessionCustomArgs: '--from-session', + }); + expect(result.args).toContain('--from-session'); + expect(result.args).not.toContain('--from-agent'); + expect(result.customArgsSource).toBe('session'); + }); + + it('uses agent customArgs when session customArgs not provided', () => { + const agent = makeAgent(); + + const result = applyAgentConfigOverrides(agent, ['--print'], { + agentConfigValues: { customArgs: '--from-agent' }, + }); + expect(result.args).toContain('--from-agent'); + expect(result.customArgsSource).toBe('agent'); + }); + + it('customArgsSource is none when no custom args exist', () => { + const agent = makeAgent(); + + const result = applyAgentConfigOverrides(agent, ['--print'], {}); + expect(result.customArgsSource).toBe('none'); + }); + + // -- parseCustomArgs (tested through applyAgentConfigOverrides) -- + it('parses quoted custom args correctly', () => { + const agent = makeAgent(); + + const result = applyAgentConfigOverrides(agent, [], { + sessionCustomArgs: '--flag "arg with spaces" \'another arg\' plain', + }); + expect(result.args).toEqual([ + '--flag', + 'arg with spaces', + 'another arg', + 'plain', + ]); + expect(result.customArgsSource).toBe('session'); + }); + + it('returns customArgsSource none for empty custom args string', () => { + const agent = makeAgent(); + + const result = applyAgentConfigOverrides(agent, ['--print'], { + sessionCustomArgs: ' ', + }); + // Whitespace-only string should parse to empty array + expect(result.customArgsSource).toBe('none'); + expect(result.args).toEqual(['--print']); + }); + + // -- env vars -- + it('merges env vars with correct precedence', () => { + const agent = makeAgent({ + defaultEnvVars: { A: 'default-a', B: 'default-b' }, + }); + + // Agent config values override defaults + const r1 = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { customEnvVars: { A: 'agent-a', C: 'agent-c' } }, + }); + expect(r1.effectiveCustomEnvVars).toEqual({ + A: 'agent-a', + B: 'default-b', + C: 'agent-c', + }); + expect(r1.customEnvSource).toBe('agent'); + + // Session env vars override both agent config and defaults + const r2 = applyAgentConfigOverrides(agent, [], { + agentConfigValues: { customEnvVars: { A: 'agent-a' } }, + sessionCustomEnvVars: { A: 'session-a', D: 'session-d' }, + }); + expect(r2.effectiveCustomEnvVars).toEqual({ + A: 'session-a', + B: 'default-b', + D: 'session-d', + }); + expect(r2.customEnvSource).toBe('session'); + }); + + it('returns undefined effectiveCustomEnvVars when no env vars exist', () => { + const agent = makeAgent(); // no defaultEnvVars + const result = applyAgentConfigOverrides(agent, [], {}); + expect(result.effectiveCustomEnvVars).toBeUndefined(); + expect(result.customEnvSource).toBe('none'); + }); + + it('returns agent defaultEnvVars when no overrides are provided', () => { + const agent = makeAgent({ + defaultEnvVars: { FOO: 'bar' }, + }); + + const result = applyAgentConfigOverrides(agent, [], {}); + expect(result.effectiveCustomEnvVars).toEqual({ FOO: 'bar' }); + // No user-configured env vars, so source should be 'none' + expect(result.customEnvSource).toBe('none'); + }); + + it('returns correct source indicators', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: 'Model', + default: 'default-model', + argBuilder: (val: any) => ['--model', String(val)], + }, + ], + defaultEnvVars: { X: '1' }, + }); + + const result = applyAgentConfigOverrides(agent, [], { + sessionCustomModel: 'my-model', + sessionCustomArgs: '--extra', + sessionCustomEnvVars: { Y: '2' }, + }); + + expect(result.modelSource).toBe('session'); + expect(result.customArgsSource).toBe('session'); + expect(result.customEnvSource).toBe('session'); + }); + + // -- null/undefined agent -- + it('handles null agent', () => { + const result = applyAgentConfigOverrides(null, ['--print'], { + sessionCustomArgs: '--extra', + }); + expect(result.args).toEqual(['--print', '--extra']); + expect(result.modelSource).toBe('default'); + }); + + it('handles undefined agent', () => { + const result = applyAgentConfigOverrides(undefined, ['--base'], {}); + expect(result.args).toEqual(['--base']); + expect(result.modelSource).toBe('default'); + expect(result.customArgsSource).toBe('none'); + expect(result.customEnvSource).toBe('none'); + }); + + it('does not mutate the original baseArgs array', () => { + const baseArgs = ['--print']; + const agent = makeAgent({ + configOptions: [ + { + key: 'foo', + type: 'text', + label: 'Foo', + description: 'Foo', + default: 'bar', + argBuilder: (val: any) => ['--foo', String(val)], + }, + ], + }); + applyAgentConfigOverrides(agent, baseArgs, {}); + expect(baseArgs).toEqual(['--print']); + }); +}); + +// --------------------------------------------------------------------------- +// getContextWindowValue +// --------------------------------------------------------------------------- +describe('getContextWindowValue', () => { + it('session-level override takes highest priority', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window', + description: 'Context window size', + default: 100000, + }, + ], + }); + + const result = getContextWindowValue( + agent, + { contextWindow: 50000 }, + 200000 + ); + expect(result).toBe(200000); + }); + + it('falls back to agentConfigValues when no session override', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window', + description: 'Context window size', + default: 100000, + }, + ], + }); + + const result = getContextWindowValue(agent, { contextWindow: 50000 }); + expect(result).toBe(50000); + }); + + it('falls back to configOption default when no agentConfigValues', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window', + description: 'Context window size', + default: 100000, + }, + ], + }); + + const result = getContextWindowValue(agent, {}); + expect(result).toBe(100000); + }); + + it('returns 0 when no config exists', () => { + const agent = makeAgent(); // no configOptions + const result = getContextWindowValue(agent, {}); + expect(result).toBe(0); + }); + + it('returns 0 when agent is null', () => { + const result = getContextWindowValue(null, {}); + expect(result).toBe(0); + }); + + it('returns 0 when agent is undefined', () => { + const result = getContextWindowValue(undefined, {}); + expect(result).toBe(0); + }); + + it('ignores session override of 0', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window', + description: 'Context window size', + default: 100000, + }, + ], + }); + + // sessionCustomContextWindow of 0 should be ignored (not > 0) + const result = getContextWindowValue(agent, { contextWindow: 50000 }, 0); + expect(result).toBe(50000); + }); + + it('ignores session override when undefined', () => { + const agent = makeAgent({ + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window', + description: 'Context window size', + default: 100000, + }, + ], + }); + + const result = getContextWindowValue( + agent, + { contextWindow: 50000 }, + undefined + ); + expect(result).toBe(50000); + }); +}); diff --git a/src/__tests__/main/utils/pricing.test.ts b/src/__tests__/main/utils/pricing.test.ts index f7f2a049..d28b11d1 100644 --- a/src/__tests__/main/utils/pricing.test.ts +++ b/src/__tests__/main/utils/pricing.test.ts @@ -1,14 +1,21 @@ /** - * Tests for pricing utility + * Comprehensive tests for pricing utility functions + * + * Covers: calculateCost, calculateClaudeCost, default pricing, custom pricing, + * edge cases (zero/large tokens), optional cache token defaults, delegation, + * manual calculation verification, and floating point precision. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { calculateCost, calculateClaudeCost, PricingConfig } from '../../../main/utils/pricing'; import { CLAUDE_PRICING, TOKENS_PER_MILLION } from '../../../main/constants'; describe('pricing utilities', () => { - describe('calculateCost', () => { - it('should calculate cost correctly with default Claude pricing', () => { + // ----------------------------------------------------------------------- + // 1. calculateCost with all token types + // ----------------------------------------------------------------------- + describe('calculateCost with all token types', () => { + it('should calculate cost correctly when all four token types are provided', () => { const cost = calculateCost({ inputTokens: 1_000_000, outputTokens: 1_000_000, @@ -16,11 +23,97 @@ describe('pricing utilities', () => { cacheCreationTokens: 1_000_000, }); - // Expected: 3 + 15 + 0.30 + 3.75 = 22.05 + // 3 + 15 + 0.30 + 3.75 = 22.05 expect(cost).toBeCloseTo(22.05, 2); }); - it('should handle zero tokens', () => { + it('should scale linearly with token counts', () => { + const costOneMillion = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + }); + + const costTwoMillion = calculateCost({ + inputTokens: 2_000_000, + outputTokens: 2_000_000, + cacheReadTokens: 2_000_000, + cacheCreationTokens: 2_000_000, + }); + + expect(costTwoMillion).toBeCloseTo(costOneMillion * 2, 10); + }); + + it('should correctly break down each cost component', () => { + const tokens = { + inputTokens: 500_000, + outputTokens: 200_000, + cacheReadTokens: 300_000, + cacheCreationTokens: 100_000, + }; + + const cost = calculateCost(tokens); + + const expectedInput = (500_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.INPUT_PER_MILLION; // 1.5 + const expectedOutput = (200_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.OUTPUT_PER_MILLION; // 3.0 + const expectedCacheRead = (300_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.CACHE_READ_PER_MILLION; // 0.09 + const expectedCacheCreation = + (100_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.CACHE_CREATION_PER_MILLION; // 0.375 + + const expectedTotal = expectedInput + expectedOutput + expectedCacheRead + expectedCacheCreation; + expect(cost).toBeCloseTo(expectedTotal, 10); + }); + }); + + // ----------------------------------------------------------------------- + // 2. calculateCost with only input/output (no cache tokens) + // ----------------------------------------------------------------------- + describe('calculateCost with only input/output (no cache tokens)', () => { + it('should calculate cost with only input and output tokens (cache explicitly zero)', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + // 3 + 15 = 18 + expect(cost).toBeCloseTo(18, 2); + }); + + it('should calculate cost when cache tokens are omitted entirely', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + // 3 + 15 = 18 (cache tokens default to 0) + expect(cost).toBeCloseTo(18, 2); + }); + + it('should produce same result whether cache tokens are omitted or set to 0', () => { + const costOmitted = calculateCost({ + inputTokens: 750_000, + outputTokens: 250_000, + }); + + const costExplicitZero = calculateCost({ + inputTokens: 750_000, + outputTokens: 250_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + expect(costOmitted).toBe(costExplicitZero); + }); + }); + + // ----------------------------------------------------------------------- + // 3. calculateCost with zero tokens + // ----------------------------------------------------------------------- + describe('calculateCost with zero tokens', () => { + it('should return 0 when all tokens are zero', () => { const cost = calculateCost({ inputTokens: 0, outputTokens: 0, @@ -31,17 +124,109 @@ describe('pricing utilities', () => { expect(cost).toBe(0); }); - it('should handle missing optional token counts', () => { + it('should return 0 when input/output are zero and cache tokens are omitted', () => { const cost = calculateCost({ - inputTokens: 1_000_000, - outputTokens: 1_000_000, + inputTokens: 0, + outputTokens: 0, }); - // Expected: 3 + 15 = 18 - expect(cost).toBeCloseTo(18, 2); + expect(cost).toBe(0); }); - it('should accept custom pricing config', () => { + it('should calculate correctly with only input tokens (others zero)', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + expect(cost).toBeCloseTo(3, 10); + }); + + it('should calculate correctly with only output tokens (others zero)', () => { + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + expect(cost).toBeCloseTo(15, 10); + }); + + it('should calculate correctly with only cache read tokens (others zero)', () => { + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 1_000_000, + cacheCreationTokens: 0, + }); + + expect(cost).toBeCloseTo(0.3, 10); + }); + + it('should calculate correctly with only cache creation tokens (others zero)', () => { + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 1_000_000, + }); + + expect(cost).toBeCloseTo(3.75, 10); + }); + }); + + // ----------------------------------------------------------------------- + // 4. calculateCost with large token counts + // ----------------------------------------------------------------------- + describe('calculateCost with large token counts', () => { + it('should handle very large token counts (1 billion tokens each)', () => { + const cost = calculateCost({ + inputTokens: 1_000_000_000, + outputTokens: 1_000_000_000, + cacheReadTokens: 1_000_000_000, + cacheCreationTokens: 1_000_000_000, + }); + + // 1000 * (3 + 15 + 0.3 + 3.75) = 1000 * 22.05 = 22050 + expect(cost).toBeCloseTo(22050, 2); + }); + + it('should handle 100 million input tokens', () => { + const cost = calculateCost({ + inputTokens: 100_000_000, + outputTokens: 0, + }); + + // 100 * 3 = 300 + expect(cost).toBeCloseTo(300, 10); + }); + + it('should maintain precision with large asymmetric token counts', () => { + const cost = calculateCost({ + inputTokens: 500_000_000, + outputTokens: 10_000, + cacheReadTokens: 999_999_999, + cacheCreationTokens: 1, + }); + + const expected = + (500_000_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.INPUT_PER_MILLION + + (10_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.OUTPUT_PER_MILLION + + (999_999_999 / TOKENS_PER_MILLION) * CLAUDE_PRICING.CACHE_READ_PER_MILLION + + (1 / TOKENS_PER_MILLION) * CLAUDE_PRICING.CACHE_CREATION_PER_MILLION; + + expect(cost).toBeCloseTo(expected, 6); + }); + }); + + // ----------------------------------------------------------------------- + // 5. calculateCost with custom pricing config + // ----------------------------------------------------------------------- + describe('calculateCost with custom pricing config', () => { + it('should use custom pricing instead of defaults', () => { const customPricing: PricingConfig = { INPUT_PER_MILLION: 1, OUTPUT_PER_MILLION: 2, @@ -59,13 +244,151 @@ describe('pricing utilities', () => { customPricing ); - // Expected: (2 * 1) + (1 * 2) + (0.5 * 0.5) + (0.25 * 1.5) = 2 + 2 + 0.25 + 0.375 = 4.625 + // (2 * 1) + (1 * 2) + (0.5 * 0.5) + (0.25 * 1.5) = 2 + 2 + 0.25 + 0.375 = 4.625 expect(cost).toBeCloseTo(4.625, 3); }); + + it('should handle custom pricing with zero rates', () => { + const freePricing: PricingConfig = { + INPUT_PER_MILLION: 0, + OUTPUT_PER_MILLION: 0, + CACHE_READ_PER_MILLION: 0, + CACHE_CREATION_PER_MILLION: 0, + }; + + const cost = calculateCost( + { + inputTokens: 10_000_000, + outputTokens: 5_000_000, + cacheReadTokens: 2_000_000, + cacheCreationTokens: 1_000_000, + }, + freePricing + ); + + expect(cost).toBe(0); + }); + + it('should handle custom pricing with very high rates', () => { + const expensivePricing: PricingConfig = { + INPUT_PER_MILLION: 100, + OUTPUT_PER_MILLION: 200, + CACHE_READ_PER_MILLION: 50, + CACHE_CREATION_PER_MILLION: 75, + }; + + const cost = calculateCost( + { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + }, + expensivePricing + ); + + // 100 + 200 + 50 + 75 = 425 + expect(cost).toBeCloseTo(425, 2); + }); + + it('should not affect other calls when custom pricing is passed', () => { + const customPricing: PricingConfig = { + INPUT_PER_MILLION: 10, + OUTPUT_PER_MILLION: 20, + CACHE_READ_PER_MILLION: 5, + CACHE_CREATION_PER_MILLION: 7.5, + }; + + // Call with custom pricing + calculateCost( + { inputTokens: 1_000_000, outputTokens: 1_000_000 }, + customPricing + ); + + // Call without custom pricing should still use defaults + const defaultCost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + // 3 + 15 = 18 (default Claude pricing) + expect(defaultCost).toBeCloseTo(18, 2); + }); }); - describe('calculateClaudeCost (legacy interface)', () => { - it('should produce same result as calculateCost', () => { + // ----------------------------------------------------------------------- + // 6. calculateCost defaults cache tokens to 0 when undefined + // ----------------------------------------------------------------------- + describe('calculateCost defaults cache tokens to 0 when undefined', () => { + it('should default cacheReadTokens to 0 when not provided', () => { + const costWithoutCacheRead = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + }); + + const costWithExplicitZero = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheCreationTokens: 1_000_000, + }); + + expect(costWithoutCacheRead).toBe(costWithExplicitZero); + }); + + it('should default cacheCreationTokens to 0 when not provided', () => { + const costWithoutCacheCreation = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }); + + const costWithExplicitZero = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheCreationTokens: 0, + }); + + expect(costWithoutCacheCreation).toBe(costWithExplicitZero); + }); + + it('should default both cache tokens to 0 when not provided', () => { + const costMinimal = calculateCost({ + inputTokens: 500_000, + outputTokens: 250_000, + }); + + const costExplicit = calculateCost({ + inputTokens: 500_000, + outputTokens: 250_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + expect(costMinimal).toBe(costExplicit); + }); + + it('should only include non-cache cost when cache tokens are omitted', () => { + const cost = calculateCost({ + inputTokens: 2_000_000, + outputTokens: 500_000, + }); + + const expectedInputCost = (2_000_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.INPUT_PER_MILLION; // 6 + const expectedOutputCost = (500_000 / TOKENS_PER_MILLION) * CLAUDE_PRICING.OUTPUT_PER_MILLION; // 7.5 + const expected = expectedInputCost + expectedOutputCost; // 13.5 + + expect(cost).toBeCloseTo(expected, 10); + }); + }); + + // ----------------------------------------------------------------------- + // 7. calculateClaudeCost delegates to calculateCost correctly + // ----------------------------------------------------------------------- + describe('calculateClaudeCost delegates to calculateCost correctly', () => { + it('should produce identical results to calculateCost for same inputs', () => { const inputTokens = 500_000; const outputTokens = 250_000; const cacheReadTokens = 100_000; @@ -87,16 +410,103 @@ describe('pricing utilities', () => { expect(legacyCost).toBe(modernCost); }); - }); - describe('TOKENS_PER_MILLION constant', () => { - it('should equal one million', () => { - expect(TOKENS_PER_MILLION).toBe(1_000_000); + it('should pass all four parameters through correctly', () => { + // Test with distinctive values so each parameter matters + const cost = calculateClaudeCost(1_000_000, 0, 0, 0); + expect(cost).toBeCloseTo(3, 10); // Only input cost + + const cost2 = calculateClaudeCost(0, 1_000_000, 0, 0); + expect(cost2).toBeCloseTo(15, 10); // Only output cost + + const cost3 = calculateClaudeCost(0, 0, 1_000_000, 0); + expect(cost3).toBeCloseTo(0.3, 10); // Only cache read cost + + const cost4 = calculateClaudeCost(0, 0, 0, 1_000_000); + expect(cost4).toBeCloseTo(3.75, 10); // Only cache creation cost + }); + + it('should handle zero values for all parameters', () => { + const cost = calculateClaudeCost(0, 0, 0, 0); + expect(cost).toBe(0); + }); + + it('should produce same result for various token combinations', () => { + const testCases = [ + { input: 100_000, output: 200_000, cacheRead: 300_000, cacheCreate: 400_000 }, + { input: 1, output: 1, cacheRead: 1, cacheCreate: 1 }, + { input: 999_999, output: 500_001, cacheRead: 123_456, cacheCreate: 789_012 }, + ]; + + for (const tc of testCases) { + const legacy = calculateClaudeCost(tc.input, tc.output, tc.cacheRead, tc.cacheCreate); + const modern = calculateCost({ + inputTokens: tc.input, + outputTokens: tc.output, + cacheReadTokens: tc.cacheRead, + cacheCreationTokens: tc.cacheCreate, + }); + expect(legacy).toBe(modern); + } }); }); - describe('CLAUDE_PRICING', () => { - it('should have all required pricing fields', () => { + // ----------------------------------------------------------------------- + // 8. calculateClaudeCost matches manual calculation + // ----------------------------------------------------------------------- + describe('calculateClaudeCost matches manual calculation', () => { + it('should match hand-computed cost for a realistic session', () => { + // Simulate a session: 50K input, 10K output, 200K cache read, 5K cache creation + const inputTokens = 50_000; + const outputTokens = 10_000; + const cacheReadTokens = 200_000; + const cacheCreationTokens = 5_000; + + const cost = calculateClaudeCost( + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens + ); + + // Manual calculation: + // Input: 50,000 / 1,000,000 * 3 = 0.15 + // Output: 10,000 / 1,000,000 * 15 = 0.15 + // Cache read: 200,000 / 1,000,000 * 0.3 = 0.06 + // Cache creation: 5,000 / 1,000,000 * 3.75 = 0.01875 + // Total: 0.15 + 0.15 + 0.06 + 0.01875 = 0.37875 + expect(cost).toBeCloseTo(0.37875, 5); + }); + + it('should match manual calculation for exactly 1 million of each token type', () => { + const cost = calculateClaudeCost(1_000_000, 1_000_000, 1_000_000, 1_000_000); + + // 3 + 15 + 0.3 + 3.75 = 22.05 + expect(cost).toBeCloseTo(22.05, 10); + }); + + it('should match manual calculation for fractional token-to-million ratios', () => { + const cost = calculateClaudeCost(333_333, 666_667, 111_111, 222_222); + + const expected = + (333_333 / 1_000_000) * 3 + + (666_667 / 1_000_000) * 15 + + (111_111 / 1_000_000) * 0.3 + + (222_222 / 1_000_000) * 3.75; + + expect(cost).toBeCloseTo(expected, 10); + }); + }); + + // ----------------------------------------------------------------------- + // 9. Verify CLAUDE_PRICING values are used by default + // ----------------------------------------------------------------------- + describe('CLAUDE_PRICING values are used by default', () => { + it('should have TOKENS_PER_MILLION equal to 1,000,000', () => { + expect(TOKENS_PER_MILLION).toBe(1_000_000); + }); + + it('should have all required pricing fields on CLAUDE_PRICING', () => { expect(CLAUDE_PRICING).toHaveProperty('INPUT_PER_MILLION'); expect(CLAUDE_PRICING).toHaveProperty('OUTPUT_PER_MILLION'); expect(CLAUDE_PRICING).toHaveProperty('CACHE_READ_PER_MILLION'); @@ -109,5 +519,145 @@ describe('pricing utilities', () => { expect(CLAUDE_PRICING.CACHE_READ_PER_MILLION).toBe(0.3); expect(CLAUDE_PRICING.CACHE_CREATION_PER_MILLION).toBe(3.75); }); + + it('should use CLAUDE_PRICING when no pricing config is provided', () => { + // Calculate with default pricing + const defaultCost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + // Calculate with explicit CLAUDE_PRICING + const explicitCost = calculateCost( + { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }, + CLAUDE_PRICING + ); + + expect(defaultCost).toBe(explicitCost); + }); + + it('should produce different results with different pricing than defaults', () => { + const differentPricing: PricingConfig = { + INPUT_PER_MILLION: 10, + OUTPUT_PER_MILLION: 30, + CACHE_READ_PER_MILLION: 1, + CACHE_CREATION_PER_MILLION: 5, + }; + + const defaultCost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }); + + const customCost = calculateCost( + { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + }, + differentPricing + ); + + expect(customCost).not.toBe(defaultCost); + expect(customCost).toBeCloseTo(40, 2); // 10 + 30 + expect(defaultCost).toBeCloseTo(18, 2); // 3 + 15 + }); + }); + + // ----------------------------------------------------------------------- + // 10. Cost precision (floating point math checks) + // ----------------------------------------------------------------------- + describe('cost precision (floating point math)', () => { + it('should handle small token counts without floating point drift', () => { + const cost = calculateCost({ + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 1, + cacheCreationTokens: 1, + }); + + // Each token cost is price / 1_000_000 + const expected = (3 + 15 + 0.3 + 3.75) / 1_000_000; + expect(cost).toBeCloseTo(expected, 15); + }); + + it('should produce finite numbers for all reasonable inputs', () => { + const cost = calculateCost({ + inputTokens: 999_999_999, + outputTokens: 999_999_999, + cacheReadTokens: 999_999_999, + cacheCreationTokens: 999_999_999, + }); + + expect(Number.isFinite(cost)).toBe(true); + expect(cost).toBeGreaterThan(0); + }); + + it('should not lose significance with mixed large and small values', () => { + const cost = calculateCost({ + inputTokens: 100_000_000, + outputTokens: 1, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }); + + // Large: 100 * 3 = 300 + // Small: 1 / 1_000_000 * 15 = 0.000015 + // Total should be 300.000015 + const expected = 300 + 15 / 1_000_000; + expect(cost).toBeCloseTo(expected, 10); + }); + + it('should handle decimal pricing rates without accumulating error', () => { + // Cache read rate (0.3) is a repeating binary fraction + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 3_000_000, + cacheCreationTokens: 0, + }); + + // 3 * 0.3 = 0.9 + expect(cost).toBeCloseTo(0.9, 10); + }); + + it('should return exactly 0 for zero tokens regardless of pricing', () => { + const weirdPricing: PricingConfig = { + INPUT_PER_MILLION: 0.1 + 0.2, // floating point imprecision intentional + OUTPUT_PER_MILLION: Math.PI, + CACHE_READ_PER_MILLION: Math.E, + CACHE_CREATION_PER_MILLION: Math.SQRT2, + }; + + const cost = calculateCost( + { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }, + weirdPricing + ); + + expect(cost).toBe(0); + }); + + it('should compute costs that can be meaningfully compared', () => { + // More output tokens should cost more than more input tokens (at default rates) + const inputHeavy = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 0, + }); + + const outputHeavy = calculateCost({ + inputTokens: 0, + outputTokens: 1_000_000, + }); + + expect(outputHeavy).toBeGreaterThan(inputHeavy); + expect(outputHeavy / inputHeavy).toBeCloseTo(5, 10); // 15/3 = 5x + }); }); }); diff --git a/src/__tests__/main/utils/remote-git.test.ts b/src/__tests__/main/utils/remote-git.test.ts new file mode 100644 index 00000000..5b4fd0eb --- /dev/null +++ b/src/__tests__/main/utils/remote-git.test.ts @@ -0,0 +1,1039 @@ +/** + * Tests for src/main/utils/remote-git.ts + * + * Tests cover remote git execution utilities that execute git commands + * on remote hosts via SSH, including worktree management and parsing. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ExecResult } from '../../../main/utils/execFile'; +import type { SshRemoteConfig } from '../../../shared/types'; + +// Mock dependencies +vi.mock('../../../main/utils/execFile', () => ({ + execFileNoThrow: vi.fn(), +})); + +vi.mock('../../../main/utils/ssh-command-builder', () => ({ + buildSshCommand: vi.fn(), +})); + +vi.mock('../../../main/utils/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +// Import mocked modules +import { execFileNoThrow } from '../../../main/utils/execFile'; +import { buildSshCommand } from '../../../main/utils/ssh-command-builder'; + +// Import functions under test +import { + execGitRemote, + execGit, + listWorktreesRemote, + worktreeInfoRemote, + worktreeCheckoutRemote, + worktreeSetupRemote, + getRepoRootRemote, +} from '../../../main/utils/remote-git'; + +// Typed mock references +const mockExecFileNoThrow = vi.mocked(execFileNoThrow); +const mockBuildSshCommand = vi.mocked(buildSshCommand); + +/** + * Helper to create an SshRemoteConfig for tests. + */ +function createSshRemote(overrides?: Partial): SshRemoteConfig { + return { + id: 'test-remote', + name: 'Test Remote', + host: 'test-host.example.com', + port: 22, + username: 'testuser', + privateKeyPath: '/home/testuser/.ssh/id_rsa', + enabled: true, + ...overrides, + }; +} + +/** + * Helper to create a successful ExecResult. + */ +function successResult(stdout: string, stderr = ''): ExecResult { + return { stdout, stderr, exitCode: 0 }; +} + +/** + * Helper to create a failed ExecResult. + */ +function failResult(stderr: string, exitCode = 1, stdout = ''): ExecResult { + return { stdout, stderr, exitCode }; +} + +describe('remote-git.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: buildSshCommand returns a mock SSH command + mockBuildSshCommand.mockResolvedValue({ + command: 'ssh', + args: ['mock-args'], + }); + }); + + // ========================================================================= + // execGitRemote + // ========================================================================= + describe('execGitRemote', () => { + it('should call buildSshCommand with correct remote command options', async () => { + const sshRemote = createSshRemote({ remoteEnv: { GIT_AUTHOR_NAME: 'Test' } }); + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + await execGitRemote(['status', '--porcelain'], { + sshRemote, + remoteCwd: '/remote/repo', + }); + + expect(mockBuildSshCommand).toHaveBeenCalledWith(sshRemote, { + command: 'git', + args: ['status', '--porcelain'], + cwd: '/remote/repo', + env: { GIT_AUTHOR_NAME: 'Test' }, + }); + }); + + it('should pass buildSshCommand result to execFileNoThrow', async () => { + const sshRemote = createSshRemote(); + mockBuildSshCommand.mockResolvedValue({ + command: '/usr/bin/ssh', + args: ['-p', '2222', 'user@host', 'git status'], + }); + mockExecFileNoThrow.mockResolvedValue(successResult('clean')); + + await execGitRemote(['status'], { sshRemote, remoteCwd: '/repo' }); + + expect(mockExecFileNoThrow).toHaveBeenCalledWith('/usr/bin/ssh', [ + '-p', + '2222', + 'user@host', + 'git status', + ]); + }); + + it('should return the result from execFileNoThrow', async () => { + const sshRemote = createSshRemote(); + const expectedResult = successResult('M file.txt\n'); + mockExecFileNoThrow.mockResolvedValue(expectedResult); + + const result = await execGitRemote(['status', '--porcelain'], { + sshRemote, + remoteCwd: '/repo', + }); + + expect(result).toEqual(expectedResult); + }); + + it('should pass undefined remoteCwd when not specified', async () => { + const sshRemote = createSshRemote(); + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + await execGitRemote(['version'], { sshRemote }); + + expect(mockBuildSshCommand).toHaveBeenCalledWith(sshRemote, { + command: 'git', + args: ['version'], + cwd: undefined, + env: undefined, + }); + }); + + it('should pass remoteEnv from sshRemote config', async () => { + const sshRemote = createSshRemote({ + remoteEnv: { PATH: '/custom/bin', HOME: '/remote/home' }, + }); + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + await execGitRemote(['log'], { sshRemote, remoteCwd: '/repo' }); + + expect(mockBuildSshCommand).toHaveBeenCalledWith( + sshRemote, + expect.objectContaining({ + env: { PATH: '/custom/bin', HOME: '/remote/home' }, + }) + ); + }); + + it('should return failed result when command fails', async () => { + const sshRemote = createSshRemote(); + const failedResult = failResult('fatal: not a git repository', 128); + mockExecFileNoThrow.mockResolvedValue(failedResult); + + const result = await execGitRemote(['status'], { + sshRemote, + remoteCwd: '/not-a-repo', + }); + + expect(result).toEqual(failedResult); + }); + }); + + // ========================================================================= + // execGit + // ========================================================================= + describe('execGit', () => { + it('should dispatch to local execution when sshRemote is not provided', async () => { + const expectedResult = successResult('On branch main\n'); + mockExecFileNoThrow.mockResolvedValue(expectedResult); + + const result = await execGit(['status'], '/local/repo'); + + expect(mockExecFileNoThrow).toHaveBeenCalledWith('git', ['status'], '/local/repo'); + expect(mockBuildSshCommand).not.toHaveBeenCalled(); + expect(result).toEqual(expectedResult); + }); + + it('should dispatch to local execution when sshRemote is null', async () => { + const expectedResult = successResult(''); + mockExecFileNoThrow.mockResolvedValue(expectedResult); + + const result = await execGit(['log'], '/local/repo', null); + + expect(mockExecFileNoThrow).toHaveBeenCalledWith('git', ['log'], '/local/repo'); + expect(mockBuildSshCommand).not.toHaveBeenCalled(); + expect(result).toEqual(expectedResult); + }); + + it('should dispatch to remote execution when sshRemote is provided', async () => { + const sshRemote = createSshRemote(); + const expectedResult = successResult('abc1234\n'); + mockExecFileNoThrow.mockResolvedValue(expectedResult); + + const result = await execGit( + ['rev-parse', 'HEAD'], + '/local/repo', + sshRemote, + '/remote/repo' + ); + + expect(mockBuildSshCommand).toHaveBeenCalledWith(sshRemote, { + command: 'git', + args: ['rev-parse', 'HEAD'], + cwd: '/remote/repo', + env: undefined, + }); + expect(result).toEqual(expectedResult); + }); + + it('should pass remoteCwd to remote execution', async () => { + const sshRemote = createSshRemote(); + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + await execGit(['status'], '/local', sshRemote, '/remote/cwd'); + + expect(mockBuildSshCommand).toHaveBeenCalledWith( + sshRemote, + expect.objectContaining({ cwd: '/remote/cwd' }) + ); + }); + }); + + // ========================================================================= + // listWorktreesRemote (MOST IMPORTANT - porcelain parsing) + // ========================================================================= + describe('listWorktreesRemote', () => { + const sshRemote = createSshRemote(); + + it('should parse standard worktree list output with multiple entries', async () => { + const porcelainOutput = [ + 'worktree /home/user/project', + 'HEAD abc1234def5678901234567890abcdef12345678', + 'branch refs/heads/main', + '', + 'worktree /home/user/project-feature', + 'HEAD 1234567890abcdef1234567890abcdef12345678', + 'branch refs/heads/feature-branch', + '', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(2); + expect(result.data![0]).toEqual({ + path: '/home/user/project', + head: 'abc1234def5678901234567890abcdef12345678', + branch: 'main', + isBare: false, + }); + expect(result.data![1]).toEqual({ + path: '/home/user/project-feature', + head: '1234567890abcdef1234567890abcdef12345678', + branch: 'feature-branch', + isBare: false, + }); + }); + + it('should handle bare worktrees', async () => { + const porcelainOutput = [ + 'worktree /home/user/bare-repo.git', + 'HEAD abc1234def5678901234567890abcdef12345678', + 'bare', + '', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/bare-repo.git', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data![0]).toEqual({ + path: '/home/user/bare-repo.git', + head: 'abc1234def5678901234567890abcdef12345678', + branch: null, + isBare: true, + }); + }); + + it('should handle detached HEAD (branch = null)', async () => { + const porcelainOutput = [ + 'worktree /home/user/project', + 'HEAD abc1234def5678901234567890abcdef12345678', + 'branch refs/heads/main', + '', + 'worktree /home/user/project-detached', + 'HEAD fedcba9876543210fedcba9876543210fedcba98', + 'detached', + '', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(2); + expect(result.data![1]).toEqual({ + path: '/home/user/project-detached', + head: 'fedcba9876543210fedcba9876543210fedcba98', + branch: null, + isBare: false, + }); + }); + + it('should strip refs/heads/ prefix from branch names', async () => { + const porcelainOutput = [ + 'worktree /repo', + 'HEAD aaaa', + 'branch refs/heads/feature/nested/branch-name', + '', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data![0].branch).toBe('feature/nested/branch-name'); + }); + + it('should handle trailing entry without final newline', async () => { + // No trailing newline after last entry + const porcelainOutput = [ + 'worktree /home/user/project', + 'HEAD abc123', + 'branch refs/heads/main', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data![0]).toEqual({ + path: '/home/user/project', + head: 'abc123', + branch: 'main', + isBare: false, + }); + }); + + it('should handle empty output (no worktrees)', async () => { + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + const result = await listWorktreesRemote('/repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('should return empty array on command failure', async () => { + mockExecFileNoThrow.mockResolvedValue( + failResult('fatal: not a git repository', 128) + ); + + const result = await listWorktreesRemote('/not-a-repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('should handle single worktree entry with trailing blank line', async () => { + const porcelainOutput = [ + 'worktree /home/user/project', + 'HEAD abc123', + 'branch refs/heads/develop', + '', + '', // extra trailing blank line + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + }); + + it('should handle multiple worktrees including bare and detached', async () => { + const porcelainOutput = [ + 'worktree /home/user/main-repo', + 'HEAD aaaa1111', + 'branch refs/heads/main', + '', + 'worktree /home/user/feature-wt', + 'HEAD bbbb2222', + 'branch refs/heads/feature-x', + '', + 'worktree /home/user/detached-wt', + 'HEAD cccc3333', + 'detached', + '', + ].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/home/user/main-repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(3); + expect(result.data![0].branch).toBe('main'); + expect(result.data![1].branch).toBe('feature-x'); + expect(result.data![2].branch).toBeNull(); + expect(result.data![2].isBare).toBe(false); + }); + + it('should default head to empty string when HEAD line is missing', async () => { + // Unusual but handle gracefully: no HEAD line + const porcelainOutput = ['worktree /repo', 'branch refs/heads/main', ''].join('\n'); + + mockExecFileNoThrow.mockResolvedValue(successResult(porcelainOutput)); + + const result = await listWorktreesRemote('/repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toHaveLength(1); + expect(result.data![0].head).toBe(''); + }); + + it('should pass correct arguments to execGitRemote', async () => { + mockExecFileNoThrow.mockResolvedValue(successResult('')); + + await listWorktreesRemote('/remote/repo', sshRemote); + + expect(mockBuildSshCommand).toHaveBeenCalledWith(sshRemote, { + command: 'git', + args: ['worktree', 'list', '--porcelain'], + cwd: '/remote/repo', + env: undefined, + }); + }); + }); + + // ========================================================================= + // worktreeInfoRemote + // ========================================================================= + describe('worktreeInfoRemote', () => { + const sshRemote = createSshRemote(); + + it('should return exists:false when path does not exist', async () => { + // First call: shell command to check path existence + mockExecFileNoThrow.mockResolvedValueOnce(successResult('NOT_EXISTS')); + + const result = await worktreeInfoRemote('/nonexistent', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ exists: false, isWorktree: false }); + }); + + it('should return exists:true, isWorktree:false when path exists but is not git', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('not a git repository', 128)); + + const result = await worktreeInfoRemote('/some/directory', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ exists: true, isWorktree: false }); + }); + + it('should return isWorktree:false for regular git repo (gitDir == gitCommonDir)', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // rev-parse --git-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // rev-parse --git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // rev-parse --abbrev-ref HEAD + mockExecFileNoThrow.mockResolvedValueOnce(successResult('main\n')); + // rev-parse --show-toplevel + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/home/user/project\n')); + + const result = await worktreeInfoRemote('/home/user/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + exists: true, + isWorktree: false, + currentBranch: 'main', + repoRoot: '/home/user/project', + }); + }); + + it('should return isWorktree:true when gitDir != gitCommonDir', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // rev-parse --git-dir + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/home/user/project/.git/worktrees/feature\n') + ); + // rev-parse --git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/home/user/project/.git\n')); + // rev-parse --abbrev-ref HEAD + mockExecFileNoThrow.mockResolvedValueOnce(successResult('feature\n')); + // Shell: dirname to get repo root from common dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/home/user/project\n')); + + const result = await worktreeInfoRemote('/home/user/project-feature', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + exists: true, + isWorktree: true, + currentBranch: 'feature', + repoRoot: '/home/user/project', + }); + }); + + it('should return error when path existence check fails', async () => { + mockExecFileNoThrow.mockResolvedValueOnce( + failResult('SSH connection refused', 255) + ); + + const result = await worktreeInfoRemote('/some/path', sshRemote); + + expect(result.success).toBe(false); + expect(result.error).toBe('SSH connection refused'); + }); + + it('should handle branch being undefined when rev-parse fails', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // rev-parse --git-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // rev-parse --git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // rev-parse --abbrev-ref HEAD fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('HEAD not found', 128)); + // rev-parse --show-toplevel + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/repo\n')); + + const result = await worktreeInfoRemote('/repo', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.currentBranch).toBeUndefined(); + }); + + it('should return error when git-dir check fails', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // rev-parse --git-dir fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('error', 1)); + + const result = await worktreeInfoRemote('/broken-repo', sshRemote); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get git directory'); + }); + + it('should fall back to gitDir when git-common-dir check fails', async () => { + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // rev-parse --is-inside-work-tree + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // rev-parse --git-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // rev-parse --git-common-dir fails (old git version) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('unknown option', 1)); + // gitDir == gitCommonDir (fallback), so isWorktree = false + // rev-parse --abbrev-ref HEAD + mockExecFileNoThrow.mockResolvedValueOnce(successResult('develop\n')); + // rev-parse --show-toplevel + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/project\n')); + + const result = await worktreeInfoRemote('/project', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.isWorktree).toBe(false); + expect(result.data!.currentBranch).toBe('develop'); + }); + }); + + // ========================================================================= + // worktreeCheckoutRemote + // ========================================================================= + describe('worktreeCheckoutRemote', () => { + const sshRemote = createSshRemote(); + + it('should return error with hasUncommittedChanges when status shows changes', async () => { + // git status --porcelain returns changes + mockExecFileNoThrow.mockResolvedValueOnce(successResult(' M file.txt\n')); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'feature', + false, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.hasUncommittedChanges).toBe(true); + expect(result.data!.error).toContain('uncommitted changes'); + }); + + it('should checkout existing branch', async () => { + // git status --porcelain (clean) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // rev-parse --verify branchName (branch exists) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('abc123\n')); + // git checkout branchName + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'feature', + false, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + expect(result.data!.hasUncommittedChanges).toBe(false); + }); + + it('should create branch when it does not exist and createIfMissing is true', async () => { + // git status --porcelain (clean) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // rev-parse --verify branchName (branch does not exist) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('', 128)); + // git checkout -b branchName + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'new-feature', + true, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + }); + + it('should return error when branch does not exist and createIfMissing is false', async () => { + // git status --porcelain (clean) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // rev-parse --verify branchName (branch does not exist) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('', 128)); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'nonexistent', + false, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.hasUncommittedChanges).toBe(false); + expect(result.data!.error).toContain("'nonexistent'"); + expect(result.data!.error).toContain('does not exist'); + }); + + it('should return error when git status check fails', async () => { + // git status --porcelain fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('connection error', 255)); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'main', + false, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.hasUncommittedChanges).toBe(false); + expect(result.data!.error).toBe('Failed to check git status'); + }); + + it('should return error when checkout fails', async () => { + // git status --porcelain (clean) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // rev-parse --verify (branch exists) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('abc123\n')); + // git checkout fails + mockExecFileNoThrow.mockResolvedValueOnce( + failResult('error: pathspec did not match', 1) + ); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'broken-branch', + false, + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.error).toContain('pathspec'); + }); + + it('should treat empty status output as clean working tree', async () => { + // git status --porcelain returns empty (with just whitespace) + mockExecFileNoThrow.mockResolvedValueOnce(successResult(' \n ')); + // rev-parse --verify (branch does not exist) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('', 128)); + + const result = await worktreeCheckoutRemote( + '/worktree', + 'new-branch', + false, + sshRemote + ); + + // Since stdout.trim().length > 0 is false for whitespace-only, it's clean + // Actually ' \n '.trim() = '' so length is 0 -> clean + expect(result.data!.hasUncommittedChanges).toBe(false); + }); + }); + + // ========================================================================= + // worktreeSetupRemote + // ========================================================================= + describe('worktreeSetupRemote', () => { + const sshRemote = createSshRemote(); + + it('should reject nested worktree (worktree path inside main repo)', async () => { + // realpath returns resolved paths + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/home/user/project\n/home/user/project/worktree\n') + ); + + const result = await worktreeSetupRemote( + '/home/user/project', + '/home/user/project/worktree', + 'feature', + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.error).toContain('cannot be inside the main repository'); + }); + + it('should create new worktree when path does not exist and branch exists', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/home/user/project\n/home/user/project-wt\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('NOT_EXISTS')); + // Branch exists check + mockExecFileNoThrow.mockResolvedValueOnce(successResult('abc123\n')); + // git worktree add + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + + const result = await worktreeSetupRemote( + '/home/user/project', + '/home/user/project-wt', + 'feature', + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + expect(result.data!.created).toBe(true); + expect(result.data!.currentBranch).toBe('feature'); + expect(result.data!.branchMismatch).toBe(false); + }); + + it('should create new worktree with new branch when branch does not exist', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/home/user/project\n/home/user/project-wt\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('NOT_EXISTS')); + // Branch does NOT exist + mockExecFileNoThrow.mockResolvedValueOnce(failResult('', 128)); + // git worktree add -b branchName + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + + const result = await worktreeSetupRemote( + '/home/user/project', + '/home/user/project-wt', + 'new-feature', + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + expect(result.data!.created).toBe(true); + }); + + it('should reuse existing worktree and report branch mismatch', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/home/user/project\n/home/user/project-wt\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // Is inside work tree -> yes + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/home/user/project/.git\n')); + // git-dir of main repo + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // Compare paths -> SAME + mockExecFileNoThrow.mockResolvedValueOnce(successResult('SAME')); + // Current branch + mockExecFileNoThrow.mockResolvedValueOnce(successResult('other-branch\n')); + + const result = await worktreeSetupRemote( + '/home/user/project', + '/home/user/project-wt', + 'feature', + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + expect(result.data!.created).toBe(false); + expect(result.data!.currentBranch).toBe('other-branch'); + expect(result.data!.requestedBranch).toBe('feature'); + expect(result.data!.branchMismatch).toBe(true); + }); + + it('should return error when path existence check fails', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('SSH error', 255)); + + const result = await worktreeSetupRemote( + '/a', + '/b', + 'branch', + sshRemote + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('SSH error'); + }); + + it('should return error when worktree creation fails', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('NOT_EXISTS')); + // Branch exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('abc\n')); + // git worktree add fails + mockExecFileNoThrow.mockResolvedValueOnce(failResult('fatal: already exists')); + + const result = await worktreeSetupRemote( + '/a', + '/b', + 'branch', + sshRemote + ); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.error).toContain('already exists'); + }); + + it('should handle existing non-empty non-git directory', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // Is inside work tree -> fails (not a git repo) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('not a git repository', 128)); + // ls -A check (directory is not empty) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('some-file.txt')); + + const result = await worktreeSetupRemote('/a', '/b', 'branch', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.error).toContain('not a git worktree'); + }); + + it('should remove empty non-git directory and create worktree', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // Is inside work tree -> fails (not a git repo) + mockExecFileNoThrow.mockResolvedValueOnce(failResult('not a git repository', 128)); + // ls -A check (directory is empty) + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // rmdir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + // Branch exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('abc\n')); + // git worktree add + mockExecFileNoThrow.mockResolvedValueOnce(successResult('')); + + const result = await worktreeSetupRemote('/a', '/b', 'branch', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(true); + expect(result.data!.created).toBe(true); + }); + + it('should detect worktree belonging to different repository', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // Is inside work tree -> yes + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/other-project/.git\n')); + // git-dir of main repo + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // Compare paths -> DIFFERENT + mockExecFileNoThrow.mockResolvedValueOnce(successResult('DIFFERENT')); + + const result = await worktreeSetupRemote('/a', '/b', 'branch', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.success).toBe(false); + expect(result.data!.error).toContain('different repository'); + }); + + it('should not report branch mismatch when requested branch is empty', async () => { + // Check nested + mockExecFileNoThrow.mockResolvedValueOnce( + successResult('/a\n/b\n') + ); + // Check path exists + mockExecFileNoThrow.mockResolvedValueOnce(successResult('EXISTS')); + // Is inside work tree -> yes + mockExecFileNoThrow.mockResolvedValueOnce(successResult('true')); + // git-common-dir + mockExecFileNoThrow.mockResolvedValueOnce(successResult('/a/.git\n')); + // git-dir of main repo + mockExecFileNoThrow.mockResolvedValueOnce(successResult('.git\n')); + // Compare paths -> SAME + mockExecFileNoThrow.mockResolvedValueOnce(successResult('SAME')); + // Current branch + mockExecFileNoThrow.mockResolvedValueOnce(successResult('some-branch\n')); + + const result = await worktreeSetupRemote('/a', '/b', '', sshRemote); + + expect(result.success).toBe(true); + expect(result.data!.branchMismatch).toBe(false); + }); + }); + + // ========================================================================= + // getRepoRootRemote + // ========================================================================= + describe('getRepoRootRemote', () => { + const sshRemote = createSshRemote(); + + it('should return trimmed repo root on success', async () => { + mockExecFileNoThrow.mockResolvedValue(successResult('/home/user/project\n')); + + const result = await getRepoRootRemote('/home/user/project/sub', sshRemote); + + expect(result.success).toBe(true); + expect(result.data).toBe('/home/user/project'); + }); + + it('should return error on failure', async () => { + mockExecFileNoThrow.mockResolvedValue( + failResult('fatal: not a git repository', 128) + ); + + const result = await getRepoRootRemote('/not-a-repo', sshRemote); + + expect(result.success).toBe(false); + expect(result.error).toBe('fatal: not a git repository'); + }); + + it('should return default error message when stderr is empty', async () => { + mockExecFileNoThrow.mockResolvedValue(failResult('', 128)); + + const result = await getRepoRootRemote('/not-a-repo', sshRemote); + + expect(result.success).toBe(false); + expect(result.error).toBe('Not a git repository'); + }); + + it('should pass correct args to execGitRemote', async () => { + mockExecFileNoThrow.mockResolvedValue(successResult('/repo\n')); + + await getRepoRootRemote('/repo/subdir', sshRemote); + + expect(mockBuildSshCommand).toHaveBeenCalledWith(sshRemote, { + command: 'git', + args: ['rev-parse', '--show-toplevel'], + cwd: '/repo/subdir', + env: undefined, + }); + }); + }); +}); diff --git a/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts new file mode 100644 index 00000000..98f64f41 --- /dev/null +++ b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts @@ -0,0 +1,677 @@ +/** + * Tests for CallbackRegistry + * + * The CallbackRegistry centralizes all callback storage for the WebServer. + * It provides typed getter/setter methods and safe fallback defaults when + * callbacks are not registered. + * + * Tested behavior: + * - Initial state returns safe defaults ([], null, false) + * - Each setter/getter pair works correctly + * - hasCallback() reflects registration state + * - Arguments are passed through to registered callbacks + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CallbackRegistry } from '../../../../main/web-server/managers/CallbackRegistry'; +import type { + SessionData, + SessionDetail, + CustomAICommand, + Theme, +} from '../../../../main/web-server/types'; +import type { HistoryEntry } from '../../../../shared/types'; + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('CallbackRegistry', () => { + let registry: CallbackRegistry; + + beforeEach(() => { + registry = new CallbackRegistry(); + }); + + // ========================================================================= + // Initial state + // ========================================================================= + + describe('initial state', () => { + it('getSessions() returns empty array when no callback set', () => { + expect(registry.getSessions()).toEqual([]); + }); + + it('getSessionDetail() returns null when no callback set', () => { + expect(registry.getSessionDetail('session-1')).toBeNull(); + }); + + it('getSessionDetail() returns null with tabId when no callback set', () => { + expect(registry.getSessionDetail('session-1', 'tab-1')).toBeNull(); + }); + + it('getTheme() returns null when no callback set', () => { + expect(registry.getTheme()).toBeNull(); + }); + + it('getCustomCommands() returns empty array when no callback set', () => { + expect(registry.getCustomCommands()).toEqual([]); + }); + + it('writeToSession() returns false when no callback set', () => { + expect(registry.writeToSession('session-1', 'data')).toBe(false); + }); + + it('executeCommand() returns false when no callback set', async () => { + expect(await registry.executeCommand('session-1', 'ls')).toBe(false); + }); + + it('interruptSession() returns false when no callback set', async () => { + expect(await registry.interruptSession('session-1')).toBe(false); + }); + + it('switchMode() returns false when no callback set', async () => { + expect(await registry.switchMode('session-1', 'terminal')).toBe(false); + }); + + it('selectSession() returns false when no callback set', async () => { + expect(await registry.selectSession('session-1')).toBe(false); + }); + + it('selectSession() returns false with tabId when no callback set', async () => { + expect(await registry.selectSession('session-1', 'tab-1')).toBe(false); + }); + + it('selectTab() returns false when no callback set', async () => { + expect(await registry.selectTab('session-1', 'tab-1')).toBe(false); + }); + + it('newTab() returns null when no callback set', async () => { + expect(await registry.newTab('session-1')).toBeNull(); + }); + + it('closeTab() returns false when no callback set', async () => { + expect(await registry.closeTab('session-1', 'tab-1')).toBe(false); + }); + + it('renameTab() returns false when no callback set', async () => { + expect(await registry.renameTab('session-1', 'tab-1', 'New Name')).toBe(false); + }); + + it('getHistory() returns empty array when no callback set', () => { + expect(registry.getHistory()).toEqual([]); + }); + + it('getHistory() returns empty array with args when no callback set', () => { + expect(registry.getHistory('/project', 'session-1')).toEqual([]); + }); + }); + + // ========================================================================= + // hasCallback + // ========================================================================= + + describe('hasCallback()', () => { + it('returns false for all callback names before any are set', () => { + const callbackNames = [ + 'getSessions', + 'getSessionDetail', + 'getTheme', + 'getCustomCommands', + 'writeToSession', + 'executeCommand', + 'interruptSession', + 'switchMode', + 'selectSession', + 'selectTab', + 'newTab', + 'closeTab', + 'renameTab', + 'getHistory', + ] as const; + + for (const name of callbackNames) { + expect(registry.hasCallback(name)).toBe(false); + } + }); + + it('returns true for getSessions after setting it', () => { + registry.setGetSessionsCallback(() => []); + expect(registry.hasCallback('getSessions')).toBe(true); + }); + + it('returns true for getSessionDetail after setting it', () => { + registry.setGetSessionDetailCallback(() => null); + expect(registry.hasCallback('getSessionDetail')).toBe(true); + }); + + it('returns true for getTheme after setting it', () => { + registry.setGetThemeCallback(() => null); + expect(registry.hasCallback('getTheme')).toBe(true); + }); + + it('returns true for getCustomCommands after setting it', () => { + registry.setGetCustomCommandsCallback(() => []); + expect(registry.hasCallback('getCustomCommands')).toBe(true); + }); + + it('returns true for writeToSession after setting it', () => { + registry.setWriteToSessionCallback(() => false); + expect(registry.hasCallback('writeToSession')).toBe(true); + }); + + it('returns true for executeCommand after setting it', () => { + registry.setExecuteCommandCallback(async () => false); + expect(registry.hasCallback('executeCommand')).toBe(true); + }); + + it('returns true for interruptSession after setting it', () => { + registry.setInterruptSessionCallback(async () => false); + expect(registry.hasCallback('interruptSession')).toBe(true); + }); + + it('returns true for switchMode after setting it', () => { + registry.setSwitchModeCallback(async () => false); + expect(registry.hasCallback('switchMode')).toBe(true); + }); + + it('returns true for selectSession after setting it', () => { + registry.setSelectSessionCallback(async () => false); + expect(registry.hasCallback('selectSession')).toBe(true); + }); + + it('returns true for selectTab after setting it', () => { + registry.setSelectTabCallback(async () => false); + expect(registry.hasCallback('selectTab')).toBe(true); + }); + + it('returns true for newTab after setting it', () => { + registry.setNewTabCallback(async () => null); + expect(registry.hasCallback('newTab')).toBe(true); + }); + + it('returns true for closeTab after setting it', () => { + registry.setCloseTabCallback(async () => false); + expect(registry.hasCallback('closeTab')).toBe(true); + }); + + it('returns true for renameTab after setting it', () => { + registry.setRenameTabCallback(async () => false); + expect(registry.hasCallback('renameTab')).toBe(true); + }); + + it('returns true for getHistory after setting it', () => { + registry.setGetHistoryCallback(() => []); + expect(registry.hasCallback('getHistory')).toBe(true); + }); + + it('does not affect other callbacks when one is set', () => { + registry.setGetSessionsCallback(() => []); + + expect(registry.hasCallback('getSessions')).toBe(true); + expect(registry.hasCallback('getSessionDetail')).toBe(false); + expect(registry.hasCallback('executeCommand')).toBe(false); + expect(registry.hasCallback('getHistory')).toBe(false); + }); + }); + + // ========================================================================= + // Setter/Getter pairs + // ========================================================================= + + describe('setGetSessionsCallback / getSessions()', () => { + it('returns the callback result', () => { + const sessions: SessionData[] = [ + { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/home/user/project', + groupId: null, + groupName: null, + groupEmoji: null, + }, + ]; + registry.setGetSessionsCallback(() => sessions); + expect(registry.getSessions()).toEqual(sessions); + }); + + it('calls the callback each time', () => { + const callback = vi.fn().mockReturnValue([]); + registry.setGetSessionsCallback(callback); + + registry.getSessions(); + registry.getSessions(); + registry.getSessions(); + + expect(callback).toHaveBeenCalledTimes(3); + }); + }); + + describe('setGetSessionDetailCallback / getSessionDetail()', () => { + it('returns the callback result for a valid session', () => { + const detail: SessionDetail = { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/home/user/project', + aiLogs: [{ timestamp: Date.now(), content: 'Hello', type: 'ai' }], + shellLogs: [], + }; + registry.setGetSessionDetailCallback(() => detail); + expect(registry.getSessionDetail('session-1')).toEqual(detail); + }); + + it('returns null when callback returns null', () => { + registry.setGetSessionDetailCallback(() => null); + expect(registry.getSessionDetail('nonexistent')).toBeNull(); + }); + + it('passes sessionId argument to the callback', () => { + const callback = vi.fn().mockReturnValue(null); + registry.setGetSessionDetailCallback(callback); + + registry.getSessionDetail('session-42'); + + expect(callback).toHaveBeenCalledWith('session-42', undefined); + }); + + it('passes sessionId and tabId arguments to the callback', () => { + const callback = vi.fn().mockReturnValue(null); + registry.setGetSessionDetailCallback(callback); + + registry.getSessionDetail('session-42', 'tab-7'); + + expect(callback).toHaveBeenCalledWith('session-42', 'tab-7'); + }); + }); + + describe('setGetThemeCallback / getTheme()', () => { + it('returns the callback result', () => { + const theme = { name: 'dark', background: '#000' } as unknown as Theme; + registry.setGetThemeCallback(() => theme); + expect(registry.getTheme()).toEqual(theme); + }); + + it('returns null when callback returns null', () => { + registry.setGetThemeCallback(() => null); + expect(registry.getTheme()).toBeNull(); + }); + }); + + describe('setGetCustomCommandsCallback / getCustomCommands()', () => { + it('returns the callback result', () => { + const commands: CustomAICommand[] = [ + { + id: 'cmd-1', + command: '/test', + description: 'Run tests', + prompt: 'Run the test suite', + }, + ]; + registry.setGetCustomCommandsCallback(() => commands); + expect(registry.getCustomCommands()).toEqual(commands); + }); + + it('returns empty array when callback returns empty array', () => { + registry.setGetCustomCommandsCallback(() => []); + expect(registry.getCustomCommands()).toEqual([]); + }); + }); + + describe('setWriteToSessionCallback / writeToSession()', () => { + it('returns true when callback succeeds', () => { + registry.setWriteToSessionCallback(() => true); + expect(registry.writeToSession('session-1', 'hello')).toBe(true); + }); + + it('returns false when callback returns false', () => { + registry.setWriteToSessionCallback(() => false); + expect(registry.writeToSession('session-1', 'hello')).toBe(false); + }); + + it('passes sessionId and data arguments to the callback', () => { + const callback = vi.fn().mockReturnValue(true); + registry.setWriteToSessionCallback(callback); + + registry.writeToSession('session-99', 'test-data'); + + expect(callback).toHaveBeenCalledWith('session-99', 'test-data'); + }); + }); + + describe('setExecuteCommandCallback / executeCommand()', () => { + it('returns true when callback resolves to true', async () => { + registry.setExecuteCommandCallback(async () => true); + expect(await registry.executeCommand('session-1', 'ls')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setExecuteCommandCallback(async () => false); + expect(await registry.executeCommand('session-1', 'ls')).toBe(false); + }); + + it('passes sessionId and command arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setExecuteCommandCallback(callback); + + await registry.executeCommand('session-5', 'npm test'); + + expect(callback).toHaveBeenCalledWith('session-5', 'npm test', undefined); + }); + + it('passes inputMode argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setExecuteCommandCallback(callback); + + await registry.executeCommand('session-5', 'npm test', 'terminal'); + + expect(callback).toHaveBeenCalledWith('session-5', 'npm test', 'terminal'); + }); + + it('passes ai inputMode argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setExecuteCommandCallback(callback); + + await registry.executeCommand('session-5', 'explain this code', 'ai'); + + expect(callback).toHaveBeenCalledWith('session-5', 'explain this code', 'ai'); + }); + }); + + describe('setInterruptSessionCallback / interruptSession()', () => { + it('returns true when callback resolves to true', async () => { + registry.setInterruptSessionCallback(async () => true); + expect(await registry.interruptSession('session-1')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setInterruptSessionCallback(async () => false); + expect(await registry.interruptSession('session-1')).toBe(false); + }); + + it('passes sessionId argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setInterruptSessionCallback(callback); + + await registry.interruptSession('session-77'); + + expect(callback).toHaveBeenCalledWith('session-77'); + }); + }); + + describe('setSwitchModeCallback / switchMode()', () => { + it('returns true when callback resolves to true', async () => { + registry.setSwitchModeCallback(async () => true); + expect(await registry.switchMode('session-1', 'terminal')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setSwitchModeCallback(async () => false); + expect(await registry.switchMode('session-1', 'ai')).toBe(false); + }); + + it('passes sessionId and mode arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSwitchModeCallback(callback); + + await registry.switchMode('session-3', 'terminal'); + + expect(callback).toHaveBeenCalledWith('session-3', 'terminal'); + }); + + it('passes ai mode correctly', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSwitchModeCallback(callback); + + await registry.switchMode('session-3', 'ai'); + + expect(callback).toHaveBeenCalledWith('session-3', 'ai'); + }); + }); + + describe('setSelectSessionCallback / selectSession()', () => { + it('returns true when callback resolves to true', async () => { + registry.setSelectSessionCallback(async () => true); + expect(await registry.selectSession('session-1')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setSelectSessionCallback(async () => false); + expect(await registry.selectSession('session-1')).toBe(false); + }); + + it('passes sessionId argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSelectSessionCallback(callback); + + await registry.selectSession('session-10'); + + expect(callback).toHaveBeenCalledWith('session-10', undefined); + }); + + it('passes sessionId and tabId arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSelectSessionCallback(callback); + + await registry.selectSession('session-10', 'tab-2'); + + expect(callback).toHaveBeenCalledWith('session-10', 'tab-2'); + }); + }); + + describe('setSelectTabCallback / selectTab()', () => { + it('returns true when callback resolves to true', async () => { + registry.setSelectTabCallback(async () => true); + expect(await registry.selectTab('session-1', 'tab-1')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setSelectTabCallback(async () => false); + expect(await registry.selectTab('session-1', 'tab-1')).toBe(false); + }); + + it('passes sessionId and tabId arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setSelectTabCallback(callback); + + await registry.selectTab('session-8', 'tab-4'); + + expect(callback).toHaveBeenCalledWith('session-8', 'tab-4'); + }); + }); + + describe('setNewTabCallback / newTab()', () => { + it('returns tab info when callback resolves with data', async () => { + registry.setNewTabCallback(async () => ({ tabId: 'new-tab-1' })); + expect(await registry.newTab('session-1')).toEqual({ tabId: 'new-tab-1' }); + }); + + it('returns null when callback resolves to null', async () => { + registry.setNewTabCallback(async () => null); + expect(await registry.newTab('session-1')).toBeNull(); + }); + + it('passes sessionId argument to the callback', async () => { + const callback = vi.fn().mockResolvedValue({ tabId: 'tab-new' }); + registry.setNewTabCallback(callback); + + await registry.newTab('session-15'); + + expect(callback).toHaveBeenCalledWith('session-15'); + }); + }); + + describe('setCloseTabCallback / closeTab()', () => { + it('returns true when callback resolves to true', async () => { + registry.setCloseTabCallback(async () => true); + expect(await registry.closeTab('session-1', 'tab-1')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setCloseTabCallback(async () => false); + expect(await registry.closeTab('session-1', 'tab-1')).toBe(false); + }); + + it('passes sessionId and tabId arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setCloseTabCallback(callback); + + await registry.closeTab('session-6', 'tab-3'); + + expect(callback).toHaveBeenCalledWith('session-6', 'tab-3'); + }); + }); + + describe('setRenameTabCallback / renameTab()', () => { + it('returns true when callback resolves to true', async () => { + registry.setRenameTabCallback(async () => true); + expect(await registry.renameTab('session-1', 'tab-1', 'New Name')).toBe(true); + }); + + it('returns false when callback resolves to false', async () => { + registry.setRenameTabCallback(async () => false); + expect(await registry.renameTab('session-1', 'tab-1', 'New Name')).toBe(false); + }); + + it('passes sessionId, tabId, and newName arguments to the callback', async () => { + const callback = vi.fn().mockResolvedValue(true); + registry.setRenameTabCallback(callback); + + await registry.renameTab('session-2', 'tab-5', 'My Renamed Tab'); + + expect(callback).toHaveBeenCalledWith('session-2', 'tab-5', 'My Renamed Tab'); + }); + }); + + describe('setGetHistoryCallback / getHistory()', () => { + it('returns the callback result', () => { + const history = [ + { + id: 'h1', + title: 'Session 1', + timestamp: Date.now(), + projectPath: '/project', + }, + ] as unknown as HistoryEntry[]; + registry.setGetHistoryCallback(() => history); + expect(registry.getHistory()).toEqual(history); + }); + + it('returns empty array when callback returns empty array', () => { + registry.setGetHistoryCallback(() => []); + expect(registry.getHistory()).toEqual([]); + }); + + it('passes no arguments when called without args', () => { + const callback = vi.fn().mockReturnValue([]); + registry.setGetHistoryCallback(callback); + + registry.getHistory(); + + expect(callback).toHaveBeenCalledWith(undefined, undefined); + }); + + it('passes projectPath argument to the callback', () => { + const callback = vi.fn().mockReturnValue([]); + registry.setGetHistoryCallback(callback); + + registry.getHistory('/home/user/project'); + + expect(callback).toHaveBeenCalledWith('/home/user/project', undefined); + }); + + it('passes projectPath and sessionId arguments to the callback', () => { + const callback = vi.fn().mockReturnValue([]); + registry.setGetHistoryCallback(callback); + + registry.getHistory('/home/user/project', 'session-20'); + + expect(callback).toHaveBeenCalledWith('/home/user/project', 'session-20'); + }); + }); + + // ========================================================================= + // Callback replacement + // ========================================================================= + + describe('callback replacement', () => { + it('replaces a previously set callback with a new one', () => { + const firstCallback = vi.fn().mockReturnValue([{ id: 'first' }]); + const secondCallback = vi.fn().mockReturnValue([{ id: 'second' }]); + + registry.setGetSessionsCallback(firstCallback as any); + expect(registry.getSessions()).toEqual([{ id: 'first' }]); + + registry.setGetSessionsCallback(secondCallback as any); + expect(registry.getSessions()).toEqual([{ id: 'second' }]); + + // First callback should only have been called once + expect(firstCallback).toHaveBeenCalledTimes(1); + }); + + it('replaces an async callback with a new one', async () => { + const firstCallback = vi.fn().mockResolvedValue(true); + const secondCallback = vi.fn().mockResolvedValue(false); + + registry.setExecuteCommandCallback(firstCallback); + expect(await registry.executeCommand('s1', 'cmd1')).toBe(true); + + registry.setExecuteCommandCallback(secondCallback); + expect(await registry.executeCommand('s1', 'cmd2')).toBe(false); + + expect(firstCallback).toHaveBeenCalledTimes(1); + expect(secondCallback).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================================= + // Multiple callbacks coexist independently + // ========================================================================= + + describe('independent callback registration', () => { + it('setting one callback does not affect others', () => { + const sessionsCallback = vi.fn().mockReturnValue([]); + registry.setGetSessionsCallback(sessionsCallback); + + // Other getters should still return defaults + expect(registry.getSessionDetail('s1')).toBeNull(); + expect(registry.getTheme()).toBeNull(); + expect(registry.getCustomCommands()).toEqual([]); + expect(registry.writeToSession('s1', 'data')).toBe(false); + expect(registry.getHistory()).toEqual([]); + + // The set callback should work + expect(registry.getSessions()).toEqual([]); + expect(sessionsCallback).toHaveBeenCalledTimes(1); + }); + + it('multiple callbacks can be set and work independently', async () => { + const sessionsCallback = vi.fn().mockReturnValue([{ id: 's1' }]); + const themeCallback = vi.fn().mockReturnValue({ name: 'dark' }); + const executeCallback = vi.fn().mockResolvedValue(true); + + registry.setGetSessionsCallback(sessionsCallback as any); + registry.setGetThemeCallback(themeCallback as any); + registry.setExecuteCommandCallback(executeCallback); + + expect(registry.getSessions()).toEqual([{ id: 's1' }]); + expect(registry.getTheme()).toEqual({ name: 'dark' }); + expect(await registry.executeCommand('s1', 'test')).toBe(true); + + expect(sessionsCallback).toHaveBeenCalledTimes(1); + expect(themeCallback).toHaveBeenCalledTimes(1); + expect(executeCallback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/__tests__/renderer/hooks/utils/useDebouncedPersistence.test.ts b/src/__tests__/renderer/hooks/utils/useDebouncedPersistence.test.ts new file mode 100644 index 00000000..8f6c20d3 --- /dev/null +++ b/src/__tests__/renderer/hooks/utils/useDebouncedPersistence.test.ts @@ -0,0 +1,1188 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useDebouncedPersistence, + DEFAULT_DEBOUNCE_DELAY, +} from '../../../../renderer/hooks/utils/useDebouncedPersistence'; +import type { Session, AITab, LogEntry } from '../../../../renderer/types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a minimal LogEntry */ +const makeLog = (id: string): LogEntry => ({ + id, + timestamp: Date.now(), + source: 'ai', + text: `log-${id}`, +}); + +/** Create a minimal AITab with sensible defaults */ +const makeTab = (overrides: Partial = {}): AITab => ({ + id: overrides.id ?? `tab-${Math.random().toString(36).slice(2, 8)}`, + agentSessionId: null, + name: null, + starred: false, + logs: [], + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + state: 'idle', + ...overrides, +}); + +/** Create a minimal Session with sensible defaults */ +const makeSession = (overrides: Partial = {}): Session => { + const defaultTab = makeTab({ id: 'default-tab' }); + return { + id: overrides.id ?? `session-${Math.random().toString(36).slice(2, 8)}`, + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + cwd: '/test', + fullPath: '/test', + projectRoot: '/test', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + inputMode: 'ai', + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + executionQueue: [], + activeTimeMs: 0, + aiTabs: [defaultTab], + activeTabId: defaultTab.id, + closedTabHistory: [], + ...overrides, + } as Session; +}; + +/** Create a ref that renderHook can use for initialLoadComplete */ +const makeInitialLoadRef = (value: boolean) => ({ current: value }); + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('useDebouncedPersistence', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ----------------------------------------------------------------------- + // DEFAULT_DEBOUNCE_DELAY export + // ----------------------------------------------------------------------- + describe('DEFAULT_DEBOUNCE_DELAY', () => { + it('should be 2000ms', () => { + expect(DEFAULT_DEBOUNCE_DELAY).toBe(2000); + }); + }); + + // ----------------------------------------------------------------------- + // prepareSessionForPersistence (tested indirectly through hook flush) + // ----------------------------------------------------------------------- + describe('prepareSessionForPersistence (via hook flush)', () => { + describe('wizard tab filtering', () => { + it('should filter out tabs with active wizard state', () => { + const regularTab = makeTab({ id: 'regular' }); + const wizardTab = makeTab({ + id: 'wizard', + wizardState: { + isActive: true, + mode: 'new', + confidence: 0, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [regularTab, wizardTab], + activeTabId: 'regular', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + // Force flush + act(() => { + result.current.flushNow(); + }); + + const calls = vi.mocked(window.maestro.sessions.setAll).mock.calls; + expect(calls.length).toBe(1); + const persisted = calls[0][0] as Session[]; + expect(persisted[0].aiTabs).toHaveLength(1); + expect(persisted[0].aiTabs[0].id).toBe('regular'); + }); + + it('should keep tabs with inactive wizard state (wizardState.isActive = false)', () => { + const tab = makeTab({ + id: 'completed-wizard', + wizardState: { + isActive: false, + mode: null, + confidence: 100, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 'completed-wizard', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs).toHaveLength(1); + expect(persisted[0].aiTabs[0].id).toBe('completed-wizard'); + }); + + it('should keep tabs with no wizard state', () => { + const tab = makeTab({ id: 'plain' }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 'plain', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs).toHaveLength(1); + expect(persisted[0].aiTabs[0].id).toBe('plain'); + }); + + it('should create a fresh empty tab when all tabs are active wizard tabs', () => { + const wizardTab1 = makeTab({ + id: 'w1', + wizardState: { + isActive: true, + mode: 'new', + confidence: 0, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const wizardTab2 = makeTab({ + id: 'w2', + wizardState: { + isActive: true, + mode: 'iterate', + confidence: 50, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [wizardTab1, wizardTab2], + activeTabId: 'w1', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs).toHaveLength(1); + // Fresh tab keeps the first tab's ID for consistency + expect(persisted[0].aiTabs[0].id).toBe('w1'); + expect(persisted[0].aiTabs[0].agentSessionId).toBeNull(); + expect(persisted[0].aiTabs[0].logs).toEqual([]); + expect(persisted[0].aiTabs[0].state).toBe('idle'); + expect(persisted[0].aiTabs[0].inputValue).toBe(''); + expect(persisted[0].aiTabs[0].starred).toBe(false); + }); + }); + + describe('log truncation', () => { + it('should truncate tab logs to 100 entries (MAX_PERSISTED_LOGS_PER_TAB)', () => { + const logs = Array.from({ length: 200 }, (_, i) => makeLog(`log-${i}`)); + const tab = makeTab({ id: 'big-logs', logs }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 'big-logs', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].logs).toHaveLength(100); + }); + + it('should keep the last 100 entries (tail of the log array)', () => { + const logs = Array.from({ length: 150 }, (_, i) => makeLog(`log-${i}`)); + const tab = makeTab({ id: 't', logs }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + // The last entry should be log-149 + expect(persisted[0].aiTabs[0].logs[99].id).toBe('log-149'); + // The first entry should be log-50 (150 - 100 = 50) + expect(persisted[0].aiTabs[0].logs[0].id).toBe('log-50'); + }); + + it('should not truncate logs with 100 or fewer entries', () => { + const logs = Array.from({ length: 100 }, (_, i) => makeLog(`log-${i}`)); + const tab = makeTab({ id: 't', logs }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].logs).toHaveLength(100); + expect(persisted[0].aiTabs[0].logs[0].id).toBe('log-0'); + }); + + it('should not truncate logs with fewer than 100 entries', () => { + const logs = Array.from({ length: 50 }, (_, i) => makeLog(`log-${i}`)); + const tab = makeTab({ id: 't', logs }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].logs).toHaveLength(50); + }); + }); + + describe('tab runtime state reset', () => { + it('should reset tab state to idle', () => { + const tab = makeTab({ id: 't', state: 'busy' }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].state).toBe('idle'); + }); + + it('should clear tab thinkingStartTime', () => { + const tab = makeTab({ id: 't', thinkingStartTime: 123456 }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].thinkingStartTime).toBeUndefined(); + }); + + it('should clear tab agentError', () => { + const tab = makeTab({ + id: 't', + agentError: { + type: 'overloaded', + message: 'API overloaded', + timestamp: Date.now(), + recovery: { type: 'retry' }, + }, + }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].agentError).toBeUndefined(); + }); + + it('should clear tab wizardState entirely from persisted data', () => { + // Even inactive wizard state should be cleared from persisted tabs + const tab = makeTab({ + id: 't', + wizardState: { + isActive: false, + mode: null, + confidence: 100, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 't', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].aiTabs[0].wizardState).toBeUndefined(); + }); + }); + + describe('session runtime fields removal', () => { + it('should remove closedTabHistory', () => { + const closedTab = makeTab({ id: 'closed' }); + const session = makeSession({ + closedTabHistory: [{ tab: closedTab, index: 0, closedAt: Date.now() }], + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].closedTabHistory).toBeUndefined(); + }); + + it('should remove session-level agentError', () => { + const session = makeSession({ + agentError: { + type: 'overloaded', + message: 'API overloaded', + timestamp: Date.now(), + recovery: { type: 'retry' }, + }, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].agentError).toBeUndefined(); + }); + + it('should remove agentErrorPaused', () => { + const session = makeSession({ + agentErrorPaused: true, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].agentErrorPaused).toBeUndefined(); + }); + + it('should remove agentErrorTabId', () => { + const session = makeSession({ + agentErrorTabId: 'some-tab-id', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].agentErrorTabId).toBeUndefined(); + }); + + it('should remove sshConnectionFailed', () => { + const session = makeSession({ + sshConnectionFailed: true, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].sshConnectionFailed).toBeUndefined(); + }); + }); + + describe('session runtime state reset', () => { + it('should reset session state to idle', () => { + const session = makeSession({ state: 'busy' }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].state).toBe('idle'); + }); + + it('should clear busySource', () => { + const session = makeSession({ busySource: 'ai' }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].busySource).toBeUndefined(); + }); + + it('should clear thinkingStartTime', () => { + const session = makeSession({ thinkingStartTime: Date.now() }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].thinkingStartTime).toBeUndefined(); + }); + + it('should clear currentCycleTokens', () => { + const session = makeSession({ currentCycleTokens: 5000 }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].currentCycleTokens).toBeUndefined(); + }); + + it('should clear currentCycleBytes', () => { + const session = makeSession({ currentCycleBytes: 128000 }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].currentCycleBytes).toBeUndefined(); + }); + + it('should clear statusMessage', () => { + const session = makeSession({ statusMessage: 'Agent is thinking...' }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].statusMessage).toBeUndefined(); + }); + }); + + describe('SSH runtime state clearing', () => { + it('should clear sshRemote', () => { + const session = makeSession({ + sshRemote: { id: 'remote-1', name: 'My Server', host: 'server.example.com' }, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].sshRemote).toBeUndefined(); + }); + + it('should clear sshRemoteId', () => { + const session = makeSession({ sshRemoteId: 'remote-1' }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].sshRemoteId).toBeUndefined(); + }); + + it('should clear remoteCwd', () => { + const session = makeSession({ remoteCwd: '/remote/home/user/project' }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].remoteCwd).toBeUndefined(); + }); + }); + + describe('activeTabId fix-up', () => { + it('should fix activeTabId when it pointed to a filtered wizard tab', () => { + const regularTab = makeTab({ id: 'regular' }); + const wizardTab = makeTab({ + id: 'active-wizard', + wizardState: { + isActive: true, + mode: 'new', + confidence: 0, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [regularTab, wizardTab], + activeTabId: 'active-wizard', // points to wizard tab that will be filtered + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + // activeTabId should now point to the first remaining tab + expect(persisted[0].activeTabId).toBe('regular'); + }); + + it('should keep activeTabId when it points to a valid non-wizard tab', () => { + const tab1 = makeTab({ id: 'tab-1' }); + const tab2 = makeTab({ id: 'tab-2' }); + const session = makeSession({ + aiTabs: [tab1, tab2], + activeTabId: 'tab-2', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].activeTabId).toBe('tab-2'); + }); + + it('should set activeTabId to fresh tab id when all tabs were wizard tabs', () => { + const wizardTab = makeTab({ + id: 'wizard-only', + wizardState: { + isActive: true, + mode: 'new', + confidence: 0, + conversationHistory: [], + previousUIState: { + readOnlyMode: false, + saveToHistory: false, + showThinking: 'off', + }, + }, + }); + const session = makeSession({ + aiTabs: [wizardTab], + activeTabId: 'wizard-only', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + // The fresh tab reuses the first tab's ID + expect(persisted[0].activeTabId).toBe('wizard-only'); + expect(persisted[0].aiTabs[0].id).toBe('wizard-only'); + }); + }); + + describe('session with no aiTabs', () => { + it('should return session as-is when aiTabs is empty', () => { + const session = makeSession({ + aiTabs: [], + activeTabId: '', + state: 'busy', + busySource: 'ai', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + // When aiTabs is empty, the session is returned as-is + expect(persisted[0].aiTabs).toEqual([]); + expect(persisted[0].state).toBe('busy'); + }); + }); + + describe('preserves non-runtime fields', () => { + it('should preserve session name, cwd, and other persistent fields', () => { + const session = makeSession({ + id: 'my-session', + name: 'Important Session', + cwd: '/projects/test', + fullPath: '/projects/test', + projectRoot: '/projects/test', + autoRunFolderPath: '/path/to/docs', + bookmarked: true, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted[0].id).toBe('my-session'); + expect(persisted[0].name).toBe('Important Session'); + expect(persisted[0].cwd).toBe('/projects/test'); + expect(persisted[0].autoRunFolderPath).toBe('/path/to/docs'); + expect(persisted[0].bookmarked).toBe(true); + }); + + it('should preserve tab fields like inputValue, starred, agentSessionId', () => { + const tab = makeTab({ + id: 'important-tab', + agentSessionId: 'session-uuid-123', + name: 'My Conversation', + starred: true, + inputValue: 'draft message', + stagedImages: ['base64img'], + }); + const session = makeSession({ + aiTabs: [tab], + activeTabId: 'important-tab', + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + const persistedTab = persisted[0].aiTabs[0]; + expect(persistedTab.agentSessionId).toBe('session-uuid-123'); + expect(persistedTab.name).toBe('My Conversation'); + expect(persistedTab.starred).toBe(true); + expect(persistedTab.inputValue).toBe('draft message'); + expect(persistedTab.stagedImages).toEqual(['base64img']); + }); + }); + + describe('multiple sessions', () => { + it('should prepare all sessions for persistence', () => { + const session1 = makeSession({ + id: 's1', + state: 'busy', + busySource: 'ai', + thinkingStartTime: 1000, + }); + const session2 = makeSession({ + id: 's2', + state: 'connecting', + statusMessage: 'Connecting...', + sshRemote: { id: 'r1', name: 'Server', host: 'host' }, + }); + + const initialLoadRef = makeInitialLoadRef(true); + const { result } = renderHook(() => + useDebouncedPersistence([session1, session2], initialLoadRef) + ); + + act(() => { + result.current.flushNow(); + }); + + const persisted = vi.mocked(window.maestro.sessions.setAll).mock.calls[0][0] as Session[]; + expect(persisted).toHaveLength(2); + + // Session 1 should be reset + expect(persisted[0].state).toBe('idle'); + expect(persisted[0].busySource).toBeUndefined(); + expect(persisted[0].thinkingStartTime).toBeUndefined(); + + // Session 2 should be reset + expect(persisted[1].state).toBe('idle'); + expect(persisted[1].statusMessage).toBeUndefined(); + expect(persisted[1].sshRemote).toBeUndefined(); + }); + }); + }); + + // ----------------------------------------------------------------------- + // Hook behavior + // ----------------------------------------------------------------------- + describe('hook behavior', () => { + describe('initialLoadComplete gate', () => { + it('should not persist before initialLoadComplete is true', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(false); + + renderHook(() => useDebouncedPersistence([session], initialLoadRef)); + + // Advance well past the debounce delay + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + }); + + it('should persist after initialLoadComplete becomes true', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(false); + + const { rerender } = renderHook( + ({ sessions, ref }) => useDebouncedPersistence(sessions, ref), + { + initialProps: { sessions: [session], ref: initialLoadRef }, + } + ); + + // Initially should not persist + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + + // Mark initial load as complete and trigger re-render with new sessions array + initialLoadRef.current = true; + const updatedSession = makeSession({ id: session.id, name: 'Updated' }); + rerender({ sessions: [updatedSession], ref: initialLoadRef }); + + // Advance past the debounce delay + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(window.maestro.sessions.setAll).toHaveBeenCalled(); + }); + }); + + describe('debounce behavior', () => { + it('should not call setAll immediately when sessions change', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + renderHook(() => useDebouncedPersistence([session], initialLoadRef)); + + // Don't advance timers - it should not have been called yet + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + }); + + it('should call setAll after debounce delay', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + renderHook(() => useDebouncedPersistence([session], initialLoadRef)); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(window.maestro.sessions.setAll).toHaveBeenCalledTimes(1); + }); + + it('should reset debounce timer on rapid session changes', () => { + const session1 = makeSession({ id: 's1', name: 'First' }); + const initialLoadRef = makeInitialLoadRef(true); + + const { rerender } = renderHook( + ({ sessions }) => useDebouncedPersistence(sessions, initialLoadRef), + { initialProps: { sessions: [session1] } } + ); + + // Advance 1500ms (not enough for debounce) + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + + // Trigger a new session change which resets the timer + const session2 = makeSession({ id: 's1', name: 'Second' }); + rerender({ sessions: [session2] }); + + // Advance another 1500ms (total 3000ms from start, but only 1500ms from last change) + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + + // Advance the remaining 500ms + act(() => { + vi.advanceTimersByTime(500); + }); + expect(window.maestro.sessions.setAll).toHaveBeenCalledTimes(1); + }); + + it('should respect custom delay parameter', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + renderHook(() => useDebouncedPersistence([session], initialLoadRef, 500)); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(window.maestro.sessions.setAll).toHaveBeenCalledTimes(1); + }); + }); + + describe('flushNow()', () => { + it('should persist immediately when called with pending changes', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + // The hook sets isPending in a useEffect, need to flush effects + // isPending won't be true until after the effect runs + // We need to advance to allow the effect to set isPending + act(() => { + // trigger the effect by advancing minimally (not the full debounce) + vi.advanceTimersByTime(0); + }); + + act(() => { + result.current.flushNow(); + }); + + expect(window.maestro.sessions.setAll).toHaveBeenCalledTimes(1); + }); + + it('should cancel pending debounce timer when flushing', () => { + const sessions = [makeSession()]; + const initialLoadRef = makeInitialLoadRef(true); + + // Use a stable sessions reference via initialProps to avoid + // creating a new array on each render (which would re-trigger + // the debounce effect) + const { result } = renderHook( + ({ s }) => useDebouncedPersistence(s, initialLoadRef), + { initialProps: { s: sessions } } + ); + + // Allow effect to set isPending + act(() => { + vi.advanceTimersByTime(0); + }); + + vi.clearAllMocks(); + + // Flush immediately - should clear the pending debounce timer + act(() => { + result.current.flushNow(); + }); + + expect(window.maestro.sessions.setAll).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); + + // Advance past the original debounce delay - the timer was cleared + // by flushNow, so no additional call should occur + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + }); + }); + + describe('isPending state', () => { + it('should be false initially (before initialLoadComplete)', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(false); + + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + expect(result.current.isPending).toBe(false); + }); + + it('should become true when sessions change after initial load', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + const { result } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + // The useEffect sets isPending to true + // It runs asynchronously after render + expect(result.current.isPending).toBe(true); + }); + + it('should become false after debounce timer fires', () => { + const sessions = [makeSession()]; + const initialLoadRef = makeInitialLoadRef(true); + + // Use stable sessions reference via initialProps + const { result } = renderHook( + ({ s }) => useDebouncedPersistence(s, initialLoadRef), + { initialProps: { s: sessions } } + ); + + expect(result.current.isPending).toBe(true); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.isPending).toBe(false); + }); + + it('should become false after flushNow', () => { + const sessions = [makeSession()]; + const initialLoadRef = makeInitialLoadRef(true); + + // Use stable sessions reference via initialProps + const { result } = renderHook( + ({ s }) => useDebouncedPersistence(s, initialLoadRef), + { initialProps: { s: sessions } } + ); + + expect(result.current.isPending).toBe(true); + + act(() => { + result.current.flushNow(); + }); + + expect(result.current.isPending).toBe(false); + }); + }); + + describe('flush on unmount', () => { + it('should persist on unmount when initialLoadComplete is true', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(true); + + const { unmount } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + unmount(); + + // Should have called setAll on unmount + expect(window.maestro.sessions.setAll).toHaveBeenCalled(); + }); + + it('should not persist on unmount when initialLoadComplete is false', () => { + const session = makeSession(); + const initialLoadRef = makeInitialLoadRef(false); + + const { unmount } = renderHook(() => + useDebouncedPersistence([session], initialLoadRef) + ); + + unmount(); + + expect(window.maestro.sessions.setAll).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/__tests__/renderer/utils/markdownConfig.test.ts b/src/__tests__/renderer/utils/markdownConfig.test.ts new file mode 100644 index 00000000..de3738a8 --- /dev/null +++ b/src/__tests__/renderer/utils/markdownConfig.test.ts @@ -0,0 +1,624 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock react-syntax-highlighter before importing the module under test +vi.mock('react-syntax-highlighter', () => ({ + Prism: vi.fn(), +})); +vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + vscDarkPlus: {}, +})); + +import { + generateProseStyles, + generateAutoRunProseStyles, + generateTerminalProseStyles, + generateDiffViewStyles, +} from '../../../renderer/utils/markdownConfig'; +import type { Theme } from '../../../shared/theme-types'; + +/** + * Tests for markdown configuration utilities. + * + * Covers: + * - generateProseStyles: CSS generation with all option permutations + * - generateAutoRunProseStyles: convenience wrapper with specific defaults + * - generateTerminalProseStyles: terminal-specific CSS generation + * - generateDiffViewStyles: diff viewer CSS generation + */ + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + textMain: '#ffffff', + textDim: '#888888', + accent: '#0066ff', + accentDim: 'rgba(0, 102, 255, 0.2)', + accentText: '#0066ff', + accentForeground: '#ffffff', + success: '#00cc00', + warning: '#ffaa00', + error: '#ff0000', + bgMain: '#1a1a1a', + bgSidebar: '#2a2a2a', + bgActivity: '#333333', + border: '#444444', + }, +}; + +// --------------------------------------------------------------------------- +// generateProseStyles +// --------------------------------------------------------------------------- + +describe('generateProseStyles', () => { + describe('default options', () => { + it('should return a non-empty CSS string', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toBeTruthy(); + expect(typeof css).toBe('string'); + }); + + it('should use .prose selector without scope prefix', () => { + const css = generateProseStyles({ theme: mockTheme }); + // Should have rules starting with .prose + expect(css).toContain('.prose'); + // Should not contain an unexpected scope prefix + expect(css).not.toMatch(/\.\S+ \.prose/); + }); + + it('should include heading rules (h1-h6)', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain('.prose h1'); + expect(css).toContain('.prose h2'); + expect(css).toContain('.prose h3'); + expect(css).toContain('.prose h4'); + expect(css).toContain('.prose h5'); + expect(css).toContain('.prose h6'); + }); + + it('should include paragraph, list, code, and table rules', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain('.prose p'); + expect(css).toContain('.prose ul'); + expect(css).toContain('.prose ol'); + expect(css).toContain('.prose code'); + expect(css).toContain('.prose pre'); + expect(css).toContain('.prose table'); + expect(css).toContain('.prose th'); + expect(css).toContain('.prose td'); + }); + + it('should include blockquote, link, and hr rules', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain('.prose blockquote'); + expect(css).toContain('.prose a'); + expect(css).toContain('.prose hr'); + }); + + it('should include checkbox styles by default (includeCheckboxStyles defaults to true)', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain('input[type="checkbox"]'); + expect(css).toContain('input[type="checkbox"]:checked'); + expect(css).toContain('input[type="checkbox"]:hover'); + }); + + it('should use textMain color for all headings by default (coloredHeadings defaults to false)', () => { + const css = generateProseStyles({ theme: mockTheme }); + // h1 through h5 should use textMain + expect(css).toContain(`.prose h1 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h2 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h3 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h4 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h5 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h6 { color: ${mockTheme.colors.textMain}`); + }); + + it('should use standard (non-compact) margins by default', () => { + const css = generateProseStyles({ theme: mockTheme }); + // Standard heading margin is 0.67em 0 + expect(css).toContain('margin: 0.67em 0 !important'); + // Standard paragraph margin is 0.5em 0 + expect(css).toContain(`.prose p { color: ${mockTheme.colors.textMain}; margin: 0.5em 0 !important`); + }); + + it('should not include first-child/last-child overrides by default (compactSpacing defaults to false)', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).not.toContain('*:first-child { margin-top: 0 !important; }'); + expect(css).not.toContain('*:last-child { margin-bottom: 0 !important; }'); + }); + }); + + describe('coloredHeadings option', () => { + it('should use accent for h1, success for h2, warning for h3 when true', () => { + const css = generateProseStyles({ theme: mockTheme, coloredHeadings: true }); + expect(css).toContain(`.prose h1 { color: ${mockTheme.colors.accent}`); + expect(css).toContain(`.prose h2 { color: ${mockTheme.colors.success}`); + expect(css).toContain(`.prose h3 { color: ${mockTheme.colors.warning}`); + }); + + it('should use textMain for h4 and h5 regardless of coloredHeadings', () => { + const css = generateProseStyles({ theme: mockTheme, coloredHeadings: true }); + expect(css).toContain(`.prose h4 { color: ${mockTheme.colors.textMain}`); + expect(css).toContain(`.prose h5 { color: ${mockTheme.colors.textMain}`); + }); + + it('should use textDim for h6 when coloredHeadings is true', () => { + const css = generateProseStyles({ theme: mockTheme, coloredHeadings: true }); + expect(css).toContain(`.prose h6 { color: ${mockTheme.colors.textDim}`); + }); + + it('should use textMain for h6 when coloredHeadings is false', () => { + const css = generateProseStyles({ theme: mockTheme, coloredHeadings: false }); + expect(css).toContain(`.prose h6 { color: ${mockTheme.colors.textMain}`); + }); + + it('should use textMain for all headings when false', () => { + const css = generateProseStyles({ theme: mockTheme, coloredHeadings: false }); + for (let i = 1; i <= 6; i++) { + expect(css).toContain(`.prose h${i} { color: ${mockTheme.colors.textMain}`); + } + }); + }); + + describe('compactSpacing option', () => { + it('should include first-child and last-child margin overrides when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('> *:first-child { margin-top: 0 !important; }'); + expect(css).toContain('> *:last-child { margin-bottom: 0 !important; }'); + }); + + it('should include global zero margin rule when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('.prose * { margin-top: 0; margin-bottom: 0; }'); + }); + + it('should use smaller heading margins when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + // Compact heading margin is 0.25em 0 + expect(css).toContain(`.prose h1 { color: ${mockTheme.colors.textMain}; font-size: 2em; font-weight: bold; margin: 0.25em 0 !important`); + }); + + it('should use zero paragraph margin when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain(`.prose p { color: ${mockTheme.colors.textMain}; margin: 0 !important`); + }); + + it('should include p+p spacing rule when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('.prose p + p { margin-top: 0.5em !important; }'); + }); + + it('should hide empty paragraphs when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('.prose p:empty { display: none; }'); + }); + + it('should use 2em padding-left for lists when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('padding-left: 2em'); + }); + + it('should use 1.5em padding-left for lists when false', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: false }); + expect(css).toContain('padding-left: 1.5em'); + }); + + it('should include nested list margin override when true', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain('.prose li ul, .prose li ol { margin: 0 !important'); + }); + + it('should use 3px border-left on blockquote when compact', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain(`border-left: 3px solid ${mockTheme.colors.border}`); + }); + + it('should use 4px border-left on blockquote when not compact', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: false }); + expect(css).toContain(`border-left: 4px solid ${mockTheme.colors.border}`); + }); + + it('should use 1px border-top for hr when compact', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: true }); + expect(css).toContain(`border-top: 1px solid ${mockTheme.colors.border}`); + }); + + it('should use 2px border-top for hr when not compact', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: false }); + expect(css).toContain(`border-top: 2px solid ${mockTheme.colors.border}`); + }); + + it('should not include first-child/last-child overrides when false', () => { + const css = generateProseStyles({ theme: mockTheme, compactSpacing: false }); + expect(css).not.toContain('*:first-child { margin-top: 0 !important; }'); + expect(css).not.toContain('*:last-child { margin-bottom: 0 !important; }'); + }); + }); + + describe('includeCheckboxStyles option', () => { + it('should include checkbox CSS when true', () => { + const css = generateProseStyles({ theme: mockTheme, includeCheckboxStyles: true }); + expect(css).toContain('input[type="checkbox"]'); + expect(css).toContain('appearance: none'); + expect(css).toContain('input[type="checkbox"]:checked'); + expect(css).toContain('input[type="checkbox"]:hover'); + }); + + it('should use accent color for checkbox border', () => { + const css = generateProseStyles({ theme: mockTheme, includeCheckboxStyles: true }); + expect(css).toContain(`border: 2px solid ${mockTheme.colors.accent}`); + }); + + it('should use accent color for checked checkbox background', () => { + const css = generateProseStyles({ theme: mockTheme, includeCheckboxStyles: true }); + expect(css).toContain(`background-color: ${mockTheme.colors.accent}`); + }); + + it('should use bgMain color for checkbox checkmark', () => { + const css = generateProseStyles({ theme: mockTheme, includeCheckboxStyles: true }); + expect(css).toContain(`border: solid ${mockTheme.colors.bgMain}`); + }); + + it('should not include checkbox CSS when false', () => { + const css = generateProseStyles({ theme: mockTheme, includeCheckboxStyles: false }); + // The base styles mention checkbox in the list-style-none rule, + // but the dedicated checkbox block should be absent + expect(css).not.toContain('appearance: none'); + expect(css).not.toContain('input[type="checkbox"]:checked'); + expect(css).not.toContain('input[type="checkbox"]:hover'); + }); + }); + + describe('scopeSelector option', () => { + it('should prefix all rules with scopeSelector when provided', () => { + const css = generateProseStyles({ theme: mockTheme, scopeSelector: '.my-panel' }); + expect(css).toContain('.my-panel .prose'); + // Verify specific rules use the scoped selector + expect(css).toContain('.my-panel .prose h1'); + expect(css).toContain('.my-panel .prose p'); + expect(css).toContain('.my-panel .prose code'); + expect(css).toContain('.my-panel .prose a'); + }); + + it('should use bare .prose when scopeSelector is empty string', () => { + const css = generateProseStyles({ theme: mockTheme, scopeSelector: '' }); + expect(css).toContain('.prose h1'); + // Should not have a leading space before .prose + expect(css).not.toMatch(/^\s+\S+ \.prose/m); + }); + + it('should use bare .prose when scopeSelector is not provided', () => { + const css = generateProseStyles({ theme: mockTheme }); + // Rules should start with .prose (possibly with whitespace) + expect(css).toContain('.prose h1'); + }); + + it('should scope checkbox styles with scopeSelector', () => { + const css = generateProseStyles({ + theme: mockTheme, + scopeSelector: '.custom-scope', + includeCheckboxStyles: true, + }); + expect(css).toContain('.custom-scope .prose input[type="checkbox"]'); + }); + }); + + describe('theme color injection', () => { + it('should inject textMain into paragraph color', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`color: ${mockTheme.colors.textMain}`); + }); + + it('should inject textDim into blockquote color', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`.prose blockquote`); + expect(css).toContain(`color: ${mockTheme.colors.textDim}`); + }); + + it('should inject accent into link color', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`.prose a { color: ${mockTheme.colors.accent}`); + }); + + it('should inject bgActivity into code background', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`background-color: ${mockTheme.colors.bgActivity}`); + }); + + it('should inject border color into table borders', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`border: 1px solid ${mockTheme.colors.border}`); + }); + + it('should inject bgActivity into th background', () => { + const css = generateProseStyles({ theme: mockTheme }); + expect(css).toContain(`.prose th { background-color: ${mockTheme.colors.bgActivity}`); + }); + + it('should reflect different theme colors when theme changes', () => { + const altTheme: Theme = { + ...mockTheme, + colors: { + ...mockTheme.colors, + textMain: '#aabbcc', + accent: '#dd1122', + bgActivity: '#556677', + }, + }; + const css = generateProseStyles({ theme: altTheme }); + expect(css).toContain('color: #aabbcc'); + expect(css).toContain('color: #dd1122'); + expect(css).toContain('background-color: #556677'); + }); + }); + + describe('combined options', () => { + it('should support coloredHeadings + compactSpacing together', () => { + const css = generateProseStyles({ + theme: mockTheme, + coloredHeadings: true, + compactSpacing: true, + }); + // Colored headings + expect(css).toContain(`.prose h1 { color: ${mockTheme.colors.accent}`); + expect(css).toContain(`.prose h2 { color: ${mockTheme.colors.success}`); + // Compact spacing + expect(css).toContain('> *:first-child { margin-top: 0 !important; }'); + expect(css).toContain('margin: 0.25em 0 !important'); + }); + + it('should support scopeSelector + includeCheckboxStyles: false', () => { + const css = generateProseStyles({ + theme: mockTheme, + scopeSelector: '.test-scope', + includeCheckboxStyles: false, + }); + expect(css).toContain('.test-scope .prose h1'); + expect(css).not.toContain('appearance: none'); + }); + + it('should support all options together', () => { + const css = generateProseStyles({ + theme: mockTheme, + coloredHeadings: true, + compactSpacing: true, + includeCheckboxStyles: true, + scopeSelector: '.full-test', + }); + expect(css).toContain('.full-test .prose h1'); + expect(css).toContain(`color: ${mockTheme.colors.accent}`); + expect(css).toContain('> *:first-child { margin-top: 0 !important; }'); + expect(css).toContain('input[type="checkbox"]'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// generateAutoRunProseStyles +// --------------------------------------------------------------------------- + +describe('generateAutoRunProseStyles', () => { + it('should return a non-empty CSS string', () => { + const css = generateAutoRunProseStyles(mockTheme); + expect(css).toBeTruthy(); + expect(typeof css).toBe('string'); + }); + + it('should scope styles to .autorun-panel .prose', () => { + const css = generateAutoRunProseStyles(mockTheme); + expect(css).toContain('.autorun-panel .prose'); + }); + + it('should use colored headings (accent for h1, success for h2, warning for h3)', () => { + const css = generateAutoRunProseStyles(mockTheme); + expect(css).toContain(`.autorun-panel .prose h1 { color: ${mockTheme.colors.accent}`); + expect(css).toContain(`.autorun-panel .prose h2 { color: ${mockTheme.colors.success}`); + expect(css).toContain(`.autorun-panel .prose h3 { color: ${mockTheme.colors.warning}`); + }); + + it('should include checkbox styles', () => { + const css = generateAutoRunProseStyles(mockTheme); + expect(css).toContain('input[type="checkbox"]'); + }); + + it('should use standard (non-compact) spacing', () => { + const css = generateAutoRunProseStyles(mockTheme); + // Standard heading margin + expect(css).toContain('margin: 0.67em 0 !important'); + // Should not have compact first-child/last-child overrides + // (the raw string check: compact adds " > *:first-child" but standard does not) + expect(css).not.toContain('.autorun-panel .prose > *:first-child { margin-top: 0 !important; }'); + }); + + it('should produce identical output to generateProseStyles with matching options', () => { + const directCss = generateProseStyles({ + theme: mockTheme, + coloredHeadings: true, + compactSpacing: false, + includeCheckboxStyles: true, + scopeSelector: '.autorun-panel', + }); + const convenienceCss = generateAutoRunProseStyles(mockTheme); + expect(convenienceCss).toBe(directCss); + }); +}); + +// --------------------------------------------------------------------------- +// generateTerminalProseStyles +// --------------------------------------------------------------------------- + +describe('generateTerminalProseStyles', () => { + const scopeSelector = '.terminal-output'; + + it('should return a non-empty CSS string', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toBeTruthy(); + expect(typeof css).toBe('string'); + }); + + it('should scope styles to the provided scopeSelector + .prose', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain('.terminal-output .prose'); + }); + + it('should work with different scope selectors', () => { + const css = generateTerminalProseStyles(mockTheme, '.group-chat-messages'); + expect(css).toContain('.group-chat-messages .prose'); + }); + + it('should use colored headings (accent for h1, success for h2, warning for h3)', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose h1 { color: ${mockTheme.colors.accent}`); + expect(css).toContain(`${scopeSelector} .prose h2 { color: ${mockTheme.colors.success}`); + expect(css).toContain(`${scopeSelector} .prose h3 { color: ${mockTheme.colors.warning}`); + }); + + it('should use textDim for h6', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose h6 { color: ${mockTheme.colors.textDim}`); + }); + + it('should use bgSidebar for code background (not bgActivity)', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`background-color: ${mockTheme.colors.bgSidebar}`); + }); + + it('should use bgSidebar for th background', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose th { background-color: ${mockTheme.colors.bgSidebar}`); + }); + + it('should include compact spacing (first-child/last-child overrides)', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain('> *:first-child { margin-top: 0 !important; }'); + expect(css).toContain('> *:last-child { margin-bottom: 0 !important; }'); + }); + + it('should include global zero margin rule', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose * { margin-top: 0; margin-bottom: 0; }`); + }); + + it('should include p+p spacing and p:empty rules', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose p + p { margin-top: 0.5em !important; }`); + expect(css).toContain(`${scopeSelector} .prose p:empty { display: none; }`); + }); + + it('should include li inline styling rules', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose li > p { margin: 0 !important; display: inline; }`); + }); + + it('should include marker styling for list items', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose li::marker { font-weight: normal; }`); + }); + + it('should include extra vertical-align rule for first-child strong/b/em/code/a in li', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose li > strong:first-child`); + expect(css).toContain('vertical-align: baseline'); + }); + + it('should include link accent color', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`${scopeSelector} .prose a { color: ${mockTheme.colors.accent}`); + }); + + it('should include border styling', () => { + const css = generateTerminalProseStyles(mockTheme, scopeSelector); + expect(css).toContain(`border: 1px solid ${mockTheme.colors.border}`); + }); +}); + +// --------------------------------------------------------------------------- +// generateDiffViewStyles +// --------------------------------------------------------------------------- + +describe('generateDiffViewStyles', () => { + it('should return a non-empty CSS string', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toBeTruthy(); + expect(typeof css).toBe('string'); + }); + + it('should include diff gutter styles', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-gutter'); + expect(css).toContain(`background-color: ${mockTheme.colors.bgSidebar} !important`); + expect(css).toContain(`color: ${mockTheme.colors.textDim} !important`); + }); + + it('should include diff code styles', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-code'); + expect(css).toContain(`background-color: ${mockTheme.colors.bgMain} !important`); + }); + + it('should include insert (green) color styling', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-gutter-insert'); + expect(css).toContain('.diff-code-insert'); + expect(css).toContain('rgba(34, 197, 94, 0.1)'); + expect(css).toContain('rgba(34, 197, 94, 0.15)'); + }); + + it('should include delete (red) color styling', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-gutter-delete'); + expect(css).toContain('.diff-code-delete'); + expect(css).toContain('rgba(239, 68, 68, 0.1)'); + expect(css).toContain('rgba(239, 68, 68, 0.15)'); + }); + + it('should include edit highlight styles within insert/delete', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-code-insert .diff-code-edit'); + expect(css).toContain('rgba(34, 197, 94, 0.3)'); + expect(css).toContain('.diff-code-delete .diff-code-edit'); + expect(css).toContain('rgba(239, 68, 68, 0.3)'); + }); + + it('should include hunk header styles', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-hunk-header'); + expect(css).toContain(`background-color: ${mockTheme.colors.bgActivity} !important`); + expect(css).toContain(`color: ${mockTheme.colors.accent} !important`); + }); + + it('should include diff line styles', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain('.diff-line'); + expect(css).toContain(`color: ${mockTheme.colors.textMain} !important`); + }); + + it('should include border styling for gutter and hunk header', () => { + const css = generateDiffViewStyles(mockTheme); + expect(css).toContain(`border-right: 1px solid ${mockTheme.colors.border} !important`); + expect(css).toContain(`border-bottom: 1px solid ${mockTheme.colors.border} !important`); + }); + + it('should reflect different theme colors', () => { + const altTheme: Theme = { + ...mockTheme, + colors: { + ...mockTheme.colors, + bgSidebar: '#112233', + bgMain: '#aabbcc', + textDim: '#ddeeff', + accent: '#ff00ff', + }, + }; + const css = generateDiffViewStyles(altTheme); + expect(css).toContain('background-color: #112233 !important'); + expect(css).toContain('background-color: #aabbcc !important'); + expect(css).toContain('color: #ddeeff !important'); + expect(css).toContain('color: #ff00ff !important'); + }); +}); diff --git a/src/__tests__/renderer/utils/markdownLinkParser.test.ts b/src/__tests__/renderer/utils/markdownLinkParser.test.ts index 89deda4c..b3297fdf 100644 --- a/src/__tests__/renderer/utils/markdownLinkParser.test.ts +++ b/src/__tests__/renderer/utils/markdownLinkParser.test.ts @@ -1,5 +1,12 @@ /** - * Tests for markdown link parser utility + * Comprehensive tests for markdown link parser utility. + * + * Tests cover: + * - extractDomain: URL domain extraction with www stripping and fallback + * - parseMarkdownLinks: wiki links, markdown links, front matter, deduplication + * - Internal path utilities (dirname, extname, joinPath, resolveRelativePath) + * tested indirectly via parseMarkdownLinks + * - Edge cases: malformed input, URL encoding, special characters */ import { @@ -8,8 +15,15 @@ import { type ParsedMarkdownLinks, } from '../../../renderer/utils/markdownLinkParser'; +// --------------------------------------------------------------------------- +// extractDomain +// --------------------------------------------------------------------------- describe('extractDomain', () => { - it('should extract domain from HTTPS URL', () => { + it('should extract domain from simple HTTPS URL', () => { + expect(extractDomain('https://example.com')).toBe('example.com'); + }); + + it('should extract domain from HTTPS URL with path', () => { expect(extractDomain('https://github.com/user/repo')).toBe('github.com'); }); @@ -21,7 +35,15 @@ describe('extractDomain', () => { expect(extractDomain('https://www.github.com/user/repo')).toBe('github.com'); }); + it('should extract domain from URL with path segments', () => { + expect(extractDomain('https://example.com/path/to/page')).toBe('example.com'); + }); + it('should handle URLs with port numbers', () => { + expect(extractDomain('https://example.com:8080')).toBe('example.com'); + }); + + it('should handle localhost with port', () => { expect(extractDomain('https://localhost:3000/path')).toBe('localhost'); }); @@ -33,14 +55,100 @@ describe('extractDomain', () => { expect(extractDomain('https://docs.github.com/en/pages')).toBe('docs.github.com'); }); - it('should return original string for invalid URLs', () => { + it('should fall back to regex for invalid URLs', () => { + // 'not-a-url' has no protocol, so URL constructor fails and regex also won't match expect(extractDomain('not-a-url')).toBe('not-a-url'); }); + + it('should fall back to regex extraction for malformed URLs with protocol', () => { + // This URL has a protocol prefix so the regex fallback can extract the domain + expect(extractDomain('http://some-domain.com')).toBe('some-domain.com'); + }); + + it('should handle IDN domains (punycode)', () => { + expect(extractDomain('https://\u4F8B\u3048.jp/path')).toBe('xn--r8jz45g.jp'); + }); + + it('should strip www prefix from complex URLs', () => { + expect(extractDomain('https://www.docs.example.com/path')).toBe('docs.example.com'); + }); + + it('should handle URLs with trailing slashes', () => { + expect(extractDomain('https://example.com/')).toBe('example.com'); + }); + + it('should handle URLs with deep paths', () => { + expect(extractDomain('https://github.com/org/repo/blob/main/src/file.ts')).toBe( + 'github.com' + ); + }); + + it('should return original for malformed URLs without match', () => { + expect(extractDomain('http://incomplete')).toBe('incomplete'); + }); + + it('should handle URLs with unusual TLDs', () => { + expect(extractDomain('https://site.museum/collection')).toBe('site.museum'); + expect(extractDomain('https://company.technology/product')).toBe('company.technology'); + }); }); +// --------------------------------------------------------------------------- +// parseMarkdownLinks +// --------------------------------------------------------------------------- describe('parseMarkdownLinks', () => { + // ----------------------------------------------------------------------- + // Null / undefined / non-string content + // ----------------------------------------------------------------------- + describe('null/undefined/non-string content', () => { + it('should return empty result for null content', () => { + // @ts-expect-error Testing runtime behavior with null input + const result = parseMarkdownLinks(null, 'doc.md'); + + expect(result.internalLinks).toEqual([]); + expect(result.externalLinks).toEqual([]); + expect(result.frontMatter).toEqual({}); + }); + + it('should return empty result for undefined content', () => { + // @ts-expect-error Testing runtime behavior with undefined input + const result = parseMarkdownLinks(undefined, 'doc.md'); + + expect(result.internalLinks).toEqual([]); + expect(result.externalLinks).toEqual([]); + expect(result.frontMatter).toEqual({}); + }); + + it('should return empty result for non-string content types', () => { + // @ts-expect-error Testing runtime behavior with number input + const resultNumber = parseMarkdownLinks(12345, 'doc.md'); + expect(resultNumber.internalLinks).toEqual([]); + expect(resultNumber.frontMatter).toEqual({}); + + // @ts-expect-error Testing runtime behavior with object input + const resultObject = parseMarkdownLinks({ text: 'content' }, 'doc.md'); + expect(resultObject.internalLinks).toEqual([]); + expect(resultObject.frontMatter).toEqual({}); + + // @ts-expect-error Testing runtime behavior with array input + const resultArray = parseMarkdownLinks(['content'], 'doc.md'); + expect(resultArray.internalLinks).toEqual([]); + expect(resultArray.frontMatter).toEqual({}); + }); + + it('should handle empty filePath gracefully', () => { + const content = 'See [[other-doc]] for more info.'; + const result = parseMarkdownLinks(content, ''); + + expect(result.internalLinks).toContain('other-doc.md'); + }); + }); + + // ----------------------------------------------------------------------- + // Wiki-style links + // ----------------------------------------------------------------------- describe('wiki-style links', () => { - it('should parse simple wiki links [[filename]]', () => { + it('should parse simple wiki links [[Note]]', () => { const content = 'See [[other-doc]] for more info.'; const result = parseMarkdownLinks(content, 'docs/readme.md'); @@ -48,28 +156,37 @@ describe('parseMarkdownLinks', () => { expect(result.externalLinks).toHaveLength(0); }); - it('should parse wiki links with display text [[path|text]]', () => { - const content = 'Check out [[getting-started|the guide]].'; - const result = parseMarkdownLinks(content, 'docs/readme.md'); - - expect(result.internalLinks).toContain('docs/getting-started.md'); - }); - - it('should parse wiki links with folders [[Folder/Note]]', () => { + it('should parse wiki links with path [[Folder/Note]]', () => { const content = 'See [[subdir/nested-doc]] for details.'; const result = parseMarkdownLinks(content, 'docs/readme.md'); expect(result.internalLinks).toContain('docs/subdir/nested-doc.md'); }); - it('should skip image embeds', () => { - const content = '![[screenshot.png]] and [[doc-link]]'; + it('should parse wiki links with display text [[Note|Display]]', () => { + const content = 'Check out [[getting-started|the guide]].'; + const result = parseMarkdownLinks(content, 'docs/readme.md'); + + expect(result.internalLinks).toContain('docs/getting-started.md'); + }); + + it('should skip image wiki links (.png, .jpg, etc.)', () => { + const content = '![[screenshot.png]] and [[image.jpg]] and [[photo.gif]] and [[doc-link]]'; const result = parseMarkdownLinks(content, 'docs/readme.md'); expect(result.internalLinks).toContain('docs/doc-link.md'); expect(result.internalLinks).toHaveLength(1); }); + it('should skip various image extensions in wiki links', () => { + const content = + '[[a.jpeg]] [[b.webp]] [[c.svg]] [[d.bmp]] [[e.ico]] [[real-doc]]'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toHaveLength(1); + expect(result.internalLinks).toContain('real-doc.md'); + }); + it('should handle multiple wiki links', () => { const content = 'Link to [[first]] and [[second]] and [[third]].'; const result = parseMarkdownLinks(content, 'readme.md'); @@ -81,8 +198,20 @@ describe('parseMarkdownLinks', () => { }); }); + // ----------------------------------------------------------------------- + // Standard markdown links + // ----------------------------------------------------------------------- describe('standard markdown links', () => { - it('should parse internal markdown links [text](path.md)', () => { + it('should parse external markdown links [text](https://example.com)', () => { + const content = 'Visit [Example](https://example.com).'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.externalLinks).toHaveLength(1); + expect(result.externalLinks[0].url).toBe('https://example.com'); + expect(result.externalLinks[0].domain).toBe('example.com'); + }); + + it('should parse internal markdown links [text](./other.md)', () => { const content = 'See the [documentation](./docs/guide.md).'; const result = parseMarkdownLinks(content, 'readme.md'); @@ -118,7 +247,7 @@ Also see [Docs](https://docs.example.com/page). expect(result.externalLinks.map((l) => l.domain)).toContain('docs.example.com'); }); - it('should skip anchor links', () => { + it('should skip anchor links (#section)', () => { const content = 'See [section](#heading) for details.'; const result = parseMarkdownLinks(content, 'readme.md'); @@ -133,77 +262,91 @@ Also see [Docs](https://docs.example.com/page). expect(result.internalLinks).toHaveLength(0); expect(result.externalLinks).toHaveLength(0); }); - }); - describe('front matter parsing', () => { - it('should parse YAML front matter', () => { - const content = `--- -title: My Document -description: A test document -version: 1.0 ---- + it('should add .md extension when missing for internal links', () => { + const content = '[doc](./other-file)'; + const result = parseMarkdownLinks(content, 'readme.md'); -# Content here -`; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter.title).toBe('My Document'); - expect(result.frontMatter.description).toBe('A test document'); - expect(result.frontMatter.version).toBe(1.0); - }); - - it('should handle boolean values in front matter', () => { - const content = `--- -draft: true -published: false ---- - -Content -`; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter.draft).toBe(true); - expect(result.frontMatter.published).toBe(false); - }); - - it('should handle quoted strings in front matter', () => { - const content = `--- -title: "Quoted Title" -subtitle: 'Single quoted' ---- - -Content -`; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter.title).toBe('Quoted Title'); - expect(result.frontMatter.subtitle).toBe('Single quoted'); - }); - - it('should return empty object when no front matter', () => { - const content = '# Just a heading\n\nSome content.'; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter).toEqual({}); - }); - - it('should ignore comments in front matter', () => { - const content = `--- -title: My Doc -# This is a comment -author: John ---- - -Content -`; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter.title).toBe('My Doc'); - expect(result.frontMatter.author).toBe('John'); - expect(Object.keys(result.frontMatter)).toHaveLength(2); + expect(result.internalLinks).toContain('other-file.md'); }); }); + // ----------------------------------------------------------------------- + // Relative path resolution (tests path utilities indirectly) + // ----------------------------------------------------------------------- + describe('relative path resolution', () => { + it('should resolve ./sibling.md correctly', () => { + const content = '[sibling](./sibling.md)'; + const result = parseMarkdownLinks(content, 'docs/current.md'); + + expect(result.internalLinks).toContain('docs/sibling.md'); + }); + + it('should resolve ../parent.md correctly', () => { + const content = '[parent](../parent.md)'; + const result = parseMarkdownLinks(content, 'docs/nested/current.md'); + + expect(result.internalLinks).toContain('docs/parent.md'); + }); + + it('should resolve ../../grandparent.md from deeply nested paths', () => { + const content = '[grandparent](../../grandparent.md)'; + const result = parseMarkdownLinks(content, 'a/b/c/current.md'); + + expect(result.internalLinks).toContain('a/grandparent.md'); + }); + + it('should resolve ../docs/file.md from nested/file.md', () => { + const content = '[doc](../docs/file.md)'; + const result = parseMarkdownLinks(content, 'nested/file.md'); + + expect(result.internalLinks).toContain('docs/file.md'); + }); + + it('should handle paths without leading ./', () => { + const content = '[[sibling-note]]'; + const result = parseMarkdownLinks(content, 'docs/readme.md'); + + expect(result.internalLinks).toContain('docs/sibling-note.md'); + }); + + it('should normalize backslashes in paths', () => { + // Wiki links with backslash separators + const content = '[[folder\\subfolder\\note]]'; + const result = parseMarkdownLinks(content, 'docs/readme.md'); + + // The internal joinPath normalizes backslashes to forward slashes + expect(result.internalLinks).toHaveLength(1); + expect(result.internalLinks[0]).not.toContain('\\'); + }); + + it('should handle absolute-like paths starting with /', () => { + const content = '[root](/root-doc.md)'; + const result = parseMarkdownLinks(content, 'docs/current.md'); + + // /root-doc.md starts with / which is not http/https and not # or mailto + // joinPath('docs', '/root-doc.md') will produce something + expect(result.internalLinks).toHaveLength(1); + }); + + it('should handle files at root level (no directory)', () => { + const content = '[[sibling]]'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toContain('sibling.md'); + }); + + it('should preserve .md extension if already present', () => { + const content = '[[already.md]]'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toContain('already.md'); + }); + }); + + // ----------------------------------------------------------------------- + // Deduplication + // ----------------------------------------------------------------------- describe('deduplication', () => { it('should deduplicate internal links', () => { const content = 'See [[doc]] and [[doc]] again.'; @@ -227,8 +370,178 @@ Content expect(result.internalLinks).toHaveLength(2); }); + + it('should deduplicate wiki link and markdown link to same target', () => { + const content = '[[other-doc]] and [link](./other-doc.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); + + // Both resolve to 'other-doc.md' from root + expect(result.internalLinks).toHaveLength(1); + expect(result.internalLinks).toContain('other-doc.md'); + }); }); + // ----------------------------------------------------------------------- + // Front matter parsing + // ----------------------------------------------------------------------- + describe('front matter parsing', () => { + it('should parse key: value pairs', () => { + const content = `--- +title: My Document +description: A test document +version: 1.0 +--- + +# Content here +`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter.title).toBe('My Document'); + expect(result.frontMatter.description).toBe('A test document'); + expect(result.frontMatter.version).toBe(1.0); + }); + + it('should handle double-quoted values', () => { + const content = `--- +title: "Quoted Title" +--- + +Content +`; + const result = parseMarkdownLinks(content, 'doc.md'); + expect(result.frontMatter.title).toBe('Quoted Title'); + }); + + it('should handle single-quoted values', () => { + const content = `--- +subtitle: 'Single quoted' +--- + +Content +`; + const result = parseMarkdownLinks(content, 'doc.md'); + expect(result.frontMatter.subtitle).toBe('Single quoted'); + }); + + it('should parse boolean values (true/false)', () => { + const content = `--- +draft: true +published: false +--- + +Content +`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter.draft).toBe(true); + expect(result.frontMatter.published).toBe(false); + }); + + it('should parse numeric values', () => { + const content = `--- +version: 42 +pi: 3.14 +negative: -7 +--- + +Content +`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter.version).toBe(42); + expect(result.frontMatter.pi).toBe(3.14); + expect(result.frontMatter.negative).toBe(-7); + }); + + it('should skip comments (#)', () => { + const content = `--- +title: My Doc +# This is a comment +author: John +--- + +Content +`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter.title).toBe('My Doc'); + expect(result.frontMatter.author).toBe('John'); + expect(Object.keys(result.frontMatter)).toHaveLength(2); + }); + + it('should return empty object when no front matter', () => { + const content = '# Just a heading\n\nSome content.'; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter).toEqual({}); + }); + + it('should return empty object for missing front matter', () => { + const content = 'No front matter at all, just plain text.'; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter).toEqual({}); + }); + + it('should handle front matter with only opening delimiter', () => { + const content = `--- +title: No closing delimiter +Some content here.`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter).toEqual({}); + }); + + it('should handle front matter with invalid YAML-like content', () => { + const content = `--- +this is not valid yaml at all +: colon at start +no colon here + indented : weirdly +--- + +# Heading`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result).toBeDefined(); + expect(result.frontMatter).toBeDefined(); + }); + + it('should handle content that is only delimiters', () => { + const content = '---\n---'; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter).toEqual({}); + }); + + it('should handle very long front matter values', () => { + const longValue = 'x'.repeat(100000); + const content = `--- +title: ${longValue} +--- + +Content`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.frontMatter.title).toBe(longValue); + }); + + it('should handle front matter with binary-like content', () => { + const content = `--- +title: \x00\x01\x02\x03 +binary: \xff\xfe +--- + +Content`; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result).toBeDefined(); + }); + }); + + // ----------------------------------------------------------------------- + // Mixed internal and external links + // ----------------------------------------------------------------------- describe('mixed content', () => { it('should parse both internal and external links together', () => { const content = `--- @@ -245,8 +558,60 @@ Also see [another doc](./other.md) here. expect(result.externalLinks).toHaveLength(1); expect(result.frontMatter.title).toBe('Mixed Doc'); }); + + it('should correctly classify wiki links as internal and http links as external', () => { + const content = ` +[[wiki-link]] +[ext](https://external.com) +[int](./internal.md) +[http-link](http://another.com/page) +[[another-wiki]] +`; + const result = parseMarkdownLinks(content, 'docs/readme.md'); + + expect(result.internalLinks).toHaveLength(3); // wiki-link, internal.md, another-wiki + expect(result.externalLinks).toHaveLength(2); // external.com, another.com + }); }); + // ----------------------------------------------------------------------- + // URL-encoded paths + // ----------------------------------------------------------------------- + describe('URL-encoded paths', () => { + it('should handle URL-encoded paths (%20 for spaces)', () => { + const content = '[doc](./my%20document.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toContain('my document.md'); + }); + + it('should handle malformed URL encoding gracefully', () => { + const content = '[doc](./my%ZZdocument.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); + + // Should use original path when decoding fails + expect(result.internalLinks).toContain('my%ZZdocument.md'); + }); + + it('should handle incomplete percent encoding', () => { + const content = '[doc](./document%.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toHaveLength(1); + expect(result.internalLinks[0]).toContain('document'); + }); + + it('should handle multiple invalid percent sequences', () => { + const content = '[doc](./my%ZZ%YYdocument%XXtest.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toHaveLength(1); + }); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- describe('edge cases', () => { it('should handle empty content', () => { const result = parseMarkdownLinks('', 'doc.md'); @@ -256,307 +621,142 @@ Also see [another doc](./other.md) here. expect(result.frontMatter).toEqual({}); }); - it('should handle URL-encoded paths', () => { - const content = '[doc](./my%20document.md)'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle content with null bytes', () => { + const content = 'Some text\x00with null\x00bytes [[link]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - expect(result.internalLinks).toContain('my document.md'); + expect(result).toBeDefined(); }); - it('should handle files at root level', () => { - const content = '[[sibling]]'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle content with control characters', () => { + const content = 'Text\x01\x02\x03\x04\x05 with [[link]] control chars.'; + const result = parseMarkdownLinks(content, 'doc.md'); - expect(result.internalLinks).toContain('sibling.md'); + expect(result.internalLinks).toContain('link.md'); }); - it('should preserve .md extension if already present', () => { - const content = '[[already.md]]'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle content with mixed line endings', () => { + const content = 'Line1\rLine2\r\nLine3\nLine4\r\n[[link]]'; + const result = parseMarkdownLinks(content, 'doc.md'); - expect(result.internalLinks).toContain('already.md'); + expect(result.internalLinks).toContain('link.md'); + }); + + it('should handle extremely long content without hanging', () => { + const longContent = 'x'.repeat(1024 * 1024) + '[[link]]'; + const result = parseMarkdownLinks(longContent, 'doc.md'); + + expect(result.internalLinks).toContain('link.md'); + }); + + it('should handle content starting and ending with brackets', () => { + const content = '[[start]] content [[end]]'; + const result = parseMarkdownLinks(content, 'doc.md'); + + expect(result.internalLinks).toContain('start.md'); + expect(result.internalLinks).toContain('end.md'); }); }); - describe('malformed markdown handling (graceful degradation)', () => { - describe('null/undefined/invalid input handling', () => { - it('should handle null content without crashing', () => { - // @ts-expect-error Testing runtime behavior with null input - const result = parseMarkdownLinks(null, 'doc.md'); + // ----------------------------------------------------------------------- + // Malformed wiki links + // ----------------------------------------------------------------------- + describe('malformed wiki links', () => { + it('should handle empty wiki links [[]]', () => { + const content = 'See [[]] and [[valid-link]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - expect(result.internalLinks).toEqual([]); - expect(result.externalLinks).toEqual([]); - expect(result.frontMatter).toEqual({}); - }); - - it('should handle undefined content without crashing', () => { - // @ts-expect-error Testing runtime behavior with undefined input - const result = parseMarkdownLinks(undefined, 'doc.md'); - - expect(result.internalLinks).toEqual([]); - expect(result.externalLinks).toEqual([]); - expect(result.frontMatter).toEqual({}); - }); - - it('should handle non-string content types without crashing', () => { - // @ts-expect-error Testing runtime behavior with number input - const resultNumber = parseMarkdownLinks(12345, 'doc.md'); - expect(resultNumber.internalLinks).toEqual([]); - expect(resultNumber.frontMatter).toEqual({}); - - // @ts-expect-error Testing runtime behavior with object input - const resultObject = parseMarkdownLinks({ text: 'content' }, 'doc.md'); - expect(resultObject.internalLinks).toEqual([]); - expect(resultObject.frontMatter).toEqual({}); - - // @ts-expect-error Testing runtime behavior with array input - const resultArray = parseMarkdownLinks(['content'], 'doc.md'); - expect(resultArray.internalLinks).toEqual([]); - expect(resultArray.frontMatter).toEqual({}); - }); - - it('should handle empty filePath gracefully', () => { - const content = 'See [[other-doc]] for more info.'; - const result = parseMarkdownLinks(content, ''); - - // Should still parse links (using empty string as base path) - expect(result.internalLinks).toContain('other-doc.md'); - }); + expect(result.internalLinks).toContain('valid-link.md'); }); - describe('malformed URL encoding', () => { - it('should handle invalid percent-encoded URLs gracefully', () => { - // %ZZ is invalid percent encoding - should not crash - const content = '[doc](./my%ZZdocument.md)'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle wiki links with only whitespace [[ ]]', () => { + const content = 'See [[ ]] and [[valid-link]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - // Should use the original path when decoding fails - expect(result.internalLinks).toContain('my%ZZdocument.md'); - }); - - it('should handle incomplete percent encoding', () => { - // % at end of string is incomplete encoding - const content = '[doc](./document%.md)'; - const result = parseMarkdownLinks(content, 'readme.md'); - - expect(result.internalLinks).toHaveLength(1); - expect(result.internalLinks[0]).toContain('document'); - }); - - it('should handle multiple invalid percent sequences', () => { - const content = '[doc](./my%ZZ%YYdocument%XXtest.md)'; - const result = parseMarkdownLinks(content, 'readme.md'); - - // Should not crash and should extract some link - expect(result.internalLinks).toHaveLength(1); - }); + expect(result.internalLinks).toContain('valid-link.md'); }); - describe('malformed front matter', () => { - it('should handle front matter with only opening delimiter', () => { - const content = `--- -title: No closing delimiter -Some content here.`; - const result = parseMarkdownLinks(content, 'doc.md'); + it('should handle unclosed wiki links', () => { + const content = 'See [[unclosed and [[closed]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - // Should not crash, should return empty front matter - expect(result.frontMatter).toEqual({}); - }); - - it('should handle front matter with invalid YAML-like content', () => { - const content = `--- -this is not valid yaml at all -: colon at start -no colon here - indented : weirdly ---- - -# Heading`; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash - expect(result).toBeDefined(); - expect(result.frontMatter).toBeDefined(); - }); - - it('should handle very long front matter values', () => { - const longValue = 'x'.repeat(100000); - const content = `--- -title: ${longValue} ---- - -Content`; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter.title).toBe(longValue); - }); - - it('should handle front matter with binary-like content', () => { - const content = `--- -title: \x00\x01\x02\x03 -binary: \xff\xfe ---- - -Content`; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash - expect(result).toBeDefined(); - }); + expect(result).toBeDefined(); }); - describe('malformed wiki links', () => { - it('should handle empty wiki links [[]]', () => { - const content = 'See [[]] and [[valid-link]].'; - const result = parseMarkdownLinks(content, 'doc.md'); + it('should handle nested brackets in wiki links', () => { + const content = 'See [[link[with]brackets]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - // Should skip empty and process valid - expect(result.internalLinks).toContain('valid-link.md'); - }); - - it('should handle wiki links with only whitespace [[ ]]', () => { - const content = 'See [[ ]] and [[valid-link]].'; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.internalLinks).toContain('valid-link.md'); - }); - - it('should handle unclosed wiki links', () => { - const content = 'See [[unclosed and [[closed]].'; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash, should find the closed link - expect(result).toBeDefined(); - }); - - it('should handle nested brackets in wiki links', () => { - const content = 'See [[link[with]brackets]].'; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash - expect(result).toBeDefined(); - }); - - it('should handle wiki links with special characters', () => { - const content = 'See [[link-with-émojis-🎉]] and [[日本語リンク]].'; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash and should parse the links - expect(result.internalLinks).toHaveLength(2); - }); + expect(result).toBeDefined(); }); - describe('malformed markdown links', () => { - it('should handle empty markdown links []()', () => { - const content = 'See []() and [text](./valid.md).'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle wiki links with special characters', () => { + const content = 'See [[link-with-\u00E9mojis-\uD83C\uDF89]] and [[\u65E5\u672C\u8A9E\u30EA\u30F3\u30AF]].'; + const result = parseMarkdownLinks(content, 'doc.md'); - // Should process valid link - expect(result.internalLinks).toContain('valid.md'); - }); + expect(result.internalLinks).toHaveLength(2); + }); - it('should handle markdown links with no URL [text]()', () => { - const content = 'See [text with no url]() here.'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle deeply nested bracket patterns', () => { + const content = '[[[[[[nested]]]]]]'; + const result = parseMarkdownLinks(content, 'doc.md'); - // Should not crash - expect(result).toBeDefined(); - }); + expect(result).toBeDefined(); + }); - it('should handle unclosed markdown links', () => { - const content = 'See [unclosed link(./file.md and [closed](./valid.md).'; - const result = parseMarkdownLinks(content, 'readme.md'); + it('should handle interleaved wiki and markdown links', () => { + const content = '[[wiki-[nested](./md.md)-link]] and [md-[[wiki]]-link](./file.md)'; + const result = parseMarkdownLinks(content, 'readme.md'); - // Should find valid link and not crash - expect(result.internalLinks).toContain('valid.md'); - }); + expect(result).toBeDefined(); + }); + }); - it('should handle markdown links with newlines inside', () => { - const content = `See [text + // ----------------------------------------------------------------------- + // Malformed markdown links + // ----------------------------------------------------------------------- + describe('malformed markdown links', () => { + it('should handle empty markdown links []()', () => { + const content = 'See []() and [text](./valid.md).'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toContain('valid.md'); + }); + + it('should handle markdown links with no URL [text]()', () => { + const content = 'See [text with no url]() here.'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result).toBeDefined(); + }); + + it('should handle unclosed markdown links', () => { + const content = 'See [unclosed link(./file.md and [closed](./valid.md).'; + const result = parseMarkdownLinks(content, 'readme.md'); + + expect(result.internalLinks).toContain('valid.md'); + }); + + it('should handle markdown links with newlines inside', () => { + const content = `See [text with newline](./file.md).`; - const result = parseMarkdownLinks(content, 'readme.md'); + const result = parseMarkdownLinks(content, 'readme.md'); - // Should not crash - expect(result).toBeDefined(); - }); - - it('should handle very long link URLs', () => { - const longPath = 'a'.repeat(10000) + '.md'; - const content = `See [link](./${longPath}).`; - const result = parseMarkdownLinks(content, 'readme.md'); - - // Should not crash - expect(result).toBeDefined(); - }); + expect(result).toBeDefined(); }); - describe('binary and special content', () => { - it('should handle content with null bytes', () => { - const content = 'Some text\x00with null\x00bytes [[link]].'; - const result = parseMarkdownLinks(content, 'doc.md'); + it('should handle very long link URLs', () => { + const longPath = 'a'.repeat(10000) + '.md'; + const content = `See [link](./${longPath}).`; + const result = parseMarkdownLinks(content, 'readme.md'); - // Should not crash - expect(result).toBeDefined(); - }); - - it('should handle content with control characters', () => { - const content = 'Text\x01\x02\x03\x04\x05 with [[link]] control chars.'; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash and parse link - expect(result.internalLinks).toContain('link.md'); - }); - - it('should handle content with mixed line endings', () => { - const content = 'Line1\rLine2\r\nLine3\nLine4\r\n[[link]]'; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.internalLinks).toContain('link.md'); - }); - - it('should handle extremely long content without hanging', () => { - // 1MB of content - const longContent = 'x'.repeat(1024 * 1024) + '[[link]]'; - const result = parseMarkdownLinks(longContent, 'doc.md'); - - expect(result.internalLinks).toContain('link.md'); - }); - }); - - describe('edge case combinations', () => { - it('should handle content that is only delimiters', () => { - const content = '---\n---'; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.frontMatter).toEqual({}); - }); - - it('should handle deeply nested bracket patterns', () => { - const content = '[[[[[[nested]]]]]]'; - const result = parseMarkdownLinks(content, 'doc.md'); - - // Should not crash (may or may not extract links depending on pattern) - expect(result).toBeDefined(); - }); - - it('should handle interleaved wiki and markdown links', () => { - const content = '[[wiki-[nested](./md.md)-link]] and [md-[[wiki]]-link](./file.md)'; - const result = parseMarkdownLinks(content, 'readme.md'); - - // Should not crash - expect(result).toBeDefined(); - }); - - it('should handle content starting and ending with brackets', () => { - const content = '[[start]] content [[end]]'; - const result = parseMarkdownLinks(content, 'doc.md'); - - expect(result.internalLinks).toContain('start.md'); - expect(result.internalLinks).toContain('end.md'); - }); + expect(result).toBeDefined(); }); }); + // ----------------------------------------------------------------------- + // External URLs with special characters + // ----------------------------------------------------------------------- describe('external URLs with special characters', () => { describe('URLs with parentheses', () => { it('should handle URLs with balanced parentheses (Wikipedia-style)', () => { @@ -583,7 +783,9 @@ with newline](./file.md).`; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toBe('https://example.com/wiki/Term_(disambiguation)'); + expect(result.externalLinks[0].url).toBe( + 'https://example.com/wiki/Term_(disambiguation)' + ); }); it('should handle multiple URLs with parentheses in same content', () => { @@ -611,7 +813,8 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); it('should handle URLs with special characters in query parameters', () => { - const content = '[Encode](https://example.com/api?data=%7B%22key%22%3A%22value%22%7D)'; + const content = + '[Encode](https://example.com/api?data=%7B%22key%22%3A%22value%22%7D)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); @@ -623,7 +826,9 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toBe('https://example.com/search?q=hello+world'); + expect(result.externalLinks[0].url).toBe( + 'https://example.com/search?q=hello+world' + ); }); }); @@ -641,7 +846,9 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toBe('https://example.com/page?id=123#heading'); + expect(result.externalLinks[0].url).toBe( + 'https://example.com/page?id=123#heading' + ); }); }); @@ -677,23 +884,25 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toBe('https://example.com/path%20with%20spaces'); + expect(result.externalLinks[0].url).toBe( + 'https://example.com/path%20with%20spaces' + ); }); it('should handle URLs with unicode path segments', () => { - const content = '[Unicode](https://example.com/文档/测试)'; + const content = '[Unicode](https://example.com/\u6587\u6863/\u6D4B\u8BD5)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toContain('文档'); + expect(result.externalLinks[0].url).toContain('\u6587\u6863'); }); it('should handle URLs with emoji in path', () => { - const content = '[Emoji](https://example.com/docs/🎉/welcome)'; + const content = '[Emoji](https://example.com/docs/\uD83C\uDF89/welcome)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); - expect(result.externalLinks[0].url).toContain('🎉'); + expect(result.externalLinks[0].url).toContain('\uD83C\uDF89'); }); it('should handle URLs with hyphens and underscores', () => { @@ -705,40 +914,10 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); }); - describe('extractDomain edge cases', () => { - it('should handle IDN domains (punycode)', () => { - // The URL constructor converts IDN to punycode - expect(extractDomain('https://例え.jp/path')).toBe('xn--r8jz45g.jp'); - }); - - it('should strip www prefix from complex URLs', () => { - expect(extractDomain('https://www.docs.example.com/path')).toBe('docs.example.com'); - }); - - it('should handle URLs with trailing slashes', () => { - expect(extractDomain('https://example.com/')).toBe('example.com'); - }); - - it('should handle URLs with deep paths', () => { - expect(extractDomain('https://github.com/org/repo/blob/main/src/file.ts')).toBe( - 'github.com' - ); - }); - - it('should return original for malformed URLs', () => { - // The regex fallback should handle these - expect(extractDomain('http://incomplete')).toBe('incomplete'); - }); - - it('should handle URLs with unusual TLDs', () => { - expect(extractDomain('https://site.museum/collection')).toBe('site.museum'); - expect(extractDomain('https://company.technology/product')).toBe('company.technology'); - }); - }); - describe('complex real-world URLs', () => { it('should handle GitHub file URLs', () => { - const content = '[Code](https://github.com/user/repo/blob/main/src/index.ts#L10-L20)'; + const content = + '[Code](https://github.com/user/repo/blob/main/src/index.ts#L10-L20)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); @@ -747,7 +926,8 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); it('should handle Google search URLs', () => { - const content = '[Google](https://www.google.com/search?q=markdown+tutorial&source=hp)'; + const content = + '[Google](https://www.google.com/search?q=markdown+tutorial&source=hp)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); @@ -755,7 +935,8 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); it('should handle YouTube URLs with video IDs', () => { - const content = '[Video](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)'; + const content = + '[Video](https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); @@ -763,7 +944,8 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); it('should handle Amazon product URLs', () => { - const content = '[Product](https://www.amazon.com/dp/B08N5WRWNW?ref=cm_sw_r_cp_api)'; + const content = + '[Product](https://www.amazon.com/dp/B08N5WRWNW?ref=cm_sw_r_cp_api)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); @@ -780,7 +962,8 @@ See [First](https://en.wikipedia.org/wiki/A_(letter)) and }); it('should handle Twitter/X status URLs', () => { - const content = '[Tweet](https://twitter.com/user/status/1234567890123456789)'; + const content = + '[Tweet](https://twitter.com/user/status/1234567890123456789)'; const result = parseMarkdownLinks(content, 'readme.md'); expect(result.externalLinks).toHaveLength(1); diff --git a/src/__tests__/renderer/utils/participantColors.test.ts b/src/__tests__/renderer/utils/participantColors.test.ts new file mode 100644 index 00000000..7ca97a15 --- /dev/null +++ b/src/__tests__/renderer/utils/participantColors.test.ts @@ -0,0 +1,765 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Theme } from '../../../shared/theme-types'; +import { + generateParticipantColor, + buildParticipantColorMap, + buildParticipantColorMapWithPreferences, + loadColorPreferences, + saveColorPreferences, + MODERATOR_COLOR_INDEX, + COLOR_PALETTE_SIZE, +} from '../../../renderer/utils/participantColors'; +import type { ParticipantColorInfo } from '../../../renderer/utils/participantColors'; + +/** + * Tests for participantColors utility + * + * Covers color generation, color map building (with and without preferences), + * preference persistence, and edge cases. + */ + +// --- Theme mocks --- + +const darkTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + border: '#533483', + textMain: '#e94560', + textDim: '#a3a3a3', + accent: '#e94560', + accentDim: 'rgba(233, 69, 96, 0.2)', + accentText: '#e94560', + accentForeground: '#ffffff', + success: '#50fa7b', + warning: '#f1fa8c', + error: '#ff5555', + }, +}; + +const lightTheme: Theme = { + id: 'github-light', + name: 'GitHub Light', + mode: 'light', + colors: { + bgMain: '#ffffff', + bgSidebar: '#f6f8fa', + bgActivity: '#eaeef2', + border: '#d0d7de', + textMain: '#1f2328', + textDim: '#656d76', + accent: '#0969da', + accentDim: 'rgba(9, 105, 218, 0.2)', + accentText: '#0969da', + accentForeground: '#ffffff', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e', + }, +}; + +// HSL regex for validating generated colors +const HSL_REGEX = /^hsl\(\d+, \d+%, \d+%\)$/; + +// --- Helper to parse HSL values from a color string --- +function parseHSL(hsl: string): { hue: number; saturation: number; lightness: number } { + const match = hsl.match(/^hsl\((\d+), (\d+)%, (\d+)%\)$/); + if (!match) throw new Error(`Invalid HSL string: ${hsl}`); + return { + hue: parseInt(match[1], 10), + saturation: parseInt(match[2], 10), + lightness: parseInt(match[3], 10), + }; +} + +// Known BASE_HUES array from source +const BASE_HUES = [210, 150, 30, 270, 0, 180, 60, 300, 120, 330]; + +// --- Tests --- + +describe('participantColors', () => { + describe('constants', () => { + it('MODERATOR_COLOR_INDEX should be 0', () => { + expect(MODERATOR_COLOR_INDEX).toBe(0); + }); + + it('COLOR_PALETTE_SIZE should be 10', () => { + expect(COLOR_PALETTE_SIZE).toBe(10); + }); + }); + + describe('generateParticipantColor', () => { + describe('basic output format', () => { + it('should return a valid HSL string for dark theme', () => { + const color = generateParticipantColor(0, darkTheme); + expect(color).toMatch(HSL_REGEX); + }); + + it('should return a valid HSL string for light theme', () => { + const color = generateParticipantColor(0, lightTheme); + expect(color).toMatch(HSL_REGEX); + }); + }); + + describe('hue selection from BASE_HUES', () => { + it('should use the correct base hue for each index within palette size', () => { + for (let i = 0; i < COLOR_PALETTE_SIZE; i++) { + const color = generateParticipantColor(i, darkTheme); + const { hue } = parseHSL(color); + expect(hue).toBe(BASE_HUES[i]); + } + }); + + it('index 0 (Moderator) should always produce hue 210 (blue)', () => { + const darkColor = generateParticipantColor(0, darkTheme); + const lightColor = generateParticipantColor(0, lightTheme); + expect(parseHSL(darkColor).hue).toBe(210); + expect(parseHSL(lightColor).hue).toBe(210); + }); + }); + + describe('dark vs light theme differentiation', () => { + it('dark theme should use lower saturation than light theme for same index', () => { + // Dark: baseSaturation = 55, Light: baseSaturation = 65 + const darkColor = generateParticipantColor(0, darkTheme); + const lightColor = generateParticipantColor(0, lightTheme); + const darkSat = parseHSL(darkColor).saturation; + const lightSat = parseHSL(lightColor).saturation; + expect(darkSat).toBe(55); + expect(lightSat).toBe(65); + }); + + it('dark theme should use higher base lightness than light theme', () => { + // Dark: baseLightness = 60, Light: baseLightness = 45 + const darkColor = generateParticipantColor(0, darkTheme); + const lightColor = generateParticipantColor(0, lightTheme); + const darkLight = parseHSL(darkColor).lightness; + const lightLight = parseHSL(lightColor).lightness; + expect(darkLight).toBe(60); + expect(lightLight).toBe(45); + }); + + it('should detect light theme from bgMain brightness > 128', () => { + // #ff... means first byte is 255 > 128 => light + const brightTheme: Theme = { + ...darkTheme, + colors: { ...darkTheme.colors, bgMain: '#ff0000' }, + }; + const color = generateParticipantColor(0, brightTheme); + const { saturation, lightness } = parseHSL(color); + // Light theme values + expect(saturation).toBe(65); + expect(lightness).toBe(45); + }); + + it('should detect dark theme from bgMain brightness <= 128', () => { + // #1a... means first byte is 26 <= 128 => dark + const color = generateParticipantColor(0, darkTheme); + const { saturation, lightness } = parseHSL(color); + expect(saturation).toBe(55); + expect(lightness).toBe(60); + }); + + it('should use bgMain first hex byte only for brightness detection', () => { + // #80 = 128, not > 128, so dark theme + const borderTheme: Theme = { + ...darkTheme, + colors: { ...darkTheme.colors, bgMain: '#80ffff' }, + }; + const color = generateParticipantColor(0, borderTheme); + const { saturation } = parseHSL(color); + expect(saturation).toBe(55); // dark theme saturation + }); + + it('should treat #81 first byte as light theme (129 > 128)', () => { + const borderTheme: Theme = { + ...darkTheme, + colors: { ...darkTheme.colors, bgMain: '#810000' }, + }; + const color = generateParticipantColor(0, borderTheme); + const { saturation } = parseHSL(color); + expect(saturation).toBe(65); // light theme saturation + }); + }); + + describe('unique colors for different indices', () => { + it('all indices within palette size should produce different colors', () => { + const colors = new Set(); + for (let i = 0; i < COLOR_PALETTE_SIZE; i++) { + colors.add(generateParticipantColor(i, darkTheme)); + } + expect(colors.size).toBe(COLOR_PALETTE_SIZE); + }); + + it('adjacent indices should have different hues', () => { + for (let i = 0; i < COLOR_PALETTE_SIZE - 1; i++) { + const color1 = generateParticipantColor(i, darkTheme); + const color2 = generateParticipantColor(i + 1, darkTheme); + expect(parseHSL(color1).hue).not.toBe(parseHSL(color2).hue); + } + }); + }); + + describe('indices beyond palette size (wrapping with variation)', () => { + it('index at palette size should wrap back to hue at index 0', () => { + const wrapColor = generateParticipantColor(COLOR_PALETTE_SIZE, darkTheme); + const baseColor = generateParticipantColor(0, darkTheme); + expect(parseHSL(wrapColor).hue).toBe(parseHSL(baseColor).hue); + }); + + it('wrapped index should have different saturation than base', () => { + const baseColor = generateParticipantColor(0, darkTheme); + const wrapColor = generateParticipantColor(COLOR_PALETTE_SIZE, darkTheme); + expect(parseHSL(wrapColor).saturation).not.toBe(parseHSL(baseColor).saturation); + }); + + it('wrapped index should have different lightness than base', () => { + const baseColor = generateParticipantColor(0, darkTheme); + const wrapColor = generateParticipantColor(COLOR_PALETTE_SIZE, darkTheme); + expect(parseHSL(wrapColor).lightness).not.toBe(parseHSL(baseColor).lightness); + }); + + it('round 1 should reduce saturation by 10 (dark theme)', () => { + const round0 = parseHSL(generateParticipantColor(0, darkTheme)); + const round1 = parseHSL(generateParticipantColor(COLOR_PALETTE_SIZE, darkTheme)); + // baseSaturation=55, round 1: 55-10=45 + expect(round0.saturation).toBe(55); + expect(round1.saturation).toBe(45); + }); + + it('round 1 dark theme should decrease lightness by 8', () => { + const round0 = parseHSL(generateParticipantColor(0, darkTheme)); + const round1 = parseHSL(generateParticipantColor(COLOR_PALETTE_SIZE, darkTheme)); + // baseLightness=60, round 1: max(40, 60-8)=52 + expect(round0.lightness).toBe(60); + expect(round1.lightness).toBe(52); + }); + + it('round 1 light theme should increase lightness by 8', () => { + const round0 = parseHSL(generateParticipantColor(0, lightTheme)); + const round1 = parseHSL(generateParticipantColor(COLOR_PALETTE_SIZE, lightTheme)); + // baseLightness=45, round 1: min(70, 45+8)=53 + expect(round0.lightness).toBe(45); + expect(round1.lightness).toBe(53); + }); + + it('saturation should not go below 25', () => { + // Round 3: 55 - 30 = 25 (clamped), Round 4: 55 - 40 = 15 => clamped to 25 + const round4 = parseHSL( + generateParticipantColor(4 * COLOR_PALETTE_SIZE, darkTheme) + ); + expect(round4.saturation).toBe(25); + }); + + it('dark theme lightness should not go below 40', () => { + // Round 3: 60 - 24 = 36 => clamped to 40 + const round3 = parseHSL( + generateParticipantColor(3 * COLOR_PALETTE_SIZE, darkTheme) + ); + expect(round3.lightness).toBe(40); + }); + + it('light theme lightness should not go above 70', () => { + // Round 4: 45 + 32 = 77 => clamped to 70 + const round4 = parseHSL( + generateParticipantColor(4 * COLOR_PALETTE_SIZE, lightTheme) + ); + expect(round4.lightness).toBe(70); + }); + + it('second round should produce different colors than first round', () => { + for (let i = 0; i < COLOR_PALETTE_SIZE; i++) { + const first = generateParticipantColor(i, darkTheme); + const second = generateParticipantColor(i + COLOR_PALETTE_SIZE, darkTheme); + expect(first).not.toBe(second); + } + }); + }); + + describe('edge cases for theme detection', () => { + it('should fall back to dark theme if bgMain has no valid hex prefix', () => { + const invalidBgTheme: Theme = { + ...darkTheme, + colors: { ...darkTheme.colors, bgMain: 'rgb(0,0,0)' }, + }; + const color = generateParticipantColor(0, invalidBgTheme); + // bgBrightness falls back to 20 (< 128) => dark theme + const { saturation } = parseHSL(color); + expect(saturation).toBe(55); // dark theme + }); + }); + }); + + describe('buildParticipantColorMap', () => { + it('should build a color map for all participants', () => { + const names = ['Alice', 'Bob', 'Charlie']; + const result = buildParticipantColorMap(names, darkTheme); + expect(Object.keys(result)).toEqual(names); + }); + + it('each participant should get a valid HSL color', () => { + const names = ['Alice', 'Bob']; + const result = buildParticipantColorMap(names, darkTheme); + for (const color of Object.values(result)) { + expect(color).toMatch(HSL_REGEX); + } + }); + + it('should assign colors by array index order', () => { + const names = ['First', 'Second', 'Third']; + const result = buildParticipantColorMap(names, darkTheme); + expect(result['First']).toBe(generateParticipantColor(0, darkTheme)); + expect(result['Second']).toBe(generateParticipantColor(1, darkTheme)); + expect(result['Third']).toBe(generateParticipantColor(2, darkTheme)); + }); + + it('should return empty object for empty array', () => { + const result = buildParticipantColorMap([], darkTheme); + expect(result).toEqual({}); + }); + + it('should handle duplicate names (last wins)', () => { + const names = ['Alice', 'Bob', 'Alice']; + const result = buildParticipantColorMap(names, darkTheme); + // forEach overwrites, so Alice gets index 2's color + expect(result['Alice']).toBe(generateParticipantColor(2, darkTheme)); + expect(Object.keys(result).length).toBe(2); // Only Alice and Bob + }); + + it('should handle participants exceeding palette size', () => { + const names = Array.from({ length: 15 }, (_, i) => `Participant${i}`); + const result = buildParticipantColorMap(names, darkTheme); + expect(Object.keys(result).length).toBe(15); + // All should be valid HSL + for (const color of Object.values(result)) { + expect(color).toMatch(HSL_REGEX); + } + }); + }); + + describe('buildParticipantColorMapWithPreferences', () => { + it('should assign Moderator to index 0 (blue)', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + expect(colors['Moderator']).toBe( + generateParticipantColor(MODERATOR_COLOR_INDEX, darkTheme) + ); + }); + + it('should identify Moderator only when name is "Moderator" and no sessionPath', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator', sessionPath: '/path/mod' }, // has sessionPath, not the real Moderator + { name: 'Alice', sessionPath: '/path/alice' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // "Moderator" with sessionPath is NOT treated as the Moderator + // It should get a regular non-zero index + expect(colors['Moderator']).not.toBe( + generateParticipantColor(MODERATOR_COLOR_INDEX, darkTheme) + ); + }); + + it('should respect existing preferences for participants', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + const preferences = { '/path/alice': 5 }; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + preferences + ); + expect(colors['Alice']).toBe(generateParticipantColor(5, darkTheme)); + }); + + it('should not allow non-moderator to claim index 0 via preferences', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + ]; + // Alice has a preference for index 0 (Moderator's reserved index) + const preferences = { '/path/alice': 0 }; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + preferences + ); + // Alice should NOT get index 0 + expect(colors['Alice']).not.toBe( + generateParticipantColor(MODERATOR_COLOR_INDEX, darkTheme) + ); + // Moderator should still have index 0 + expect(colors['Moderator']).toBe( + generateParticipantColor(MODERATOR_COLOR_INDEX, darkTheme) + ); + }); + + it('should assign remaining participants from index 1 onward', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // Alice gets index 1, Bob gets index 2 + expect(colors['Alice']).toBe(generateParticipantColor(1, darkTheme)); + expect(colors['Bob']).toBe(generateParticipantColor(2, darkTheme)); + }); + + it('should skip already-used indices when assigning remaining participants', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + { name: 'Charlie', sessionPath: '/path/charlie' }, + ]; + // Alice has preference for index 2 + const preferences = { '/path/alice': 2 }; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + preferences + ); + expect(colors['Alice']).toBe(generateParticipantColor(2, darkTheme)); + // Bob should get 1 (first available after 0), Charlie should get 3 (2 is used) + expect(colors['Bob']).toBe(generateParticipantColor(1, darkTheme)); + expect(colors['Charlie']).toBe(generateParticipantColor(3, darkTheme)); + }); + + it('should return newPreferences only for participants without prior preferences', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + const preferences = { '/path/alice': 3 }; + const { newPreferences } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + preferences + ); + // Only Bob should appear in newPreferences (Alice already had a preference) + expect(newPreferences['/path/bob']).toBeDefined(); + expect(newPreferences['/path/alice']).toBeUndefined(); + }); + + it('should not include Moderator in newPreferences', () => { + const participants: ParticipantColorInfo[] = [{ name: 'Moderator' }]; + const { newPreferences } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + expect(Object.keys(newPreferences).length).toBe(0); + }); + + it('should handle participants without sessionPath (no preference saved)', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice' }, // no sessionPath + ]; + const { colors, newPreferences } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // Alice should still get a color + expect(colors['Alice']).toMatch(HSL_REGEX); + // But no preference is saved for her (no sessionPath) + expect(Object.keys(newPreferences).length).toBe(0); + }); + + it('should handle no Moderator in participants', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // Without Moderator, index 0 is still skipped (nextIndex starts at 1) + expect(colors['Alice']).toBe(generateParticipantColor(1, darkTheme)); + expect(colors['Bob']).toBe(generateParticipantColor(2, darkTheme)); + }); + + it('should handle empty participants array', () => { + const { colors, newPreferences } = buildParticipantColorMapWithPreferences( + [], + darkTheme, + {} + ); + expect(colors).toEqual({}); + expect(newPreferences).toEqual({}); + }); + + it('should handle duplicate participant names (first assignment wins)', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice1' }, + { name: 'Alice', sessionPath: '/path/alice2' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // First Alice gets index 1; second Alice is skipped because colors['Alice'] already exists + expect(colors['Alice']).toBe(generateParticipantColor(1, darkTheme)); + }); + + it('should handle conflicting preferences (first valid claim wins)', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + // Both prefer index 3 + const preferences = { '/path/alice': 3, '/path/bob': 3 }; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + preferences + ); + // Alice gets index 3 first + expect(colors['Alice']).toBe(generateParticipantColor(3, darkTheme)); + // Bob can't use 3, falls through to second pass + expect(colors['Bob']).not.toBe(generateParticipantColor(3, darkTheme)); + }); + + it('should use light theme when provided', () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + ]; + const { colors } = buildParticipantColorMapWithPreferences( + participants, + lightTheme, + {} + ); + expect(colors['Moderator']).toBe( + generateParticipantColor(MODERATOR_COLOR_INDEX, lightTheme) + ); + expect(colors['Alice']).toBe(generateParticipantColor(1, lightTheme)); + }); + + it('should handle many participants beyond palette size', () => { + const participants: ParticipantColorInfo[] = [{ name: 'Moderator' }]; + for (let i = 0; i < 25; i++) { + participants.push({ name: `Agent${i}`, sessionPath: `/path/agent${i}` }); + } + const { colors } = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + // All 26 participants should have colors + expect(Object.keys(colors).length).toBe(26); + // All colors should be valid HSL + for (const color of Object.values(colors)) { + expect(color).toMatch(HSL_REGEX); + } + // All colors beyond palette size should still be unique within their round + const colorValues = Object.values(colors); + const uniqueColors = new Set(colorValues); + expect(uniqueColors.size).toBe(colorValues.length); + }); + }); + + describe('loadColorPreferences', () => { + let originalMaestro: typeof window.maestro; + + beforeEach(() => { + originalMaestro = window.maestro; + // @ts-expect-error - mock partial maestro object + window.maestro = { + settings: { + get: vi.fn(), + set: vi.fn(), + }, + }; + }); + + afterEach(() => { + window.maestro = originalMaestro; + }); + + it('should return stored preferences', async () => { + const storedPrefs = { '/path/alice': 3, '/path/bob': 5 }; + vi.mocked(window.maestro.settings.get).mockResolvedValue(storedPrefs); + + const result = await loadColorPreferences(); + expect(result).toEqual(storedPrefs); + expect(window.maestro.settings.get).toHaveBeenCalledWith('groupChatColorPreferences'); + }); + + it('should return empty object if no preferences stored', async () => { + vi.mocked(window.maestro.settings.get).mockResolvedValue(null); + + const result = await loadColorPreferences(); + expect(result).toEqual({}); + }); + + it('should return empty object if undefined is returned', async () => { + vi.mocked(window.maestro.settings.get).mockResolvedValue(undefined); + + const result = await loadColorPreferences(); + expect(result).toEqual({}); + }); + + it('should return empty object on error', async () => { + vi.mocked(window.maestro.settings.get).mockRejectedValue( + new Error('Settings unavailable') + ); + + const result = await loadColorPreferences(); + expect(result).toEqual({}); + }); + }); + + describe('saveColorPreferences', () => { + let originalMaestro: typeof window.maestro; + + beforeEach(() => { + originalMaestro = window.maestro; + // @ts-expect-error - mock partial maestro object + window.maestro = { + settings: { + get: vi.fn(), + set: vi.fn().mockResolvedValue(undefined), + }, + }; + }); + + afterEach(() => { + window.maestro = originalMaestro; + }); + + it('should call settings.set with the correct key and preferences', async () => { + const prefs = { '/path/alice': 3, '/path/bob': 5 }; + await saveColorPreferences(prefs); + + expect(window.maestro.settings.set).toHaveBeenCalledWith( + 'groupChatColorPreferences', + prefs + ); + }); + + it('should save empty preferences', async () => { + await saveColorPreferences({}); + + expect(window.maestro.settings.set).toHaveBeenCalledWith( + 'groupChatColorPreferences', + {} + ); + }); + + it('should propagate errors from settings.set', async () => { + vi.mocked(window.maestro.settings.set).mockRejectedValue( + new Error('Write failed') + ); + + await expect(saveColorPreferences({ '/path/alice': 1 })).rejects.toThrow( + 'Write failed' + ); + }); + }); + + describe('integration: round-trip preferences', () => { + let originalMaestro: typeof window.maestro; + let mockStore: Record; + + beforeEach(() => { + originalMaestro = window.maestro; + mockStore = {}; + // @ts-expect-error - mock partial maestro object + window.maestro = { + settings: { + get: vi.fn((key: string) => Promise.resolve(mockStore[key] ?? null)), + set: vi.fn((key: string, value: unknown) => { + mockStore[key] = value; + return Promise.resolve(); + }), + }, + }; + }); + + afterEach(() => { + window.maestro = originalMaestro; + }); + + it('should persist and retrieve preferences across calls', async () => { + const prefs = { '/path/alice': 3, '/path/bob': 7 }; + await saveColorPreferences(prefs); + const loaded = await loadColorPreferences(); + expect(loaded).toEqual(prefs); + }); + + it('full workflow: build colors, save new prefs, reload and rebuild', async () => { + const participants: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + + // First build: no existing preferences + const first = buildParticipantColorMapWithPreferences( + participants, + darkTheme, + {} + ); + expect(first.colors['Alice']).toBe(generateParticipantColor(1, darkTheme)); + expect(first.colors['Bob']).toBe(generateParticipantColor(2, darkTheme)); + + // Save new preferences + await saveColorPreferences(first.newPreferences); + + // Load preferences back + const loadedPrefs = await loadColorPreferences(); + + // Second build: with preferences, add a new participant + const participants2: ParticipantColorInfo[] = [ + { name: 'Moderator' }, + { name: 'Alice', sessionPath: '/path/alice' }, + { name: 'Charlie', sessionPath: '/path/charlie' }, + { name: 'Bob', sessionPath: '/path/bob' }, + ]; + const second = buildParticipantColorMapWithPreferences( + participants2, + darkTheme, + loadedPrefs + ); + + // Alice and Bob should retain their preferred colors from first build + expect(second.colors['Alice']).toBe(first.colors['Alice']); + expect(second.colors['Bob']).toBe(first.colors['Bob']); + // Charlie should get a new color that doesn't conflict + expect(second.colors['Charlie']).toMatch(HSL_REGEX); + expect(second.colors['Charlie']).not.toBe(second.colors['Alice']); + expect(second.colors['Charlie']).not.toBe(second.colors['Bob']); + expect(second.colors['Charlie']).not.toBe(second.colors['Moderator']); + }); + }); +}); diff --git a/src/__tests__/renderer/utils/tabExport.test.ts b/src/__tests__/renderer/utils/tabExport.test.ts new file mode 100644 index 00000000..7ca4b167 --- /dev/null +++ b/src/__tests__/renderer/utils/tabExport.test.ts @@ -0,0 +1,935 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('marked', () => ({ + marked: { + parse: (text: string) => `

${text}

`, + setOptions: vi.fn(), + }, +})); + +import { generateTabExportHtml } from '../../../renderer/utils/tabExport'; +import type { AITab, LogEntry, Theme } from '../../../renderer/types'; + +// Mock theme for testing +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#1e1f29', + border: '#44475a', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: 'rgba(189, 147, 249, 0.1)', + accentText: '#bd93f9', + accentForeground: '#282a36', + success: '#50fa7b', + warning: '#f1fa8c', + error: '#ff5555', + }, +}; + +const mockSession = { + name: 'My Session', + cwd: '/home/user/project', + toolType: 'claude-code', +}; + +function createLogEntry(overrides?: Partial): LogEntry { + return { + id: `log-${Math.random().toString(36).slice(2, 8)}`, + timestamp: Date.now(), + source: 'user', + text: 'Hello world', + ...overrides, + }; +} + +function createMockTab(overrides?: Partial): AITab { + return { + id: 'tab-001', + agentSessionId: 'abc12345-def6-7890-ghij-klmnopqrstuv', + name: 'Test Tab', + starred: false, + logs: [], + inputValue: '', + stagedImages: [], + createdAt: 1703116800000, // 2023-12-21T00:00:00.000Z + state: 'idle', + ...overrides, + }; +} + +describe('tabExport', () => { + describe('generateTabExportHtml', () => { + describe('basic HTML structure', () => { + it('returns valid HTML with DOCTYPE, head, and body', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('includes meta charset and viewport tags', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + expect(html).toContain(' { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + }); + }); + + describe('tab name display and fallbacks', () => { + it('includes tab name in title and header when name is provided', () => { + const tab = createMockTab({ name: 'My Custom Tab' }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('My Custom Tab - Maestro Tab Export'); + // Also check the header h1 + expect(html).toContain('My Custom Tab'); + }); + + it('falls back to session ID prefix when no name is provided', () => { + const tab = createMockTab({ + name: null, + agentSessionId: 'abc12345-def6-7890-ghij-klmnopqrstuv', + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Session ABC12345'); + expect(html).toContain( + 'Session ABC12345 - Maestro Tab Export' + ); + }); + + it('falls back to "New Session" when no name or session ID', () => { + const tab = createMockTab({ name: null, agentSessionId: null }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('New Session'); + expect(html).toContain('New Session - Maestro Tab Export'); + }); + }); + + describe('theme colors', () => { + it('applies theme colors as CSS variables', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('--bg-primary: #282a36'); + expect(html).toContain('--bg-secondary: #21222c'); + expect(html).toContain('--bg-tertiary: #1e1f29'); + expect(html).toContain('--text-primary: #f8f8f2'); + expect(html).toContain('--text-secondary: #6272a4'); + expect(html).toContain('--text-dim: #6272a4'); + expect(html).toContain('--border: #44475a'); + expect(html).toContain('--accent: #bd93f9'); + expect(html).toContain('--accent-dim: rgba(189, 147, 249, 0.1)'); + expect(html).toContain('--success: #50fa7b'); + expect(html).toContain('--warning: #f1fa8c'); + expect(html).toContain('--error: #ff5555'); + }); + + it('uses different theme colors when provided', () => { + const lightTheme: Theme = { + id: 'github-light', + name: 'GitHub Light', + mode: 'light', + colors: { + bgMain: '#ffffff', + bgSidebar: '#f6f8fa', + bgActivity: '#f0f0f0', + border: '#d0d7de', + textMain: '#24292f', + textDim: '#57606a', + accent: '#0969da', + accentDim: 'rgba(9, 105, 218, 0.1)', + accentText: '#0969da', + accentForeground: '#ffffff', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e', + }, + }; + + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, lightTheme); + + expect(html).toContain('--bg-primary: #ffffff'); + expect(html).toContain('--accent: #0969da'); + expect(html).toContain('Theme: GitHub Light'); + }); + + it('includes theme name in footer', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Theme: Dracula'); + }); + }); + + describe('message rendering', () => { + it('renders user messages with message-user class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-user'); + }); + + it('renders AI messages with message-agent class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'ai', text: 'Response' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + }); + + it('renders stdout messages with message-agent class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'stdout', text: 'Output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + }); + + it('renders error messages with message-agent class (not user)', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'error', text: 'Error occurred' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + // Error messages should NOT have message-user + expect(html).not.toMatch(/class="message message-user"[^>]*>.*Error occurred/s); + }); + + it('renders system messages with message-agent class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'system', text: 'System info' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + }); + + it('renders thinking messages with message-agent class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'thinking', text: 'Reasoning...' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + }); + + it('renders tool messages with message-agent class', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'tool', text: 'Tool output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('message-agent'); + }); + + it('shows read-only badge for readOnly entries', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Query', readOnly: true })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('read-only-badge'); + expect(html).toContain('read-only'); + }); + + it('does not show read-only badge when readOnly is false or absent', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Normal message', readOnly: false })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // The CSS class definition for read-only-badge will exist in the style section, + // but the actual badge element should not appear in the message HTML + expect(html).not.toContain('read-only'); + }); + + it('displays correct source labels for each source type', () => { + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'User msg' }), + createLogEntry({ source: 'ai', text: 'AI msg' }), + createLogEntry({ source: 'error', text: 'Error msg' }), + createLogEntry({ source: 'system', text: 'System msg' }), + createLogEntry({ source: 'thinking', text: 'Thinking msg' }), + createLogEntry({ source: 'tool', text: 'Tool msg' }), + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('User'); + expect(html).toContain('AI'); + expect(html).toContain('Error'); + expect(html).toContain('System'); + expect(html).toContain('Thinking'); + expect(html).toContain('Tool'); + }); + + it('uses theme accent color for user messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.accent}"`); + }); + + it('uses theme success color for AI messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'ai', text: 'Reply' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.success}"`); + }); + + it('uses theme error color for error messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'error', text: 'Fail' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.error}"`); + }); + + it('uses theme warning color for system messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'system', text: 'Info' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.warning}"`); + }); + + it('uses theme textDim color for thinking messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'thinking', text: 'Hmm' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.textDim}"`); + }); + + it('uses theme accentDim color for tool messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'tool', text: 'Running' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.accentDim}"`); + }); + }); + + describe('stats grid', () => { + it('shows correct total message count', () => { + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Q1' }), + createLogEntry({ source: 'ai', text: 'A1' }), + createLogEntry({ source: 'user', text: 'Q2' }), + createLogEntry({ source: 'ai', text: 'A2' }), + createLogEntry({ source: 'system', text: 'Info' }), + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // Total messages = 5 + expect(html).toContain('
5
'); + }); + + it('shows correct user message count', () => { + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Q1' }), + createLogEntry({ source: 'ai', text: 'A1' }), + createLogEntry({ source: 'user', text: 'Q2' }), + createLogEntry({ source: 'ai', text: 'A2' }), + createLogEntry({ source: 'user', text: 'Q3' }), + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // User messages = 3 + expect(html).toContain('
3
'); + }); + + it('counts AI messages including stdout source', () => { + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'ai', text: 'AI msg' }), + createLogEntry({ source: 'stdout', text: 'Stdout msg' }), + createLogEntry({ source: 'error', text: 'Error msg' }), + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // AI messages = 2 (ai + stdout) + expect(html).toContain('
2
'); + }); + + it('displays stat labels', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Q' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Messages'); + expect(html).toContain('User'); + expect(html).toContain('AI'); + expect(html).toContain('Duration'); + }); + + it('shows zero counts for empty logs', () => { + const tab = createMockTab({ logs: [] }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
0
'); + }); + }); + + describe('duration calculation', () => { + it('shows 0m for fewer than 2 log entries', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Alone' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
0m
'); + }); + + it('shows 0m for empty logs', () => { + const tab = createMockTab({ logs: [] }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
0m
'); + }); + + it('shows minutes format for durations under 1 hour', () => { + const baseTime = 1703116800000; + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Start', timestamp: baseTime }), + createLogEntry({ + source: 'ai', + text: 'End', + timestamp: baseTime + 25 * 60 * 1000, + }), // +25 minutes + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
25m
'); + }); + + it('shows hours and minutes format for durations over 1 hour', () => { + const baseTime = 1703116800000; + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Start', timestamp: baseTime }), + createLogEntry({ + source: 'ai', + text: 'End', + timestamp: baseTime + 2 * 60 * 60 * 1000 + 30 * 60 * 1000, + }), // +2h 30m + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
2h 30m
'); + }); + + it('shows exact hour with 0 remaining minutes', () => { + const baseTime = 1703116800000; + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Start', timestamp: baseTime }), + createLogEntry({ + source: 'ai', + text: 'End', + timestamp: baseTime + 3 * 60 * 60 * 1000, + }), // +3h exactly + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('
3h 0m
'); + }); + }); + + describe('usage stats formatting', () => { + it('shows N/A when usageStats is undefined', () => { + const tab = createMockTab({ usageStats: undefined }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('N/A'); + }); + + it('formats token counts with locale separators', () => { + const tab = createMockTab({ + usageStats: { + inputTokens: 12345, + outputTokens: 6789, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.0512, + contextWindow: 200000, + }, + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // toLocaleString() formats with separators + expect(html).toContain('12,345 input'); + expect(html).toContain('6,789 output'); + }); + + it('formats cost with 4 decimal places', () => { + const tab = createMockTab({ + usageStats: { + inputTokens: 100, + outputTokens: 200, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.1, + contextWindow: 200000, + }, + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('$0.1000'); + }); + + it('shows N/A when all stats values are zero/falsy', () => { + const tab = createMockTab({ + usageStats: { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0, + contextWindow: 200000, + }, + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // All values are 0 (falsy), so all parts are skipped -> N/A + expect(html).toContain('N/A'); + }); + + it('joins parts with middle dot separator', () => { + const tab = createMockTab({ + usageStats: { + inputTokens: 500, + outputTokens: 300, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: 0.05, + contextWindow: 200000, + }, + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // Parts joined with ' \u00b7 ' (middle dot) + expect(html).toMatch(/500 input .+ 300 output .+ \$0\.0500/); + }); + }); + + describe('HTML escaping', () => { + it('escapes special characters in tab name', () => { + const tab = createMockTab({ name: 'Tab ' }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + expect(html).toContain('"xss"'); + }); + + it('escapes special characters in session name', () => { + const session = { ...mockSession, name: 'Session bold' }; + const tab = createMockTab(); + const html = generateTabExportHtml(tab, session, mockTheme); + + expect(html).toContain('Session <b>bold</b>'); + }); + + it('escapes special characters in working directory', () => { + const session = { ...mockSession, cwd: '/path/with "quotes" & ' }; + const tab = createMockTab(); + const html = generateTabExportHtml(tab, session, mockTheme); + + expect(html).toContain('&'); + expect(html).toContain('<brackets>'); + expect(html).toContain('"quotes"'); + }); + + it('escapes special characters in tool type', () => { + const session = { ...mockSession, toolType: 'agent' }; + const tab = createMockTab(); + const html = generateTabExportHtml(tab, session, mockTheme); + + expect(html).toContain('agent<type>'); + }); + + it('escapes ampersands correctly', () => { + const tab = createMockTab({ name: 'Tab & More' }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Tab & More'); + }); + + it('escapes single quotes', () => { + const tab = createMockTab({ name: "Tab's Name" }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Tab's Name'); + }); + + it('escapes source labels in messages', () => { + // Source labels are already fixed strings (User, AI, etc.), + // but the escapeHtml call wraps them - verify it does not break + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('User'); + }); + }); + + describe('session details section', () => { + it('includes agent type in details', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Agent'); + expect(html).toContain('claude-code'); + }); + + it('includes working directory in details', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Working Directory'); + expect(html).toContain('/home/user/project'); + }); + + it('includes session name in details', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Session Name'); + expect(html).toContain('My Session'); + }); + + it('includes created timestamp in details', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Created'); + }); + + it('includes usage stats in details', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Usage'); + }); + + it('includes session ID when agentSessionId is provided', () => { + const tab = createMockTab({ + agentSessionId: 'session-abc-def-123', + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Session ID'); + expect(html).toContain('session-abc-def-123'); + }); + + it('omits session ID row when agentSessionId is null', () => { + const tab = createMockTab({ agentSessionId: null }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // Session ID label should not appear + expect(html).not.toContain('>Session ID<'); + }); + + it('renders details section with correct CSS classes', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="section-title"'); + expect(html).toContain('class="info-grid"'); + expect(html).toContain('class="info-label"'); + expect(html).toContain('class="info-value"'); + }); + }); + + describe('markdown content rendering', () => { + it('passes message text through marked for rendering', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'ai', text: '**bold text**' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // Our mock wraps in

tags + expect(html).toContain('

**bold text**

'); + }); + + it('renders content inside message-content div', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Some content' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="message-content"'); + expect(html).toContain('

Some content

'); + }); + }); + + describe('branding section', () => { + it('includes Maestro branding section', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="branding"'); + expect(html).toContain('Maestro'); + }); + + it('includes tagline about multi-agent orchestration', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Multi-agent orchestration'); + }); + + it('includes runmaestro.ai link', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('href="https://runmaestro.ai"'); + expect(html).toContain('runmaestro.ai'); + }); + + it('includes GitHub link', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('href="https://github.com/pedramamini/Maestro"'); + expect(html).toContain('GitHub'); + }); + + it('includes Maestro logo image', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="branding-logo"'); + expect(html).toContain('data:image/png;base64,'); + }); + }); + + describe('footer', () => { + it('includes Maestro attribution with runmaestro.ai link', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="footer"'); + expect(html).toContain('Exported from'); + expect(html).toContain('href="https://runmaestro.ai"'); + }); + + it('includes theme name in footer', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="footer-theme"'); + expect(html).toContain('Theme: Dracula'); + }); + }); + + describe('edge cases', () => { + it('handles empty logs array', () => { + const tab = createMockTab({ logs: [] }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('handles unicode characters in messages', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello! Cafe ☕ 日本語' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Cafe'); + expect(html).toContain('☕'); + expect(html).toContain('日本語'); + }); + + it('handles very long messages', () => { + const longContent = 'A'.repeat(10000); + const tab = createMockTab({ + logs: [createLogEntry({ source: 'ai', text: longContent })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(longContent); + }); + + it('handles mixed source types in same conversation', () => { + const tab = createMockTab({ + logs: [ + createLogEntry({ source: 'user', text: 'Question' }), + createLogEntry({ source: 'thinking', text: 'Reasoning' }), + createLogEntry({ source: 'tool', text: 'Tool call' }), + createLogEntry({ source: 'ai', text: 'Answer' }), + createLogEntry({ source: 'error', text: 'Error' }), + createLogEntry({ source: 'system', text: 'System' }), + createLogEntry({ source: 'stderr', text: 'Stderr' }), + createLogEntry({ source: 'stdout', text: 'Stdout' }), + ], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + // All 8 messages should be rendered + const messageMatches = html.match(/class="message /g); + expect(messageMatches).toHaveLength(8); + }); + + it('handles tab with all optional fields missing', () => { + const tab = createMockTab({ + name: null, + agentSessionId: null, + usageStats: undefined, + logs: [], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(''); + expect(html).toContain('New Session'); + }); + }); + + describe('CSS responsiveness', () => { + it('includes mobile media query', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('@media (max-width: 640px)'); + }); + + it('includes print media query', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('@media print'); + }); + }); + + describe('conversation section', () => { + it('includes conversation section header', () => { + const tab = createMockTab(); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Conversation'); + }); + + it('renders messages container', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="messages"'); + }); + + it('includes message timestamps', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="message-time"'); + }); + + it('includes message headers with from label', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'user', text: 'Hello' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('class="message-header"'); + expect(html).toContain('class="message-from"'); + }); + }); + + describe('stderr source handling', () => { + it('renders stderr with error color', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'stderr', text: 'stderr output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.error}"`); + }); + + it('labels stderr as Error', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'stderr', text: 'stderr output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain('Error'); + }); + }); + + describe('stdout source handling', () => { + it('renders stdout with success color', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'stdout', text: 'stdout output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + expect(html).toContain(`style="color: ${mockTheme.colors.success}"`); + }); + + it('labels stdout as AI', () => { + const tab = createMockTab({ + logs: [createLogEntry({ source: 'stdout', text: 'stdout output' })], + }); + const html = generateTabExportHtml(tab, mockSession, mockTheme); + + // stdout gets labeled as 'AI' + expect(html).toContain('>AI<'); + }); + }); + }); +}); diff --git a/src/__tests__/renderer/utils/textProcessing.test.ts b/src/__tests__/renderer/utils/textProcessing.test.ts new file mode 100644 index 00000000..f96ef202 --- /dev/null +++ b/src/__tests__/renderer/utils/textProcessing.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('dompurify', () => ({ + default: { sanitize: vi.fn((html: string) => html) }, +})); + +import { + processCarriageReturns, + processLogTextHelper, + filterTextByLinesHelper, + stripMarkdown, + ANSI_CACHE_MAX_SIZE, + getCachedAnsiHtml, + clearAnsiCache, +} from '../../../renderer/utils/textProcessing'; + +// ============================================================================ +// processCarriageReturns +// ============================================================================ + +describe('processCarriageReturns', () => { + it('returns text unchanged when there are no carriage returns', () => { + expect(processCarriageReturns('hello world')).toBe('hello world'); + }); + + it('handles empty string', () => { + expect(processCarriageReturns('')).toBe(''); + }); + + it('replaces line content with text after a single \\r', () => { + expect(processCarriageReturns('old text\rnew text')).toBe('new text'); + }); + + it('takes the last non-empty segment when multiple \\r are present on the same line', () => { + expect(processCarriageReturns('first\rsecond\rthird')).toBe('third'); + }); + + it('skips empty trailing segments to find the last non-empty one', () => { + // "first\rsecond\r" -> segments: ["first", "second", ""] + // last non-empty is "second" + expect(processCarriageReturns('first\rsecond\r')).toBe('second'); + }); + + it('returns empty string when all segments after \\r are empty or whitespace-only', () => { + // "\r\r" -> segments: ["", "", ""] + // all are empty/whitespace -> returns "" + expect(processCarriageReturns('\r\r')).toBe(''); + }); + + it('returns empty string for a single \\r', () => { + expect(processCarriageReturns('\r')).toBe(''); + }); + + it('handles mixed lines with and without carriage returns', () => { + const input = 'line one\nold\rnew\nline three'; + expect(processCarriageReturns(input)).toBe('line one\nnew\nline three'); + }); + + it('preserves newlines between processed lines', () => { + const input = 'a\nb\nc'; + expect(processCarriageReturns(input)).toBe('a\nb\nc'); + }); + + it('handles carriage returns on multiple lines independently', () => { + const input = 'x\ry\nfoo\rbar\rbaz'; + // Line 1: "x\ry" -> last non-empty is "y" + // Line 2: "foo\rbar\rbaz" -> last non-empty is "baz" + expect(processCarriageReturns(input)).toBe('y\nbaz'); + }); + + it('simulates progress indicator overwrites', () => { + const input = 'Progress: 10%\rProgress: 50%\rProgress: 100%'; + expect(processCarriageReturns(input)).toBe('Progress: 100%'); + }); +}); + +// ============================================================================ +// processLogTextHelper +// ============================================================================ + +describe('processLogTextHelper', () => { + describe('non-terminal mode', () => { + it('returns carriage-return-processed text without filtering', () => { + const input = 'old\rnew\n\n$\nbash-5.2$'; + const result = processLogTextHelper(input, false); + // Should apply CR processing but NOT filter prompts or empty lines + expect(result).toBe('new\n\n$\nbash-5.2$'); + }); + + it('returns empty lines intact in non-terminal mode', () => { + const input = 'line1\n\n\nline2'; + expect(processLogTextHelper(input, false)).toBe('line1\n\n\nline2'); + }); + }); + + describe('terminal mode', () => { + it('filters out empty lines', () => { + const input = 'line1\n\n\nline2'; + expect(processLogTextHelper(input, true)).toBe('line1\nline2'); + }); + + it('filters out bash prompt "bash-5.2$"', () => { + const input = 'output\nbash-5.2$\nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out bash prompt with trailing space "bash-5.2$ "', () => { + const input = 'output\nbash-5.2$ \nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out zsh% prompt', () => { + const input = 'output\nzsh%\nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out zsh# prompt', () => { + const input = 'output\nzsh#\nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out standalone $ prompt', () => { + const input = 'output\n$\nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out standalone # prompt', () => { + const input = 'output\n#\nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('filters out prompts with leading whitespace', () => { + const input = 'output\n $ \nmore output'; + expect(processLogTextHelper(input, true)).toBe('output\nmore output'); + }); + + it('keeps lines that contain prompts as part of other text', () => { + const input = 'echo $ hello\nprice is $5\nbash-5.2$ echo hello'; + // These lines have content beyond just the prompt pattern + expect(processLogTextHelper(input, true)).toBe( + 'echo $ hello\nprice is $5\nbash-5.2$ echo hello' + ); + }); + + it('filters combination of prompts and empty lines, keeps real output', () => { + const input = 'bash-5.2$\n\nreal output\n$\n\nzsh%\nanother line\n#'; + expect(processLogTextHelper(input, true)).toBe('real output\nanother line'); + }); + + it('applies carriage return processing before filtering', () => { + const input = 'old\rnew\nbash-5.2$\n\nkeep this'; + expect(processLogTextHelper(input, true)).toBe('new\nkeep this'); + }); + + it('returns empty string when all lines are prompts or empty', () => { + const input = 'bash-5.2$\n\n$\n#\nzsh%'; + expect(processLogTextHelper(input, true)).toBe(''); + }); + + it('filters bash prompts with different version numbers', () => { + const input = 'bash-4.4$\nbash-5.2$\nbash-3.0$'; + expect(processLogTextHelper(input, true)).toBe(''); + }); + }); +}); + +// ============================================================================ +// filterTextByLinesHelper +// ============================================================================ + +describe('filterTextByLinesHelper', () => { + const sampleText = 'Error: file not found\nWarning: low memory\nInfo: process started\nError: timeout'; + + describe('empty query', () => { + it('returns original text when query is empty string', () => { + expect(filterTextByLinesHelper(sampleText, '', 'include', false)).toBe(sampleText); + }); + + it('returns original text when query is empty in exclude mode', () => { + expect(filterTextByLinesHelper(sampleText, '', 'exclude', false)).toBe(sampleText); + }); + + it('returns original text when query is empty with regex enabled', () => { + expect(filterTextByLinesHelper(sampleText, '', 'include', true)).toBe(sampleText); + }); + }); + + describe('include mode - plain text', () => { + it('keeps lines containing the query (case-insensitive)', () => { + const result = filterTextByLinesHelper(sampleText, 'error', 'include', false); + expect(result).toBe('Error: file not found\nError: timeout'); + }); + + it('is case-insensitive', () => { + const result = filterTextByLinesHelper(sampleText, 'ERROR', 'include', false); + expect(result).toBe('Error: file not found\nError: timeout'); + }); + + it('matches partial words', () => { + const result = filterTextByLinesHelper(sampleText, 'warn', 'include', false); + expect(result).toBe('Warning: low memory'); + }); + + it('returns empty when no lines match', () => { + const result = filterTextByLinesHelper(sampleText, 'xyz', 'include', false); + expect(result).toBe(''); + }); + }); + + describe('exclude mode - plain text', () => { + it('removes lines containing the query', () => { + const result = filterTextByLinesHelper(sampleText, 'error', 'exclude', false); + expect(result).toBe('Warning: low memory\nInfo: process started'); + }); + + it('is case-insensitive in exclude mode', () => { + const result = filterTextByLinesHelper(sampleText, 'WARNING', 'exclude', false); + expect(result).toBe('Error: file not found\nInfo: process started\nError: timeout'); + }); + + it('returns all lines when no lines match the exclude query', () => { + const result = filterTextByLinesHelper(sampleText, 'xyz', 'exclude', false); + expect(result).toBe(sampleText); + }); + }); + + describe('include mode - regex', () => { + it('filters using a valid regex pattern', () => { + const result = filterTextByLinesHelper(sampleText, '^Error', 'include', true); + expect(result).toBe('Error: file not found\nError: timeout'); + }); + + it('supports regex special characters', () => { + const result = filterTextByLinesHelper(sampleText, 'Error.*timeout', 'include', true); + expect(result).toBe('Error: timeout'); + }); + + it('is case-insensitive in regex mode', () => { + const result = filterTextByLinesHelper(sampleText, 'error', 'include', true); + expect(result).toBe('Error: file not found\nError: timeout'); + }); + }); + + describe('exclude mode - regex', () => { + it('excludes lines matching regex pattern', () => { + const result = filterTextByLinesHelper(sampleText, '^(Error|Warning)', 'exclude', true); + expect(result).toBe('Info: process started'); + }); + }); + + describe('invalid regex fallback', () => { + it('falls back to plain text search when regex is invalid', () => { + // "[" is an invalid regex (unclosed bracket) + const text = 'line with [bracket\nline without\nanother [bracket] line'; + const result = filterTextByLinesHelper(text, '[bracket', 'include', true); + // Falls back to plain text includes() which matches "[bracket" + expect(result).toBe('line with [bracket\nanother [bracket] line'); + }); + + it('falls back to plain text search in exclude mode with invalid regex', () => { + const text = 'line with [bracket\nline without'; + const result = filterTextByLinesHelper(text, '[bracket', 'exclude', true); + expect(result).toBe('line without'); + }); + }); + + describe('multi-line filtering', () => { + it('handles single line text', () => { + expect(filterTextByLinesHelper('hello world', 'hello', 'include', false)).toBe( + 'hello world' + ); + }); + + it('handles text with many lines', () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`); + const text = lines.join('\n'); + const result = filterTextByLinesHelper(text, 'line 5', 'include', false); + // Should match "line 5", "line 50"-"line 59" + const resultLines = result.split('\n'); + expect(resultLines).toContain('line 5'); + expect(resultLines).toContain('line 50'); + expect(resultLines.length).toBe(11); // line 5, line 50-59 + }); + }); +}); + +// ============================================================================ +// stripMarkdown +// ============================================================================ + +describe('stripMarkdown', () => { + it('strips code blocks keeping content', () => { + const input = '```javascript\nconst x = 1;\nconsole.log(x);\n```'; + expect(stripMarkdown(input)).toBe('const x = 1;\nconsole.log(x);'); + }); + + it('strips code blocks with no language specifier', () => { + const input = '```\ncode here\n```'; + expect(stripMarkdown(input)).toBe('code here'); + }); + + it('strips inline code backticks', () => { + expect(stripMarkdown('use `npm install` to install')).toBe('use npm install to install'); + }); + + it('strips bold with double asterisks', () => { + expect(stripMarkdown('this is **bold** text')).toBe('this is bold text'); + }); + + it('strips bold with double underscores', () => { + expect(stripMarkdown('this is __bold__ text')).toBe('this is bold text'); + }); + + it('strips italic with single asterisks', () => { + expect(stripMarkdown('this is *italic* text')).toBe('this is italic text'); + }); + + it('strips italic with single underscores', () => { + expect(stripMarkdown('this is _italic_ text')).toBe('this is italic text'); + }); + + it('strips bold italic with triple asterisks', () => { + expect(stripMarkdown('this is ***bold italic*** text')).toBe('this is bold italic text'); + }); + + it('strips bold italic with triple underscores', () => { + expect(stripMarkdown('this is ___bold italic___ text')).toBe('this is bold italic text'); + }); + + it('strips headers of various levels', () => { + expect(stripMarkdown('# Header 1')).toBe('Header 1'); + expect(stripMarkdown('## Header 2')).toBe('Header 2'); + expect(stripMarkdown('### Header 3')).toBe('Header 3'); + expect(stripMarkdown('###### Header 6')).toBe('Header 6'); + }); + + it('strips blockquotes', () => { + expect(stripMarkdown('> This is a quote')).toBe('This is a quote'); + }); + + it('strips multi-line blockquotes', () => { + const input = '> line one\n> line two'; + expect(stripMarkdown(input)).toBe('line one\nline two'); + }); + + it('preserves horizontal rules as --- for dash-based rules', () => { + expect(stripMarkdown('---')).toBe('---'); + expect(stripMarkdown('----')).toBe('---'); + expect(stripMarkdown('----------')).toBe('---'); + }); + + it('transforms asterisk/underscore horizontal rules through bold/italic stripping first', () => { + // *** gets processed by *(.+?)* first (matches middle *), leaving * + // ___ gets processed by _(.+?)_ first (matches middle _), leaving _ + // This is expected behavior: the bold/italic regexes run before the HR regex + expect(stripMarkdown('***')).toBe('*'); + expect(stripMarkdown('___')).toBe('_'); + }); + + it('converts links to just the text', () => { + expect(stripMarkdown('[Click here](https://example.com)')).toBe('Click here'); + }); + + it('processes images: link regex runs first, leaving the ! prefix', () => { + // The link regex [text](url) matches inside ![alt](url) first, + // so ![Alt text](image.png) -> !Alt text (the image regex cannot match afterward) + expect(stripMarkdown('![Alt text](image.png)')).toBe('!Alt text'); + }); + + it('strips strikethrough', () => { + expect(stripMarkdown('this is ~~deleted~~ text')).toBe('this is deleted text'); + }); + + it('normalizes bullet points to dash prefix', () => { + expect(stripMarkdown('* item one')).toBe('- item one'); + expect(stripMarkdown('+ item two')).toBe('- item two'); + expect(stripMarkdown('- item three')).toBe('- item three'); + }); + + it('normalizes indented bullet points', () => { + expect(stripMarkdown(' * nested item')).toBe('- nested item'); + expect(stripMarkdown(' + deep nested')).toBe('- deep nested'); + }); + + it('normalizes numbered lists', () => { + expect(stripMarkdown('1. First item')).toBe('1. First item'); + expect(stripMarkdown(' 2. Second item')).toBe('2. Second item'); + }); + + it('handles a complex markdown document', () => { + const input = [ + '# Title', + '', + 'This is **bold** and *italic* text.', + '', + '> A blockquote', + '', + '```python', + 'print("hello")', + '```', + '', + '- Item 1', + '- Item 2', + '', + '[Link](https://example.com) and ![Image](pic.png)', + '', + '~~removed~~', + ].join('\n'); + + const expected = [ + 'Title', + '', + 'This is bold and italic text.', + '', + 'A blockquote', + '', + // Note: blank line between code block output and bullet list is consumed + // by the bullet point regex (^[\s]*[-*+]\s+ greedily matches preceding \n) + 'print("hello")', + '- Item 1', + '- Item 2', + '', + // Note: ![Image](pic.png) becomes !Image because link regex runs before image regex + 'Link and !Image', + '', + 'removed', + ].join('\n'); + + expect(stripMarkdown(input)).toBe(expected); + }); + + it('returns plain text unchanged', () => { + expect(stripMarkdown('just plain text')).toBe('just plain text'); + }); + + it('handles empty string', () => { + expect(stripMarkdown('')).toBe(''); + }); +}); + +// ============================================================================ +// ANSI Cache +// ============================================================================ + +describe('ANSI_CACHE_MAX_SIZE', () => { + it('equals 500', () => { + expect(ANSI_CACHE_MAX_SIZE).toBe(500); + }); +}); + +describe('getCachedAnsiHtml', () => { + let mockConverter: { toHtml: ReturnType }; + + beforeEach(() => { + clearAnsiCache(); + mockConverter = { + toHtml: vi.fn((text: string) => `${text}`), + }; + }); + + it('converts text and returns the result', () => { + const result = getCachedAnsiHtml('hello', 'dark', mockConverter as never); + expect(result).toBe('hello'); + expect(mockConverter.toHtml).toHaveBeenCalledWith('hello'); + }); + + it('returns cached result on second call with same text and theme', () => { + getCachedAnsiHtml('hello', 'dark', mockConverter as never); + const result = getCachedAnsiHtml('hello', 'dark', mockConverter as never); + expect(result).toBe('hello'); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(1); + }); + + it('creates separate cache entries for different themes', () => { + getCachedAnsiHtml('hello', 'dark', mockConverter as never); + getCachedAnsiHtml('hello', 'light', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(2); + }); + + it('creates separate cache entries for different texts', () => { + getCachedAnsiHtml('hello', 'dark', mockConverter as never); + getCachedAnsiHtml('world', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(2); + }); + + it('uses substring-based key for long texts (>200 chars)', () => { + const longText = 'A'.repeat(250); + const result = getCachedAnsiHtml(longText, 'dark', mockConverter as never); + expect(result).toBe(`${longText}`); + + // Second call should use cached result + const result2 = getCachedAnsiHtml(longText, 'dark', mockConverter as never); + expect(result2).toBe(`${longText}`); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(1); + }); + + it('differentiates long texts with different content but same length', () => { + // Two texts of same length but different first/last 100 chars + const text1 = 'A'.repeat(100) + 'X'.repeat(100) + 'B'.repeat(100); + const text2 = 'C'.repeat(100) + 'X'.repeat(100) + 'D'.repeat(100); + getCachedAnsiHtml(text1, 'dark', mockConverter as never); + getCachedAnsiHtml(text2, 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(2); + }); + + it('evicts oldest entry when cache exceeds max size', () => { + // Fill cache to max size + for (let i = 0; i < ANSI_CACHE_MAX_SIZE; i++) { + getCachedAnsiHtml(`text-${i}`, 'dark', mockConverter as never); + } + expect(mockConverter.toHtml).toHaveBeenCalledTimes(ANSI_CACHE_MAX_SIZE); + + // Add one more, which should evict the first entry ("text-0") + getCachedAnsiHtml('new-text', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(ANSI_CACHE_MAX_SIZE + 1); + + // "text-0" was evicted, so requesting it triggers a new conversion + getCachedAnsiHtml('text-0', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(ANSI_CACHE_MAX_SIZE + 2); + + // An entry near the end of the cache should still be cached + // "text-499" was the most recently added (before "new-text"), still present + getCachedAnsiHtml('text-499', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(ANSI_CACHE_MAX_SIZE + 2); + }); +}); + +describe('clearAnsiCache', () => { + let mockConverter: { toHtml: ReturnType }; + + beforeEach(() => { + clearAnsiCache(); + mockConverter = { + toHtml: vi.fn((text: string) => `${text}`), + }; + }); + + it('empties the cache so previously cached items are recomputed', () => { + getCachedAnsiHtml('hello', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(1); + + clearAnsiCache(); + + // Should need to recompute after clearing + getCachedAnsiHtml('hello', 'dark', mockConverter as never); + expect(mockConverter.toHtml).toHaveBeenCalledTimes(2); + }); + + it('can be called multiple times safely', () => { + clearAnsiCache(); + clearAnsiCache(); + clearAnsiCache(); + // Should not throw + }); +}); diff --git a/src/__tests__/shared/contextUsage.test.ts b/src/__tests__/shared/contextUsage.test.ts new file mode 100644 index 00000000..6c8020dc --- /dev/null +++ b/src/__tests__/shared/contextUsage.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for the Context Usage Estimation Utilities. + * + * These tests verify: + * - DEFAULT_CONTEXT_WINDOWS constant values + * - COMBINED_CONTEXT_AGENTS membership + * - calculateContextTokens() with various agent types and token fields + * - estimateContextUsage() percentage calculation, fallback logic, and capping + */ + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_CONTEXT_WINDOWS, + COMBINED_CONTEXT_AGENTS, + calculateContextTokens, + estimateContextUsage, + type ContextUsageStats, +} from '../../shared/contextUsage'; + +describe('DEFAULT_CONTEXT_WINDOWS', () => { + it('should have the correct context window for claude-code', () => { + expect(DEFAULT_CONTEXT_WINDOWS['claude-code']).toBe(200000); + }); + + it('should have the correct context window for codex', () => { + expect(DEFAULT_CONTEXT_WINDOWS['codex']).toBe(200000); + }); + + it('should have the correct context window for opencode', () => { + expect(DEFAULT_CONTEXT_WINDOWS['opencode']).toBe(128000); + }); + + it('should have the correct context window for factory-droid', () => { + expect(DEFAULT_CONTEXT_WINDOWS['factory-droid']).toBe(200000); + }); + + it('should have zero context window for terminal', () => { + expect(DEFAULT_CONTEXT_WINDOWS['terminal']).toBe(0); + }); + + it('should have entries for all expected agent types', () => { + const expectedKeys = ['claude-code', 'codex', 'opencode', 'factory-droid', 'terminal']; + expect(Object.keys(DEFAULT_CONTEXT_WINDOWS).sort()).toEqual(expectedKeys.sort()); + }); +}); + +describe('COMBINED_CONTEXT_AGENTS', () => { + it('should contain codex', () => { + expect(COMBINED_CONTEXT_AGENTS.has('codex')).toBe(true); + }); + + it('should not contain claude-code', () => { + expect(COMBINED_CONTEXT_AGENTS.has('claude-code')).toBe(false); + }); + + it('should not contain opencode', () => { + expect(COMBINED_CONTEXT_AGENTS.has('opencode')).toBe(false); + }); + + it('should not contain factory-droid', () => { + expect(COMBINED_CONTEXT_AGENTS.has('factory-droid')).toBe(false); + }); + + it('should not contain terminal', () => { + expect(COMBINED_CONTEXT_AGENTS.has('terminal')).toBe(false); + }); + + it('should have exactly one member', () => { + expect(COMBINED_CONTEXT_AGENTS.size).toBe(1); + }); +}); + +describe('calculateContextTokens', () => { + it('should calculate Claude-style tokens: input + cacheRead + cacheCreation (no output)', () => { + const stats: ContextUsageStats = { + inputTokens: 1000, + cacheReadInputTokens: 5000, + cacheCreationInputTokens: 2000, + outputTokens: 3000, + }; + const result = calculateContextTokens(stats, 'claude-code'); + expect(result).toBe(8000); // 1000 + 5000 + 2000, output excluded + }); + + it('should calculate Codex tokens: input + cacheRead + cacheCreation + output (combined)', () => { + const stats: ContextUsageStats = { + inputTokens: 1000, + cacheReadInputTokens: 5000, + cacheCreationInputTokens: 2000, + outputTokens: 3000, + }; + const result = calculateContextTokens(stats, 'codex'); + expect(result).toBe(11000); // 1000 + 5000 + 2000 + 3000 + }); + + it('should default missing token fields to 0', () => { + const stats: ContextUsageStats = { + inputTokens: 500, + }; + const result = calculateContextTokens(stats, 'claude-code'); + expect(result).toBe(500); // 500 + 0 + 0 + }); + + it('should handle all undefined token fields', () => { + const stats: ContextUsageStats = {}; + const result = calculateContextTokens(stats, 'claude-code'); + expect(result).toBe(0); + }); + + it('should use base formula for terminal agent', () => { + const stats: ContextUsageStats = { + inputTokens: 100, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 300, + outputTokens: 400, + }; + const result = calculateContextTokens(stats, 'terminal'); + expect(result).toBe(600); // 100 + 200 + 300, no output + }); + + it('should use base formula when no agentId is provided', () => { + const stats: ContextUsageStats = { + inputTokens: 100, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 300, + outputTokens: 400, + }; + const result = calculateContextTokens(stats); + expect(result).toBe(600); // 100 + 200 + 300, no output + }); + + it('should return 0 when all tokens are zero', () => { + const stats: ContextUsageStats = { + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + }; + const result = calculateContextTokens(stats, 'claude-code'); + expect(result).toBe(0); + }); + + it('should use base formula for opencode agent', () => { + const stats: ContextUsageStats = { + inputTokens: 1000, + cacheReadInputTokens: 2000, + cacheCreationInputTokens: 500, + outputTokens: 1500, + }; + const result = calculateContextTokens(stats, 'opencode'); + expect(result).toBe(3500); // 1000 + 2000 + 500, output excluded + }); + + it('should use base formula for factory-droid agent', () => { + const stats: ContextUsageStats = { + inputTokens: 1000, + outputTokens: 2000, + }; + const result = calculateContextTokens(stats, 'factory-droid'); + expect(result).toBe(1000); // only input, no cacheRead or cacheCreation + }); + + it('should default outputTokens to 0 for codex when undefined', () => { + const stats: ContextUsageStats = { + inputTokens: 1000, + }; + const result = calculateContextTokens(stats, 'codex'); + expect(result).toBe(1000); // 1000 + 0 + 0 + 0 + }); +}); + +describe('estimateContextUsage', () => { + it('should use contextWindow from stats when provided', () => { + const stats: ContextUsageStats = { + inputTokens: 5000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + contextWindow: 10000, + }; + const result = estimateContextUsage(stats, 'claude-code'); + expect(result).toBe(50); // 5000 / 10000 * 100 = 50% + }); + + it('should fall back to DEFAULT_CONTEXT_WINDOWS when no contextWindow in stats', () => { + const stats: ContextUsageStats = { + inputTokens: 100000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }; + const result = estimateContextUsage(stats, 'claude-code'); + // 100000 / 200000 * 100 = 50% + expect(result).toBe(50); + }); + + it('should return null for terminal agent', () => { + const stats: ContextUsageStats = { + inputTokens: 100, + }; + const result = estimateContextUsage(stats, 'terminal'); + expect(result).toBeNull(); + }); + + it('should return null when no agentId and no contextWindow', () => { + const stats: ContextUsageStats = { + inputTokens: 100, + }; + const result = estimateContextUsage(stats); + expect(result).toBeNull(); + }); + + it('should return 0 when all tokens are 0', () => { + const stats: ContextUsageStats = { + inputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + }; + const result = estimateContextUsage(stats, 'claude-code'); + expect(result).toBe(0); + }); + + it('should cap at 100% when tokens exceed context window', () => { + const stats: ContextUsageStats = { + inputTokens: 300000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }; + const result = estimateContextUsage(stats, 'claude-code'); + // 300000 / 200000 * 100 = 150%, capped at 100 + expect(result).toBe(100); + }); + + it('should cap at 100% when using stats contextWindow', () => { + const stats: ContextUsageStats = { + inputTokens: 15000, + contextWindow: 10000, + }; + const result = estimateContextUsage(stats, 'claude-code'); + expect(result).toBe(100); + }); + + it('should calculate ~50% usage for claude-code agent', () => { + const stats: ContextUsageStats = { + inputTokens: 50000, + cacheReadInputTokens: 30000, + cacheCreationInputTokens: 20000, + }; + const result = estimateContextUsage(stats, 'claude-code'); + // (50000 + 30000 + 20000) / 200000 * 100 = 50% + expect(result).toBe(50); + }); + + it('should include output tokens in calculation for codex agent', () => { + const stats: ContextUsageStats = { + inputTokens: 50000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 50000, + }; + const result = estimateContextUsage(stats, 'codex'); + // (50000 + 0 + 0 + 50000) / 200000 * 100 = 50% + expect(result).toBe(50); + }); + + it('should use contextWindow from stats even without agentId', () => { + const stats: ContextUsageStats = { + inputTokens: 5000, + contextWindow: 10000, + }; + const result = estimateContextUsage(stats); + // 5000 / 10000 * 100 = 50% + expect(result).toBe(50); + }); + + it('should round the percentage to nearest integer', () => { + const stats: ContextUsageStats = { + inputTokens: 33333, + contextWindow: 100000, + }; + const result = estimateContextUsage(stats, 'claude-code'); + // 33333 / 100000 * 100 = 33.333 => rounded to 33 + expect(result).toBe(33); + }); + + it('should use opencode default context window of 128000', () => { + const stats: ContextUsageStats = { + inputTokens: 64000, + }; + const result = estimateContextUsage(stats, 'opencode'); + // 64000 / 128000 * 100 = 50% + expect(result).toBe(50); + }); + + it('should return null for unknown agent without contextWindow', () => { + const stats: ContextUsageStats = { + inputTokens: 100, + }; + // Cast to bypass type checking for an unknown agent + const result = estimateContextUsage(stats, 'unknown-agent' as any); + expect(result).toBeNull(); + }); + + it('should handle contextWindow of 0 by falling back to defaults', () => { + const stats: ContextUsageStats = { + inputTokens: 100000, + contextWindow: 0, + }; + const result = estimateContextUsage(stats, 'claude-code'); + // contextWindow is 0 (falsy), falls back to default 200000 + // 100000 / 200000 * 100 = 50% + expect(result).toBe(50); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e5d6b3a5..17daef7c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6476,6 +6476,11 @@ You are taking over this conversation. Based on the context above, provide a bri const group = groups.find((g) => g.id === activeSession.groupId); const groupName = group?.name || 'Ungrouped'; + // Calculate elapsed time since last synopsis (or tab creation if no previous synopsis) + const elapsedTimeMs = activeTab.lastSynopsisTime + ? synopsisTime - activeTab.lastSynopsisTime + : synopsisTime - activeTab.createdAt; + // Add to history addHistoryEntry({ type: 'AUTO', @@ -6486,6 +6491,7 @@ You are taking over this conversation. Based on the context above, provide a bri projectPath: activeSession.cwd, sessionName: activeTab.name || undefined, usageStats: result.usageStats, + elapsedTimeMs, }); // Update the pending log with success AND set lastSynopsisTime diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index 85fc217f..57332bd0 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -498,6 +498,9 @@ const LogItemComponent = memo( safeStr(toolInput.pattern) || safeStr(toolInput.file_path) || safeStr(toolInput.query) || + safeStr(toolInput.description) || // Task tool + safeStr(toolInput.prompt) || // Task tool fallback + safeStr(toolInput.task_id) || // TaskOutput tool null : null; diff --git a/src/renderer/hooks/agent/useAgentSessionManagement.ts b/src/renderer/hooks/agent/useAgentSessionManagement.ts index d994af66..1c5fa951 100644 --- a/src/renderer/hooks/agent/useAgentSessionManagement.ts +++ b/src/renderer/hooks/agent/useAgentSessionManagement.ts @@ -21,6 +21,8 @@ export interface HistoryEntryInput { sessionName?: string; /** Whether the operation succeeded (false for errors/failures) */ success?: boolean; + /** Task execution time in milliseconds */ + elapsedTimeMs?: number; } /** @@ -127,6 +129,8 @@ export function useAgentSessionManagement( usageStats: entry.usageStats, // Pass through success field for error/failure tracking success: entry.success, + // Pass through task execution time + elapsedTimeMs: entry.elapsedTimeMs, }); // Refresh history panel to show the new entry