mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
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.
This commit is contained in:
93
src/browser/actions.zig
Normal file
93
src/browser/actions.zig
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||||
|
//
|
||||||
|
// Francis Bouvier <francis@lightpanda.io>
|
||||||
|
// Pierre Tachoire <pierre@lightpanda.io>
|
||||||
|
//
|
||||||
|
// 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,18 +165,10 @@ fn clickNode(cmd: anytype) !void {
|
|||||||
const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
|
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 = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId;
|
||||||
|
|
||||||
if (node.dom.is(DOMNode.Element)) |el| {
|
lp.actions.clickNode(node.dom, page) catch |err| {
|
||||||
if (el.is(DOMNode.Element.Html)) |html_el| {
|
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||||
html_el.click(page) catch |err| {
|
return error.InternalError;
|
||||||
log.err(.cdp, "click failed", .{ .err = err });
|
};
|
||||||
return error.InternalError;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return error.InvalidParam;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return error.InvalidParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd.sendResult(.{}, .{});
|
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 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 = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.InvalidNodeId;
|
||||||
|
|
||||||
if (node.dom.is(DOMNode.Element)) |el| {
|
lp.actions.fillNode(node.dom, params.text, page) catch |err| {
|
||||||
if (el.is(DOMNode.Element.Html.Input)) |input| {
|
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||||
input.setValue(params.text, page) catch |err| {
|
return error.InternalError;
|
||||||
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(.{}, .{});
|
return cmd.sendResult(.{}, .{});
|
||||||
}
|
}
|
||||||
@@ -245,33 +212,19 @@ fn scrollNode(cmd: anytype) !void {
|
|||||||
|
|
||||||
const input_node_id = params.nodeId orelse params.backendNodeId;
|
const input_node_id = params.nodeId orelse params.backendNodeId;
|
||||||
|
|
||||||
|
var target_node: ?*DOMNode = null;
|
||||||
if (input_node_id) |node_id| {
|
if (input_node_id) |node_id| {
|
||||||
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
|
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
|
||||||
|
target_node = node.dom;
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lp.actions.scrollNode(target_node, x, y, page) catch |err| {
|
||||||
|
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||||
|
return error.InternalError;
|
||||||
|
};
|
||||||
|
|
||||||
return cmd.sendResult(.{}, .{});
|
return cmd.sendResult(.{}, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
test "cdp.lp: getMarkdown" {
|
test "cdp.lp: getMarkdown" {
|
||||||
var ctx = testing.context();
|
var ctx = testing.context();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
|
|||||||
pub const SemanticTree = @import("SemanticTree.zig");
|
pub const SemanticTree = @import("SemanticTree.zig");
|
||||||
pub const CDPNode = @import("cdp/Node.zig");
|
pub const CDPNode = @import("cdp/Node.zig");
|
||||||
pub const interactive = @import("browser/interactive.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 structured_data = @import("browser/structured_data.zig");
|
||||||
pub const mcp = @import("mcp.zig");
|
pub const mcp = @import("mcp.zig");
|
||||||
pub const build_config = @import("build_config");
|
pub const build_config = @import("build_config");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const log = lp.log;
|
|||||||
const js = lp.js;
|
const js = lp.js;
|
||||||
|
|
||||||
const Element = @import("../browser/webapi/Element.zig");
|
const Element = @import("../browser/webapi/Element.zig");
|
||||||
|
const DOMNode = @import("../browser/webapi/Node.zig");
|
||||||
const Selector = @import("../browser/webapi/selector/Selector.zig");
|
const Selector = @import("../browser/webapi/selector/Selector.zig");
|
||||||
const protocol = @import("protocol.zig");
|
const protocol = @import("protocol.zig");
|
||||||
const Server = @import("Server.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");
|
return server.sendError(id, .InvalidParams, "Node not found");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.dom.is(Element)) |el| {
|
lp.actions.clickNode(node.dom, page) catch |err| {
|
||||||
if (el.is(Element.Html)) |html_el| {
|
if (err == error.InvalidNodeType) {
|
||||||
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");
|
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
|
||||||
}
|
}
|
||||||
} else {
|
return server.sendError(id, .InternalError, "Failed to click element");
|
||||||
return server.sendError(id, .InvalidParams, "Node is not an element");
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }};
|
const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }};
|
||||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
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");
|
return server.sendError(id, .InvalidParams, "Node not found");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.dom.is(Element)) |el| {
|
lp.actions.fillNode(node.dom, args.text, page) catch |err| {
|
||||||
if (el.is(Element.Html.Input)) |input| {
|
if (err == error.InvalidNodeType) {
|
||||||
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");
|
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
|
||||||
}
|
}
|
||||||
|
return server.sendError(id, .InternalError, "Failed to fill element");
|
||||||
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." }};
|
const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }};
|
||||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
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 x = args.x orelse 0;
|
||||||
const y = args.y orelse 0;
|
const y = args.y orelse 0;
|
||||||
|
|
||||||
|
var target_node: ?*DOMNode = null;
|
||||||
if (args.backendNodeId) |node_id| {
|
if (args.backendNodeId) |node_id| {
|
||||||
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
|
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
|
||||||
return server.sendError(id, .InvalidParams, "Node not found");
|
return server.sendError(id, .InvalidParams, "Node not found");
|
||||||
};
|
};
|
||||||
|
target_node = node.dom;
|
||||||
|
}
|
||||||
|
|
||||||
if (node.dom.is(Element)) |el| {
|
lp.actions.scrollNode(target_node, x, y, page) catch |err| {
|
||||||
if (args.x != null) {
|
if (err == error.InvalidNodeType) {
|
||||||
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");
|
return server.sendError(id, .InvalidParams, "Node is not an element");
|
||||||
}
|
}
|
||||||
} else {
|
return server.sendError(id, .InternalError, "Failed to scroll");
|
||||||
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." }};
|
const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }};
|
||||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
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 {
|
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) {
|
if (arguments == null) {
|
||||||
try server.sendError(id, .InvalidParams, "Missing arguments");
|
try server.sendError(id, .InvalidParams, "Missing arguments");
|
||||||
|
|||||||
Reference in New Issue
Block a user