/** * Tests for issue resolution in routing module. * * Verifies that issue references (--issue N or #N positional arg) * are resolved before interactive mode and passed to selectAndExecuteTask * via selectOptions.issues. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn(), }), })); vi.mock('../infra/github/issue.js', () => ({ parseIssueNumbers: vi.fn(() => []), checkGhCli: vi.fn(), fetchIssue: vi.fn(), formatIssueAsTask: vi.fn(), isIssueReference: vi.fn(), resolveIssueTask: vi.fn(), createIssue: vi.fn(), })); vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), createIssueAndSaveTask: vi.fn(), })); vi.mock('../features/pipeline/index.js', () => ({ executePipeline: vi.fn(), })); vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), resolveLanguage: vi.fn(() => 'en'), })); vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3 })), })); vi.mock('../shared/constants.js', () => ({ DEFAULT_PIECE_NAME: 'default', })); const mockOpts: Record = {}; vi.mock('../app/cli/program.js', () => { const chainable = { opts: vi.fn(() => mockOpts), argument: vi.fn().mockReturnThis(), action: vi.fn().mockReturnThis(), }; return { program: chainable, resolvedCwd: '/test/cwd', pipelineMode: false, }; }); vi.mock('../app/cli/helpers.js', () => ({ resolveAgentOverrides: vi.fn(), parseCreateWorktreeOption: vi.fn(), isDirectTask: vi.fn(() => false), })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; import { loadGlobalConfig } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; const mockCheckGhCli = vi.mocked(checkGhCli); const mockFetchIssue = vi.mocked(fetchIssue); const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockIsDirectTask = vi.mocked(isDirectTask); function createMockIssue(number: number): GitHubIssue { return { number, title: `Issue #${number}`, body: `Body of issue #${number}`, labels: [], comments: [], }; } beforeEach(() => { vi.clearAllMocks(); // Reset opts for (const key of Object.keys(mockOpts)) { delete mockOpts[key]; } // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); }); describe('Issue resolution in routing', () => { describe('--issue option', () => { it('should resolve issue and pass to interactive mode when --issue is specified', async () => { // Given mockOpts.issue = 131; const issue131 = createMockIssue(131); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); // When await executeDefaultAction(); // Then: issue should be fetched expect(mockFetchIssue).toHaveBeenCalledWith(131); // Then: interactive mode should receive the formatted issue as initial input expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), undefined, ); // Then: selectAndExecuteTask should receive issues in options expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( '/test/cwd', 'summarized task', expect.objectContaining({ issues: [issue131], }), undefined, ); }); it('should exit with error when gh CLI is unavailable for --issue', async () => { // Given mockOpts.issue = 131; mockCheckGhCli.mockReturnValue({ available: false, error: 'gh CLI is not installed', }); const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); // When / Then await expect(executeDefaultAction()).rejects.toThrow('process.exit called'); expect(mockExit).toHaveBeenCalledWith(1); expect(mockInteractiveMode).not.toHaveBeenCalled(); mockExit.mockRestore(); }); }); describe('#N positional argument', () => { it('should resolve issue reference and pass to interactive mode', async () => { // Given const issue131 = createMockIssue(131); mockIsDirectTask.mockReturnValue(true); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131: Issue #131'); mockParseIssueNumbers.mockReturnValue([131]); // When await executeDefaultAction('#131'); // Then: interactive mode should be entered with formatted issue expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), undefined, ); // Then: selectAndExecuteTask should receive issues expect(mockSelectAndExecuteTask).toHaveBeenCalledWith( '/test/cwd', 'summarized task', expect.objectContaining({ issues: [issue131], }), undefined, ); }); }); describe('non-issue input', () => { it('should pass regular text input to interactive mode without issues', async () => { // When await executeDefaultAction('refactor the code'); // Then: interactive mode should receive the original text expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', 'refactor the code', expect.anything(), undefined, ); // Then: no issue fetching should occur expect(mockFetchIssue).not.toHaveBeenCalled(); // Then: selectAndExecuteTask should be called without issues const callArgs = mockSelectAndExecuteTask.mock.calls[0]; expect(callArgs?.[2]?.issues).toBeUndefined(); }); it('should enter interactive mode with no input when no args provided', async () => { // When await executeDefaultAction(); // Then: interactive mode should be entered with undefined input expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), undefined, ); // Then: no issue fetching should occur expect(mockFetchIssue).not.toHaveBeenCalled(); }); }); describe('interactive mode cancel', () => { it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => { // Given mockOpts.issue = 131; const issue131 = createMockIssue(131); mockCheckGhCli.mockReturnValue({ available: true }); mockFetchIssue.mockReturnValue(issue131); mockFormatIssueAsTask.mockReturnValue('## GitHub Issue #131'); mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); // When await executeDefaultAction(); // Then expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); describe('create_issue action', () => { it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); // When await executeDefaultAction(); // Then: createIssueAndSaveTask should be called with correct args expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith( '/test/cwd', 'New feature request', 'default', ); }); it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); // When await executeDefaultAction(); // Then: selectAndExecuteTask should NOT be called expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); describe('session selection with provider=claude', () => { it('should pass selected session ID to interactiveMode when provider is claude', async () => { // Given mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); mockSelectRecentSession.mockResolvedValue('session-xyz'); // When await executeDefaultAction(); // Then: selectRecentSession should be called expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); // Then: interactiveMode should receive the session ID as 4th argument expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), 'session-xyz', ); }); it('should not call selectRecentSession when provider is not claude', async () => { // Given mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); // When await executeDefaultAction(); // Then: selectRecentSession should NOT be called expect(mockSelectRecentSession).not.toHaveBeenCalled(); // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), undefined, ); }); }); });