diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index aab3576ce..d66373490 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -79,7 +79,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! .listener_targets = listener_targets, .label_index = &label_index, }; - self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| { + self.walk(&ctx, &visitor) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -98,7 +98,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v .listener_targets = listener_targets, .label_index = &label_index, }; - self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| { + self.walk(&ctx, &visitor) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -130,15 +130,60 @@ const WalkContext = struct { label_index: *Label.LabelByForIndex, }; -fn walk( +// 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, - current_depth: u32, ) !void { + const current_depth = stack.items.len; if (current_depth > self.max_depth) return; // 1. Skip non-content nodes @@ -216,8 +261,6 @@ fn walk( try appendXPathSegment(node, ctx.xpath_buffer, self.arena, index); const xpath = ctx.xpath_buffer.items; - var name = try axn.getName(self.frame, self.arena, ctx.label_index); - const has_explicit_label = if (node.is(Element)) |el| el.getAttributeInterned("aria-label") != null or el.getAttributeInterned("title") != null else @@ -225,12 +268,14 @@ fn walk( const structural = isStructuralRole(role); - // Filter out computed concatenated names for generic containers without explicit labels. + // 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. - if (name != null and structural and !has_explicit_label) { - name = null; - } + // 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) { @@ -285,32 +330,12 @@ fn walk( did_visit = false; } - if (should_walk_children) { - // If we are printing this node normally OR skipping it and unrolling its children, - // we walk the children iterator. - var it = node.childrenIterator(); - var tag_counts: std.StringArrayHashMapUnmanaged(usize) = .empty; - while (it.next()) |child| { - var tag: []const u8 = "text()"; - if (child.is(Element)) |el| { - tag = el.getTagNameLower(); - } - - const gop = try tag_counts.getOrPut(self.arena, tag); - if (!gop.found_existing) { - gop.value_ptr.* = 0; - } - gop.value_ptr.* += 1; - - try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1); - } - } - - if (did_visit) { - try visitor.leave(); - } - - ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); + 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 { @@ -787,3 +812,34 @@ test "SemanticTree max_depth" { 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 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\":[]}]}")); +} diff --git a/src/server/cdp/AXNode.zig b/src/server/cdp/AXNode.zig index 64dc8e703..af0fc17e0 100644 --- a/src/server/cdp/AXNode.zig +++ b/src/server/cdp/AXNode.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const Frame = @import("../../browser/Frame.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); const Label = @import("../../browser/webapi/element/html/Label.zig"); +const TreeWalker = @import("../../browser/webapi/TreeWalker.zig"); const interactive = @import("../../browser/interactive.zig"); const NodeRegistry = @import("../../NodeRegistry.zig"); @@ -1058,7 +1059,7 @@ fn writeName( if (use_name_for_content) { var buf: std.Io.Writer.Allocating = .init(scratchAllocator(temp_arena, frame)); - try writeAccessibleNameFallback(node, &buf.writer, frame); + try writeAccessibleNameFallback(node, &buf.writer); if (buf.written().len > 0) { try writeString(buf.written(), w); return .contents; @@ -1085,9 +1086,22 @@ fn writeName( }; } -fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, frame: *Frame) !void { - var it = node.childrenIterator(); - while (it.next()) |child| { +fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer) !void { + var tw = TreeWalker.FullExcludeSelf.init(node, .{}); + while (tw.next()) |child| { + const parent = child._parent.?; + const in_svg = if (parent.is(DOMNode.Element)) |p| p.getTag() == .svg else false; + if (in_svg and parent != node) { + // Inside an SVG, only a names it + const is_title = if (child.is(DOMNode.Element)) |el| std.mem.eql(u8, el.getTagNameLower(), "title") else false; + if (is_title) { + try writer.writeByte(' '); + } else { + tw.skipChildren(); + } + continue; + } + switch (child._type) { .cdata => { const cd = child.subtype(DOMNode.CData); @@ -1101,26 +1115,15 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, frame: *F }, .element => { const el = child.subtype(DOMNode.Element); - if (el.getTag() == .img) { + const tag = el.getTag(); + if (tag == .img) { if (el.getAttributeSafe(.wrap("alt"))) |alt| { try writer.writeAll(alt); try writer.writeByte(' '); } - } else if (el.getTag() == .svg) { - // Try to find a <title> inside SVG - var sit = child.childrenIterator(); - while (sit.next()) |s_child| { - if (s_child.is(DOMNode.Element)) |s_el| { - if (std.mem.eql(u8, s_el.getTagNameLower(), "title")) { - try writeAccessibleNameFallback(s_child, writer, frame); - try writer.writeByte(' '); - } - } - } - } else { - if (!el.getTag().isMetadata()) { - try writeAccessibleNameFallback(child, writer, frame); - } + tw.skipChildren(); + } else if (tag != .svg and tag.isMetadata()) { + tw.skipChildren(); } }, else => {},