360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('../shared/ui/index.js', () => ({
|
|
info: vi.fn(),
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
withProgress: vi.fn(async (_start, _done, operation) => operation()),
|
|
}));
|
|
|
|
vi.mock('../shared/prompt/index.js', () => ({
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const { mockCheckCliStatus, mockFetchIssue, mockFetchPrReviewComments } = vi.hoisted(() => ({
|
|
mockCheckCliStatus: vi.fn(),
|
|
mockFetchIssue: vi.fn(),
|
|
mockFetchPrReviewComments: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../infra/git/index.js', () => ({
|
|
getGitProvider: () => ({
|
|
checkCliStatus: (...args: unknown[]) => mockCheckCliStatus(...args),
|
|
fetchIssue: (...args: unknown[]) => mockFetchIssue(...args),
|
|
fetchPrReviewComments: (...args: unknown[]) => mockFetchPrReviewComments(...args),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../infra/github/issue.js', () => ({
|
|
parseIssueNumbers: vi.fn(() => []),
|
|
formatIssueAsTask: vi.fn(),
|
|
isIssueReference: vi.fn(),
|
|
resolveIssueTask: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../features/tasks/index.js', () => ({
|
|
selectAndExecuteTask: vi.fn(),
|
|
determinePiece: vi.fn(),
|
|
saveTaskFromInteractive: vi.fn(),
|
|
createIssueAndSaveTask: vi.fn(),
|
|
promptLabelSelection: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
vi.mock('../features/pipeline/index.js', () => ({
|
|
executePipeline: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../features/interactive/index.js', () => ({
|
|
interactiveMode: vi.fn(),
|
|
selectInteractiveMode: vi.fn(() => 'assistant'),
|
|
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();
|
|
const mockCheckoutBranch = vi.fn();
|
|
vi.mock('../infra/task/index.js', () => ({
|
|
TaskRunner: vi.fn(() => ({
|
|
listAllTaskItems: mockListAllTaskItems,
|
|
})),
|
|
isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args),
|
|
checkoutBranch: (...args: unknown[]) => mockCheckoutBranch(...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' })),
|
|
loadPersonaSessions: vi.fn(() => ({})),
|
|
}));
|
|
|
|
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(),
|
|
isDirectTask: vi.fn(() => false),
|
|
}));
|
|
|
|
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
|
|
import { interactiveMode } from '../features/interactive/index.js';
|
|
import { executePipeline } from '../features/pipeline/index.js';
|
|
import { executeDefaultAction } from '../app/cli/routing.js';
|
|
import { error as logError } from '../shared/ui/index.js';
|
|
import type { InteractiveModeResult } from '../features/interactive/index.js';
|
|
import type { PrReviewData } from '../infra/git/index.js';
|
|
|
|
const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
|
const mockExecutePipeline = vi.mocked(executePipeline);
|
|
const mockLogError = vi.mocked(logError);
|
|
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
|
|
|
|
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
|
|
return {
|
|
number: 456,
|
|
title: 'Fix auth bug',
|
|
body: 'PR description',
|
|
url: 'https://github.com/org/repo/pull/456',
|
|
headRefName: 'fix/auth-bug',
|
|
comments: [{ author: 'commenter1', body: 'Update tests' }],
|
|
reviews: [{ author: 'reviewer1', body: 'Fix null check' }],
|
|
files: ['src/auth.ts'],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(mockOpts)) {
|
|
delete mockOpts[key];
|
|
}
|
|
mockDeterminePiece.mockResolvedValue('default');
|
|
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' });
|
|
mockListAllTaskItems.mockReturnValue([]);
|
|
mockIsStaleRunningTask.mockReturnValue(false);
|
|
});
|
|
|
|
describe('PR resolution in routing', () => {
|
|
describe('--pr option', () => {
|
|
it('should resolve PR review comments and pass to interactive mode', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const prReview = createMockPrReview();
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
expect.stringContaining('## PR #456 Review Comments:'),
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
{ excludeActions: ['create_issue'] },
|
|
);
|
|
});
|
|
|
|
it('should pass PR base branch as baseBranch when interactive save_task is selected', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const actionResult: InteractiveModeResult = {
|
|
action: 'save_task',
|
|
task: 'Saved PR task',
|
|
};
|
|
mockInteractiveMode.mockResolvedValue(actionResult);
|
|
const prReview = createMockPrReview({ baseRefName: 'release/main', headRefName: 'feat/my-pr-branch' });
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'Saved PR task',
|
|
'default',
|
|
expect.objectContaining({
|
|
presetSettings: expect.objectContaining({
|
|
worktree: true,
|
|
branch: 'feat/my-pr-branch',
|
|
autoPr: false,
|
|
baseBranch: 'release/main',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should execute task after resolving PR review comments', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const prReview = createMockPrReview({ headRefName: 'feat/my-pr-branch' });
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then: selectAndExecuteTask is called
|
|
expect(mockSelectAndExecuteTask).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
'summarized task',
|
|
expect.any(Object),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should checkout PR branch before executing task', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const prReview = createMockPrReview({ headRefName: 'feat/my-pr-branch' });
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then: checkoutBranch is called with the PR's head branch
|
|
expect(mockCheckoutBranch).toHaveBeenCalledWith('/test/cwd', 'feat/my-pr-branch');
|
|
});
|
|
|
|
it('should exit with error when gh CLI is unavailable', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
mockCheckCliStatus.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();
|
|
});
|
|
|
|
it('should pass to interactive mode even when PR has no review comments', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const emptyPrReview = createMockPrReview({ reviews: [], comments: [] });
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(emptyPrReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then: PR title/description/files are still passed to interactive mode
|
|
expect(mockInteractiveMode).toHaveBeenCalledWith(
|
|
'/test/cwd',
|
|
expect.stringContaining('## PR #456 Review Comments:'),
|
|
expect.anything(),
|
|
undefined,
|
|
undefined,
|
|
{ excludeActions: ['create_issue'] },
|
|
);
|
|
});
|
|
|
|
it('should not resolve issues when --pr is specified', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
const prReview = createMockPrReview();
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockFetchIssue).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('--pr and --issue mutual exclusion', () => {
|
|
it('should exit with error when both --pr and --issue are specified', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
mockOpts.issue = 123;
|
|
|
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
throw new Error('process.exit called');
|
|
});
|
|
|
|
// When/Then
|
|
await expect(executeDefaultAction()).rejects.toThrow('process.exit called');
|
|
expect(mockLogError).toHaveBeenCalledWith('--pr and --issue cannot be used together');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
|
|
mockExit.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('--pr and --task mutual exclusion', () => {
|
|
it('should exit with error when both --pr and --task are specified', async () => {
|
|
// Given
|
|
mockOpts.pr = 456;
|
|
mockOpts.task = 'some task';
|
|
|
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
throw new Error('process.exit called');
|
|
});
|
|
|
|
// When/Then
|
|
await expect(executeDefaultAction()).rejects.toThrow('process.exit called');
|
|
expect(mockLogError).toHaveBeenCalledWith('--pr and --task cannot be used together');
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
|
|
mockExit.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('--pr in pipeline mode', () => {
|
|
it('should pass prNumber to executePipeline', async () => {
|
|
// Given: override pipelineMode
|
|
const programModule = await import('../app/cli/program.js');
|
|
const originalPipelineMode = programModule.pipelineMode;
|
|
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
|
|
|
|
mockOpts.pr = 456;
|
|
mockExecutePipeline.mockResolvedValue(0);
|
|
|
|
// When
|
|
await executeDefaultAction();
|
|
|
|
// Then
|
|
expect(mockExecutePipeline).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prNumber: 456,
|
|
}),
|
|
);
|
|
|
|
// Cleanup
|
|
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
|
|
});
|
|
});
|
|
});
|