feat: add Vitest unit tests for utility functions

- Set up Vitest with 27 tests across 3 test suites
- relativeTime: edge cases, time buckets, future timestamps
- sessionDisplayName: labels, kinds, channels, UUID truncation
- messagesToMarkdown: roles, blocks, tool calls, system events
- Add test and test:watch npm scripts
- Add test step to CI workflow
This commit is contained in:
Nicolas Varrot
2026-02-13 06:58:39 +00:00
parent f05db6aa6d
commit c4725e65c2
6 changed files with 558 additions and 2 deletions

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi } from 'vitest';
import { messagesToMarkdown } from '../exportChat';
import type { ChatMessage } from '../../types';
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: '1',
role: 'user',
content: 'Hello world',
timestamp: new Date('2026-01-15T10:30:00Z').getTime(),
blocks: [],
isSystemEvent: false,
...overrides,
} as ChatMessage;
}
describe('messagesToMarkdown', () => {
it('includes session label as heading', () => {
const md = messagesToMarkdown([], 'Test Session');
expect(md).toContain('# Test Session');
});
it('includes export timestamp', () => {
const md = messagesToMarkdown([]);
expect(md).toContain('Exported from PinchChat on');
});
it('labels user messages with 👤 User', () => {
const md = messagesToMarkdown([makeMessage({ role: 'user' })]);
expect(md).toContain('👤 User');
});
it('labels assistant messages with 🤖 Assistant', () => {
const md = messagesToMarkdown([makeMessage({ role: 'assistant' })]);
expect(md).toContain('🤖 Assistant');
});
it('labels system events with ⚙️ System Event', () => {
const md = messagesToMarkdown([makeMessage({ role: 'user', isSystemEvent: true })]);
expect(md).toContain('⚙️ System Event');
});
it('renders text blocks', () => {
const md = messagesToMarkdown([makeMessage({
content: '',
blocks: [{ type: 'text', text: 'Some content here' }],
})]);
expect(md).toContain('Some content here');
});
it('renders tool_use blocks with name and input', () => {
const md = messagesToMarkdown([makeMessage({
role: 'assistant',
content: '',
blocks: [{ type: 'tool_use', name: 'exec', input: { command: 'ls' } }],
})]);
expect(md).toContain('`exec`');
expect(md).toContain('"command": "ls"');
});
it('falls back to content when blocks are empty', () => {
const md = messagesToMarkdown([makeMessage({ content: 'Fallback text', blocks: [] })]);
expect(md).toContain('Fallback text');
});
it('renders image blocks as placeholder', () => {
const md = messagesToMarkdown([makeMessage({
content: '',
blocks: [{ type: 'image', mediaType: 'image/png' }],
})]);
expect(md).toContain('*[Image]*');
});
it('wraps thinking blocks in details tags', () => {
vi.stubGlobal('Date', globalThis.Date);
const md = messagesToMarkdown([makeMessage({
role: 'assistant',
content: '',
blocks: [{ type: 'thinking', text: 'Let me think...' }],
})]);
expect(md).toContain('<details><summary>💭 Thinking</summary>');
expect(md).toContain('Let me think...');
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { relativeTime } from '../relativeTime';
describe('relativeTime', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns null for undefined', () => {
expect(relativeTime(undefined)).toBeNull();
});
it('returns null for 0', () => {
expect(relativeTime(0)).toBeNull();
});
it('returns "<1m" for timestamps less than 60s ago', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now - 30_000)).toBe('<1m');
expect(relativeTime(now)).toBe('<1m');
});
it('returns minutes for 1-59 minutes ago', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now - 60_000)).toBe('1m');
expect(relativeTime(now - 45 * 60_000)).toBe('45m');
});
it('returns hours for 1-23 hours ago', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now - 3_600_000)).toBe('1h');
expect(relativeTime(now - 23 * 3_600_000)).toBe('23h');
});
it('returns days for 1-29 days ago', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now - 86_400_000)).toBe('1d');
expect(relativeTime(now - 7 * 86_400_000)).toBe('7d');
});
it('returns months for 30+ days ago', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now - 30 * 86_400_000)).toBe('1mo');
expect(relativeTime(now - 90 * 86_400_000)).toBe('3mo');
});
it('clamps negative diff to 0 (future timestamps)', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
expect(relativeTime(now + 60_000)).toBe('<1m');
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { sessionDisplayName } from '../sessionName';
import type { Session } from '../../types';
function makeSession(overrides: Partial<Session> = {}): Session {
return {
key: 'agent:main:test',
kind: 'main',
channel: '',
lastActivity: Date.now(),
...overrides,
} as Session;
}
describe('sessionDisplayName', () => {
it('returns label when set', () => {
expect(sessionDisplayName(makeSession({ label: 'My Task' }))).toBe('My Task');
});
it('returns "Main" for main sessions without channel', () => {
expect(sessionDisplayName(makeSession({ kind: 'main', channel: '' }))).toBe('Main');
});
it('returns "Main · Discord" for main sessions with channel', () => {
expect(sessionDisplayName(makeSession({ kind: 'main', channel: 'discord' }))).toBe('Main · Discord');
});
it('returns "Cron" for cron sessions without channel', () => {
expect(sessionDisplayName(makeSession({ kind: 'cron', channel: '' }))).toBe('Cron');
});
it('returns "Cron · Telegram" for cron sessions with channel', () => {
expect(sessionDisplayName(makeSession({ kind: 'cron', channel: 'telegram' }))).toBe('Cron · Telegram');
});
it('returns "Task · Discord" for isolated sessions', () => {
expect(sessionDisplayName(makeSession({ kind: 'isolated', channel: 'discord' }))).toBe('Task · Discord');
});
it('returns capitalized channel as fallback', () => {
expect(sessionDisplayName(makeSession({ kind: undefined as unknown as string, channel: 'slack' } as Partial<Session>))).toBe('Slack');
});
it('truncates UUID session keys', () => {
const name = sessionDisplayName(makeSession({
kind: undefined as unknown as string,
channel: '',
key: 'agent:main:a1b2c3d4-e5f6-7890-abcd-ef1234567890',
} as Partial<Session>));
expect(name).toBe('a1b2c3d4…');
});
it('strips agent prefix from non-UUID keys', () => {
const name = sessionDisplayName(makeSession({
kind: undefined as unknown as string,
channel: '',
key: 'agent:bot:mycustomkey',
} as Partial<Session>));
expect(name).toBe('mycustomkey');
});
});