Skip to content

Commit 196ff04

Browse files
feat: Add 'Generate Tests' feature
This commit introduces a new feature allowing you to generate unit tests for specific code symbols (functions, methods, etc.) using an LLM. Key components of this feature include: 1. **`generateTestsTool.ts`**: A new capability in `src/core/tools/` that: * Takes a file path and symbol name as input. * Uses an enhanced tree-sitter service (`extractSymbolCode`) to precisely extract the source code of the specified symbol. * Constructs a detailed prompt for the configured LLM, including the symbol's code and language-specific hints (e.g., for Jest, PyTest). * Calls the LLM and streams the response. * Returns the generated test code. 2. **VS Code Command (`roo-cline.generateTests`)**: * Registered in `src/activate/registerCommands.ts` and `package.json`. * Accessible via the command palette as "Roo: Generate Tests for Symbol". * Prompts you for a file path and symbol name. * Initiates a new Roo Code task, instructing me to use my test generation capability with the provided parameters. 3. **Tree-sitter Enhancements (`src/services/tree-sitter/index.ts`)**: * Refactored to provide structured `SymbolDefinition` objects, including the full source code of symbols. * Added `extractSymbolCode(filePath, symbolName)` for direct retrieval of a symbol's code. 4. **Capability Registration**: * My test generation capability is registered in the tool dispatcher (`src/core/assistant-message/presentAssistantMessage.ts`), allowing me to be invoked by the Roo Code task system. 5. **User Experience**: * Generated tests are displayed in the Roo Code chat interface. 6. **Error Handling**: * I've included error handling for missing parameters, file/symbol not found, LLM API errors, and empty responses. 7. **Documentation & Testing**: * Developer-focused documentation (JSDoc, inline comments) has been added. * A comprehensive suite of unit tests (`generateTestsTool.test.ts`) has been created to ensure the tool's reliability.
1 parent dd295d9 commit 196ff04

File tree

6 files changed

+676
-164
lines changed

6 files changed

+676
-164
lines changed

src/activate/registerCommands.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,50 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
190190

191191
visibleProvider.postMessageToWebview({ type: "acceptInput" })
192192
},
193+
// Command to generate unit tests for a specified symbol in a file.
194+
// It prompts the user for the file path and symbol name, then constructs
195+
// a tool_use string and initiates a new task flow via initClineWithTask.
196+
// The generateTestsTool is then expected to be invoked by the Task's tool dispatcher.
197+
"roo-cline.generateTests": async () => {
198+
const visibleProvider = await ClineProvider.getInstance();
199+
if (!visibleProvider) {
200+
vscode.window.showErrorMessage("Roo Code is not active. Please open Roo Code chat.");
201+
return;
202+
}
203+
204+
const filePath = await vscode.window.showInputBox({
205+
prompt: "Enter the path to the file (relative to workspace root)",
206+
placeHolder: "e.g., src/utils/myHelper.ts",
207+
});
208+
if (!filePath) {
209+
return; // User cancelled
210+
}
211+
212+
const symbolName = await vscode.window.showInputBox({
213+
prompt: "Enter the symbol name (e.g., function or class name)",
214+
placeHolder: "e.g., myHelperFunction",
215+
});
216+
if (!symbolName) {
217+
return; // User cancelled
218+
}
219+
220+
// Construct the tool use string as if the LLM requested it.
221+
// Ensure parameters are XML-attribute-safe. For simplicity, assuming they are for now.
222+
// A more robust solution would properly escape attributes if needed.
223+
const toolCallString = `<tool_use tool_name="generateTestsTool" filePath="${filePath}" symbolName="${symbolName}"></tool_use>`;
224+
225+
try {
226+
// This will start a new interaction flow within the visible Roo Code panel,
227+
// treating the toolCallString as the initial "user" message.
228+
// The Task machinery should then parse and execute this tool call.
229+
await visibleProvider.initClineWithTask(toolCallString);
230+
vscode.window.showInformationMessage(`Roo is generating tests for ${symbolName} in ${filePath}. Check the Roo Code chat.`);
231+
} catch (error) {
232+
vscode.window.showErrorMessage(`Failed to start test generation: ${error instanceof Error ? error.message : String(error)}`);
233+
// Log to output channel as well
234+
visibleProvider.log(`Error in roo-cline.generateTests command: ${error instanceof Error ? error.message : String(error)}`);
235+
}
236+
},
193237
})
194238

