takt/src/__tests__/cli-routing-issue-resolve.test.ts
2026-02-10 23:44:03 +09:00

344 lines
11 KiB
TypeScript

/**
* 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<Record<string, unknown>>()),
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<string, unknown> = {};
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,
);
});
});
});