From 5e79af42f45017b3b24a06e853e0e2c1f353fad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 09:47:14 +0200 Subject: [PATCH 1/4] mcp: Add hover, press, selectOption, setChecked New browser actions and MCP tools for AI agent interaction: - hover: dispatches mouseover/mouseenter events on an element - press: dispatches keydown/keyup keyboard events (Enter, Tab, etc.) - selectOption: selects a dropdown option by value with input/change events - setChecked: checks/unchecks checkbox or radio with input/change/click events --- src/browser/actions.zig | 110 ++++++++++++++++++++++ src/mcp/tools.zig | 201 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 37d17ccb..d55721b9 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -22,6 +22,7 @@ const DOMNode = @import("webapi/Node.zig"); const Element = @import("webapi/Element.zig"); const Event = @import("webapi/Event.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig"); +const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const Page = @import("Page.zig"); const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); @@ -43,6 +44,115 @@ pub fn click(node: *DOMNode, page: *Page) !void { }; } +pub fn hover(node: *DOMNode, page: *Page) !void { + const el = node.is(Element) orelse return error.InvalidNodeType; + + const mouseover_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + }, page); + + page._event_manager.dispatch(el.asEventTarget(), mouseover_event.asEvent()) catch |err| { + lp.log.err(.app, "hover mouseover failed", .{ .err = err }); + return error.ActionFailed; + }; + + const mouseenter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{ + .composed = true, + }, page); + + page._event_manager.dispatch(el.asEventTarget(), mouseenter_event.asEvent()) catch |err| { + lp.log.err(.app, "hover mouseenter failed", .{ .err = err }); + return error.ActionFailed; + }; +} + +pub fn press(node: ?*DOMNode, key: []const u8, page: *Page) !void { + const target = if (node) |n| + (n.is(Element) orelse return error.InvalidNodeType).asEventTarget() + else + page.document.asNode().asEventTarget(); + + const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .key = key, + }, page); + + page._event_manager.dispatch(target, keydown_event.asEvent()) catch |err| { + lp.log.err(.app, "press keydown failed", .{ .err = err }); + return error.ActionFailed; + }; + + const keyup_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keyup"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .key = key, + }, page); + + page._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| { + lp.log.err(.app, "press keyup failed", .{ .err = err }); + return error.ActionFailed; + }; +} + +pub fn selectOption(node: *DOMNode, value: []const u8, page: *Page) !void { + const el = node.is(Element) orelse return error.InvalidNodeType; + const select = el.is(Element.Html.Select) orelse return error.InvalidNodeType; + + select.setValue(value, page) catch |err| { + lp.log.err(.app, "select setValue failed", .{ .err = err }); + return error.ActionFailed; + }; + + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + lp.log.err(.app, "dispatch input event failed", .{ .err = err }); + }; + + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + lp.log.err(.app, "dispatch change event failed", .{ .err = err }); + }; +} + +pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void { + const el = node.is(Element) orelse return error.InvalidNodeType; + const input = el.is(Element.Html.Input) orelse return error.InvalidNodeType; + + if (input._input_type != .checkbox and input._input_type != .radio) { + return error.InvalidNodeType; + } + + input.setChecked(checked, page) catch |err| { + lp.log.err(.app, "setChecked failed", .{ .err = err }); + return error.ActionFailed; + }; + + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + lp.log.err(.app, "dispatch input event failed", .{ .err = err }); + }; + + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + lp.log.err(.app, "dispatch change event failed", .{ .err = err }); + }; + + const click_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + }, page); + + page._event_manager.dispatch(el.asEventTarget(), click_event.asEvent()) catch |err| { + lp.log.err(.app, "dispatch click event failed", .{ .err = err }); + }; +} + pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { const el = node.is(Element) orelse return error.InvalidNodeType; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 15c4b099..ec8ed889 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -179,6 +179,61 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "hover", + .description = "Hover over an element, triggering mouseover and mouseenter events. Useful for menus, tooltips, and hover states.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to hover over." } + \\ }, + \\ "required": ["backendNodeId"] + \\} + ), + }, + .{ + .name = "press", + .description = "Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document." } + \\ }, + \\ "required": ["key"] + \\} + ), + }, + .{ + .name = "selectOption", + .description = "Select an option in a element." }, + \\ "value": { "type": "string", "description": "The value of the option to select." } + \\ }, + \\ "required": ["backendNodeId", "value"] + \\} + ), + }, + .{ + .name = "setChecked", + .description = "Check or uncheck a checkbox or radio button. Dispatches input, change, and click events.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." }, + \\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element." } + \\ }, + \\ "required": ["backendNodeId", "checked"] + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -279,6 +334,10 @@ const ToolAction = enum { fill, scroll, waitForSelector, + hover, + press, + selectOption, + setChecked, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -296,6 +355,10 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "fill", .fill }, .{ "scroll", .scroll }, .{ "waitForSelector", .waitForSelector }, + .{ "hover", .hover }, + .{ "press", .press }, + .{ "selectOption", .selectOption }, + .{ "setChecked", .setChecked }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -330,6 +393,10 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .fill => try handleFill(server, arena, req.id.?, call_params.arguments), .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), .waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments), + .hover => try handleHover(server, arena, req.id.?, call_params.arguments), + .press => try handlePress(server, arena, req.id.?, call_params.arguments), + .selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments), + .setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments), } } @@ -630,6 +697,140 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } +fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + backendNodeId: CDPNode.Id, + }; + const args = try parseArgs(Params, arena, arguments, server, id, "hover"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + lp.actions.hover(node.dom, page) catch |err| { + if (err == error.InvalidNodeType) { + return server.sendError(id, .InvalidParams, "Node is not an HTML element"); + } + return server.sendError(id, .InternalError, "Failed to hover element"); + }; + + const page_title = page.getTitle() catch null; + const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ + args.backendNodeId, + page.url, + page_title orelse "(none)", + }); + const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handlePress(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + key: []const u8, + backendNodeId: ?CDPNode.Id = null, + }; + const args = try parseArgs(Params, arena, arguments, server, id, "press"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + var target_node: ?*DOMNode = null; + if (args.backendNodeId) |node_id| { + const node = server.node_registry.lookup_by_id.get(node_id) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + target_node = node.dom; + } + + lp.actions.press(target_node, args.key, page) catch |err| { + if (err == error.InvalidNodeType) { + return server.sendError(id, .InvalidParams, "Node is not an HTML element"); + } + return server.sendError(id, .InternalError, "Failed to press key"); + }; + + const page_title = page.getTitle() catch null; + const result_text = try std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{ + args.key, + page.url, + page_title orelse "(none)", + }); + const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + backendNodeId: CDPNode.Id, + value: []const u8, + }; + const args = try parseArgs(Params, arena, arguments, server, id, "selectOption"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { + return server.sendError(id, .InvalidParams, "Node not found"); + }; + + lp.actions.selectOption(node.dom, args.value, page) catch |err| { + if (err == error.InvalidNodeType) { + return server.sendError(id, .InvalidParams, "Node is not a + + + + diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index ec8ed889..ebb7baf5 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -928,7 +928,7 @@ test "MCP - evaluate error reporting" { } }, out.written()); } -test "MCP - Actions: click, fill, scroll" { +test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked" { defer testing.reset(); const aa = testing.arena_allocator; @@ -989,7 +989,67 @@ test "MCP - Actions: click, fill, scroll" { out.clearRetainingCapacity(); } - // Evaluate assertions + { + // Test Hover + const el = page.document.getElementById("hoverTarget", page).?.asNode(); + const el_id = (try server.node_registry.register(el)).id; + var id_buf: [12]u8 = undefined; + const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; + const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" }); + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Hovered element") != null); + out.clearRetainingCapacity(); + } + + { + // Test Press + const el = page.document.getElementById("keyTarget", page).?.asNode(); + const el_id = (try server.node_registry.register(el)).id; + var id_buf: [12]u8 = undefined; + const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; + const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" }); + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Pressed key") != null); + out.clearRetainingCapacity(); + } + + { + // Test SelectOption + const el = page.document.getElementById("sel2", page).?.asNode(); + const el_id = (try server.node_registry.register(el)).id; + var id_buf: [12]u8 = undefined; + const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; + const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" }); + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Selected option") != null); + out.clearRetainingCapacity(); + } + + { + // Test SetChecked (checkbox) + const el = page.document.getElementById("chk", page).?.asNode(); + const el_id = (try server.node_registry.register(el)).id; + var id_buf: [12]u8 = undefined; + const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; + const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" }); + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null); + out.clearRetainingCapacity(); + } + + { + // Test SetChecked (radio) + const el = page.document.getElementById("rad", page).?.asNode(); + const el_id = (try server.node_registry.register(el)).id; + var id_buf: [12]u8 = undefined; + const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable; + const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" }); + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null); + out.clearRetainingCapacity(); + } + + // Evaluate JS assertions for all actions var ls: js.Local.Scope = undefined; page.js.localScope(&ls); defer ls.deinit(); @@ -1001,7 +1061,12 @@ test "MCP - Actions: click, fill, scroll" { const result = try ls.local.exec( \\ window.clicked === true && window.inputVal === 'hello' && \\ window.changed === true && window.selChanged === 'opt2' && - \\ window.scrolled === true + \\ window.scrolled === true && + \\ window.hovered === true && + \\ window.keyPressed === 'Enter' && window.keyReleased === 'Enter' && + \\ window.sel2Changed === 'b' && + \\ window.chkClicked === true && window.chkChanged === true && + \\ window.radClicked === true && window.radChanged === true , null); try testing.expect(result.isTrue()); From 46a63e0b4b94ad0dee1702b6a0ea73ee53cd174b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 11:03:49 +0200 Subject: [PATCH 3/4] Add focus before fill and findElement MCP tool - fill action now calls focus() on the element before setting its value, ensuring focus/focusin events fire for JS listeners - Add findElement MCP tool for locating interactive elements by ARIA role and/or accessible name (case-insensitive substring match) - Add tests for findElement (by role, by name, no matches, missing params) --- src/browser/actions.zig | 4 ++ src/mcp/tools.zig | 121 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 9341572e..68b6d666 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -157,6 +157,10 @@ pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void { pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { const el = node.is(Element) orelse return error.InvalidNodeType; + el.focus(page) catch |err| { + lp.log.err(.app, "fill focus failed", .{ .err = err }); + }; + if (el.is(Element.Html.Input)) |input| { input.setValue(text, page) catch |err| { lp.log.err(.app, "fill input failed", .{ .err = err }); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index ebb7baf5..8da468fc 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -234,6 +234,19 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "findElement", + .description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." }, + \\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -338,6 +351,7 @@ const ToolAction = enum { press, selectOption, setChecked, + findElement, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -359,6 +373,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "press", .press }, .{ "selectOption", .selectOption }, .{ "setChecked", .setChecked }, + .{ "findElement", .findElement }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -397,6 +412,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .press => try handlePress(server, arena, req.id.?, call_params.arguments), .selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments), .setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments), + .findElement => try handleFindElement(server, arena, req.id.?, call_params.arguments), } } @@ -831,6 +847,62 @@ fn handleSetChecked(server: *Server, arena: std.mem.Allocator, id: std.json.Valu try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } +fn handleFindElement(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const Params = struct { + role: ?[]const u8 = null, + name: ?[]const u8 = null, + }; + const args = try parseArgsOrDefault(Params, arena, arguments, server, id); + + if (args.role == null and args.name == null) { + return server.sendError(id, .InvalidParams, "At least one of 'role' or 'name' must be provided"); + } + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { + log.err(.mcp, "elements collection failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to collect interactive elements"); + }; + + var matches: std.ArrayList(lp.interactive.InteractiveElement) = .empty; + for (elements) |el| { + if (args.role) |role| { + const el_role = el.role orelse continue; + if (!std.ascii.eqlIgnoreCase(el_role, role)) continue; + } + if (args.name) |name| { + const el_name = el.name orelse continue; + if (!containsIgnoreCase(el_name, name)) continue; + } + try matches.append(arena, el); + } + + const matched = try matches.toOwnedSlice(arena); + lp.interactive.registerNodes(matched, &server.node_registry) catch |err| { + log.err(.mcp, "node registration failed", .{ .err = err }); + return server.sendError(id, .InternalError, "Failed to register element nodes"); + }; + + var aw: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(matched, .{}, &aw.writer); + + const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + if (needle.len == 0) return true; + const end = haystack.len - needle.len + 1; + for (0..end) |i| { + if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true; + } + return false; +} + fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page { if (url) |u| { try performGoto(server, u, id); @@ -1072,6 +1144,55 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked try testing.expect(result.isTrue()); } +test "MCP - findElement" { + defer testing.reset(); + const aa = testing.arena_allocator; + + var out: std.io.Writer.Allocating = .init(aa); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); + defer server.deinit(); + + { + // Find by role + const msg = + \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"button"}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null); + out.clearRetainingCapacity(); + } + + { + // Find by name (case-insensitive substring) + const msg = + \\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"click"}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null); + out.clearRetainingCapacity(); + } + + { + // Find with no matches + const msg = + \\{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"slider"}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "[]") != null); + out.clearRetainingCapacity(); + } + + { + // Error: no params provided + const msg = + \\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"findElement","arguments":{}}} + ; + try router.handleMessage(server, aa, msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "error") != null); + out.clearRetainingCapacity(); + } +} + test "MCP - waitForSelector: existing element" { defer testing.reset(); var out: std.io.Writer.Allocating = .init(testing.arena_allocator); From 6c9a5ddab891aabc3d9f9856048b1cf26ed55a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 2 Apr 2026 11:20:28 +0200 Subject: [PATCH 4/4] Extract shared helpers to reduce duplication - Extract dispatchInputAndChangeEvents() in actions.zig, used by fill, selectOption, and setChecked - Extract resolveNodeAndPage() in tools.zig, used by click, fill, hover, selectOption, setChecked, and nodeDetails handlers --- src/browser/actions.zig | 42 ++++++----------- src/mcp/tools.zig | 101 ++++++++++++++-------------------------- 2 files changed, 51 insertions(+), 92 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 68b6d666..198e92ee 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -27,6 +27,18 @@ const Page = @import("Page.zig"); const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); +fn dispatchInputAndChangeEvents(el: *Element, page: *Page) !void { + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + lp.log.err(.app, "dispatch input event failed", .{ .err = err }); + }; + + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); + page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + lp.log.err(.app, "dispatch change event failed", .{ .err = err }); + }; +} + pub fn click(node: *DOMNode, page: *Page) !void { const el = node.is(Element) orelse return error.InvalidNodeType; @@ -108,15 +120,7 @@ pub fn selectOption(node: *DOMNode, value: []const u8, page: *Page) !void { return error.ActionFailed; }; - const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { - lp.log.err(.app, "dispatch input event failed", .{ .err = err }); - }; - - const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { - lp.log.err(.app, "dispatch change event failed", .{ .err = err }); - }; + try dispatchInputAndChangeEvents(el, page); } pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void { @@ -143,15 +147,7 @@ pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void { lp.log.err(.app, "dispatch click event failed", .{ .err = err }); }; - const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { - lp.log.err(.app, "dispatch input event failed", .{ .err = err }); - }; - - const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { - lp.log.err(.app, "dispatch change event failed", .{ .err = err }); - }; + try dispatchInputAndChangeEvents(el, page); } pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { @@ -180,15 +176,7 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { return error.InvalidNodeType; } - const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { - lp.log.err(.app, "dispatch input event failed", .{ .err = err }); - }; - - const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page); - page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { - lp.log.err(.app, "dispatch change event failed", .{ .err = err }); - }; + try dispatchInputAndChangeEvents(el, page); } pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 8da468fc..0c6f9818 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -477,17 +477,9 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val backendNodeId: CDPNode.Id, }; const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails"); + const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - _ = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { - return server.sendError(id, .InvalidParams, "Node not found"); - }; - - const page = server.session.currentPage().?; - const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &server.node_registry, page) catch { + const details = lp.SemanticTree.getNodeDetails(arena, resolved.node, &server.node_registry, resolved.page) catch { return server.sendError(id, .InternalError, "Failed to get node details"); }; @@ -587,26 +579,19 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar backendNodeId: CDPNode.Id, }; const args = try parseArgs(ClickParams, arena, arguments, server, id, "click"); + const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - const page = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { - return server.sendError(id, .InvalidParams, "Node not found"); - }; - - lp.actions.click(node.dom, page) catch |err| { + lp.actions.click(resolved.node, resolved.page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } return server.sendError(id, .InternalError, "Failed to click element"); }; - const page_title = page.getTitle() catch null; + const page_title = resolved.page.getTitle() catch null; const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ args.backendNodeId, - page.url, + resolved.page.url, page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; @@ -619,27 +604,20 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg text: []const u8, }; const args = try parseArgs(FillParams, arena, arguments, server, id, "fill"); + const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - const page = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { - return server.sendError(id, .InvalidParams, "Node not found"); - }; - - lp.actions.fill(node.dom, args.text, page) catch |err| { + lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } return server.sendError(id, .InternalError, "Failed to fill element"); }; - const page_title = page.getTitle() catch null; + const page_title = resolved.page.getTitle() catch null; const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{ args.backendNodeId, args.text, - page.url, + resolved.page.url, page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; @@ -718,26 +696,19 @@ fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar backendNodeId: CDPNode.Id, }; const args = try parseArgs(Params, arena, arguments, server, id, "hover"); + const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - const page = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { - return server.sendError(id, .InvalidParams, "Node not found"); - }; - - lp.actions.hover(node.dom, page) catch |err| { + lp.actions.hover(resolved.node, resolved.page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } return server.sendError(id, .InternalError, "Failed to hover element"); }; - const page_title = page.getTitle() catch null; + const page_title = resolved.page.getTitle() catch null; const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{ args.backendNodeId, - page.url, + resolved.page.url, page_title orelse "(none)", }); const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; @@ -786,27 +757,20 @@ fn handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Va value: []const u8, }; const args = try parseArgs(Params, arena, arguments, server, id, "selectOption"); + const resolved = try resolveNodeAndPage(server, id, args.backendNodeId); - const page = server.session.currentPage() orelse { - return server.sendError(id, .PageNotLoaded, "Page not loaded"); - }; - - const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse { - return server.sendError(id, .InvalidParams, "Node not found"); - }; - - lp.actions.selectOption(node.dom, args.value, page) catch |err| { + lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not a