From b10d866e4bd7a30da02ce553663ad01aa8f5d281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 13:41:19 +0900 Subject: [PATCH 01/11] Add click, fill, and scroll interaction tools Adds click, fill, and scroll functionality to both CDP and MCP to support programmatic browser interactions. --- src/cdp/domains/lp.zig | 126 +++++++++++++++++++++++++++++ src/mcp/tools.zig | 175 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 2026b17d..9b51139f 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -32,6 +32,9 @@ pub fn processMessage(cmd: anytype) !void { getSemanticTree, getInteractiveElements, getStructuredData, + clickNode, + fillNode, + scrollNode, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -39,6 +42,9 @@ pub fn processMessage(cmd: anytype) !void { .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), .getStructuredData => return getStructuredData(cmd), + .clickNode => return clickNode(cmd), + .fillNode => return fillNode(cmd), + .scrollNode => return scrollNode(cmd), } } @@ -146,6 +152,126 @@ fn getStructuredData(cmd: anytype) !void { }, .{}); } +fn clickNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + 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; + } + + return cmd.sendResult(.{}, .{}); +} + +fn fillNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + text: []const u8, + }; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + 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; + } + + return cmd.sendResult(.{}, .{}); +} + +fn scrollNode(cmd: anytype) !void { + const Params = struct { + nodeId: ?Node.Id = null, + backendNodeId: ?Node.Id = null, + x: ?i32 = null, + y: ?i32 = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const x = params.x orelse 0; + const y = params.y orelse 0; + + const input_node_id = params.nodeId orelse params.backendNodeId; + + 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; + }; + } + + return cmd.sendResult(.{}, .{}); +} + const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index f5126be0..adebc120 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -98,6 +98,47 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "click", + .description = "Click on an interactive element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." } + \\ }, + \\ "required": ["backendNodeId"] + \\} + ), + }, + .{ + .name = "fill", + .description = "Fill text into an input element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." }, + \\ "text": { "type": "string", "description": "The text to fill into the input element." } + \\ }, + \\ "required": ["backendNodeId", "text"] + \\} + ), + }, + .{ + .name = "scroll", + .description = "Scroll the page or a specific element.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." }, + \\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." }, + \\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -182,6 +223,9 @@ const ToolAction = enum { structuredData, evaluate, semantic_tree, + click, + fill, + scroll, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -193,6 +237,9 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "structuredData", .structuredData }, .{ "evaluate", .evaluate }, .{ "semantic_tree", .semantic_tree }, + .{ "click", .click }, + .{ "fill", .fill }, + .{ "scroll", .scroll }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -221,6 +268,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), + .click => try handleClick(server, arena, req.id.?, call_params.arguments), + .fill => try handleFill(server, arena, req.id.?, call_params.arguments), + .scroll => try handleScroll(server, arena, req.id.?, call_params.arguments), } } @@ -380,6 +430,131 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } +fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const ClickParams = struct { + backendNodeId: CDPNode.Id, + }; + const args = try parseArguments(ClickParams, arena, arguments, server, id, "click"); + + 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"); + }; + + 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 { + return server.sendError(id, .InvalidParams, "Node is not an HTML element"); + } + } else { + return server.sendError(id, .InvalidParams, "Node is not an element"); + } + + const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const FillParams = struct { + backendNodeId: CDPNode.Id, + text: []const u8, + }; + const args = try parseArguments(FillParams, arena, arguments, server, id, "fill"); + + 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"); + }; + + 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 { + 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"); + } + + const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }}; + try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); +} + +fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { + const ScrollParams = struct { + backendNodeId: ?CDPNode.Id = null, + x: ?i32 = null, + y: ?i32 = null, + }; + const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll"); + + const page = server.session.currentPage() orelse { + return server.sendError(id, .PageNotLoaded, "Page not loaded"); + }; + + const x = args.x orelse 0; + const y = args.y orelse 0; + + 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"); + }; + + 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 { + 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"); + }; + } + + 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"); From 1972142703ff52887357d93e0b747a2c9ec1c476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 14:16:20 +0900 Subject: [PATCH 02/11] mcp: add tests for click, fill, and scroll actions --- src/browser/tests/mcp_actions.html | 14 +++++++ src/cdp/domains/lp.zig | 60 ++++++++++++++++++++++++++++ src/mcp/tools.zig | 63 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/browser/tests/mcp_actions.html diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html new file mode 100644 index 00000000..88cb70b1 --- /dev/null +++ b/src/browser/tests/mcp_actions.html @@ -0,0 +1,14 @@ + + + + + + +
+
Long content
+
+ + diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 9b51139f..d876dddd 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -321,3 +321,63 @@ test "cdp.lp: getStructuredData" { const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("structuredData") != null); } + +test "cdp.lp: action tools" { + var ctx = testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + _ = bc.session.wait(5000); + + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try bc.node_registry.register(btn)).id; + try ctx.processMessage(.{ + .id = 1, + .method = "LP.clickNode", + .params = .{ .backendNodeId = btn_id }, + }); + + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try bc.node_registry.register(inp)).id; + try ctx.processMessage(.{ + .id = 2, + .method = "LP.fillNode", + .params = .{ .backendNodeId = inp_id, .text = "hello" }, + }); + + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try bc.node_registry.register(sel)).id; + try ctx.processMessage(.{ + .id = 3, + .method = "LP.fillNode", + .params = .{ .backendNodeId = sel_id, .text = "opt2" }, + }); + + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try bc.node_registry.register(scrollbox)).id; + try ctx.processMessage(.{ + .id = 4, + .method = "LP.scrollNode", + .params = .{ .backendNodeId = scrollbox_id, .y = 50 }, + }); + + // Evaluate assertions + var ls: lp.js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + + try testing.expect(result.isTrue()); +} diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index adebc120..c2881a40 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -630,3 +630,66 @@ test "MCP - evaluate error reporting" { \\} , out_alloc.writer.buffered()); } + +test "MCP - Actions: click, fill, scroll" { + defer testing.reset(); + const allocator = testing.allocator; + const app = testing.test_app; + + var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); + defer out_alloc.deinit(); + + var server = try Server.init(allocator, app, &out_alloc.writer); + defer server.deinit(); + + const aa = testing.arena_allocator; + const page = try server.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + _ = server.session.wait(5000); + + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try server.node_registry.register(btn)).id; + var btn_id_buf: [12]u8 = undefined; + const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; + const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); + try router.handleMessage(server, aa, click_msg); + + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try server.node_registry.register(inp)).id; + var inp_id_buf: [12]u8 = undefined; + const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; + const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); + try router.handleMessage(server, aa, fill_msg); + + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try server.node_registry.register(sel)).id; + var sel_id_buf: [12]u8 = undefined; + const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; + const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); + try router.handleMessage(server, aa, fill_sel_msg); + + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try server.node_registry.register(scrollbox)).id; + var scroll_id_buf: [12]u8 = undefined; + const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; + const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); + try router.handleMessage(server, aa, scroll_msg); + + // Evaluate assertions + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + + try testing.expect(result.isTrue()); +} 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 03/11] 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"); From 21e9967a8a06ddbab54a5fbd0b559b0a4c6b8f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 16:31:33 +0900 Subject: [PATCH 04/11] actions: simplify function names --- src/browser/actions.zig | 12 ++++++------ src/cdp/domains/lp.zig | 6 +++--- src/mcp/tools.zig | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 641466b8..ceb698e2 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -23,8 +23,8 @@ 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| { +pub fn click(node: *DOMNode, page: *Page) !void { + if (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 }); @@ -38,8 +38,8 @@ pub fn clickNode(dom_node: *DOMNode, page: *Page) !void { } } -pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { - if (dom_node.is(Element)) |el| { +pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { + if (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 }); @@ -69,8 +69,8 @@ pub fn fillNode(dom_node: *DOMNode, text: []const u8, page: *Page) !void { } } -pub fn scrollNode(dom_node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { - if (dom_node) |n| { +pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { + if (node) |n| { if (n.is(Element)) |el| { if (x != 0) { el.setScrollLeft(x, page) catch {}; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 4443629e..ed6c6994 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -165,7 +165,7 @@ 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; - lp.actions.clickNode(node.dom, page) catch |err| { + lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; @@ -187,7 +187,7 @@ 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; - lp.actions.fillNode(node.dom, params.text, page) catch |err| { + lp.actions.fill(node.dom, params.text, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; @@ -218,7 +218,7 @@ fn scrollNode(cmd: anytype) !void { target_node = node.dom; } - lp.actions.scrollNode(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, x, y, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 65bd3ee8..6f256dca 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -445,7 +445,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar return server.sendError(id, .InvalidParams, "Node not found"); }; - lp.actions.clickNode(node.dom, page) catch |err| { + lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an HTML element"); } @@ -471,7 +471,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg return server.sendError(id, .InvalidParams, "Node not found"); }; - lp.actions.fillNode(node.dom, args.text, page) catch |err| { + lp.actions.fill(node.dom, args.text, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select"); } @@ -505,7 +505,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a target_node = node.dom; } - lp.actions.scrollNode(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, x, y, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } From f5bc7310b14ca7740b817cc4d0ddfd90e30b8e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 16:38:21 +0900 Subject: [PATCH 05/11] actions: refactor node type checks for idiomatic flattening --- src/browser/actions.zig | 80 +++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index ceb698e2..60b03f1e 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -24,66 +24,58 @@ const Event = @import("webapi/Event.zig"); const Page = @import("Page.zig"); pub fn click(node: *DOMNode, page: *Page) !void { - if (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; - } + if (node.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; } } pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { - if (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 el = node.is(Element) orelse 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 {}; + 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 {}; } pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { if (node) |n| { - if (n.is(Element)) |el| { - if (x != 0) { - el.setScrollLeft(x, page) catch {}; - } - if (y != 0) { - el.setScrollTop(y, page) catch {}; - } + const el = n.is(Element) orelse return error.InvalidNodeType; - 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; + 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 { page.window.scrollTo(.{ .x = x }, y, page) catch |err| { lp.log.err(.app, "scroll failed", .{ .err = err }); From a74e46debf89476037adcdb4429bf880920d2b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 22:44:37 +0900 Subject: [PATCH 06/11] actions: make scroll coordinates optional Updates the scroll action to accept optional x and y coordinates. This allows scrolling on a single axis without resetting the other to zero. --- src/browser/actions.zig | 12 ++++++------ src/cdp/domains/lp.zig | 5 +---- src/mcp/tools.zig | 5 +---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 60b03f1e..36b96bc5 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -63,21 +63,21 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; } -pub fn scroll(node: ?*DOMNode, x: i32, y: i32, page: *Page) !void { +pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { if (node) |n| { const el = n.is(Element) orelse return error.InvalidNodeType; - if (x != 0) { - el.setScrollLeft(x, page) catch {}; + if (x) |val| { + el.setScrollLeft(val, page) catch {}; } - if (y != 0) { - el.setScrollTop(y, page) catch {}; + if (y) |val| { + el.setScrollTop(val, 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 { - page.window.scrollTo(.{ .x = x }, y, page) catch |err| { + page.window.scrollTo(.{ .x = x orelse 0 }, 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 ed6c6994..fedd71e6 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -207,9 +207,6 @@ fn scrollNode(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const x = params.x orelse 0; - const y = params.y orelse 0; - const input_node_id = params.nodeId orelse params.backendNodeId; var target_node: ?*DOMNode = null; @@ -218,7 +215,7 @@ fn scrollNode(cmd: anytype) !void { target_node = node.dom; } - lp.actions.scroll(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, params.x, params.y, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; return error.InternalError; }; diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 6f256dca..d8fd4ead 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -494,9 +494,6 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; - 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 { @@ -505,7 +502,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a target_node = node.dom; } - lp.actions.scroll(target_node, x, y, page) catch |err| { + lp.actions.scroll(target_node, args.x, args.y, page) catch |err| { if (err == error.InvalidNodeType) { return server.sendError(id, .InvalidParams, "Node is not an element"); } From c8265f4807c1d2eb6ffa18265314e2ac5ef98e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:41:22 +0900 Subject: [PATCH 07/11] browser.actions: improve error handling --- src/browser/actions.zig | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 36b96bc5..38cad01d 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -56,11 +56,15 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { 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 input_evt: *Event = try .initTrusted(comptime lp.String.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 = try Event.initTrusted(comptime lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch {}; + const change_evt: *Event = try .initTrusted(comptime lp.String.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 scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { @@ -68,14 +72,22 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { const el = n.is(Element) orelse return error.InvalidNodeType; if (x) |val| { - el.setScrollLeft(val, page) catch {}; + el.setScrollLeft(val, page) catch |err| { + lp.log.err(.app, "setScrollLeft failed", .{ .err = err }); + return error.ActionFailed; + }; } if (y) |val| { - el.setScrollTop(val, page) catch {}; + el.setScrollTop(val, page) catch |err| { + lp.log.err(.app, "setScrollTop failed", .{ .err = err }); + return error.ActionFailed; + }; } - const scroll_evt = try Event.initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch {}; + const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { + lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); + }; } else { page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| { lp.log.err(.app, "scroll failed", .{ .err = err }); From 548c6eeb7af8c859fd326d7fb717fb0ca04fb369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:45:07 +0900 Subject: [PATCH 08/11] browser.actions: remove redundant result ignores --- src/browser/actions.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 38cad01d..09052881 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -57,12 +57,12 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { } const input_evt: *Event = try .initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { + 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 lp.String.wrap("change"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { + page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { lp.log.err(.app, "dispatch change event failed", .{ .err = err }); }; } @@ -85,7 +85,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { } const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); - _ = page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { + page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; } else { From f508d37426f9329013199e7320dd938cc84ec1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 16 Mar 2026 23:50:15 +0900 Subject: [PATCH 09/11] lp: validate params in node actions and rename variables --- src/cdp/domains/lp.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index fedd71e6..19fc8cac 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -157,13 +157,13 @@ fn clickNode(cmd: anytype) !void { nodeId: ?Node.Id = null, backendNodeId: ?Node.Id = null, }; - const params = (try cmd.params(Params)) orelse Params{}; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - 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; + const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.click(node.dom, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; @@ -184,8 +184,8 @@ fn fillNode(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - 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; + const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; + const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; lp.actions.fill(node.dom, params.text, page) catch |err| { if (err == error.InvalidNodeType) return error.InvalidParam; @@ -202,15 +202,15 @@ fn scrollNode(cmd: anytype) !void { x: ?i32 = null, y: ?i32 = null, }; - const params = (try cmd.params(Params)) orelse Params{}; + const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; - const input_node_id = params.nodeId orelse params.backendNodeId; + const maybe_node_id = params.nodeId orelse params.backendNodeId; var target_node: ?*DOMNode = null; - if (input_node_id) |node_id| { + if (maybe_node_id) |node_id| { const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId; target_node = node.dom; } From 44a83c0e1c759527defa37cd62efdfe546665160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:55:10 +0900 Subject: [PATCH 10/11] browser.actions: use .wrap directly Co-authored-by: Karl Seguin --- src/browser/actions.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 09052881..9b4025e8 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -56,12 +56,12 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { return error.InvalidNodeType; } - const input_evt: *Event = try .initTrusted(comptime lp.String.wrap("input"), .{ .bubbles = true }, page); + 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 lp.String.wrap("change"), .{ .bubbles = true }, page); + 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 }); }; @@ -84,7 +84,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { }; } - const scroll_evt: *Event = try .initTrusted(comptime lp.String.wrap("scroll"), .{ .bubbles = true }, page); + const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page); page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; From 463aac9b59d643af297fa8e2bdba51850070f9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 17 Mar 2026 13:22:55 +0900 Subject: [PATCH 11/11] browser.actions: refactor click to use trusted MouseEvent --- src/browser/Page.zig | 7 ++++--- src/browser/actions.zig | 23 +++++++++++++++-------- src/browser/webapi/event/MouseEvent.zig | 13 +++++++++++-- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index cb62cb31..0fa5bba3 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -62,6 +62,7 @@ const storage = @import("webapi/storage/storage.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); +const MouseEvent = @import("webapi/event/MouseEvent.zig"); const HttpClient = @import("HttpClient.zig"); const ArenaPool = App.ArenaPool; @@ -3255,14 +3256,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .type = self._type, }); } - const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ + const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{ .bubbles = true, .cancelable = true, .composed = true, .clientX = x, .clientY = y, - }, self)).asEvent(); - try self._event_manager.dispatch(target.asEventTarget(), event); + }, self); + try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent()); } // callback when the "click" event reaches the pages. diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 9b4025e8..951f2b1e 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -21,17 +21,24 @@ const lp = @import("../lightpanda.zig"); 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 Page = @import("Page.zig"); pub fn click(node: *DOMNode, page: *Page) !void { - if (node.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; - } + const el = node.is(Element) orelse return error.InvalidNodeType; + + const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = 0, + .clientY = 0, + }, page); + + page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); + return error.ActionFailed; + }; } pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void { diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig index 6b032433..cae21509 100644 --- a/src/browser/webapi/event/MouseEvent.zig +++ b/src/browser/webapi/event/MouseEvent.zig @@ -83,12 +83,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { const arena = try page.getArena(.{ .debug = "MouseEvent" }); errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); + return initWithTrusted(arena, type_string, _opts, false, page); +} +pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent { + const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" }); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, _opts, true, page); +} + +fn initWithTrusted(arena: std.mem.Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent { const opts = _opts orelse Options{}; const event = try page._factory.uiEvent( arena, - type_string, + typ, MouseEvent{ ._type = .generic, ._proto = undefined, @@ -106,7 +115,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent { }, ); - Event.populatePrototypes(event, opts, false); + Event.populatePrototypes(event, opts, trusted); return event; }