diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts new file mode 100644 index 00000000..7fe14ab0 --- /dev/null +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resumeCommand } from './resumeCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('resumeCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should return a dialog action to open the resume dialog', async () => { + // Ensure the command has an action to test. + if (!resumeCommand.action) { + throw new Error('The resume command must have an action.'); + } + + const result = await resumeCommand.action(mockContext, ''); + + // Assert that the action returns the correct object to trigger the resume dialog. + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should have the correct name and description', () => { + expect(resumeCommand.name).toBe('resume'); + expect(resumeCommand.description).toBe('Resume a previous session'); + }); +}); diff --git a/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx new file mode 100644 index 00000000..52330624 --- /dev/null +++ b/packages/cli/src/ui/components/ResumeSessionDialog.test.tsx @@ -0,0 +1,303 @@ +/** + * @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 { ResumeSessionDialog } from './ResumeSessionDialog.js'; +import { KeypressProvider } from '../contexts/KeypressContext.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, + }); +}); + +// Mock SessionService and getGitBranch +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual('@qwen-code/qwen-code-core'); + return { + ...actual, + SessionService: vi.fn().mockImplementation(() => mockSessionService), + getGitBranch: vi.fn().mockReturnValue('main'), + }; +}); + +// Helper to create mock sessions +function createMockSession( + overrides: Partial = {}, +): 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, + }; +} + +// Default mock session service +let mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), +}; + +describe('ResumeSessionDialog', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Loading State', () => { + it('should show loading state initially', () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + expect(output).toContain('Loading sessions...'); + }); + }); + + describe('Empty State', () => { + it('should show "No sessions found" when there are no sessions', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('No sessions found'); + }); + }); + + describe('Session Display', () => { + it('should display sessions after loading', async () => { + const sessions = [ + createMockSession({ + sessionId: 'session-1', + prompt: 'First session prompt', + messageCount: 10, + }), + createMockSession({ + sessionId: 'session-2', + prompt: 'Second session prompt', + messageCount: 5, + }), + ]; + + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('First session prompt'); + }); + + it('should filter out empty sessions', async () => { + const sessions = [ + createMockSession({ + sessionId: 'empty-session', + prompt: '', + messageCount: 0, + }), + createMockSession({ + sessionId: 'valid-session', + prompt: 'Valid prompt', + messageCount: 5, + }), + ]; + + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: sessions, + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Valid prompt'); + // Empty session should be filtered out + expect(output).not.toContain('empty-session'); + }); + }); + + describe('Footer', () => { + it('should show navigation instructions in footer', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('to navigate'); + expect(output).toContain('Enter to select'); + expect(output).toContain('Esc to cancel'); + }); + + it('should show branch toggle hint when currentBranch is available', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + // Should show B key hint since getGitBranch is mocked to return 'main' + expect(output).toContain('B'); + expect(output).toContain('toggle branch'); + }); + }); + + describe('Terminal Height', () => { + it('should accept availableTerminalHeight prop', async () => { + mockSessionService = { + listSessions: vi.fn().mockResolvedValue({ + items: [createMockSession()], + hasMore: false, + nextCursor: undefined, + } as ListSessionsResult), + }; + + const onSelect = vi.fn(); + const onCancel = vi.fn(); + + // Should not throw with availableTerminalHeight prop + const { lastFrame } = render( + + + , + ); + + await wait(100); + + const output = lastFrame(); + expect(output).toContain('Resume Session'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 7c2c04f9..fbc2244b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false* │ -│ │ │ ▼ │ │ │ │ │ @@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Language Auto (detect from system) │ │ │ +│ Terminal Bell true │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ │ │ │ Show Status in Title false │ │ │ -│ Hide Tips true* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts new file mode 100644 index 00000000..3303b644 --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useResumeCommand } from './useResumeCommand.js'; + +describe('useResumeCommand', () => { + it('should initialize with dialog closed', () => { + const { result } = renderHook(() => useResumeCommand()); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should open the dialog when openResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + }); + + it('should close the dialog when closeResumeDialog is called', () => { + const { result } = renderHook(() => useResumeCommand()); + + // Open the dialog first + act(() => { + result.current.openResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(true); + + // Close the dialog + act(() => { + result.current.closeResumeDialog(); + }); + + expect(result.current.isResumeDialogOpen).toBe(false); + }); + + it('should maintain stable function references across renders', () => { + const { result, rerender } = renderHook(() => useResumeCommand()); + + const initialOpenFn = result.current.openResumeDialog; + const initialCloseFn = result.current.closeResumeDialog; + + rerender(); + + expect(result.current.openResumeDialog).toBe(initialOpenFn); + expect(result.current.closeResumeDialog).toBe(initialCloseFn); + }); +});