mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 20:32:00 -04:00
839 lines
28 KiB
Zig
839 lines
28 KiB
Zig
// 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. See <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
|
|
const interactive = @import("browser/interactive.zig");
|
|
const SelectorPath = @import("browser/SelectorPath.zig");
|
|
|
|
const CData = @import("browser/webapi/CData.zig");
|
|
const Element = @import("browser/webapi/Element.zig");
|
|
const Label = @import("browser/webapi/element/html/Label.zig");
|
|
const Node = @import("browser/webapi/Node.zig");
|
|
const AXNode = @import("server/cdp/AXNode.zig");
|
|
|
|
const NodeRegistry = @import("NodeRegistry.zig");
|
|
const isAllWhitespace = @import("string.zig").isAllWhitespace;
|
|
|
|
const log = lp.log;
|
|
const Frame = lp.Frame;
|
|
|
|
const Self = @This();
|
|
|
|
dom_node: *Node,
|
|
registry: *NodeRegistry,
|
|
frame: *Frame, // we never visit iframes, every node we visit is in the same frame as dom_node
|
|
arena: std.mem.Allocator,
|
|
prune: bool,
|
|
interactive_only: bool,
|
|
max_depth: u32,
|
|
|
|
pub const Opts = struct {
|
|
prune: bool = true,
|
|
interactive_only: bool = false,
|
|
max_depth: u32 = std.math.maxInt(u32) - 1,
|
|
};
|
|
|
|
/// `frame` only seeds the owner lookup; the tree is walked with the frame
|
|
/// that owns `node`. A node whose document has no frame (DOMParser, XHR, or
|
|
/// a frame that has since navigated away) has no styles or layout to
|
|
/// describe.
|
|
pub fn init(arena: std.mem.Allocator, node: *Node, registry: *NodeRegistry, frame: *Frame, opts: Opts) error{FramelessNode}!Self {
|
|
return .{
|
|
.dom_node = node,
|
|
.registry = registry,
|
|
.frame = node.ownerFrame(frame) orelse return error.FramelessNode,
|
|
.arena = arena,
|
|
.prune = opts.prune,
|
|
.interactive_only = opts.interactive_only,
|
|
.max_depth = opts.max_depth,
|
|
};
|
|
}
|
|
|
|
/// Walk the pruned tree with `visitor`: `visit(*Node, *NodeData) !bool`
|
|
/// returns whether to descend into the children, `leave() !void` closes a
|
|
/// visited node.
|
|
fn visitAll(self: @This(), visitor: anytype) error{WriteFailed}!void {
|
|
var xpath_buffer: std.ArrayList(u8) = .empty;
|
|
const listener_targets = interactive.buildListenerTargetMap(self.frame, self.arena) catch |err| {
|
|
log.err(.app, "listener map failed", .{ .err = err });
|
|
return error.WriteFailed;
|
|
};
|
|
var label_index: Label.LabelByForIndex = .{};
|
|
var ctx: WalkContext = .{
|
|
.xpath_buffer = &xpath_buffer,
|
|
.listener_targets = listener_targets,
|
|
.label_index = &label_index,
|
|
};
|
|
self.walk(&ctx, visitor) catch |err| {
|
|
log.err(.app, "semantic tree walk failed", .{ .err = err });
|
|
return error.WriteFailed;
|
|
};
|
|
}
|
|
|
|
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
|
|
var visitor = JsonVisitor{ .jw = jw, .tree = self };
|
|
return self.visitAll(&visitor);
|
|
}
|
|
|
|
pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void {
|
|
var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 };
|
|
return self.visitAll(&visitor);
|
|
}
|
|
|
|
const OptionData = struct {
|
|
value: []const u8,
|
|
text: []const u8,
|
|
selected: bool,
|
|
};
|
|
|
|
const NodeData = struct {
|
|
id: NodeRegistry.Id,
|
|
axn: AXNode,
|
|
role: []const u8,
|
|
name: ?[]const u8,
|
|
value: ?[]const u8,
|
|
options: ?[]OptionData = null,
|
|
checked: ?bool = null,
|
|
xpath: []const u8,
|
|
interactive: bool,
|
|
disabled: bool,
|
|
tag_name: []const u8,
|
|
};
|
|
|
|
const WalkContext = struct {
|
|
xpath_buffer: *std.ArrayList(u8),
|
|
listener_targets: interactive.ListenerTargetMap,
|
|
label_index: *Label.LabelByForIndex,
|
|
};
|
|
|
|
// A node whose children are still being walked
|
|
const Open = struct {
|
|
next_child: ?*Node,
|
|
// for the children's xpath index
|
|
tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty,
|
|
name: ?[]const u8, // The children's parent_name
|
|
xpath_len: usize,
|
|
visited: bool,
|
|
};
|
|
|
|
fn walk(self: @This(), ctx: *WalkContext, visitor: anytype) !void {
|
|
var stack: std.ArrayList(Open) = .empty;
|
|
defer stack.deinit(self.arena);
|
|
|
|
try self.visitNode(ctx, &stack, self.dom_node, null, visitor, 1);
|
|
while (stack.items.len > 0) {
|
|
// Everything read from `top` is read before visitNode, which can grow (move) the stack.
|
|
const top = &stack.items[stack.items.len - 1];
|
|
if (top.next_child) |child| {
|
|
top.next_child = child._next;
|
|
|
|
var tag: []const u8 = "text()";
|
|
if (child.is(Element)) |el| {
|
|
tag = el.getTagNameLower();
|
|
}
|
|
const gop = try top.tag_counts.getOrPut(self.arena, tag);
|
|
if (!gop.found_existing) {
|
|
gop.value_ptr.* = 0;
|
|
}
|
|
gop.value_ptr.* += 1;
|
|
|
|
try self.visitNode(ctx, &stack, child, top.name, visitor, gop.value_ptr.*);
|
|
continue;
|
|
}
|
|
|
|
const done = stack.pop().?;
|
|
if (done.visited) {
|
|
try visitor.leave();
|
|
}
|
|
ctx.xpath_buffer.shrinkRetainingCapacity(done.xpath_len);
|
|
}
|
|
}
|
|
|
|
// Every ancestor of `node` below the root is open, so the stack's length is its depth.
|
|
fn visitNode(
|
|
self: @This(),
|
|
ctx: *WalkContext,
|
|
stack: *std.ArrayList(Open),
|
|
node: *Node,
|
|
parent_name: ?[]const u8,
|
|
visitor: anytype,
|
|
index: usize,
|
|
) !void {
|
|
const current_depth = stack.items.len;
|
|
if (current_depth > self.max_depth) return;
|
|
|
|
// 1. Skip non-content nodes
|
|
if (node.is(Element)) |el| {
|
|
const tag = el.getTag();
|
|
if (tag.isMetadata() or tag == .svg) return;
|
|
|
|
// We handle options/optgroups natively inside their parents, skip them in the general walk
|
|
if (tag == .datalist or tag == .option or tag == .optgroup) return;
|
|
|
|
// Hidden subtrees are never entered, so below the root only the
|
|
// element's own display matters.
|
|
const style_manager = &self.frame._style_manager;
|
|
const hidden = if (current_depth == 0)
|
|
style_manager.isHidden(el, .{})
|
|
else
|
|
style_manager.hasDisplayNone(el);
|
|
if (hidden) {
|
|
return;
|
|
}
|
|
|
|
if (el.is(Element.Html)) |html_el| {
|
|
if (html_el.getHidden()) return;
|
|
}
|
|
} else if (node.is(CData.Text)) |text_node| {
|
|
const text = text_node.ownData();
|
|
if (isAllWhitespace(text)) {
|
|
return;
|
|
}
|
|
} else if (node._type != .document and node._type != .document_fragment) {
|
|
return;
|
|
}
|
|
|
|
const cdp_node = try self.registry.register(node);
|
|
const axn = AXNode.fromNode(node);
|
|
const role = try axn.getRole();
|
|
|
|
var is_interactive = false;
|
|
var is_disabled = false;
|
|
var value: ?[]const u8 = null;
|
|
var options: ?[]OptionData = null;
|
|
var checked: ?bool = null;
|
|
var tag_name: []const u8 = "text";
|
|
|
|
if (node.is(Element)) |el| {
|
|
tag_name = el.getTagNameLower();
|
|
|
|
if (el.is(Element.Html.Input)) |input| {
|
|
value = input.getRedactedValue();
|
|
if (input._input_type == .checkbox or input._input_type == .radio) {
|
|
checked = input.getChecked();
|
|
}
|
|
if (el.getAttributeSafe(comptime .wrap("list"))) |list_id| {
|
|
options = try extractDataListOptions(list_id, self.frame, self.arena);
|
|
}
|
|
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
|
value = textarea.getValue();
|
|
} else if (el.is(Element.Html.Select)) |select| {
|
|
value = select.getValue(self.frame);
|
|
options = try extractSelectOptions(el.asNode(), self.frame, self.arena);
|
|
}
|
|
|
|
if (el.is(Element.Html)) |html_el| {
|
|
if (interactive.classifyInteractivity(self.frame, el, html_el, ctx.listener_targets) != null) {
|
|
is_interactive = true;
|
|
}
|
|
}
|
|
|
|
is_disabled = el.isDisabled();
|
|
} else if (node._type == .document or node._type == .document_fragment) {
|
|
tag_name = "root";
|
|
}
|
|
|
|
const initial_xpath_len = ctx.xpath_buffer.items.len;
|
|
try appendXPathSegment(node, ctx.xpath_buffer, self.arena, index);
|
|
const xpath = ctx.xpath_buffer.items;
|
|
|
|
const has_explicit_label = if (node.is(Element)) |el|
|
|
el.getAttributeInterned("aria-label") != null or el.getAttributeInterned("title") != null
|
|
else
|
|
false;
|
|
|
|
const structural = isStructuralRole(role);
|
|
|
|
// No computed concatenated names for generic containers without explicit labels.
|
|
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
|
|
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
|
|
// Not computing it also keeps a deep chain of containers from being O(depth²).
|
|
const name = if (structural and !has_explicit_label)
|
|
null
|
|
else
|
|
try axn.getName(self.frame, self.arena, ctx.label_index);
|
|
|
|
var should_visit = true;
|
|
if (self.interactive_only) {
|
|
var keep = false;
|
|
if (interactive.isInteractiveRole(role)) {
|
|
keep = true;
|
|
} else if (interactive.isContentRole(role)) {
|
|
if (name != null and name.?.len > 0) {
|
|
keep = true;
|
|
}
|
|
} else if (std.mem.eql(u8, role, "RootWebArea")) {
|
|
keep = true;
|
|
} else if (is_interactive) {
|
|
keep = true;
|
|
}
|
|
if (!keep) {
|
|
should_visit = false;
|
|
}
|
|
} else if (self.prune) {
|
|
if (structural and !is_interactive and !has_explicit_label) {
|
|
should_visit = false;
|
|
}
|
|
|
|
if (std.mem.eql(u8, role, "StaticText") and node._parent != null) {
|
|
if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) {
|
|
should_visit = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
var did_visit = false;
|
|
var should_walk_children = true;
|
|
var data: NodeData = .{
|
|
.id = cdp_node.id,
|
|
.axn = axn,
|
|
.role = role,
|
|
.name = name,
|
|
.value = value,
|
|
.options = options,
|
|
.checked = checked,
|
|
.xpath = xpath,
|
|
.interactive = is_interactive,
|
|
.disabled = is_disabled,
|
|
.tag_name = tag_name,
|
|
};
|
|
|
|
if (should_visit) {
|
|
should_walk_children = try visitor.visit(node, &data);
|
|
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
|
|
} else {
|
|
// If we skip the node, we must NOT tell the visitor to close it later
|
|
did_visit = false;
|
|
}
|
|
|
|
try stack.append(self.arena, .{
|
|
.next_child = if (should_walk_children) node._first_child else null,
|
|
.name = name,
|
|
.xpath_len = initial_xpath_len,
|
|
.visited = did_visit,
|
|
});
|
|
}
|
|
|
|
fn extractSelectOptions(node: *Node, frame: *Frame, arena: std.mem.Allocator) ![]OptionData {
|
|
var options: std.ArrayList(OptionData) = .empty;
|
|
var it = node.childrenIterator();
|
|
while (it.next()) |child| {
|
|
if (child.is(Element)) |el| {
|
|
if (el.getTag() == .option) {
|
|
if (el.is(Element.Html.Option)) |opt| {
|
|
const text = opt.getText(frame);
|
|
const value = opt.getValue(frame);
|
|
const selected = opt.getSelected();
|
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
|
}
|
|
} else if (el.getTag() == .optgroup) {
|
|
var group_it = child.childrenIterator();
|
|
while (group_it.next()) |group_child| {
|
|
if (group_child.is(Element.Html.Option)) |opt| {
|
|
const text = opt.getText(frame);
|
|
const value = opt.getValue(frame);
|
|
const selected = opt.getSelected();
|
|
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return options.toOwnedSlice(arena);
|
|
}
|
|
|
|
fn extractDataListOptions(list_id: []const u8, frame: *Frame, arena: std.mem.Allocator) !?[]OptionData {
|
|
if (frame.document.getElementById(list_id, frame)) |referenced_el| {
|
|
if (referenced_el.getTag() == .datalist) {
|
|
return try extractSelectOptions(referenced_el.asNode(), frame, arena);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn appendXPathSegment(node: *Node, buffer: *std.ArrayList(u8), allocator: std.mem.Allocator, index: usize) !void {
|
|
if (node.is(Element)) |el| {
|
|
const tag = el.getTagNameLower();
|
|
try buffer.print(allocator, "/{s}[{d}]", .{ tag, index });
|
|
} else if (node.is(CData.Text)) |_| {
|
|
try buffer.print(allocator, "/text()[{d}]", .{index});
|
|
}
|
|
}
|
|
|
|
const JsonVisitor = struct {
|
|
jw: *std.json.Stringify,
|
|
tree: Self,
|
|
|
|
pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool {
|
|
try self.jw.beginObject();
|
|
|
|
try self.jw.objectField("nodeId");
|
|
try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id}));
|
|
|
|
try self.jw.objectField("backendDOMNodeId");
|
|
try self.jw.write(data.id);
|
|
|
|
try self.jw.objectField("nodeName");
|
|
try self.jw.write(data.tag_name);
|
|
|
|
try self.jw.objectField("xpath");
|
|
try self.jw.write(data.xpath);
|
|
|
|
if (node.is(Element)) |el| {
|
|
try self.jw.objectField("nodeType");
|
|
try self.jw.write(1);
|
|
|
|
try self.jw.objectField("isInteractive");
|
|
try self.jw.write(data.interactive);
|
|
|
|
if (data.disabled) {
|
|
try self.jw.objectField("isDisabled");
|
|
try self.jw.write(true);
|
|
}
|
|
|
|
try self.jw.objectField("role");
|
|
try self.jw.write(data.role);
|
|
|
|
if (data.name) |name| {
|
|
if (name.len > 0) {
|
|
try self.jw.objectField("name");
|
|
try self.jw.write(name);
|
|
}
|
|
}
|
|
|
|
if (data.value) |value| {
|
|
try self.jw.objectField("value");
|
|
try self.jw.write(value);
|
|
}
|
|
|
|
if (!el._attributes.isEmpty()) {
|
|
try self.jw.objectField("attributes");
|
|
try self.jw.beginObject();
|
|
for (el.attributeEntries()) |*attr| {
|
|
try self.jw.objectField(attr.name());
|
|
try self.jw.write(attr.value());
|
|
}
|
|
try self.jw.endObject();
|
|
}
|
|
|
|
if (data.checked) |checked| {
|
|
try self.jw.objectField("checked");
|
|
try self.jw.write(checked);
|
|
}
|
|
|
|
if (data.options) |options| {
|
|
try self.jw.objectField("options");
|
|
try self.jw.beginArray();
|
|
for (options) |opt| {
|
|
try self.jw.beginObject();
|
|
try self.jw.objectField("value");
|
|
try self.jw.write(opt.value);
|
|
try self.jw.objectField("text");
|
|
try self.jw.write(opt.text);
|
|
try self.jw.objectField("selected");
|
|
try self.jw.write(opt.selected);
|
|
try self.jw.endObject();
|
|
}
|
|
try self.jw.endArray();
|
|
}
|
|
} else if (node.is(CData.Text)) |text_node| {
|
|
try self.jw.objectField("nodeType");
|
|
try self.jw.write(3);
|
|
try self.jw.objectField("nodeValue");
|
|
try self.jw.write(text_node.ownData());
|
|
} else {
|
|
try self.jw.objectField("nodeType");
|
|
try self.jw.write(9);
|
|
}
|
|
|
|
try self.jw.objectField("children");
|
|
try self.jw.beginArray();
|
|
|
|
if (data.options != null) {
|
|
// Signal to not walk children, as we handled them natively
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
pub fn leave(self: *JsonVisitor) !void {
|
|
try self.jw.endArray();
|
|
try self.jw.endObject();
|
|
}
|
|
};
|
|
|
|
fn isStructuralRole(role: []const u8) bool {
|
|
const structural_roles = std.StaticStringMap(void).initComptime(.{
|
|
.{ "none", {} },
|
|
.{ "generic", {} },
|
|
.{ "InlineTextBox", {} },
|
|
.{ "banner", {} },
|
|
.{ "navigation", {} },
|
|
.{ "main", {} },
|
|
.{ "list", {} },
|
|
.{ "listitem", {} },
|
|
.{ "table", {} },
|
|
.{ "rowgroup", {} },
|
|
.{ "row", {} },
|
|
.{ "cell", {} },
|
|
.{ "region", {} },
|
|
});
|
|
return structural_roles.has(role);
|
|
}
|
|
|
|
const TextVisitor = struct {
|
|
writer: *std.Io.Writer,
|
|
tree: Self,
|
|
depth: usize,
|
|
|
|
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
|
|
for (0..self.depth) |_| {
|
|
try self.writer.writeByte(' ');
|
|
}
|
|
|
|
var name_to_print: ?[]const u8 = null;
|
|
if (data.name) |n| {
|
|
if (n.len > 0) {
|
|
name_to_print = n;
|
|
}
|
|
} else if (node.is(CData.Text)) |text_node| {
|
|
const trimmed = std.mem.trim(u8, text_node.ownData(), " \t\r\n");
|
|
if (trimmed.len > 0) {
|
|
name_to_print = trimmed;
|
|
}
|
|
}
|
|
|
|
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
|
|
|
|
try self.writer.print("{d}", .{data.id});
|
|
if (data.interactive) {
|
|
try self.writer.writeAll(if (data.disabled) " [i:disabled]" else " [i]");
|
|
}
|
|
if (!is_text_only) {
|
|
try self.writer.print(" {s}", .{data.role});
|
|
}
|
|
if (name_to_print) |n| {
|
|
try self.writer.print(" '{s}'", .{n});
|
|
}
|
|
|
|
if (data.value) |v| {
|
|
if (v.len > 0) {
|
|
try self.writer.print(" value='{s}'", .{v});
|
|
}
|
|
}
|
|
|
|
if (data.checked) |c| {
|
|
if (c) {
|
|
try self.writer.writeAll(" [checked]");
|
|
} else {
|
|
try self.writer.writeAll(" [unchecked]");
|
|
}
|
|
}
|
|
|
|
if (data.options) |options| {
|
|
try self.writer.writeAll(" options=[");
|
|
for (options, 0..) |opt, i| {
|
|
if (i > 0) try self.writer.writeAll(",");
|
|
try self.writer.print("'{s}'", .{opt.value});
|
|
if (opt.selected) {
|
|
try self.writer.writeAll("*");
|
|
}
|
|
}
|
|
try self.writer.writeAll("]\n");
|
|
self.depth += 1;
|
|
return false; // Native handling complete, do not walk children
|
|
}
|
|
|
|
try self.writer.writeByte('\n');
|
|
self.depth += 1;
|
|
|
|
// If this is a leaf-like semantic node and we already have a name,
|
|
// skip children to avoid redundant StaticText or noise.
|
|
const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or
|
|
std.mem.eql(u8, data.role, "button") or
|
|
std.mem.eql(u8, data.role, "heading") or
|
|
std.mem.eql(u8, data.role, "code");
|
|
if (is_leaf_semantic and data.name != null and data.name.?.len > 0) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
pub fn leave(self: *TextVisitor) !void {
|
|
if (self.depth > 0) {
|
|
self.depth -= 1;
|
|
}
|
|
}
|
|
};
|
|
|
|
const NodeDetails = struct {
|
|
backendNodeId: NodeRegistry.Id,
|
|
tag_name: []const u8,
|
|
role: []const u8,
|
|
name: ?[]const u8,
|
|
interactive: bool,
|
|
disabled: bool,
|
|
value: ?[]const u8 = null,
|
|
input_type: ?[]const u8 = null,
|
|
placeholder: ?[]const u8 = null,
|
|
href: ?[]const u8 = null,
|
|
id: ?[]const u8 = null,
|
|
class: ?[]const u8 = null,
|
|
selector: ?[]const u8 = null,
|
|
checked: ?bool = null,
|
|
options: ?[]OptionData = null,
|
|
|
|
pub fn jsonStringify(self: *const NodeDetails, jw: anytype) !void {
|
|
try jw.beginObject();
|
|
|
|
try jw.objectField("backendNodeId");
|
|
try jw.write(self.backendNodeId);
|
|
|
|
try jw.objectField("tagName");
|
|
try jw.write(self.tag_name);
|
|
|
|
try jw.objectField("role");
|
|
try jw.write(self.role);
|
|
|
|
if (self.name) |n| {
|
|
try jw.objectField("name");
|
|
try jw.write(n);
|
|
}
|
|
|
|
try jw.objectField("isInteractive");
|
|
try jw.write(self.interactive);
|
|
|
|
if (self.disabled) {
|
|
try jw.objectField("isDisabled");
|
|
try jw.write(true);
|
|
}
|
|
|
|
if (self.value) |v| {
|
|
try jw.objectField("value");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.input_type) |v| {
|
|
try jw.objectField("inputType");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.placeholder) |v| {
|
|
try jw.objectField("placeholder");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.href) |v| {
|
|
try jw.objectField("href");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.id) |v| {
|
|
try jw.objectField("id");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.class) |v| {
|
|
try jw.objectField("class");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.selector) |v| {
|
|
try jw.objectField("selector");
|
|
try jw.write(v);
|
|
}
|
|
|
|
if (self.checked) |c| {
|
|
try jw.objectField("checked");
|
|
try jw.write(c);
|
|
}
|
|
|
|
if (self.options) |opts| {
|
|
try jw.objectField("options");
|
|
try jw.beginArray();
|
|
for (opts) |opt| {
|
|
try jw.beginObject();
|
|
try jw.objectField("value");
|
|
try jw.write(opt.value);
|
|
try jw.objectField("text");
|
|
try jw.write(opt.text);
|
|
if (opt.selected) {
|
|
try jw.objectField("selected");
|
|
try jw.write(true);
|
|
}
|
|
try jw.endObject();
|
|
}
|
|
try jw.endArray();
|
|
}
|
|
|
|
try jw.endObject();
|
|
}
|
|
};
|
|
|
|
pub fn nodeDetails(self: Self) !NodeDetails {
|
|
const arena = self.arena;
|
|
const node = self.dom_node;
|
|
const frame = self.frame;
|
|
|
|
const cdp_node = try self.registry.register(node);
|
|
const axn = AXNode.fromNode(node);
|
|
const role = try axn.getRole();
|
|
var labels: Label.LabelByForIndex = .{};
|
|
const name = try axn.getName(frame, arena, &labels);
|
|
|
|
var is_interactive = false;
|
|
var is_disabled = false;
|
|
var tag_name: []const u8 = "text";
|
|
var value: ?[]const u8 = null;
|
|
var input_type: ?[]const u8 = null;
|
|
var placeholder: ?[]const u8 = null;
|
|
var href: ?[]const u8 = null;
|
|
var id_attr: ?[]const u8 = null;
|
|
var class_attr: ?[]const u8 = null;
|
|
var selector: ?[]const u8 = null;
|
|
var checked: ?bool = null;
|
|
var options: ?[]OptionData = null;
|
|
|
|
if (node.is(Element)) |el| {
|
|
tag_name = el.getTagNameLower();
|
|
is_disabled = el.isDisabled();
|
|
id_attr = el.getId();
|
|
class_attr = el.getClassName();
|
|
selector = try SelectorPath.init(arena, frame).build(el);
|
|
placeholder = el.getAttributeInterned("placeholder");
|
|
|
|
if (el.getAttributeInterned("href")) |h| {
|
|
const URL = lp.URL;
|
|
href = URL.resolve(arena, frame.base(), h, .{ .encoding = frame.charset }) catch h;
|
|
}
|
|
|
|
if (el.is(Element.Html.Input)) |input| {
|
|
value = input.getRedactedValue();
|
|
input_type = input._input_type.toString();
|
|
if (input._input_type == .checkbox or input._input_type == .radio) {
|
|
checked = input.getChecked();
|
|
}
|
|
if (el.getAttributeSafe(comptime .wrap("list"))) |list_id| {
|
|
options = try extractDataListOptions(list_id, frame, arena);
|
|
}
|
|
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
|
value = textarea.getValue();
|
|
} else if (el.is(Element.Html.Select)) |select| {
|
|
value = select.getValue(frame);
|
|
options = try extractSelectOptions(el.asNode(), frame, arena);
|
|
}
|
|
|
|
if (el.is(Element.Html)) |html_el| {
|
|
const listener_targets = try interactive.buildListenerTargetMap(frame, arena);
|
|
if (interactive.classifyInteractivity(frame, el, html_el, listener_targets) != null) {
|
|
is_interactive = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return .{
|
|
.backendNodeId = cdp_node.id,
|
|
.tag_name = tag_name,
|
|
.role = role,
|
|
.name = name,
|
|
.interactive = is_interactive,
|
|
.disabled = is_disabled,
|
|
.value = value,
|
|
.input_type = input_type,
|
|
.placeholder = placeholder,
|
|
.href = href,
|
|
.id = id_attr,
|
|
.class = class_attr,
|
|
.selector = selector,
|
|
.checked = checked,
|
|
.options = options,
|
|
};
|
|
}
|
|
|
|
const testing = @import("testing.zig");
|
|
|
|
test "SemanticTree backendDOMNodeId" {
|
|
var registry: NodeRegistry = .init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
var page = try testing.pageTest("cdp/registry1.html", .{});
|
|
defer page.close();
|
|
const frame = page.frame().?;
|
|
|
|
const st: Self = try .init(testing.arena_allocator, frame.window._document.asNode(), ®istry, frame, .{ .prune = false });
|
|
|
|
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
|
defer testing.allocator.free(json_str);
|
|
|
|
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
|
|
}
|
|
|
|
test "SemanticTree max_depth" {
|
|
var registry: NodeRegistry = .init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
var page = try testing.pageTest("cdp/registry1.html", .{});
|
|
defer page.close();
|
|
const frame = page.frame().?;
|
|
|
|
const st: Self = try .init(testing.arena_allocator, frame.window._document.asNode(), ®istry, frame, .{ .prune = false, .max_depth = 1 });
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
|
defer aw.deinit();
|
|
|
|
try st.textStringify(&aw.writer);
|
|
const text_str = aw.written();
|
|
|
|
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
|
}
|
|
|
|
test "SemanticTree: deep nesting doesn't overflow the native stack" {
|
|
var registry: NodeRegistry = .init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const frame = try testing.createFrame();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
// The link's name comes from its content: the whole chain. The <g>s are
|
|
// pruned, so the JSON only nests link > text. SVG, as an HTML element's
|
|
// pointer-events lookup walks its ancestors: O(depth²).
|
|
const depth = 50_000;
|
|
const doc = frame.window._document;
|
|
var top = try doc.createTextNode("deep");
|
|
for (0..depth) |_| {
|
|
const parent = (try doc.createElementNS("http://www.w3.org/2000/svg", "g", frame)).asNode();
|
|
_ = try parent.appendChild(top, frame);
|
|
top = parent;
|
|
}
|
|
const link = try doc.createElement("a", null, frame);
|
|
try link.setAttribute(.wrap("href"), .wrap("#"), frame);
|
|
_ = try link.asNode().appendChild(top, frame);
|
|
|
|
const st: Self = try .init(testing.arena_allocator, link.asNode(), ®istry, frame, .{});
|
|
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
|
defer testing.allocator.free(json_str);
|
|
|
|
try testing.expect(std.mem.indexOf(u8, json_str, "\"role\":\"link\",\"name\":\"deep\"") != null);
|
|
try testing.expectEqual(depth, std.mem.count(u8, json_str, "/g[1]"));
|
|
try testing.expect(std.mem.endsWith(u8, json_str, "/text()[1]\",\"nodeType\":3,\"nodeValue\":\"deep\",\"children\":[]}]}"));
|
|
}
|