mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
added a bunch of tests
This commit is contained in:
960
src/__tests__/main/claude-session-storage.test.ts
Normal file
960
src/__tests__/main/claude-session-storage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
694
src/__tests__/main/group-chat/session-recovery.test.ts
Normal file
694
src/__tests__/main/group-chat/session-recovery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1390
src/__tests__/main/history-manager.test.ts
Normal file
1390
src/__tests__/main/history-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1189
src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts
Normal file
1189
src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
618
src/__tests__/main/utils/agent-args.test.ts
Normal file
618
src/__tests__/main/utils/agent-args.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
|
||||
expect(cost).toBe(0);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Expected: 3 + 15 = 18
|
||||
expect(cost).toBeCloseTo(18, 2);
|
||||
expect(cost).toBeCloseTo(15, 10);
|
||||
});
|
||||
|
||||
it('should accept custom pricing config', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
describe('TOKENS_PER_MILLION constant', () => {
|
||||
it('should equal one million', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE_PRICING', () => {
|
||||
it('should have all required pricing fields', () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1039
src/__tests__/main/utils/remote-git.test.ts
Normal file
1039
src/__tests__/main/utils/remote-git.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
677
src/__tests__/main/web-server/managers/CallbackRegistry.test.ts
Normal file
677
src/__tests__/main/web-server/managers/CallbackRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1188
src/__tests__/renderer/hooks/utils/useDebouncedPersistence.test.ts
Normal file
1188
src/__tests__/renderer/hooks/utils/useDebouncedPersistence.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
624
src/__tests__/renderer/utils/markdownConfig.test.ts
Normal file
624
src/__tests__/renderer/utils/markdownConfig.test.ts
Normal 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
765
src/__tests__/renderer/utils/participantColors.test.ts
Normal file
765
src/__tests__/renderer/utils/participantColors.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
935
src/__tests__/renderer/utils/tabExport.test.ts
Normal file
935
src/__tests__/renderer/utils/tabExport.test.ts
Normal 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('<script>');
|
||||
expect(html).toContain('"xss"');
|
||||
});
|
||||
|
||||
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 <b>bold</b>');
|
||||
});
|
||||
|
||||
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('&');
|
||||
expect(html).toContain('<brackets>');
|
||||
expect(html).toContain('"quotes"');
|
||||
});
|
||||
|
||||
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<type>');
|
||||
});
|
||||
|
||||
it('escapes ampersands correctly', () => {
|
||||
const tab = createMockTab({ name: 'Tab & More' });
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Tab & More');
|
||||
});
|
||||
|
||||
it('escapes single quotes', () => {
|
||||
const tab = createMockTab({ name: "Tab's Name" });
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Tab's Name');
|
||||
});
|
||||
|
||||
it('escapes source labels in messages', () => {
|
||||
// Source labels are already fixed strings (User, AI, etc.),
|
||||
// but the escapeHtml call wraps them - verify it does not break
|
||||
const tab = createMockTab({
|
||||
logs: [createLogEntry({ source: 'user', text: 'Hello' })],
|
||||
});
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session details section', () => {
|
||||
it('includes agent type in details', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Agent');
|
||||
expect(html).toContain('claude-code');
|
||||
});
|
||||
|
||||
it('includes working directory in details', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Working Directory');
|
||||
expect(html).toContain('/home/user/project');
|
||||
});
|
||||
|
||||
it('includes session name in details', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Session Name');
|
||||
expect(html).toContain('My Session');
|
||||
});
|
||||
|
||||
it('includes created timestamp in details', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Created');
|
||||
});
|
||||
|
||||
it('includes usage stats in details', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Usage');
|
||||
});
|
||||
|
||||
it('includes session ID when agentSessionId is provided', () => {
|
||||
const tab = createMockTab({
|
||||
agentSessionId: 'session-abc-def-123',
|
||||
});
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('Session ID');
|
||||
expect(html).toContain('session-abc-def-123');
|
||||
});
|
||||
|
||||
it('omits session ID row when agentSessionId is null', () => {
|
||||
const tab = createMockTab({ agentSessionId: null });
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
// Session ID label should not appear
|
||||
expect(html).not.toContain('>Session ID<');
|
||||
});
|
||||
|
||||
it('renders details section with correct CSS classes', () => {
|
||||
const tab = createMockTab();
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
expect(html).toContain('class="section-title"');
|
||||
expect(html).toContain('class="info-grid"');
|
||||
expect(html).toContain('class="info-label"');
|
||||
expect(html).toContain('class="info-value"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown content rendering', () => {
|
||||
it('passes message text through marked for rendering', () => {
|
||||
const tab = createMockTab({
|
||||
logs: [createLogEntry({ source: 'ai', text: '**bold text**' })],
|
||||
});
|
||||
const html = generateTabExportHtml(tab, mockSession, mockTheme);
|
||||
|
||||
// Our mock wraps in <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<');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
555
src/__tests__/renderer/utils/textProcessing.test.ts
Normal file
555
src/__tests__/renderer/utils/textProcessing.test.ts
Normal 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  first,
|
||||
// so  -> !Alt text (the image regex cannot match afterward)
|
||||
expect(stripMarkdown('')).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 ',
|
||||
'',
|
||||
'~~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:  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
|
||||
});
|
||||
});
|
||||
313
src/__tests__/shared/contextUsage.test.ts
Normal file
313
src/__tests__/shared/contextUsage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user