|
| 1 | +import { BrowserActionHandler } from "../BrowserActionHandler" |
| 2 | +import { Cline } from "../../../Cline" |
| 3 | +import { ToolUse } from "../../../assistant-message" |
| 4 | +import { formatResponse } from "../../../prompts/responses" |
| 5 | +import { BrowserSession } from "../../../../services/browser/BrowserSession" // Re-corrected path |
| 6 | +import { BrowserAction, BrowserActionResult, browserActions } from "../../../../shared/ExtensionMessage" |
| 7 | +import { telemetryService } from "../../../../services/telemetry/TelemetryService" |
| 8 | + |
| 9 | +// --- Mocks --- |
| 10 | +jest.mock("../../../Cline") |
| 11 | +const MockCline = Cline as jest.MockedClass<typeof Cline> |
| 12 | + |
| 13 | +jest.mock("../../../../services/browser/BrowserSession") // Corrected path for jest.mock |
| 14 | +const MockBrowserSession = BrowserSession as jest.MockedClass<typeof BrowserSession> |
| 15 | + |
| 16 | +jest.mock("../../../prompts/responses", () => ({ |
| 17 | + formatResponse: { |
| 18 | + toolError: jest.fn((msg) => `ERROR: ${msg}`), |
| 19 | + toolResult: jest.fn((text, images) => (images ? `${text} [with images]` : text)), |
| 20 | + }, |
| 21 | +})) |
| 22 | + |
| 23 | +jest.mock("../../../../services/telemetry/TelemetryService", () => ({ |
| 24 | + telemetryService: { |
| 25 | + captureToolUsage: jest.fn(), |
| 26 | + }, |
| 27 | +})) |
| 28 | + |
| 29 | +describe("BrowserActionHandler", () => { |
| 30 | + let mockClineInstance: jest.MockedObject<Cline> |
| 31 | + let mockBrowserSessionInstance: jest.MockedObject<BrowserSession> |
| 32 | + let mockToolUse: ToolUse |
| 33 | + |
| 34 | + const mockActionResult: BrowserActionResult = { |
| 35 | + logs: "Console log output", |
| 36 | + screenshot: "base64-screenshot-data", |
| 37 | + } |
| 38 | + |
| 39 | + beforeEach(() => { |
| 40 | + jest.clearAllMocks() |
| 41 | + |
| 42 | + // Mock vscode.ExtensionContext (provide minimal structure needed) |
| 43 | + const mockContext = { |
| 44 | + extensionUri: { fsPath: "/mock/extension/path" }, |
| 45 | + // Add other properties if BrowserSession constructor uses them |
| 46 | + } as any // Use 'any' for simplicity, or define a partial mock type |
| 47 | + |
| 48 | + // Create a mock instance of BrowserSession, passing the mock context |
| 49 | + mockBrowserSessionInstance = new MockBrowserSession(mockContext) as jest.MockedObject<BrowserSession> |
| 50 | + |
| 51 | + // Correctly mock methods to match signatures (return Promises) |
| 52 | + // Use mockResolvedValue for async methods |
| 53 | + mockBrowserSessionInstance.launchBrowser.mockResolvedValue() |
| 54 | + mockBrowserSessionInstance.navigateToUrl.mockResolvedValue(mockActionResult) |
| 55 | + mockBrowserSessionInstance.click.mockResolvedValue(mockActionResult) |
| 56 | + mockBrowserSessionInstance.type.mockResolvedValue(mockActionResult) |
| 57 | + mockBrowserSessionInstance.scrollDown.mockResolvedValue(mockActionResult) |
| 58 | + mockBrowserSessionInstance.scrollUp.mockResolvedValue(mockActionResult) |
| 59 | + // Ensure the return type for closeBrowser matches BrowserActionResult or handle appropriately |
| 60 | + // Casting the specific return value for closeBrowser might be needed if it differs significantly |
| 61 | + mockBrowserSessionInstance.closeBrowser.mockResolvedValue({ logs: "Browser closed", screenshot: undefined }) |
| 62 | + |
| 63 | + mockClineInstance = { |
| 64 | + cwd: "/workspace", |
| 65 | + consecutiveMistakeCount: 0, |
| 66 | + taskId: "test-task-id", |
| 67 | + browserSession: mockBrowserSessionInstance, // Assign the mock session instance |
| 68 | + ask: jest.fn(() => Promise.resolve({})), // Default ask response |
| 69 | + say: jest.fn(() => Promise.resolve()), |
| 70 | + pushToolResult: jest.fn(() => Promise.resolve()), |
| 71 | + handleErrorHelper: jest.fn(() => Promise.resolve()), |
| 72 | + sayAndCreateMissingParamError: jest.fn((tool, param) => Promise.resolve(`Missing ${param}`)), |
| 73 | + askApprovalHelper: jest.fn(() => Promise.resolve(true)), // Default approval for launch |
| 74 | + providerRef: { deref: () => ({ getState: () => Promise.resolve({}) }) }, |
| 75 | + emit: jest.fn(), |
| 76 | + getTokenUsage: jest.fn(() => ({})), |
| 77 | + removeClosingTag: jest.fn((tag, value) => value), // Simple mock for removeClosingTag |
| 78 | + } as unknown as jest.MockedObject<Cline> |
| 79 | + |
| 80 | + // Reset mockToolUse for each test |
| 81 | + mockToolUse = { |
| 82 | + type: "tool_use", |
| 83 | + name: "browser_action", |
| 84 | + params: { |
| 85 | + action: "launch", // Default action |
| 86 | + url: "https://example.com", |
| 87 | + }, |
| 88 | + partial: false, |
| 89 | + } |
| 90 | + }) |
| 91 | + |
| 92 | + // --- Test validateParams --- |
| 93 | + test.each(browserActions)("validateParams should pass for valid action '%s'", (action) => { |
| 94 | + mockToolUse.params = { action } |
| 95 | + // Add required params for specific actions |
| 96 | + if (action === "launch") mockToolUse.params.url = "https://test.com" |
| 97 | + if (action === "click") mockToolUse.params.coordinate = "{x:10, y:20}" |
| 98 | + if (action === "type") mockToolUse.params.text = "hello" |
| 99 | + |
| 100 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 101 | + expect(() => handler.validateParams()).not.toThrow() |
| 102 | + }) |
| 103 | + |
| 104 | + test("validateParams should throw if action is missing or invalid", () => { |
| 105 | + delete mockToolUse.params.action |
| 106 | + let handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 107 | + expect(() => handler.validateParams()).toThrow(/Missing or invalid required parameter 'action'/) |
| 108 | + |
| 109 | + mockToolUse.params.action = "invalid_action" |
| 110 | + handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 111 | + expect(() => handler.validateParams()).toThrow(/Missing or invalid required parameter 'action'/) |
| 112 | + }) |
| 113 | + |
| 114 | + test("validateParams should throw if url is missing for launch", () => { |
| 115 | + mockToolUse.params = { action: "launch" } // url missing |
| 116 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 117 | + expect(() => handler.validateParams()).toThrow("Missing required parameter 'url' for 'launch' action.") |
| 118 | + }) |
| 119 | + |
| 120 | + test("validateParams should throw if coordinate is missing for click", () => { |
| 121 | + mockToolUse.params = { action: "click" } // coordinate missing |
| 122 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 123 | + expect(() => handler.validateParams()).toThrow("Missing required parameter 'coordinate' for 'click' action.") |
| 124 | + }) |
| 125 | + |
| 126 | + test("validateParams should throw if text is missing for type", () => { |
| 127 | + mockToolUse.params = { action: "type" } // text missing |
| 128 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 129 | + expect(() => handler.validateParams()).toThrow("Missing required parameter 'text' for 'type' action.") |
| 130 | + }) |
| 131 | + |
| 132 | + // --- Test handlePartial --- |
| 133 | + test("handlePartial should call ask for launch action", async () => { |
| 134 | + mockToolUse.partial = true |
| 135 | + mockToolUse.params = { action: "launch", url: "partial.com" } |
| 136 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 137 | + await handler.handle() |
| 138 | + expect(mockClineInstance.ask).toHaveBeenCalledWith("browser_action_launch", "partial.com", true) |
| 139 | + expect(mockClineInstance.say).not.toHaveBeenCalled() |
| 140 | + }) |
| 141 | + |
| 142 | + test.each(["click", "type", "scroll_down", "scroll_up", "close"])( |
| 143 | + "handlePartial should call say for non-launch action '%s'", |
| 144 | + async (action) => { |
| 145 | + mockToolUse.partial = true |
| 146 | + mockToolUse.params = { action } |
| 147 | + if (action === "click") mockToolUse.params.coordinate = "{x:1,y:1}" |
| 148 | + if (action === "type") mockToolUse.params.text = "partial text" |
| 149 | + |
| 150 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 151 | + await handler.handle() |
| 152 | + |
| 153 | + expect(mockClineInstance.say).toHaveBeenCalledWith( |
| 154 | + "browser_action", |
| 155 | + expect.stringContaining(`"action":"${action}"`), |
| 156 | + undefined, |
| 157 | + true, |
| 158 | + ) |
| 159 | + expect(mockClineInstance.ask).not.toHaveBeenCalled() |
| 160 | + }, |
| 161 | + ) |
| 162 | + |
| 163 | + // --- Test handleComplete --- |
| 164 | + test("handleComplete should ask for approval and launch browser", async () => { |
| 165 | + mockToolUse.params = { action: "launch", url: "https://approved.com" } |
| 166 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 167 | + await handler.handle() |
| 168 | + |
| 169 | + expect(mockClineInstance.askApprovalHelper).toHaveBeenCalledWith( |
| 170 | + mockToolUse, |
| 171 | + "browser_action_launch", |
| 172 | + "https://approved.com", |
| 173 | + ) |
| 174 | + expect(mockClineInstance.say).toHaveBeenCalledWith("browser_action_result", "") // Loading spinner |
| 175 | + expect(mockBrowserSessionInstance.launchBrowser).toHaveBeenCalled() |
| 176 | + expect(mockBrowserSessionInstance.navigateToUrl).toHaveBeenCalledWith("https://approved.com") |
| 177 | + expect(mockClineInstance.say).toHaveBeenCalledWith("browser_action_result", JSON.stringify(mockActionResult)) // Show result |
| 178 | + const expectedLaunchResultText = `The browser action 'launch' has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${mockActionResult.logs}\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser.) [with images]` |
| 179 | + expect(mockClineInstance.pushToolResult).toHaveBeenCalledWith( |
| 180 | + mockToolUse, |
| 181 | + expectedLaunchResultText, // Expect the exact final string |
| 182 | + ) |
| 183 | + expect(telemetryService.captureToolUsage).toHaveBeenCalledWith(mockClineInstance.taskId, "browser_action") |
| 184 | + }) |
| 185 | + |
| 186 | + test("handleComplete should skip launch if approval denied", async () => { |
| 187 | + mockToolUse.params = { action: "launch", url: "https://denied.com" } |
| 188 | + ;(mockClineInstance.askApprovalHelper as jest.Mock).mockResolvedValue(false) // Deny approval |
| 189 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 190 | + await handler.handle() |
| 191 | + |
| 192 | + expect(mockClineInstance.askApprovalHelper).toHaveBeenCalledWith( |
| 193 | + mockToolUse, |
| 194 | + "browser_action_launch", |
| 195 | + "https://denied.com", |
| 196 | + ) |
| 197 | + expect(mockBrowserSessionInstance.launchBrowser).not.toHaveBeenCalled() |
| 198 | + expect(mockBrowserSessionInstance.navigateToUrl).not.toHaveBeenCalled() |
| 199 | + expect(mockClineInstance.pushToolResult).not.toHaveBeenCalled() // Handled by helper |
| 200 | + }) |
| 201 | + |
| 202 | + test.each([ |
| 203 | + ["click", { coordinate: "{x:10, y:20}" }, "click", ["{x:10, y:20}"]], |
| 204 | + ["type", { text: "typing test" }, "type", ["typing test"]], |
| 205 | + ["scroll_down", {}, "scrollDown", []], |
| 206 | + ["scroll_up", {}, "scrollUp", []], |
| 207 | + ])("handleComplete should execute action '%s'", async (action, params, expectedMethod, methodArgs) => { |
| 208 | + mockToolUse.params = { action, ...params } |
| 209 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 210 | + await handler.handle() |
| 211 | + |
| 212 | + expect(mockClineInstance.say).toHaveBeenCalledWith( |
| 213 | + "browser_action", |
| 214 | + expect.stringContaining(`"action":"${action}"`), |
| 215 | + undefined, |
| 216 | + false, |
| 217 | + ) |
| 218 | + expect(mockBrowserSessionInstance[expectedMethod as keyof BrowserSession]).toHaveBeenCalledWith(...methodArgs) |
| 219 | + expect(mockClineInstance.say).toHaveBeenCalledWith("browser_action_result", JSON.stringify(mockActionResult)) |
| 220 | + const expectedActionResultText = `The browser action '${action}' has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${mockActionResult.logs}\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser.) [with images]` |
| 221 | + expect(mockClineInstance.pushToolResult).toHaveBeenCalledWith( |
| 222 | + mockToolUse, |
| 223 | + expectedActionResultText, // Expect the exact final string |
| 224 | + ) |
| 225 | + expect(telemetryService.captureToolUsage).toHaveBeenCalledWith(mockClineInstance.taskId, "browser_action") |
| 226 | + }) |
| 227 | + |
| 228 | + test("handleComplete should close browser", async () => { |
| 229 | + mockToolUse.params = { action: "close" } |
| 230 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 231 | + await handler.handle() |
| 232 | + |
| 233 | + expect(mockClineInstance.say).toHaveBeenCalledWith( |
| 234 | + "browser_action", |
| 235 | + expect.stringContaining('"action":"close"'), |
| 236 | + undefined, |
| 237 | + false, |
| 238 | + ) |
| 239 | + expect(mockBrowserSessionInstance.closeBrowser).toHaveBeenCalled() |
| 240 | + expect(mockClineInstance.say).not.toHaveBeenCalledWith("browser_action_result", expect.anything()) // No result display for close |
| 241 | + expect(mockClineInstance.pushToolResult).toHaveBeenCalledWith( |
| 242 | + mockToolUse, |
| 243 | + expect.stringContaining("browser has been closed"), // Specific message for close |
| 244 | + ) |
| 245 | + expect(telemetryService.captureToolUsage).toHaveBeenCalledWith(mockClineInstance.taskId, "browser_action") |
| 246 | + }) |
| 247 | + |
| 248 | + test("handleComplete should handle errors during action execution and close browser", async () => { |
| 249 | + const actionError = new Error("Click failed") |
| 250 | + mockToolUse.params = { action: "click", coordinate: "{x:0, y:0}" } |
| 251 | + ;(mockBrowserSessionInstance.click as jest.Mock).mockRejectedValue(actionError) |
| 252 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 253 | + await handler.handle() |
| 254 | + |
| 255 | + expect(mockClineInstance.handleErrorHelper).toHaveBeenCalledWith( |
| 256 | + mockToolUse, |
| 257 | + "executing browser action 'click'", |
| 258 | + actionError, |
| 259 | + ) |
| 260 | + // Verify browser is closed even on error |
| 261 | + expect(mockBrowserSessionInstance.closeBrowser).toHaveBeenCalled() |
| 262 | + }) |
| 263 | + |
| 264 | + test("handleComplete should re-throw validation errors", async () => { |
| 265 | + mockToolUse.params = { action: "launch" } // Missing URL |
| 266 | + const handler = new BrowserActionHandler(mockClineInstance, mockToolUse) |
| 267 | + await handler.handle() |
| 268 | + |
| 269 | + expect(mockClineInstance.handleErrorHelper).toHaveBeenCalledWith( |
| 270 | + mockToolUse, |
| 271 | + "executing browser action 'launch'", |
| 272 | + expect.any(Error), // Expect an error object |
| 273 | + ) |
| 274 | + expect(mockClineInstance.handleErrorHelper.mock.calls[0][2].message).toContain( |
| 275 | + "Missing required parameter 'url'", |
| 276 | + ) // Check error message |
| 277 | + expect(mockBrowserSessionInstance.closeBrowser).toHaveBeenCalled() // Ensure browser closed on validation error too |
| 278 | + }) |
| 279 | +}) |
0 commit comments