From 32f450f803960e7d62a06f9f97c328d3847e8f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 14:22:15 +0900 Subject: [PATCH] browser: centralize node interaction logic Extracts click, fill, and scroll logic from CDP and MCP domains into a new dedicated actions module to reduce code duplication. --- src/browser/actions.zig | 93 +++++++++++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 77 +++++++--------------------------- src/lightpanda.zig | 1 + src/mcp/tools.zig | 72 +++++++------------------------ 4 files changed, 125 insertions(+), 118 deletions(-) create mode 100644 src/browser/actions.zig diff --git a/src/browser/actions.zig b/src/browser/actions.zig new file mode 100644 index 00000000..641466b8 --- /dev/null +++ b/src/browser/actions.zig @@ -0,0 +1,93 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("../lightpanda.zig"); +const DOMNode = @import("webapi/Node.zig"); +const Element = @import("webapi/Element.zig"); +const Event = @import("webapi/Event.zig"); +const Page = @import("Page.zig"); + +pub fn clickNode(dom_node: *DOMNode, page: *Page) !void { + if (dom_node.is(Element)) |el| { + if (el.is(Element.Html)) |html_el| { + html_el.click(page) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); + return error.ActionFailed; + }; + } else { + return error.InvalidNodeType; + } + } else { + return error.InvalidNodeType; + } +} + +pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { + if (dom_node.is(Element)) |el| { + if (el.is(Element.Html.Input)) |input| { + input.setValue(text, page) catch |err| { + lp.log.err(.app, "fill input failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.TextArea)) |textarea| { + textarea.setValue(text, page) catch |err| { + lp.log.err(.app, "fill textarea failed", .{ .err = err }); + return error.ActionFailed; + }; + } else if (el.is(Element.Html.Select)) |select| { + select.setValue(text, page) catch |err| { + lp.log.err(.app, "fill select failed", .{ .err = err }); + return error.ActionFailed; + }; + } else { + return error.InvalidNodeType; + } + + const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; + + const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + } else { + return error.InvalidNodeType; + } +} + +pub fn scrollNode(dom_node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { + if (dom_node) |n| { + if (n.is(Element)) |el| { + if (x != 0) { + el.setScrollLeft(x, page) catch {}; + } + if (y != 0) { + el.setScrollTop(y, page) catch {}; + } + + const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + } else { + return error.InvalidNodeType; + } + } else { + page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + lp.log.err(.app, "scroll failed", .{ .err = err }); + return error.ActionFailed; + }; + } +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index d876dddd..4443629e 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -165,18 +165,10 @@ fn clickNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - if (node.dom.is(DOMNode.Element)) |el| { - if (el.is(DOMNode.Element.Html)) |html_el| { - html_el.click(page) catch |err| { - log.err(.cdp, "click failed", .{ .err = err }); - return error.InternalError; - }; - } else { - return error.InvalidParam; - } - } else { - return error.InvalidParam; - } + lp.actions.clickNode(node.dom, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; return cmd.sendResult(.{}, .{}); } @@ -195,35 +187,10 @@ fn fillNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId; - if (node.dom.is(DOMNode.Element)) |el| { - if (el.is(DOMNode.Element.Html.Input)) |input| { - input.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill input failed", .{ .err = err }); - return error.InternalError; - }; - } else if (el.is(DOMNode.Element.Html.TextArea)) |textarea| { - textarea.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill textarea failed", .{ .err = err }); - return error.InternalError; - }; - } else if (el.is(DOMNode.Element.Html.Select)) |select| { - select.setValue(params.text, page) catch |err| { - log.err(.cdp, "fill select failed", .{ .err = err }); - return error.InternalError; - }; - } else { - return error.InvalidParam; - } - - const Event = @import("../../browser/webapi/Event.zig"); - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; - - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; - } else { - return error.InvalidParam; - } + lp.actions.fillNode(node.dom, params.text, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; return cmd.sendResult(.{}, .{}); } @@ -245,33 +212,19 @@ fn scrollNode(cmd: anytype) !void { const input_node_id = params.nodeId orelse params.backendNodeId; + var target_node: ?*DOMNode = null; if (input_node_id) |node_id| { const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; - - if (node.dom.is(DOMNode.Element)) |el| { - if (params.x != null) { - el.setScrollLeft(x, page) catch {}; - } - if (params.y != null) { - el.setScrollTop(y, page) catch {}; - } - - const Event = @import("../../browser/webapi/Event.zig"); - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; - } else { - return error.InvalidParam; - } - } else { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { - log.err(.cdp, "scroll failed", .{ .err = err }); - return error.InternalError; - }; + target_node = node.dom; } + lp.actions.scrollNode(target_node, x, y, page) catch |err| { + if (err == error.InvalidNodeType) return error.InvalidParam; + return error.InternalError; + }; + return cmd.sendResult(.{}, .{}); } - const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 4fac3921..a9c7a1f0 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.zig"); +pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index c2881a40..65bd3ee8 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -5,6 +5,7 @@ const log = lp.log; const js = lp.js; const Element = @import("../browser/webapi/Element.zig"); +const DOMNode = @import("../browser/webapi/Node.zig"); const Selector = @import("../browser/webapi/selector/Selector.zig"); const protocol = @import("protocol.zig"); const Server = @import("Server.zig"); @@ -444,18 +445,12 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar return server.sendError(id, .InvalidParams, "Node not found"); }; - if (node.dom.is(Element)) |el| { - if (el.is(Element.Html)) |html_el| { - html_el.click(page) catch |err| { - log.err(.mcp, "click failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to click element"); - }; - } else { + lp.actions.clickNode(node.dom, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } - } else { - return server.sendError(id, .InvalidParams, "Node is not an element"); - } + return server.sendError(id, .InternalError, "Failed to click element"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); @@ -476,35 +471,12 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return server.sendError(id, .InvalidParams, "Node not found"); }; - if (node.dom.is(Element)) |el| { - if (el.is(Element.Html.Input)) |input| { - input.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill input failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill input"); - }; - } else if (el.is(Element.Html.TextArea)) |textarea| { - textarea.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill textarea failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill textarea"); - }; - } else if (el.is(Element.Html.Select)) |select| { - select.setValue(args.text, page) catch |err| { - log.err(.mcp, "fill select failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to fill select"); - }; - } else { + lp.actions.fillNode(node.dom, args.text, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } - - const Event = @import("../browser/webapi/Event.zig"); - const input_evt = try Event.initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch {}; - - const change_evt = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; - } else { - return server.sendError(id, .InvalidParams, "Node is not an element"); - } + return server.sendError(id, .InternalError, "Failed to fill element"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); @@ -525,36 +497,24 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a const x = args.x orelse 0; const y = args.y orelse 0; + 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; + } - if (node.dom.is(Element)) |el| { - if (args.x != null) { - el.setScrollLeft(x, page) catch {}; - } - if (args.y != null) { - el.setScrollTop(y, page) catch {}; - } - - const Event = @import("../browser/webapi/Event.zig"); - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; - } else { + lp.actions.scrollNode(target_node, x, y, page) catch |err| { + if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } - } else { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { - log.err(.mcp, "scroll failed", .{ .err = err }); - return server.sendError(id, .InternalError, "Failed to scroll"); - }; - } + return server.sendError(id, .InternalError, "Failed to scroll"); + }; const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } - fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { if (arguments == null) { try server.sendError(id, .InvalidParams, "Missing arguments");