Add tests

This commit is contained in:
Alexander Farber
2025-12-12 20:55:54 +01:00
parent a761be80a5
commit 2de50ae436
2 changed files with 591 additions and 2 deletions

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionPicker } from './ResumeSessionPicker.js';
import type {
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
// Mock terminal size
const mockTerminalSize = { columns: 80, rows: 24 };
beforeEach(() => {
Object.defineProperty(process.stdout, 'columns', {
value: mockTerminalSize.columns,
configurable: true,
});
Object.defineProperty(process.stdout, 'rows', {
value: mockTerminalSize.rows,
configurable: true,
});
});
// Helper to create mock sessions
function createMockSession(
overrides: Partial<SessionListItem> = {},
): SessionListItem {
return {
sessionId: 'test-session-id',
cwd: '/test/path',
startTime: '2025-01-01T00:00:00.000Z',
mtime: Date.now(),
prompt: 'Test prompt',
gitBranch: 'main',
filePath: '/test/path/sessions/test-session-id.jsonl',
messageCount: 5,
...overrides,
};
}
// Helper to create mock session service
function createMockSessionService(
sessions: SessionListItem[] = [],
hasMore = false,
) {
return {
listSessions: vi.fn().mockResolvedValue({
items: sessions,
hasMore,
nextCursor: hasMore ? Date.now() : undefined,
} as ListSessionsResult),
loadSession: vi.fn(),
loadLastSession: vi
.fn()
.mockResolvedValue(sessions.length > 0 ? {} : undefined),
};
}
describe('SessionPicker', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
vi.clearAllMocks();
});
describe('Empty Sessions Filtering', () => {
it('should filter out sessions with 0 messages', async () => {
const sessions = [
createMockSession({
sessionId: 'empty-1',
messageCount: 0,
prompt: '',
}),
createMockSession({
sessionId: 'with-messages',
messageCount: 5,
prompt: 'Hello',
}),
createMockSession({
sessionId: 'empty-2',
messageCount: 0,
prompt: '(empty prompt)',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
// Should show the session with messages
expect(output).toContain('Hello');
// Should NOT show empty sessions
expect(output).not.toContain('empty-1');
expect(output).not.toContain('empty-2');
});
it('should show "No sessions found" when all sessions are empty', async () => {
const sessions = [
createMockSession({ sessionId: 'empty-1', messageCount: 0 }),
createMockSession({ sessionId: 'empty-2', messageCount: 0 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('No sessions found');
});
it('should show sessions with 1 or more messages', async () => {
const sessions = [
createMockSession({
sessionId: 'one-msg',
messageCount: 1,
prompt: 'Single message',
}),
createMockSession({
sessionId: 'many-msg',
messageCount: 10,
prompt: 'Many messages',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Single message');
expect(output).toContain('Many messages');
expect(output).toContain('1 message');
expect(output).toContain('10 messages');
});
});
describe('Branch Filtering', () => {
it('should filter by branch when B is pressed', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
prompt: 'Main branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
gitBranch: 'feature',
prompt: 'Feature branch',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
gitBranch: 'main',
prompt: 'Also main',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// All sessions should be visible initially
let output = lastFrame();
expect(output).toContain('Main branch');
expect(output).toContain('Feature branch');
// Press B to filter by branch
stdin.write('B');
await wait(50);
output = lastFrame();
// Only main branch sessions should be visible
expect(output).toContain('Main branch');
expect(output).toContain('Also main');
expect(output).not.toContain('Feature branch');
});
it('should combine empty session filter with branch filter', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
gitBranch: 'main',
messageCount: 0,
prompt: 'Empty main',
}),
createMockSession({
sessionId: 's2',
gitBranch: 'main',
messageCount: 5,
prompt: 'Valid main',
}),
createMockSession({
sessionId: 's3',
gitBranch: 'feature',
messageCount: 5,
prompt: 'Valid feature',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// Press B to filter by branch
stdin.write('B');
await wait(50);
const output = lastFrame();
// Should only show non-empty sessions from main branch
expect(output).toContain('Valid main');
expect(output).not.toContain('Empty main');
expect(output).not.toContain('Valid feature');
});
});
describe('Keyboard Navigation', () => {
it('should navigate with arrow keys', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First session',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second session',
messageCount: 1,
}),
createMockSession({
sessionId: 's3',
prompt: 'Third session',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame, stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// First session should be selected initially (indicated by >)
let output = lastFrame();
expect(output).toContain('First session');
// Navigate down
stdin.write('\u001B[B'); // Down arrow
await wait(50);
output = lastFrame();
// Selection indicator should move
expect(output).toBeDefined();
});
it('should navigate with vim keys (j/k)', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'First',
messageCount: 1,
}),
createMockSession({
sessionId: 's2',
prompt: 'Second',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin, unmount } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// Navigate with j (down)
stdin.write('j');
await wait(50);
// Navigate with k (up)
stdin.write('k');
await wait(50);
unmount();
});
it('should select session on Enter', async () => {
const sessions = [
createMockSession({
sessionId: 'selected-session',
prompt: 'Select me',
messageCount: 1,
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// Press Enter to select
stdin.write('\r');
await wait(50);
expect(onSelect).toHaveBeenCalledWith('selected-session');
});
it('should cancel on Escape', async () => {
const sessions = [
createMockSession({ sessionId: 's1', messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { stdin } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
// Press Escape to cancel
stdin.write('\u001B');
await wait(50);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
});
describe('Display', () => {
it('should show session metadata', async () => {
const sessions = [
createMockSession({
sessionId: 's1',
prompt: 'Test prompt text',
messageCount: 5,
gitBranch: 'feature-branch',
}),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Test prompt text');
expect(output).toContain('5 messages');
expect(output).toContain('feature-branch');
});
it('should show header and footer', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('Resume Session');
expect(output).toContain('to navigate');
expect(output).toContain('Esc to cancel');
});
it('should show branch toggle hint when currentBranch is provided', async () => {
const sessions = [createMockSession({ messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
currentBranch="main"
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('B');
expect(output).toContain('toggle branch');
});
it('should truncate long prompts', async () => {
const longPrompt = 'A'.repeat(300);
const sessions = [
createMockSession({ prompt: longPrompt, messageCount: 1 }),
];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
// Should contain ellipsis for truncated text
expect(output).toContain('...');
// Should NOT contain the full untruncated prompt (300 A's in a row)
expect(output).not.toContain(longPrompt);
});
it('should show "(empty prompt)" for sessions without prompt text', async () => {
const sessions = [createMockSession({ prompt: '', messageCount: 1 })];
const mockService = createMockSessionService(sessions);
const onSelect = vi.fn();
const onCancel = vi.fn();
const { lastFrame } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(100);
const output = lastFrame();
expect(output).toContain('(empty prompt)');
});
});
describe('Pagination', () => {
it('should load more sessions when scrolling to bottom', async () => {
const firstPage = Array.from({ length: 5 }, (_, i) =>
createMockSession({
sessionId: `session-${i}`,
prompt: `Session ${i}`,
messageCount: 1,
mtime: Date.now() - i * 1000,
}),
);
const secondPage = Array.from({ length: 3 }, (_, i) =>
createMockSession({
sessionId: `session-${i + 5}`,
prompt: `Session ${i + 5}`,
messageCount: 1,
mtime: Date.now() - (i + 5) * 1000,
}),
);
const mockService = {
listSessions: vi
.fn()
.mockResolvedValueOnce({
items: firstPage,
hasMore: true,
nextCursor: Date.now() - 5000,
})
.mockResolvedValueOnce({
items: secondPage,
hasMore: false,
nextCursor: undefined,
}),
loadSession: vi.fn(),
loadLastSession: vi.fn().mockResolvedValue({}),
};
const onSelect = vi.fn();
const onCancel = vi.fn();
const { unmount } = render(
<SessionPicker
sessionService={mockService as never}
onSelect={onSelect}
onCancel={onCancel}
/>,
);
await wait(200);
// First page should be loaded
expect(mockService.listSessions).toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -17,7 +17,8 @@ import { formatRelativeTime } from '../utils/formatters.js';
const PAGE_SIZE = 20;
interface SessionPickerProps {
// Exported for testing
export interface SessionPickerProps {
sessionService: SessionService;
currentBranch?: string;
onSelect: (sessionId: string) => void;
@@ -33,7 +34,8 @@ function truncateText(text: string, maxWidth: number): string {
return text.slice(0, maxWidth - 3) + '...';
}
function SessionPicker({
// Exported for testing
export function SessionPicker({
sessionService,
currentBranch,
onSelect,