Files
Claude-Code-Workflow/ccw/src/tools/__tests__/ask-question.test.ts
catlog22 345437415f Add end-to-end tests for workspace switching and backend tests for ask_question tool
- Implemented E2E tests for workspace switching functionality, covering scenarios such as switching workspaces, data isolation, language preference maintenance, and UI updates.
- Added tests to ensure workspace data is cleared on logout and handles unsaved changes during workspace switches.
- Created comprehensive backend tests for the ask_question tool, validating question creation, execution, answer handling, cancellation, and timeout scenarios.
- Included edge case tests to ensure robustness against duplicate questions and invalid answers.
2026-01-31 16:02:20 +08:00

734 lines
20 KiB
TypeScript

// ========================================
// ask_question Tool Backend Tests
// ========================================
// Tests for the ask_question MCP tool functionality
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
execute,
handleAnswer,
cancelQuestion,
getPendingQuestions,
clearPendingQuestions,
} from '../tools/ask-question';
import type {
Question,
QuestionAnswer,
AskQuestionParams,
AskQuestionResult,
} from '../core/a2ui/A2UITypes';
describe('ask_question Tool', () => {
beforeEach(() => {
// Clear all pending questions before each test
clearPendingQuestions();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
clearPendingQuestions();
vi.useRealTimers();
});
describe('Question Validation', () => {
const createValidQuestion = (): Question => ({
id: 'test-question-1',
type: 'confirm',
title: 'Test Question',
});
it('should validate a valid confirm question', async () => {
const params: AskQuestionParams = {
question: createValidQuestion(),
};
// Should not throw during validation
const result = await execute(params);
expect(result).toBeDefined();
});
it('should validate a valid select question with options', async () => {
const question: Question = {
id: 'test-select',
type: 'select',
title: 'Select an option',
options: [
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2' },
],
};
const params: AskQuestionParams = { question };
const result = await execute(params);
expect(result).toBeDefined();
});
it('should validate a valid input question', async () => {
const question: Question = {
id: 'test-input',
type: 'input',
title: 'Enter your name',
placeholder: 'Name',
};
const params: AskQuestionParams = { question };
const result = await execute(params);
expect(result).toBeDefined();
});
it('should validate a valid multi-select question', async () => {
const question: Question = {
id: 'test-multi',
type: 'multi-select',
title: 'Select multiple options',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'c', label: 'C' },
],
};
const params: AskQuestionParams = { question };
const result = await execute(params);
expect(result).toBeDefined();
});
it('should reject question with missing id', async () => {
const invalidQuestion = {
type: 'confirm',
title: 'Test',
} as unknown as Question;
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('id');
});
it('should reject question with missing type', async () => {
const invalidQuestion = {
id: 'test',
title: 'Test',
} as unknown as Question;
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('type');
});
it('should reject question with missing title', async () => {
const invalidQuestion = {
id: 'test',
type: 'confirm',
} as unknown as Question;
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('title');
});
it('should reject invalid question type', async () => {
const invalidQuestion = {
id: 'test',
type: 'invalid-type',
title: 'Test',
} as unknown as Question;
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid question type');
});
it('should reject select question without options', async () => {
const invalidQuestion: Question = {
id: 'test',
type: 'select',
title: 'Test',
options: [],
};
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('options');
});
it('should reject options with missing value', async () => {
const invalidQuestion = {
id: 'test',
type: 'select',
title: 'Test',
options: [{ label: 'Option' }],
} as unknown as Question;
const params: AskQuestionParams = { question: invalidQuestion };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('value');
});
it('should reject invalid timeout (too small)', async () => {
const question: Question = {
id: 'test',
type: 'confirm',
title: 'Test',
timeout: 500, // Less than 1000ms
};
const params: AskQuestionParams = { question };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('Timeout');
});
it('should reject invalid timeout (too large)', async () => {
const question: Question = {
id: 'test',
type: 'confirm',
title: 'Test',
timeout: 3600001, // More than 1 hour
};
const params: AskQuestionParams = { question };
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('Timeout');
});
});
describe('Question Execution', () => {
it('should create pending question on execute', async () => {
const question: Question = {
id: 'test-pending',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
// Start execution (but don't await)
const executePromise = execute(params);
// Check that question is pending
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe('test-pending');
// Cancel to clean up
cancelQuestion('test-pending');
await executePromise;
});
it('should use provided surfaceId', async () => {
const question: Question = {
id: 'test-surface-id',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = {
question,
surfaceId: 'custom-surface-123',
};
const executePromise = execute(params);
const pending = getPendingQuestions();
expect(pending[0].surfaceId).toBe('custom-surface-123');
cancelQuestion('test-surface-id');
await executePromise;
});
it('should auto-generate surfaceId if not provided', async () => {
const question: Question = {
id: 'test-auto-surface',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const pending = getPendingQuestions();
expect(pending[0].surfaceId).toMatch(/^question-test-auto-surface-\d+$/);
cancelQuestion('test-auto-surface');
await executePromise;
});
it('should use default timeout if not specified', async () => {
const question: Question = {
id: 'test-timeout',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const pending = getPendingQuestions();
expect(pending[0].timeout).toBe(5 * 60 * 1000); // 5 minutes
cancelQuestion('test-timeout');
await executePromise;
});
});
describe('Answer Handling', () => {
it('should accept valid confirm answer', async () => {
const question: Question = {
id: 'test-confirm-answer',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Send answer
const answer: QuestionAnswer = {
questionId: 'test-confirm-answer',
value: true,
cancelled: false,
};
const handled = handleAnswer(answer);
expect(handled).toBe(true);
const result = await executePromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(false);
});
it('should accept valid select answer', async () => {
const question: Question = {
id: 'test-select-answer',
type: 'select',
title: 'Test',
options: [
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2' },
],
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-select-answer',
value: 'opt1',
};
const handled = handleAnswer(answer);
expect(handled).toBe(true);
const result = await executePromise;
expect(result.success).toBe(true);
});
it('should accept valid input answer', async () => {
const question: Question = {
id: 'test-input-answer',
type: 'input',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-input-answer',
value: 'User input text',
};
const handled = handleAnswer(answer);
expect(handled).toBe(true);
const result = await executePromise;
expect(result.success).toBe(true);
});
it('should accept valid multi-select answer', async () => {
const question: Question = {
id: 'test-multi-answer',
type: 'multi-select',
title: 'Test',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
{ value: 'c', label: 'C' },
],
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-multi-answer',
value: ['a', 'c'],
};
const handled = handleAnswer(answer);
expect(handled).toBe(true);
const result = await executePromise;
expect(result.success).toBe(true);
});
it('should reject answer for non-existent question', () => {
const answer: QuestionAnswer = {
questionId: 'non-existent',
value: 'test',
};
const handled = handleAnswer(answer);
expect(handled).toBe(false);
});
it('should reject answer with wrong questionId', async () => {
const question: Question = {
id: 'test-wrong-id',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'different-id',
value: true,
};
const handled = handleAnswer(answer);
expect(handled).toBe(false);
// Clean up
cancelQuestion('test-wrong-id');
await executePromise;
});
it('should reject invalid select answer (not in options)', async () => {
const question: Question = {
id: 'test-invalid-select',
type: 'select',
title: 'Test',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
],
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-invalid-select',
value: 'c', // Not in options
};
const handled = handleAnswer(answer);
expect(handled).toBe(false);
cancelQuestion('test-invalid-select');
await executePromise;
});
it('should reject invalid multi-select answer (contains invalid value)', async () => {
const question: Question = {
id: 'test-invalid-multi',
type: 'multi-select',
title: 'Test',
options: [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
],
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-invalid-multi',
value: ['a', 'c'], // c is not in options
};
const handled = handleAnswer(answer);
expect(handled).toBe(false);
cancelQuestion('test-invalid-multi');
await executePromise;
});
it('should handle cancelled answers', async () => {
const question: Question = {
id: 'test-cancelled',
type: 'input',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
const answer: QuestionAnswer = {
questionId: 'test-cancelled',
cancelled: true,
};
const handled = handleAnswer(answer);
expect(handled).toBe(true);
const result = await executePromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(true);
});
});
describe('Question Cancellation', () => {
it('should cancel pending question', async () => {
const question: Question = {
id: 'test-cancel',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Cancel the question
const cancelled = cancelQuestion('test-cancel');
expect(cancelled).toBe(true);
// Result should be resolved
const result = await executePromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(true);
expect(result.result?.error).toBe('Question cancelled');
});
it('should return false when cancelling non-existent question', () => {
const cancelled = cancelQuestion('non-existent');
expect(cancelled).toBe(false);
});
it('should remove question from pending after cancellation', async () => {
const question: Question = {
id: 'test-cancel-pending',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
cancelQuestion('test-cancel-pending');
expect(getPendingQuestions()).toHaveLength(0);
await executePromise;
});
});
describe('Timeout Handling', () => {
it('should timeout question after specified duration', async () => {
const question: Question = {
id: 'test-timeout',
type: 'confirm',
title: 'Test',
timeout: 5000, // 5 seconds
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Fast-forward time
vi.advanceTimersByTime(5000);
const result = await executePromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(false);
expect(result.result?.error).toBe('Question timed out');
});
it('should use default timeout if not specified', async () => {
const question: Question = {
id: 'test-default-timeout',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Fast-forward past default timeout (5 minutes)
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
const result = await executePromise;
expect(result.success).toBe(true);
expect(result.result?.error).toBe('Question timed out');
});
});
describe('getPendingQuestions()', () => {
it('should return empty array when no questions pending', () => {
const pending = getPendingQuestions();
expect(pending).toEqual([]);
});
it('should return all pending questions', async () => {
const executePromises = [];
for (let i = 1; i <= 3; i++) {
const question: Question = {
id: `test-pending-${i}`,
type: 'confirm',
title: `Question ${i}`,
};
const params: AskQuestionParams = { question };
executePromises.push(execute(params));
}
const pending = getPendingQuestions();
expect(pending).toHaveLength(3);
expect(pending.map((p) => p.id)).toEqual(['test-pending-1', 'test-pending-2', 'test-pending-3']);
// Clean up
cancelQuestion('test-pending-1');
cancelQuestion('test-pending-2');
cancelQuestion('test-pending-3');
await Promise.all(executePromises);
});
});
describe('clearPendingQuestions()', () => {
it('should clear all pending questions', async () => {
const executePromises = [];
for (let i = 1; i <= 3; i++) {
const question: Question = {
id: `test-clear-${i}`,
type: 'confirm',
title: `Question ${i}`,
};
const params: AskQuestionParams = { question };
executePromises.push(execute(params));
}
expect(getPendingQuestions()).toHaveLength(3);
clearPendingQuestions();
expect(getPendingQuestions()).toHaveLength(0);
// All promises should be rejected
for (const promise of executePromises) {
const result = await promise;
expect(result.success).toBe(false);
}
});
});
describe('Edge Cases', () => {
it('should handle multiple questions with same id correctly', async () => {
const question: Question = {
id: 'duplicate-id',
type: 'confirm',
title: 'First',
};
const params1: AskQuestionParams = { question };
const executePromise1 = execute(params1);
// Second execution with same ID should replace first
const question2: Question = {
id: 'duplicate-id',
type: 'confirm',
title: 'Second',
};
const params2: AskQuestionParams = { question: question2 };
const executePromise2 = execute(params2);
// There should still be only one pending
expect(getPendingQuestions()).toHaveLength(1);
// Clean up
cancelQuestion('duplicate-id');
await Promise.all([executePromise1, executePromise2]);
});
it('should handle answer after question is cancelled', async () => {
const question: Question = {
id: 'test-then-cancel',
type: 'confirm',
title: 'Test',
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Cancel first
cancelQuestion('test-then-cancel');
await executePromise;
// Then try to send answer
const answer: QuestionAnswer = {
questionId: 'test-then-cancel',
value: true,
};
const handled = handleAnswer(answer);
expect(handled).toBe(false);
});
it('should handle all question types with default values', async () => {
const testCases: Array<{ type: Question['type']; defaultValue: unknown }> = [
{ type: 'input', defaultValue: 'default text' },
{ type: 'select', defaultValue: 'opt1' },
];
for (const testCase of testCases) {
const question: Question = {
id: `test-default-${testCase.type}`,
type: testCase.type,
title: 'Test',
options: testCase.type === 'select' ? [
{ value: 'opt1', label: 'Option 1' },
{ value: 'opt2', label: 'Option 2' },
] : undefined,
defaultValue: testCase.defaultValue as string,
};
const params: AskQuestionParams = { question };
const executePromise = execute(params);
// Should execute without error
expect(getPendingQuestions()).toHaveLength(1);
cancelQuestion(`test-default-${testCase.type}`);
await executePromise;
}
});
});
});