* fix: add retry logic to sendInboxMessage for concurrent writes On Windows, parallel writes to the same inbox file cause race conditions where atomicWrite verification fails (another process overwrites between write and verify). Added retry loop (8 attempts) matching the existing pattern in addTaskComment. Bumps teamctl version to 11. Fixes CI failure: test (windows-latest) "parallel messages to same inbox" * fix: enhance CLI installer and session management - Updated the postinstall script in package.json to handle rebuild failures gracefully. - Added clearContext option in team launch requests to allow starting fresh sessions without resuming previous context. - Improved CLI installer logging by integrating raw output chunks for better terminal rendering. - Refactored components to utilize TerminalLogPanel for displaying installation logs, enhancing user experience during CLI installation. - Updated various services and hooks to support the new clearContext feature and raw logging. * fix: update MemberBadge and LaunchTeamDialog components for improved functionality - Modified MemberBadge to display 'lead' for team leads instead of the full name. - Refactored LaunchTeamDialog to simplify model selection logic and replace the Select component with a custom button-based interface for better user experience. - Enhanced KanbanTaskCard to include meta actions for task management, improving the layout and functionality for manual review tasks. * feat: auto-publish releases with stable download links - Change releaseType from draft to release for auto-publishing - Add upload-stable-links job to create version-agnostic asset copies - Update README with direct download URLs per platform - Add Requirements section to Installation - Remove downloads/platform badges - Add docs/RELEASE.md with versioning and release guide - Move community docs to .github/ * improvemtns * improvement * fix: handle Windows spawn EINVAL on non-ASCII paths and add helper utilities * improvements * fix: enhance child process environment handling for Windows - Added a helper function to build the child process environment with the correct HOME directory, addressing issues with non-ASCII usernames on Windows. - Updated CLI installer methods to utilize the new environment setup for improved compatibility and error handling. * refactor: replace execFile and spawn with execCli and spawnCli in CLI and TeamProvisioning services - Updated CliInstallerService and TeamProvisioningService to use execCli and spawnCli for improved error handling and compatibility, particularly on Windows. - Enhanced child process utility functions to better manage non-ASCII paths and provide consistent behavior across different platforms. - Adjusted tests to mock new child process utilities and verify correct usage in service methods. * fix * fix windows * feat: add download badges with direct links per platform * refactor: move download buttons to Installation section * refactor: move Docker files to docker/ and CHANGELOG to docs/ * refactor: move vite.standalone.config to docker/, remove .nvmrc * refactor: merge tsconfig.test.json into tsconfig.json * refactor: remove .editorconfig, .gitattributes, merge knip.json into package.json * fix: adjust macOS download badge sizing * feat: implement in-app project editor with CodeMirror integration - Added architectural plan and iteration plan for the in-app project editor. - Introduced new components for the editor, including CodeEditorOverlay, FileTreePanel, and EditorTabsPanel. - Established state management using Zustand for editor state persistence. - Implemented IPC channels for file operations and editor functionality. - Enhanced TeamDetailView with a button to open the editor overlay. - Conducted reuse analysis for existing components to optimize codebase integration. * feat: enhance in-app project editor with architecture documentation and service updates - Added detailed architecture and component hierarchy documentation for the in-app project editor. - Introduced `ProjectFileService` to manage file operations with improved path validation. - Updated `electron.vite.config.ts` to set `UV_THREADPOOL_SIZE` for better performance on Windows. - Deferred non-critical startup tasks in `index.ts` to avoid thread pool contention. - Enhanced `CliInstallerService` with timeout handling for status gathering to prevent UI hangs. - Added tests for `CliInstallerService` to ensure proper timeout behavior. * feat: enhance project editor with file management, Git integration, and UI improvements - Introduced `EditorFileWatcher` for live file change detection and `GitStatusService` for displaying Git status in the file tree. - Added context menu for file operations (create, delete) and implemented multi-tab support for the editor. - Enhanced user experience with keyboard shortcuts, search functionality, and breadcrumb navigation. - Updated IPC channels for file operations and integrated conflict detection during file saves. - Improved performance with file watcher optimizations and virtualized file tree rendering. * feat: enhance project editor with autosave, improved file management, and performance optimizations - Implemented draft autosave functionality to prevent data loss during crashes, with recovery options for unsaved changes. - Updated file management services to support better path validation and conflict detection. - Enhanced performance with optimized file watcher and caching strategies for project scanning. - Improved user experience with confirmation dialogs for unsaved changes and refined keyboard shortcuts. - Documented testing strategies and rollback plans for iterative development. * feat: integrate simple-git for enhanced Git status tracking and improve file watcher performance - Replaced direct Git command usage with `simple-git` for more reliable status tracking, including support for renamed files and conflict detection. - Updated IPC channels to reflect changes in Git status retrieval method. - Enhanced file watcher initialization on Windows to prevent UV thread pool saturation by starting watchers sequentially. - Improved application startup by staggering context system initialization and notification listeners to optimize performance. * feat: optimize IPC initialization and context management for improved app performance - Deferred IPC-heavy initialization to occur after the first paint to prevent app freezing on Windows. - Staggered notification listener setup to avoid saturating the UV thread pool during startup. - Updated context system initialization to be lazy, ensuring local context is always ready without upfront costs. - Enhanced data fetching sequence to reduce simultaneous IPC calls, improving overall responsiveness. * feat: enhance project editor with new error boundary and file handling improvements - Introduced `EditorErrorBoundary` component to catch runtime errors in CodeMirror, providing a fallback UI to prevent crashes. - Updated file handling logic to utilize `isbinaryfile` for more reliable binary detection, replacing manual null-byte scans. - Enhanced `openFile` method to prevent duplicate tabs for already opened files. - Improved documentation for new components and updated file lists to reflect recent changes. - Optimized file watcher and path validation processes for better performance and reliability. * feat: enhance TeamConfigReader with improved file handling and concurrency - Introduced `mapLimit` function to manage concurrent processing of team directories, optimizing performance. - Added `readFileHead` function to read the beginning of large configuration files efficiently. - Implemented `extractQuotedString` to safely extract values from JSON strings in configuration headers. - Enhanced error handling and validation for team configuration files, ensuring robust processing of team data. - Updated logic to handle large configuration files differently, improving overall reliability and performance. * feat: enhance team management with improved session and project path history handling - Introduced constants for maximum session and project path history limits to optimize memory usage. - Updated `TeamConfigReader` and `TeamProvisioningService` to limit session and project path history to defined maximums. - Enhanced `TeamMembersMetaStore` to handle large meta files more efficiently by checking file size before processing. - Refactored state management in `teamSlice` to include optimized lookups for team summaries by name and session ID. * feat: enhance GlobalTaskDetailDialog and TaskDetailDialog with loading state management - Added a loading state to the TaskDetailDialog to display a loading indicator while fetching team data. - Updated GlobalTaskDetailDialog to pass the loading state to TaskDetailDialog. - Modified the selectTeam function in teamSlice to accept options for skipping project auto-selection, improving team data handling. * feat: optimize team display name resolution and enhance file change handling - Introduced a caching mechanism for team display names to reduce redundant API calls and improve performance. - Updated file change event handling to debounce cache invalidation, preventing unnecessary rescans during rapid file changes. - Enhanced GlobalTaskDetailDialog and TaskDetailDialog to manage loading states and improve team data fetching logic. - Refactored teamSlice to streamline team selection and data loading processes. * fix: improve team selection logic and prevent duplicate fetches - Enhanced GlobalTaskDetailDialog to handle loading states more effectively, preventing unnecessary re-fetching of team data. - Updated selectTeam function in teamSlice to guard against duplicate in-flight fetches for the same team, improving performance and user experience. - Refactored dependencies in useEffect to ensure proper data loading behavior. * feat: enhance team data fetching with performance logging and timeout handling - Added performance logging to the handleGetData function, tracking the duration of team data retrieval and logging warnings for slow responses. - Implemented a timeout mechanism in the selectTeam function to prevent long-running fetch operations, improving user experience. - Enhanced the getTeamData method with detailed timing metrics for each data loading step, allowing for better performance analysis and debugging. * feat: implement timeout handling and logging for team data fetching - Added a timeout mechanism to the getBranch calls in TeamDataService to prevent hangs on Windows setups, improving reliability during team data retrieval. - Introduced performance logging in TeamDetailView and teamSlice to track the start and completion of team selection processes, enhancing debugging capabilities. - Updated error handling to provide clearer warnings during team provisioning and selection, improving user experience. * feat: enhance MarkdownViewer and task dialogs with loading state management and performance logging - Introduced character limits for Markdown content in MarkdownViewer to prevent UI freezes with large content. - Added state management for raw content display in MarkdownViewer, allowing users to expand and view large markdown files. - Implemented performance logging in GlobalTaskDetailDialog and TaskDetailDialog to track loading states and improve debugging. - Updated loading state handling in TaskDetailDialog to ensure accurate representation of loading conditions. * feat: enhance TaskCommentsSection with improved comment rendering and visibility management - Added state management for visible comments, allowing users to see a limited number of comments for better performance. - Implemented logic to cap the number of rendered comments, preventing UI freezes with large comment lists. - Introduced sorting for comments based on creation date to display the most recent comments first. - Updated the UI to inform users when only a subset of comments is being displayed, enhancing user experience. * feat: enhance MarkdownViewer with improved character limits and syntax highlighting management - Updated character limits for Markdown content to prevent UI freezes with large inputs. - Introduced logic to disable syntax highlighting for medium/large content and show raw previews for very large content. - Enhanced responsiveness of the MarkdownViewer by managing rendering based on content size. * refactor: remove console warnings from team-related components for cleaner logging - Eliminated console warnings in TeamDetailView, GlobalTaskDetailDialog, and TaskDetailDialog to streamline logging and reduce clutter during team data operations. - Updated the selectTeam function in teamSlice to remove unnecessary logging, enhancing performance and readability. * feat: add project editor with drag & drop file management - Backend: ProjectFileService with file CRUD, search, git status, file watcher - IPC: 12 editor channels with security validation and path containment - Store: editorSlice with multi-tab management, draft persistence, conflict detection - UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus - Move: fs.rename with EXDEV fallback, full path remapping across all caches - Tests: comprehensive coverage for services, IPC handlers, store, and utilities * fix: rename closeTab/setActiveTab to closeEditorTab/setActiveEditorTab Resolve naming collision between editorSlice and tabSlice. Both slices defined closeTab and setActiveTab, and since editorSlice was spread last in the store composition, it silently overwrote the tabSlice methods, breaking tab management. * fix: editor improvements — isDir bug, scroll-to-line, Quick Open, a11y - Fix isDir heuristic: use backend-provided isDirectory instead of filename-based guessing (breaks for Makefile, .github, etc.) - Add scroll-to-line on search result click via editorPendingGoToLine - Add Cmd+Shift+W shortcut for toggling line wrap - Rewrite Quick Open to fetch all project files from backend API instead of flattening the loaded tree (limited to expanded dirs) - Fix fd leak in atomicWrite: close file handle in finally block - Add a11y: role=dialog/alert, aria-modal, aria-label on modals - Add type=button on error state buttons --------- Co-authored-by: Алексей <aleksei@example.com>
716 lines
22 KiB
TypeScript
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: any;
|
|
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');
|
|
});
|
|
});
|