agent-ecosystem/test/renderer/utils/sessionExporter.test.ts
Paul Holstein d3b7d9dfeb feat: add session export (Markdown, JSON, Plain Text)
Add an export button to the TabBar header that lets users export
the current session as Markdown, JSON, or Plain Text. The button
appears between Search and Notifications, only for session tabs.

- sessionExporter.ts: formatters for all three formats + download trigger
- ExportDropdown.tsx: dropdown UI component with format selection
- TabBar.tsx: integration with conditional rendering for session tabs
- 51 new tests covering all formatters, edge cases, and download

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 11:18:52 -05:00

716 lines
22 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
extractTextFromContent,
exportAsPlainText,
exportAsMarkdown,
exportAsJson,
triggerDownload,
type ExportFormat,
} from '@renderer/utils/sessionExporter';
// =============================================================================
// Test Fixtures
// =============================================================================
function makeMetrics(overrides = {}) {
return {
durationMs: 60000,
totalTokens: 5000,
inputTokens: 3000,
outputTokens: 2000,
cacheReadTokens: 500,
cacheCreationTokens: 100,
messageCount: 10,
costUsd: 0.05,
...overrides,
};
}
function makeSession(overrides = {}) {
return {
id: 'test-session-123',
projectId: '-Users-test-project',
projectPath: '/Users/test/project',
createdAt: new Date('2025-01-15T10:00:00Z').getTime(),
hasSubagents: false,
messageCount: 10,
firstMessage: 'Hello, help me debug this',
gitBranch: 'main',
...overrides,
};
}
function makeMessage(overrides: Record<string, unknown> = {}) {
return {
uuid: 'msg-1',
parentUuid: null,
type: 'user' as const,
timestamp: new Date('2025-01-15T10:00:00Z'),
content: 'Hello world',
isMeta: false,
isSidechain: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
function makeUserChunk(overrides: Record<string, unknown> = {}) {
const msg = makeMessage();
return {
id: 'chunk-user-1',
chunkType: 'user' as const,
startTime: new Date('2025-01-15T10:00:00Z'),
endTime: new Date('2025-01-15T10:00:01Z'),
durationMs: 1000,
metrics: makeMetrics({ messageCount: 1 }),
userMessage: msg,
...overrides,
};
}
function makeAIChunk(overrides: Record<string, unknown> = {}) {
const response = makeMessage({
uuid: 'msg-2',
type: 'assistant',
content: [{ type: 'text', text: 'Here is the answer' }],
});
return {
id: 'chunk-ai-1',
chunkType: 'ai' as const,
startTime: new Date('2025-01-15T10:00:01Z'),
endTime: new Date('2025-01-15T10:00:05Z'),
durationMs: 4000,
metrics: makeMetrics({ messageCount: 2 }),
responses: [response],
processes: [],
sidechainMessages: [],
toolExecutions: [],
...overrides,
};
}
function makeSystemChunk(overrides: Record<string, unknown> = {}) {
return {
id: 'chunk-system-1',
chunkType: 'system' as const,
startTime: new Date('2025-01-15T10:00:06Z'),
endTime: new Date('2025-01-15T10:00:07Z'),
durationMs: 1000,
metrics: makeMetrics({ messageCount: 1 }),
message: makeMessage({ type: 'user', content: 'command output here' }),
commandOutput: 'Set model to sonnet',
...overrides,
};
}
function makeCompactChunk(overrides: Record<string, unknown> = {}) {
return {
id: 'chunk-compact-1',
chunkType: 'compact' as const,
startTime: new Date('2025-01-15T10:01:00Z'),
endTime: new Date('2025-01-15T10:01:00Z'),
durationMs: 0,
metrics: makeMetrics({ messageCount: 0 }),
message: makeMessage({ type: 'summary', content: 'Summary of conversation' }),
...overrides,
};
}
function makeSessionDetail(overrides: Record<string, unknown> = {}) {
const userChunk = makeUserChunk();
const aiChunk = makeAIChunk();
return {
session: makeSession(),
messages: [userChunk.userMessage as any, (aiChunk.responses as any)[0]],
chunks: [userChunk, aiChunk],
processes: [],
metrics: makeMetrics(),
...overrides,
};
}
// =============================================================================
// extractTextFromContent
// =============================================================================
describe('extractTextFromContent', () => {
it('returns string content directly', () => {
expect(extractTextFromContent('Hello world')).toBe('Hello world');
});
it('returns empty string for empty string', () => {
expect(extractTextFromContent('')).toBe('');
});
it('extracts text from TextContent blocks', () => {
const blocks = [
{ type: 'text', text: 'First part.' },
{ type: 'text', text: 'Second part.' },
];
expect(extractTextFromContent(blocks as any)).toBe('First part.\nSecond part.');
});
it('includes thinking content when option is set', () => {
const blocks = [
{ type: 'thinking', thinking: 'Let me think about this...' },
{ type: 'text', text: 'Answer here.' },
];
expect(extractTextFromContent(blocks as any, { includeThinking: true })).toBe(
'Let me think about this...\nAnswer here.'
);
});
it('excludes thinking content by default', () => {
const blocks = [
{ type: 'thinking', thinking: 'Let me think...' },
{ type: 'text', text: 'Answer here.' },
];
expect(extractTextFromContent(blocks as any)).toBe('Answer here.');
});
it('extracts tool_use content as formatted string', () => {
const blocks = [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/foo.ts' } }];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('Tool: Read');
expect(result).toContain('/foo.ts');
});
it('extracts tool_result content', () => {
const blocks = [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file contents here' }];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('file contents here');
});
it('handles tool_result with array content', () => {
const blocks = [
{
type: 'tool_result',
tool_use_id: 'tu-1',
content: [{ type: 'text', text: 'result text' }],
},
];
const result = extractTextFromContent(blocks as any);
expect(result).toContain('result text');
});
it('skips image blocks gracefully', () => {
const blocks = [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } },
{ type: 'text', text: 'Caption' },
];
expect(extractTextFromContent(blocks as any)).toBe('[Image]\nCaption');
});
it('returns empty string for empty array', () => {
expect(extractTextFromContent([])).toBe('');
});
});
// =============================================================================
// exportAsPlainText
// =============================================================================
describe('exportAsPlainText', () => {
it('includes session header with metadata', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('SESSION EXPORT');
expect(result).toContain('test-session-123');
expect(result).toContain('/Users/test/project');
});
it('includes metrics section', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('METRICS');
expect(result).toContain('5,000');
expect(result).toContain('$0.05');
});
it('renders user chunks with USER: label', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('USER:');
expect(result).toContain('Hello world');
});
it('renders AI chunks with ASSISTANT: label', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('ASSISTANT:');
expect(result).toContain('Here is the answer');
});
it('renders system chunks with SYSTEM: label', () => {
const detail = makeSessionDetail({
chunks: [makeSystemChunk()],
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('SYSTEM:');
expect(result).toContain('Set model to sonnet');
});
it('renders compact chunks as [Context compacted]', () => {
const detail = makeSessionDetail({
chunks: [makeCompactChunk()],
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('[Context compacted]');
});
it('renders tool executions with TOOL: label', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: {
id: 'tu-1',
name: 'Read',
input: { file_path: '/src/main.ts' },
isTask: false,
},
result: { toolUseId: 'tu-1', content: 'file content', isError: false },
startTime: new Date('2025-01-15T10:00:02Z'),
endTime: new Date('2025-01-15T10:00:03Z'),
durationMs: 1000,
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('TOOL: Read');
expect(result).toContain('/src/main.ts');
expect(result).toContain('file content');
});
it('renders thinking blocks with THINKING: label', () => {
const aiChunk = makeAIChunk({
responses: [
makeMessage({
uuid: 'msg-think',
type: 'assistant',
content: [
{ type: 'thinking', thinking: 'Let me reason about this...' },
{ type: 'text', text: 'Final answer.' },
],
}),
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('THINKING:');
expect(result).toContain('Let me reason about this...');
expect(result).toContain('Final answer.');
});
it('handles tool execution with error result', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'rm -rf /' }, isTask: false },
result: { toolUseId: 'tu-1', content: 'Permission denied', isError: true },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsPlainText(detail as any);
expect(result).toContain('TOOL: Bash');
expect(result).toContain('[ERROR]');
expect(result).toContain('Permission denied');
});
it('uses separator lines between chunks', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
// Should contain horizontal rule separators
expect(result).toMatch(/─{20,}/);
});
it('formats cost as N/A when undefined', () => {
const detail = makeSessionDetail({
metrics: makeMetrics({ costUsd: undefined }),
});
const result = exportAsPlainText(detail as any);
expect(result).toContain('N/A');
});
it('includes branch info when available', () => {
const detail = makeSessionDetail();
const result = exportAsPlainText(detail as any);
expect(result).toContain('main');
});
});
// =============================================================================
// exportAsMarkdown
// =============================================================================
describe('exportAsMarkdown', () => {
it('starts with # Session Export heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toMatch(/^# Session Export/);
});
it('includes property table with session info', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('| Property | Value |');
expect(result).toContain('test-session-123');
expect(result).toContain('/Users/test/project');
expect(result).toContain('main');
});
it('includes ## Metrics table', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('## Metrics');
expect(result).toContain('| Metric | Value |');
expect(result).toContain('5,000');
expect(result).toContain('$0.05');
});
it('includes ## Conversation section', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('## Conversation');
});
it('renders user chunks with ### User heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### User');
expect(result).toContain('Hello world');
});
it('renders AI chunks with ### Assistant heading', () => {
const detail = makeSessionDetail();
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### Assistant');
expect(result).toContain('Here is the answer');
});
it('renders system chunks with ### System heading', () => {
const detail = makeSessionDetail({
chunks: [makeSystemChunk()],
});
const result = exportAsMarkdown(detail as any);
expect(result).toContain('### System');
expect(result).toContain('Set model to sonnet');
});
it('renders compact chunks with --- and italic text', () => {
const detail = makeSessionDetail({
chunks: [makeCompactChunk()],
});
const result = exportAsMarkdown(detail as any);
expect(result).toContain('---');
expect(result).toContain('*Context compacted*');
});
it('renders tool calls with **Tool:** and code blocks', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: {
id: 'tu-1',
name: 'Read',
input: { file_path: '/src/app.ts' },
isTask: false,
},
result: { toolUseId: 'tu-1', content: 'export default App;', isError: false },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('**Tool:** `Read`');
expect(result).toContain('```json');
expect(result).toContain('file_path');
expect(result).toContain('```');
expect(result).toContain('export default App;');
});
it('renders thinking as blockquotes', () => {
const aiChunk = makeAIChunk({
responses: [
makeMessage({
uuid: 'msg-think',
type: 'assistant',
content: [
{ type: 'thinking', thinking: 'Deep thought here' },
{ type: 'text', text: 'Output text' },
],
}),
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('> *Thinking:*');
expect(result).toContain('> Deep thought here');
});
it('marks error tool results', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'fail' }, isTask: false },
result: { toolUseId: 'tu-1', content: 'Error: not found', isError: true },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const result = exportAsMarkdown(detail as any);
expect(result).toContain('**Error:**');
});
it('numbers turns sequentially', () => {
const detail = makeSessionDetail({
chunks: [
makeUserChunk(),
makeAIChunk(),
makeUserChunk({ id: 'chunk-user-2' }),
makeAIChunk({ id: 'chunk-ai-2' }),
],
});
const result = exportAsMarkdown(detail as any);
// Check that turn numbers appear (Turn 1, Turn 2, etc.)
const turnMatches = result.match(/### (User|Assistant)/g);
expect(turnMatches).toBeTruthy();
expect(turnMatches!.length).toBeGreaterThanOrEqual(2);
});
});
// =============================================================================
// exportAsJson
// =============================================================================
describe('exportAsJson', () => {
it('returns valid JSON', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
expect(() => JSON.parse(result)).not.toThrow();
});
it('returns pretty-printed JSON with 2-space indentation', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
// Pretty-printed JSON has newlines and indentation
expect(result).toContain('\n');
expect(result).toContain(' ');
});
it('preserves session data', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.session.id).toBe('test-session-123');
expect(parsed.session.projectPath).toBe('/Users/test/project');
});
it('preserves metrics', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.metrics.totalTokens).toBe(5000);
expect(parsed.metrics.costUsd).toBe(0.05);
});
it('preserves chunks array', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.chunks).toBeDefined();
expect(Array.isArray(parsed.chunks)).toBe(true);
expect(parsed.chunks.length).toBe(2);
});
it('preserves messages array', () => {
const detail = makeSessionDetail();
const result = exportAsJson(detail as any);
const parsed = JSON.parse(result);
expect(parsed.messages).toBeDefined();
expect(Array.isArray(parsed.messages)).toBe(true);
});
});
// =============================================================================
// triggerDownload
// =============================================================================
describe('triggerDownload', () => {
let createElementSpy: ReturnType<typeof vi.spyOn>;
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockAnchor = {
href: '',
download: '',
click: vi.fn(),
};
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any);
vi.spyOn(document.body, 'appendChild').mockReturnValue(mockAnchor as any);
vi.spyOn(document.body, 'removeChild').mockReturnValue(mockAnchor as any);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
it('creates anchor element and triggers click for markdown', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'markdown');
expect(createElementSpy).toHaveBeenCalledWith('a');
expect(mockAnchor.download).toBe('session-test-session-123.md');
expect(mockAnchor.click).toHaveBeenCalled();
});
it('uses .json extension for json format', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'json');
expect(mockAnchor.download).toBe('session-test-session-123.json');
});
it('uses .txt extension for plaintext format', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'plaintext');
expect(mockAnchor.download).toBe('session-test-session-123.txt');
});
it('creates and revokes object URL', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'markdown');
expect(URL.createObjectURL).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('appends and removes anchor from body', () => {
const detail = makeSessionDetail();
triggerDownload(detail as any, 'plaintext');
expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
// =============================================================================
// Edge cases
// =============================================================================
describe('edge cases', () => {
it('handles empty chunks array', () => {
const detail = makeSessionDetail({ chunks: [], messages: [] });
expect(() => exportAsPlainText(detail as any)).not.toThrow();
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
expect(() => exportAsJson(detail as any)).not.toThrow();
});
it('handles AI chunk with no tool executions', () => {
const aiChunk = makeAIChunk({ toolExecutions: [] });
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const text = exportAsPlainText(detail as any);
expect(text).toContain('ASSISTANT:');
expect(text).not.toContain('TOOL:');
});
it('handles AI chunk with no responses', () => {
const aiChunk = makeAIChunk({ responses: [] });
const detail = makeSessionDetail({ chunks: [aiChunk] });
expect(() => exportAsPlainText(detail as any)).not.toThrow();
expect(() => exportAsMarkdown(detail as any)).not.toThrow();
});
it('handles tool execution without result', () => {
const aiChunk = makeAIChunk({
toolExecutions: [
{
toolCall: { id: 'tu-1', name: 'Bash', input: { command: 'ls' }, isTask: false },
startTime: new Date('2025-01-15T10:00:02Z'),
},
],
});
const detail = makeSessionDetail({ chunks: [makeUserChunk(), aiChunk] });
const text = exportAsPlainText(detail as any);
expect(text).toContain('TOOL: Bash');
expect(text).toContain('[No result]');
});
it('handles mixed chunk types in sequence', () => {
const detail = makeSessionDetail({
chunks: [
makeUserChunk(),
makeAIChunk(),
makeSystemChunk(),
makeCompactChunk(),
makeUserChunk({ id: 'chunk-user-2' }),
makeAIChunk({ id: 'chunk-ai-2' }),
],
});
const text = exportAsPlainText(detail as any);
expect(text).toContain('USER:');
expect(text).toContain('ASSISTANT:');
expect(text).toContain('SYSTEM:');
expect(text).toContain('[Context compacted]');
const md = exportAsMarkdown(detail as any);
expect(md).toContain('### User');
expect(md).toContain('### Assistant');
expect(md).toContain('### System');
expect(md).toContain('*Context compacted*');
});
it('handles content blocks with mixed types', () => {
const blocks = [
{ type: 'thinking', thinking: 'Hmm...' },
{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: '/a.ts' } },
{ type: 'text', text: 'Result text' },
{ type: 'tool_result', tool_use_id: 'tu-1', content: 'file data' },
];
const result = extractTextFromContent(blocks as any, { includeThinking: true });
expect(result).toContain('Hmm...');
expect(result).toContain('Tool: Read');
expect(result).toContain('Result text');
expect(result).toContain('file data');
});
});