195239
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { formatResponse } from "../prompts/responses"
3131
import { validateToolUse } from "../tools/validateToolUse"
3232
import { Task } from "../task/Task"
3333
import { codebaseSearchTool } from "../tools/codebaseSearchTool"
34+
import { generateTestsTool } from "../tools/generateTestsTool";
3435

3536
/**
3637
* Processes and presents assistant message content to the user interface.
@@ -184,8 +185,10 @@ export async function presentAssistantMessage(cline: Task) {
184185
return `[${block.name}]`
185186
case "switch_mode":
186187
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
187-
case "codebase_search": // Add case for the new tool
188+
case "codebase_search":
188189
return `[${block.name} for '${block.params.query}']`
190+
case "generateTestsTool":
191+
return `[${block.name} for '${block.params.symbolName}' in '${block.params.filePath}']`
189192
case "new_task": {
190193
const mode = block.params.mode ?? defaultModeSlug
191194
const message = block.params.message ?? "(no message)"
@@ -466,6 +469,10 @@ export async function presentAssistantMessage(cline: Task) {
466469
askFinishSubTaskApproval,
467470
)
468471
break
472+
// Handles requests to generate unit tests for a specified code symbol.
473+
case "generateTestsTool":
474+
await generateTestsTool(cline, block, askApproval, handleError, pushToolResult)
475+
break;
469476
}
470477

471478
break
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { generateTestsTool } from '../generateTestsTool';
3+
import type { Task } from '../../task/Task'; // Using type for Task
4+
import type { ToolUse, Anthropic } from '@roo-code/types'; // Using type for ToolUse
5+
6+
// Mock dependencies
7+
// Using vi.hoisted for variables that need to be accessed in vi.mock factory
8+
const { extractSymbolCodeMock, pathExtnameMock } = vi.hoisted(() => {
9+
return {
10+
extractSymbolCodeMock: vi.fn(),
11+
pathExtnameMock: vi.fn(),
12+
};
13+
});
14+
15+
vi.mock('../../../services/tree-sitter', () => ({
16+
extractSymbolCode: extractSymbolCodeMock,
17+
}));
18+
19+
vi.mock('path', async () => {
20+
const actualPath = await vi.importActual<typeof import('path')>('path');
21+
return {
22+
...actualPath,
23+
extname: pathExtnameMock,
24+
resolve: vi.fn((...paths) => actualPath.join(...paths)), // Use join for testing consistency
25+
};
26+
});
27+
28+
// Helper to create an async iterable stream
29+
async function* createMockStream(chunks: Array<{ type: string; text?: string; error?: any }>) {
30+
for (const chunk of chunks) {
31+
yield chunk;
32+
}
33+
}
34+
35+
describe('generateTestsTool', () => {
36+
let mockCline: Task;
37+
let mockBlock: ToolUse;
38+
let mockAskApproval: ReturnType<typeof vi.fn>;
39+
let mockHandleError: ReturnType<typeof vi.fn>;
40+
let mockPushToolResult: ReturnType<typeof vi.fn>;
41+
42+
beforeEach(() => {
43+
mockAskApproval = vi.fn();
44+
mockHandleError = vi.fn();
45+
mockPushToolResult = vi.fn();
46+
47+
mockCline = {
48+
api: {
49+
createMessage: vi.fn(),
50+
},
51+
cwd: '/test/workspace',
52+
taskId: 'test-task-id',
53+
// Add other Task properties/methods if generateTestsTool starts using them
54+
} as unknown as Task; // Cast to Task, acknowledging it's a partial mock
55+
56+
mockBlock = {
57+
tool_name: 'generateTestsTool',
58+
tool_id: 'test-tool-id',
59+
params: {},
60+
raw_content: '<tool_use tool_name="generateTestsTool"></tool_use>', // Example raw_content
61+
};
62+
});
63+
64+
afterEach(() => {
65+
vi.clearAllMocks();
66+
});
67+
68+
// Test Cases will go here
69+
70+
it('should call handleError if filePath is missing', async () => {
71+
mockBlock.params = { symbolName: 'testSymbol' };
72+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
73+
expect(mockHandleError).toHaveBeenCalledWith(new Error('Missing required parameter: filePath'));
74+
expect(mockPushToolResult).not.toHaveBeenCalled();
75+
});
76+
77+
it('should call handleError if symbolName is missing', async () => {
78+
mockBlock.params = { filePath: 'src/test.ts' };
79+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
80+
expect(mockHandleError).toHaveBeenCalledWith(new Error('Missing required parameter: symbolName'));
81+
expect(mockPushToolResult).not.toHaveBeenCalled();
82+
});
83+
84+
it('should call handleError if extractSymbolCode returns null (symbol not found)', async () => {
85+
mockBlock.params = { filePath: 'src/test.ts', symbolName: 'testSymbol' };
86+
extractSymbolCodeMock.mockResolvedValue(null);
87+
pathExtnameMock.mockReturnValue('.ts'); // Needed for prompt construction path
88+
89+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
90+
91+
expect(extractSymbolCodeMock).toHaveBeenCalledWith('/test/workspace/src/test.ts', 'testSymbol', undefined);
92+
expect(mockHandleError).toHaveBeenCalledWith(
93+
new Error('Could not extract code for symbol "testSymbol" from src/test.ts. The symbol may not exist, the file type might be unsupported for symbol extraction, or the file itself may not be found.')
94+
);
95+
expect(mockPushToolResult).not.toHaveBeenCalled();
96+
});
97+
98+
it('happy path: should call pushToolResult with generated tests', async () => {
99+
const filePath = 'src/component.jsx';
100+
const symbolName = 'MyComponent';
101+
const symbolCode = 'const MyComponent = () => <div>Hello</div>;';
102+
const generatedTestCode = 'describe("MyComponent", () => { it("should render", () => {}); });';
103+
104+
mockBlock.params = { filePath, symbolName };
105+
extractSymbolCodeMock.mockResolvedValue(symbolCode);
106+
pathExtnameMock.mockReturnValue('.jsx');
107+
(mockCline.api.createMessage as ReturnType<typeof vi.fn>).mockReturnValue(
108+
createMockStream([{ type: 'text', text: generatedTestCode }])
109+
);
110+
111+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
112+
113+
expect(extractSymbolCodeMock).toHaveBeenCalledWith(`/test/workspace/${filePath}`, symbolName, undefined);
114+
expect(pathExtnameMock).toHaveBeenCalledWith(filePath);
115+
expect(mockCline.api.createMessage).toHaveBeenCalled();
116+
expect(mockPushToolResult).toHaveBeenCalledWith(generatedTestCode);
117+
expect(mockHandleError).not.toHaveBeenCalled();
118+
});
119+
120+
it('should call handleError if LLM stream returns an error chunk', async () => {
121+
const filePath = 'src/error.py';
122+
const symbolName = 'errorFunc';
123+
const symbolCode = 'def errorFunc(): pass';
124+
125+
mockBlock.params = { filePath, symbolName };
126+
extractSymbolCodeMock.mockResolvedValue(symbolCode);
127+
pathExtnameMock.mockReturnValue('.py');
128+
(mockCline.api.createMessage as ReturnType<typeof vi.fn>).mockReturnValue(
129+
createMockStream([{ type: 'error', error: { message: 'LLM API error' } }])
130+
);
131+
132+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
133+
134+
expect(mockHandleError).toHaveBeenCalledWith(new Error('LLM call failed during test generation: LLM API error'));
135+
expect(mockPushToolResult).not.toHaveBeenCalled();
136+
});
137+
138+
it('should call handleError if LLM stream throws an error', async () => {
139+
const filePath = 'src/streamError.js';
140+
const symbolName = 'streamErrorFunc';
141+
const symbolCode = 'function streamErrorFunc() {}';
142+
143+
mockBlock.params = { filePath, symbolName };
144+
extractSymbolCodeMock.mockResolvedValue(symbolCode);
145+
pathExtnameMock.mockReturnValue('.js');
146+
(mockCline.api.createMessage as ReturnType<typeof vi.fn>).mockImplementation(() => {
147+
// Simulate a stream that throws an error
148+
return (async function*() {
149+
yield { type: 'text', text: 'some partial text...' };
150+
throw new Error('Network connection lost');
151+
})();
152+
});
153+
154+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
155+
156+
expect(mockHandleError).toHaveBeenCalledWith(new Error('LLM call failed during test generation: Network connection lost'));
157+
expect(mockPushToolResult).not.toHaveBeenCalled();
158+
});
159+
160+
it('should call handleError if LLM returns empty response', async () => {
161+
const filePath = 'src/empty.ts';
162+
const symbolName = 'EmptySym';
163+
const symbolCode = 'class EmptySym {}';
164+
165+
mockBlock.params = { filePath, symbolName };
166+
extractSymbolCodeMock.mockResolvedValue(symbolCode);
167+
pathExtnameMock.mockReturnValue('.ts');
168+
(mockCline.api.createMessage as ReturnType<typeof vi.fn>).mockReturnValue(
169+
createMockStream([{ type: 'text', text: ' ' }]) // Empty or whitespace only
170+
);
171+
172+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
173+
174+
expect(mockHandleError).toHaveBeenCalledWith(new Error('LLM returned empty response for test generation.'));
175+
expect(mockPushToolResult).not.toHaveBeenCalled();
176+
});
177+
178+
describe('Prompt Construction', () => {
179+
const symbolCode = 'function example() {}';
180+
beforeEach(() => {
181+
extractSymbolCodeMock.mockResolvedValue(symbolCode);
182+
(mockCline.api.createMessage as ReturnType<typeof vi.fn>).mockReturnValue(createMockStream([])); // Prevent actual call
183+
});
184+
185+
it('should use correct language hint for TypeScript', async () => {
186+
mockBlock.params = { filePath: 'myFile.ts', symbolName: 'tsFunc' };
187+
pathExtnameMock.mockReturnValue('.ts');
188+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
189+
190+
const createMessageArgs = (mockCline.api.createMessage as ReturnType<typeof vi.fn>).mock.calls[0];
191+
const systemPrompt = createMessageArgs[0];
192+
const userMessage = createMessageArgs[1][0].content;
193+
194+
expect(systemPrompt).toContain('TypeScript/JavaScript code');
195+
expect(userMessage).toContain('TypeScript/JavaScript code');
196+
});
197+
198+
it('should use correct language hint for Python', async () => {
199+
mockBlock.params = { filePath: 'myScript.py', symbolName: 'pyFunc' };
200+
pathExtnameMock.mockReturnValue('.py');
201+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
202+
203+
const createMessageArgs = (mockCline.api.createMessage as ReturnType<typeof vi.fn>).mock.calls[0];
204+
const systemPrompt = createMessageArgs[0];
205+
const userMessage = createMessageArgs[1][0].content;
206+
207+
expect(systemPrompt).toContain('PyTest style unit tests');
208+
expect(userMessage).toContain('PyTest style unit tests');
209+
});
210+
211+
it('should use generic language hint for unknown extension', async () => {
212+
mockBlock.params = { filePath: 'myCode.unknown', symbolName: 'unknownFunc' };
213+
pathExtnameMock.mockReturnValue('.unknown');
214+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
215+
216+
const createMessageArgs = (mockCline.api.createMessage as ReturnType<typeof vi.fn>).mock.calls[0];
217+
const systemPrompt = createMessageArgs[0];
218+
const userMessage = createMessageArgs[1][0].content;
219+
220+
expect(systemPrompt).toContain('Generate unit tests for this code.');
221+
expect(userMessage).toContain('Generate unit tests for this code.');
222+
});
223+
224+
it('should include filePath and symbolName in user message', async () => {
225+
const filePath = 'src/app.js';
226+
const symbolName = 'initialize';
227+
mockBlock.params = { filePath, symbolName };
228+
pathExtnameMock.mockReturnValue('.js');
229+
await generateTestsTool(mockCline, mockBlock, mockAskApproval, mockHandleError, mockPushToolResult);
230+
231+
const createMessageArgs = (mockCline.api.createMessage as ReturnType<typeof vi.fn>).mock.calls[0];
232+
const userMessage = createMessageArgs[1][0].content;
233+
234+
expect(userMessage).toContain(`from the file "${filePath}"`);
235+
expect(userMessage).toContain(`symbol "${symbolName}"`);
236+
expect(userMessage).toContain(symbolCode);
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)