Skip to content

Commit 2cd2a43

Browse files
committed
BrowserActionHandler.test
1 parent 2737e39 commit 2cd2a43

File tree

1 file changed

+279
-0
lines changed

1 file changed

+279
-0
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)