564 lines
18 KiB
TypeScript
564 lines
18 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/prompt/index.js', () => ({
|
|
confirm: vi.fn(() => true),
|
|
}));
|
|
|
|
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(),
|
|
createIssueFromTask: 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'),
|
|
selectRun: vi.fn(() => null),
|
|
loadRunSessionContext: vi.fn(),
|
|
listRecentRuns: vi.fn(() => []),
|
|
normalizeTaskHistorySummary: vi.fn((items: unknown[]) => items),
|
|
dispatchConversationAction: vi.fn(async (result: { action: string }, handlers: Record<string, (r: unknown) => unknown>) => {
|
|
return handlers[result.action](result);
|
|
}),
|
|
}));
|
|
|
|
const mockListAllTaskItems = vi.fn();
|
|
const mockIsStaleRunningTask = vi.fn();
|
|
vi.mock('../infra/task/index.js', () => ({
|
|
TaskRunner: vi.fn(() => ({
|
|
listAllTaskItems: mockListAllTaskItems,
|
|
})),
|
|
isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args),
|
|
}));
|
|
|
|
vi.mock('../infra/config/index.js', () => ({
|
|
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })),
|
|
resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)),
|
|
resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })),
|
|
}));
|
|
|
|
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, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js';
|
|
import { interactiveMode, selectRecentSession } from '../features/interactive/index.js';
|
|
import { resolveConfigValues } from '../infra/config/index.js';
|
|
import { confirm } from '../shared/prompt/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 mockCreateIssueFromTask = vi.mocked(createIssueFromTask);
|
|
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
|
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
|
const mockSelectRecentSession = vi.mocked(selectRecentSession);
|
|
const mockResolveConfigValues = vi.mocked(resolveConfigValues);
|
|
const mockConfirm = vi.mocked(confirm);
|
|
const mockIsDirectTask = vi.mocked(isDirectTask);
|
|
const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems);
|
|
|
|
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' });
|
|
mockConfirm.mockResolvedValue(true);
|
|
mockIsDirectTask.mockReturnValue(false);
|
|
mockParseIssueNumbers.mockReturnValue([]);
|
|
mockTaskRunnerListAllTaskItems.mockReturnValue([]);
|
|
mockIsStaleRunningTask.mockReturnValue(false);
|
|
});
|
|
|
|
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('task history injection', () => {
|
|
it('should include failed/completed/interrupted tasks in pieceContext for interactive mode', async () => {
|
|
const failedTask = {
|
|
kind: 'failed' as const,
|
|
name: 'failed-task',
|
|
createdAt: '2026-02-17T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'failed',
|
|
worktreePath: '/tmp/task/failed',
|
|
branch: 'takt/failed',
|
|
startedAt: '2026-02-17T00:00:00.000Z',
|
|
completedAt: '2026-02-17T00:10:00.000Z',
|
|
failure: { error: 'syntax error' },
|
|
};
|
|
const completedTask = {
|
|
kind: 'completed' as const,
|
|
name: 'completed-task',
|
|
createdAt: '2026-02-16T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'done',
|
|
worktreePath: '/tmp/task/completed',
|
|
branch: 'takt/completed',
|
|
startedAt: '2026-02-16T00:00:00.000Z',
|
|
completedAt: '2026-02-16T00:07:00.000Z',
|
|
};
|
|
const runningTask = {
|
|
kind: 'running' as const,
|
|
name: 'running-task',
|
|
createdAt: '2026-02-15T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'running',
|
|
worktreePath: '/tmp/task/interrupted',
|
|
ownerPid: 555,
|
|
startedAt: '2026-02-15T00:00:00.000Z',
|
|
};
|
|
mockTaskRunnerListAllTaskItems.mockReturnValue([failedTask, completedTask, runningTask]);
|
|
mockIsStaleRunningTask.mockReturnValue(true);
|
|
|
|
// When
|
|
await executeDefaultAction('add feature');
|
|
|
|
// Then
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'add feature',
|
|
expect.objectContaining({
|
|
taskHistory: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
worktreeId: '/tmp/task/failed',
|
|
status: 'failed',
|
|
finalResult: 'failed',
|
|
logKey: 'takt/failed',
|
|
}),
|
|
expect.objectContaining({
|
|
worktreeId: '/tmp/task/completed',
|
|
status: 'completed',
|
|
finalResult: 'completed',
|
|
logKey: 'takt/completed',
|
|
}),
|
|
expect.objectContaining({
|
|
worktreeId: '/tmp/task/interrupted',
|
|
status: 'interrupted',
|
|
finalResult: 'interrupted',
|
|
logKey: '/tmp/task/interrupted',
|
|
}),
|
|
]),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should treat running tasks with no ownerPid as interrupted', async () => {
|
|
const runningTaskWithoutPid = {
|
|
kind: 'running' as const,
|
|
name: 'running-task-no-owner',
|
|
createdAt: '2026-02-15T00:00:00.000Z',
|
|
filePath: '/project/.takt/tasks.yaml',
|
|
content: 'running',
|
|
worktreePath: '/tmp/task/running-no-owner',
|
|
branch: 'takt/running-no-owner',
|
|
startedAt: '2026-02-15T00:00:00.000Z',
|
|
};
|
|
mockTaskRunnerListAllTaskItems.mockReturnValue([runningTaskWithoutPid]);
|
|
mockIsStaleRunningTask.mockReturnValue(true);
|
|
|
|
await executeDefaultAction('recover interrupted');
|
|
|
|
expect(mockIsStaleRunningTask).toHaveBeenCalledWith(undefined);
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'recover interrupted',
|
|
expect.objectContaining({
|
|
taskHistory: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
worktreeId: '/tmp/task/running-no-owner',
|
|
status: 'interrupted',
|
|
finalResult: 'interrupted',
|
|
logKey: 'takt/running-no-owner',
|
|
}),
|
|
]),
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should continue interactive mode when task list retrieval fails', async () => {
|
|
mockTaskRunnerListAllTaskItems.mockImplementation(() => {
|
|
throw new Error('list failed');
|
|
});
|
|
|
|
// When
|
|
await executeDefaultAction('fix issue');
|
|
|
|
// Then
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'fix issue',
|
|
expect.objectContaining({ taskHistory: [] }),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should pass empty taskHistory when task list is empty', async () => {
|
|
mockTaskRunnerListAllTaskItems.mockReturnValue([]);
|
|
|
|
await executeDefaultAction('verify history');
|
|
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'verify history',
|
|
expect.objectContaining({ taskHistory: [] }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 create issue first, then delegate final confirmation to saveTaskFromInteractive', async () => {
|
|
// Given
|
|
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
|
|
mockCreateIssueFromTask.mockReturnValue(226);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then: issue is created first
|
|
expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request');
|
|
// Then: saveTaskFromInteractive receives final confirmation message
|
|
expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'New feature request',
|
|
'default',
|
|
{ issue: 226, confirmAtEndMessage: 'Add this issue to tasks?' },
|
|
);
|
|
});
|
|
|
|
it('should skip confirmation and task save when issue creation fails', async () => {
|
|
// Given
|
|
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' });
|
|
mockCreateIssueFromTask.mockReturnValue(undefined);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request');
|
|
expect(mockSaveTaskFromInteractive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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
|
|
mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' });
|
|
mockConfirm.mockResolvedValue(true);
|
|
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',
|
|
);
|
|
|
|
expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false);
|
|
});
|
|
|
|
it('should not call selectRecentSession when user selects no in confirmation', async () => {
|
|
// Given
|
|
mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' });
|
|
mockConfirm.mockResolvedValue(false);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false);
|
|
expect(mockSelectRecentSession).not.toHaveBeenCalled();
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
undefined,
|
|
expect.anything(),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should not call selectRecentSession when provider is not claude', async () => {
|
|
// Given
|
|
mockResolveConfigValues.mockReturnValue({ language: 'en', 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,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('run session reference', () => {
|
|
it('should not prompt run session reference in default interactive flow', async () => {
|
|
await executeDefaultAction();
|
|
|
|
expect(mockConfirm).not.toHaveBeenCalledWith(
|
|
"Reference a previous run's results?",
|
|
false,
|
|
);
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
undefined,
|
|
expect.anything(),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
});
|