added a bunch of tests

This commit is contained in:
Pedram Amini
2026-01-31 13:31:29 -05:00
parent 32e12f99ff
commit 90a463b919
18 changed files with 12106 additions and 413 deletions

View File

@@ -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<string, unknown>) {
const data: Record<string, unknown> = { 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, unknown>[]): 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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 200, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 300, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 2048, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 500, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 300, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 1024, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 800, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 500, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 4096, mtimeMs, mtime: new Date(mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 400, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
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<typeof fs.stat>;
}
return Promise.resolve({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) }) as unknown as ReturnType<typeof fs.stat>;
});
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
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<typeof fs.stat>;
}
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<typeof fs.stat>;
});
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 512, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 2048, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 128, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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<ReturnType<typeof fs.readdir>>);
vi.mocked(fs.stat).mockResolvedValue({ size: 256, mtimeMs: DEFAULT_STATS.mtimeMs, mtime: new Date(DEFAULT_STATS.mtimeMs) } as unknown as Awaited<ReturnType<typeof fs.stat>>);
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');
});
});
});

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>();
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<string, unknown>;
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']);
});
});
});

View File

@@ -0,0 +1,935 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('marked', () => ({
marked: {
parse: (text: string) => `<p>${text}</p>`,
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>): 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>): 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('<!DOCTYPE html>');
expect(html).toContain('<html lang="en">');
expect(html).toContain('</html>');
expect(html).toContain('<head>');
expect(html).toContain('</head>');
expect(html).toContain('<body>');
expect(html).toContain('</body>');
});
it('includes meta charset and viewport tags', () => {
const tab = createMockTab();
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).toContain('<meta charset="UTF-8">');
expect(html).toContain('<meta name="viewport"');
});
it('includes embedded CSS styles', () => {
const tab = createMockTab();
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).toContain('<style>');
expect(html).toContain('</style>');
});
});
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('<title>My Custom Tab - Maestro Tab Export</title>');
// 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(
'<title>Session ABC12345 - Maestro Tab Export</title>'
);
});
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('<title>New Session - Maestro Tab Export</title>');
});
});
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('<span class="read-only-badge">read-only</span>');
});
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('<div class="stat-value">5</div>');
});
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('<div class="stat-value">3</div>');
});
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('<div class="stat-value">2</div>');
});
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('<div class="stat-value">0</div>');
});
});
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('<div class="stat-value">0m</div>');
});
it('shows 0m for empty logs', () => {
const tab = createMockTab({ logs: [] });
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).toContain('<div class="stat-value">0m</div>');
});
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('<div class="stat-value">25m</div>');
});
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('<div class="stat-value">2h 30m</div>');
});
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('<div class="stat-value">3h 0m</div>');
});
});
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 <script>alert("xss")</script>' });
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).not.toContain('<script>alert("xss")</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('&quot;xss&quot;');
});
it('escapes special characters in session name', () => {
const session = { ...mockSession, name: 'Session <b>bold</b>' };
const tab = createMockTab();
const html = generateTabExportHtml(tab, session, mockTheme);
expect(html).toContain('Session &lt;b&gt;bold&lt;/b&gt;');
});
it('escapes special characters in working directory', () => {
const session = { ...mockSession, cwd: '/path/with "quotes" & <brackets>' };
const tab = createMockTab();
const html = generateTabExportHtml(tab, session, mockTheme);
expect(html).toContain('&amp;');
expect(html).toContain('&lt;brackets&gt;');
expect(html).toContain('&quot;quotes&quot;');
});
it('escapes special characters in tool type', () => {
const session = { ...mockSession, toolType: 'agent<type>' };
const tab = createMockTab();
const html = generateTabExportHtml(tab, session, mockTheme);
expect(html).toContain('agent&lt;type&gt;');
});
it('escapes ampersands correctly', () => {
const tab = createMockTab({ name: 'Tab & More' });
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).toContain('Tab &amp; More');
});
it('escapes single quotes', () => {
const tab = createMockTab({ name: "Tab's Name" });
const html = generateTabExportHtml(tab, mockSession, mockTheme);
expect(html).toContain('Tab&#039;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 <p> tags
expect(html).toContain('<p>**bold text**</p>');
});
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('<p>Some content</p>');
});
});
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('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
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('<!DOCTYPE html>');
// 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('<!DOCTYPE html>');
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<');
});
});
});
});

View File

@@ -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<typeof vi.fn> };
beforeEach(() => {
clearAnsiCache();
mockConverter = {
toHtml: vi.fn((text: string) => `<span>${text}</span>`),
};
});
it('converts text and returns the result', () => {
const result = getCachedAnsiHtml('hello', 'dark', mockConverter as never);
expect(result).toBe('<span>hello</span>');
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('<span>hello</span>');
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(`<span>${longText}</span>`);
// Second call should use cached result
const result2 = getCachedAnsiHtml(longText, 'dark', mockConverter as never);
expect(result2).toBe(`<span>${longText}</span>`);
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<typeof vi.fn> };
beforeEach(() => {
clearAnsiCache();
mockConverter = {
toHtml: vi.fn((text: string) => `<span>${text}</span>`),
};
});
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
});
});

View File

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

View File

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

View File

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

View File

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