cdp: improve AX tree visibility and label resolution

Prunes hidden subtrees from the accessibility tree and implements
accessible name resolution via labels. Adds the `labels` property
to labellable HTML elements.
This commit is contained in:
Adrià Arrufat
2026-04-17 08:33:13 +02:00
parent 6caca237fd
commit cee72cabb9
12 changed files with 331 additions and 16 deletions

View File

@@ -231,6 +231,33 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
return false;
}
/// Computed display:none for a single element (own property, no ancestor walk).
/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule
/// `[hidden] { display: none }`.
pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool {
self.rebuildIfDirty() catch return false;
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
return self.isElementHidden(el, .{});
}
/// Computed visibility:hidden for an element, walking ancestors since `visibility`
/// inherits by default. Only considers visibility-level hidden (not display:none)
/// — display:none on an ancestor means the element isn't rendered, but its
/// computed `visibility` still reflects inherited visibility chain.
pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
const combined = self.isElementHidden(elem, .{ .check_visibility = true });
if (combined) {
const display_only = self.isElementHidden(elem, .{});
if (!display_only) return true;
}
current = elem.parentElement();
}
return false;
}
/// Check if a single element (not ancestors) is hidden.
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
// Track best match per property (value + priority)

View File

@@ -78,6 +78,20 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 {
pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
const normalized = normalizePropertyName(property_name, &page.buf);
const wrapped = String.wrap(normalized);
// Computed styles must reflect stylesheet rules, not just the element's
// inline `style=` attribute. Limited to display/visibility — what aria
// tree builders (Playwright ariaSnapshot) consult on every element.
if (self._is_computed) {
if (self._element) |element| {
if (wrapped.eql(comptime .wrap("display"))) {
if (page._style_manager.hasDisplayNone(element)) return "none";
} else if (wrapped.eql(comptime .wrap("visibility"))) {
if (page._style_manager.hasVisibilityHiddenInherited(element)) return "hidden";
}
}
}
const prop = self.findProperty(wrapped) orelse {
// Only return default values for computed styles
if (self._is_computed) {

View File

@@ -110,6 +110,10 @@ pub fn getForm(self: *Button, page: *Page) ?*Form {
return null;
}
pub fn getLabels(self: *Button, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Button);
@@ -125,6 +129,7 @@ pub const JsApi = struct {
pub const form = bridge.accessor(Button.getForm, null, .{});
pub const value = bridge.accessor(Button.getValue, Button.setValue, .{});
pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{});
pub const labels = bridge.accessor(Button.getLabels, null, .{});
};
pub const Build = struct {

View File

@@ -498,6 +498,13 @@ pub fn setSelectionRange(
try self.dispatchSelectionChangeEvent(page);
}
pub fn getLabels(self: *Input, page: *Page) !js.Array {
if (self._input_type == .hidden) {
return page.js.local.?.newArray(0);
}
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub fn getForm(self: *Input, page: *Page) ?*Form {
const element = self.asElement();
@@ -897,6 +904,7 @@ pub const JsApi = struct {
pub const size = bridge.accessor(Input.getSize, Input.setSize, .{});
pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{});
pub const form = bridge.accessor(Input.getForm, null, .{});
pub const labels = bridge.accessor(Input.getLabels, null, .{});
pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{});
pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{});
pub const min = bridge.accessor(Input.getMin, Input.setMin, .{});

View File

@@ -1,3 +1,4 @@
const std = @import("std");
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Node = @import("../../Node.zig");
@@ -51,6 +52,59 @@ fn isLabelable(el: *Element) bool {
};
}
/// First ancestor `<label>` element of `control`, if any.
pub fn findWrappingLabel(control: *Element) ?*Element {
var current: ?*Node = control.asNode()._parent;
while (current) |n| : (current = n._parent) {
const el = n.is(Element) orelse continue;
if (el.getTag() == .label) return el;
}
return null;
}
/// First `<label for="id">` descendant of `root`, if any.
pub fn findLabelByFor(root: *Node, id: []const u8) ?*Element {
var it = TreeWalker.Full.Elements.init(root, .{});
while (it.next()) |el| {
if (el.getTag() != .label) continue;
const for_attr = el.getAttributeSafe(comptime .wrap("for")) orelse continue;
if (std.mem.eql(u8, for_attr, id)) return el;
}
return null;
}
/// Collects the `<label>` elements associated with a labellable form control.
/// Matches HTMLInputElement.labels (and the equivalent on button/select/etc).
/// Includes every `<label for="id">` reference plus the nearest ancestor
/// `<label>` wrapping the control.
pub fn getControlLabels(control: *Element, page: *Page) !js.Array {
const local = page.js.local orelse return error.NotHandled;
var arr = local.newArray(0);
var idx: u32 = 0;
if (control.getAttributeSafe(comptime .wrap("id"))) |id_value| {
if (id_value.len > 0) {
const doc = control.asNode().ownerDocument(page);
const search_root: *Node = if (doc) |d| d.asNode() else control.asNode();
var it = TreeWalker.Full.Elements.init(search_root, .{});
while (it.next()) |el| {
if (el.getTag() != .label) continue;
const for_attr = el.getAttributeSafe(comptime .wrap("for")) orelse continue;
if (!std.mem.eql(u8, for_attr, id_value)) continue;
_ = try arr.set(idx, el, .{});
idx += 1;
}
}
}
if (findWrappingLabel(control)) |wrap_label| {
_ = try arr.set(idx, wrap_label, .{});
idx += 1;
}
return arr;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Label);

View File

@@ -1,4 +1,5 @@
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -14,6 +15,10 @@ pub fn asNode(self: *Meter) *Node {
return self.asElement().asNode();
}
pub fn getLabels(self: *Meter, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Meter);
@@ -22,4 +27,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const labels = bridge.accessor(Meter.getLabels, null, .{});
};

View File

@@ -1,4 +1,5 @@
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -14,6 +15,10 @@ pub fn asNode(self: *Output) *Node {
return self.asElement().asNode();
}
pub fn getLabels(self: *Output, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Output);
@@ -22,4 +27,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const labels = bridge.accessor(Output.getLabels, null, .{});
};

View File

@@ -1,4 +1,5 @@
const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -14,6 +15,10 @@ pub fn asNode(self: *Progress) *Node {
return self.asElement().asNode();
}
pub fn getLabels(self: *Progress, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Progress);
@@ -22,4 +27,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const labels = bridge.accessor(Progress.getLabels, null, .{});
};

View File

@@ -238,6 +238,10 @@ pub fn getForm(self: *Select, page: *Page) ?*Form {
return null;
}
pub fn getLabels(self: *Select, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Select);
@@ -258,6 +262,7 @@ pub const JsApi = struct {
pub const form = bridge.accessor(Select.getForm, null, .{});
pub const size = bridge.accessor(Select.getSize, Select.setSize, .{});
pub const length = bridge.accessor(Select.getLength, null, .{});
pub const labels = bridge.accessor(Select.getLabels, null, .{});
};
pub const Build = struct {

View File

@@ -280,6 +280,10 @@ pub fn getForm(self: *TextArea, page: *Page) ?*Form {
return null;
}
pub fn getLabels(self: *TextArea, page: *Page) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(TextArea);
@@ -289,6 +293,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const labels = bridge.accessor(TextArea.getLabels, null, .{});
pub const onselectionchange = bridge.accessor(TextArea.getOnSelectionChange, TextArea.setOnSelectionChange, .{});
pub const value = bridge.accessor(TextArea.getValue, TextArea.setValue, .{});
pub const defaultValue = bridge.accessor(TextArea.getDefaultValue, TextArea.setDefaultValue, .{});

View File

@@ -22,6 +22,7 @@ const jsonStringify = std.json.Stringify;
const log = @import("../log.zig");
const Page = @import("../browser/Page.zig");
const DOMNode = @import("../browser/webapi/Node.zig");
const Label = @import("../browser/webapi/element/html/Label.zig");
const Node = @import("Node.zig");
@@ -35,6 +36,7 @@ pub const Writer = struct {
root: *const Node,
registry: *Node.Registry,
page: *Page,
visibility_cache: *DOMNode.Element.VisibilityCache,
pub const Opts = struct {};
@@ -51,13 +53,13 @@ pub const Writer = struct {
fn toJSON(self: *const Writer, node: *const Node, w: anytype) !void {
try w.beginArray();
const root = AXNode.fromNode(node.dom);
if (try self.writeNode(node.id, root, w)) {
try self.writeNodeChildren(root, w);
if (try self.writeNode(node.id, root, false, w)) {
try self.writeNodeChildren(root, false, w);
}
return w.endArray();
}
fn writeNodeChildren(self: *const Writer, parent: AXNode, w: anytype) !void {
fn writeNodeChildren(self: *const Writer, parent: AXNode, in_aria_hidden: bool, w: anytype) !void {
// Add ListMarker for listitem elements
if (parent.dom.is(DOMNode.Element)) |parent_el| {
if (parent_el.getTag() == .li) {
@@ -65,6 +67,11 @@ pub const Writer = struct {
}
}
const child_in_aria_hidden = in_aria_hidden or blk: {
const parent_el = parent.dom.is(DOMNode.Element) orelse break :blk false;
break :blk hasAriaHiddenTrue(parent_el);
};
var it = parent.dom.childrenIterator();
const ignore_text = ignoreText(parent.dom);
while (it.next()) |dom_node| {
@@ -77,14 +84,22 @@ pub const Writer = struct {
continue;
}
},
.element => {},
.element => {
// Prune hidden subtrees entirely (display:none,
// visibility:hidden, aria-hidden, hidden, inert). Matches
// Chromium: these elements aren't exposed to the AX tree.
const child_el = dom_node.as(DOMNode.Element);
if (child_in_aria_hidden or isHidden(child_el, self.page, self.visibility_cache)) {
continue;
}
},
else => continue,
}
const node = try self.registry.register(dom_node);
const axn = AXNode.fromNode(node.dom);
if (try self.writeNode(node.id, axn, w)) {
try self.writeNodeChildren(axn, w);
if (try self.writeNode(node.id, axn, child_in_aria_hidden, w)) {
try self.writeNodeChildren(axn, child_in_aria_hidden, w);
}
}
}
@@ -212,7 +227,12 @@ pub const Writer = struct {
// w.objectField("value");
// self.writeAXValue(.{ .type = .computedString, .value = value.value }, w);
.contents => "contents",
.label_element, .label_wrap => "TODO", // TODO
.label_element => blk: {
try w.objectField("attribute");
try w.write("for");
break :blk "relatedElement";
},
.label_wrap => "relatedElement",
};
try w.objectField("type");
try w.write(source_type);
@@ -438,7 +458,7 @@ pub const Writer = struct {
}
// write a node. returns true if children must be written.
fn writeNode(self: *const Writer, id: u32, axn: AXNode, w: anytype) !bool {
fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, w: anytype) !bool {
// ignore empty texts
try w.beginObject();
@@ -451,7 +471,7 @@ pub const Writer = struct {
try w.objectField("role");
try self.writeAXValue(.{ .role = try axn.getRole() }, w);
const ignore = axn.isIgnore(self.page);
const ignore = axn.isIgnore(self.page, self.visibility_cache, in_aria_hidden);
try w.objectField("ignored");
try w.write(ignore);
@@ -502,6 +522,11 @@ pub const Writer = struct {
const write_children = axn.ignoreChildren() == false;
const skip_text = ignoreText(axn.dom);
const child_in_aria_hidden = in_aria_hidden or blk: {
const self_el = n.is(DOMNode.Element) orelse break :blk false;
break :blk hasAriaHiddenTrue(self_el);
};
try w.objectField("childIds");
try w.beginArray();
if (write_children) {
@@ -513,6 +538,14 @@ pub const Writer = struct {
continue;
}
// Skip hidden element children so childIds matches the
// subtree-pruning done in writeNodeChildren.
if (child.is(DOMNode.Element)) |child_el| {
if (child_in_aria_hidden or isHidden(child_el, self.page, self.visibility_cache)) {
continue;
}
}
const child_node = try registry.register(child);
try w.write(child_node.id);
}
@@ -839,6 +872,12 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource {
return .aria_label;
}
if (isLabellableTag(el.getTag())) {
if (try writeLabelName(node, el, page, w)) |source| {
return source;
}
}
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try w.write(alt);
return .alt;
@@ -948,7 +987,48 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Pa
}
}
fn isHidden(elt: *DOMNode.Element) bool {
fn hasAriaHiddenTrue(elt: *DOMNode.Element) bool {
if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| {
return std.mem.eql(u8, value, "true");
}
return false;
}
fn isLabellableTag(tag: DOMNode.Element.Tag) bool {
return switch (tag) {
.button, .meter, .output, .progress, .select, .textarea, .input => true,
else => false,
};
}
fn writeLabelName(node: *DOMNode, el: *DOMNode.Element, page: *Page, w: anytype) !?AXSource {
if (el.getAttributeSafe(comptime .wrap("id"))) |id_value| {
if (id_value.len > 0) {
if (node.ownerDocument(page)) |doc| {
if (Label.findLabelByFor(doc.asNode(), id_value)) |label_el| {
if (try writeLabelInnerText(label_el, page, w)) return .label_element;
}
}
}
}
if (Label.findWrappingLabel(el)) |wrap_label| {
if (try writeLabelInnerText(wrap_label, page, w)) return .label_wrap;
}
return null;
}
fn writeLabelInnerText(label_el: *DOMNode.Element, page: *Page, w: anytype) !bool {
var buf: std.Io.Writer.Allocating = .init(page.call_arena);
try label_el.getInnerText(&buf.writer);
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
if (text.len == 0) return false;
try writeString(text, w);
return true;
}
fn isHidden(elt: *DOMNode.Element, page: *Page, cache: *DOMNode.Element.VisibilityCache) bool {
if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| {
if (std.mem.eql(u8, value, "true")) {
return true;
@@ -963,8 +1043,11 @@ fn isHidden(elt: *DOMNode.Element) bool {
return true;
}
// TODO Check if aria-hidden ancestor exists
// TODO Check CSS visibility (if you have access to computed styles)
// CSS display:none and visibility:hidden (both inherited from ancestors via
// style computation). Matches Chromium's AX tree which prunes both.
if (page._style_manager.isHidden(elt, cache, .{ .check_visibility = true })) {
return true;
}
return false;
}
@@ -1011,12 +1094,12 @@ fn ignoreChildren(self: AXNode) bool {
};
}
fn isIgnore(self: AXNode, page: *Page) bool {
fn isIgnore(self: AXNode, page: *Page, cache: *DOMNode.Element.VisibilityCache, in_aria_hidden: bool) bool {
const node = self.dom;
const role_attr = self.role_attr;
// Don't ignore non-Element node: CData, Document...
const elt = node.is(DOMNode.Element) orelse return false;
const elt = node.is(DOMNode.Element) orelse return in_aria_hidden;
// Ignore non-HTML elements: svg...
if (elt._type != .html) {
return true;
@@ -1053,7 +1136,11 @@ fn isIgnore(self: AXNode, page: *Page) bool {
}
}
if (isHidden(elt)) {
if (in_aria_hidden) {
return true;
}
if (isHidden(elt, page, cache)) {
return true;
}
@@ -1068,7 +1155,7 @@ fn isIgnore(self: AXNode, page: *Page) bool {
var it = node.childrenIterator();
while (it.next()) |child| {
const axn = AXNode.fromNode(child);
if (!axn.isIgnore(page)) {
if (!axn.isIgnore(page, cache, false)) {
return false;
}
}
@@ -1184,10 +1271,14 @@ test "AXNode: writer" {
var doc = page.window._document;
const node = try registry.register(doc.asNode());
// Cache inserts go through page.call_arena, so give the cache the same
// allocator and let the page arena clean it up.
var visibility_cache: DOMNode.Element.VisibilityCache = .empty;
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
.root = node,
.registry = &registry,
.page = page,
.visibility_cache = &visibility_cache,
}, .{});
defer testing.allocator.free(json);
@@ -1241,3 +1332,84 @@ test "AXNode: writer" {
}
return error.HeadingNodeNotFound;
}
test "AXNode: writer prunes hidden and resolves labels" {
var registry = Node.Registry.init(testing.allocator);
defer registry.deinit();
var page = try testing.pageTest("cdp/ax_tree.html", .{});
defer page._session.removePage();
var doc = page.window._document;
const node = try registry.register(doc.asNode());
var visibility_cache: DOMNode.Element.VisibilityCache = .empty;
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
.root = node,
.registry = &registry,
.page = page,
.visibility_cache = &visibility_cache,
}, .{});
defer testing.allocator.free(json);
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
defer parsed.deinit();
const nodes = parsed.value.array.items;
// No hidden-subtree text should have leaked into the tree.
const hidden_texts = [_][]const u8{
"under-display-none",
"under-visibility-hidden",
"under-hidden-attr",
"under-aria-hidden",
};
for (nodes) |node_val| {
const obj = node_val.object;
const name_obj = obj.get("name") orelse continue;
const value = name_obj.object.get("value") orelse continue;
if (value != .string) continue;
for (hidden_texts) |bad| {
try testing.expect(std.mem.indexOf(u8, value.string, bad) == null);
}
}
// The visible paragraph's text leaks into the tree as a StaticText child.
var found_visible = false;
for (nodes) |node_val| {
const obj = node_val.object;
const name_obj = obj.get("name") orelse continue;
const value = name_obj.object.get("value") orelse continue;
if (value == .string and std.mem.indexOf(u8, value.string, "visible-para") != null) {
found_visible = true;
break;
}
}
try testing.expect(found_visible);
// The search input gets its name from <label for=search-input>.
var search_named = false;
for (nodes) |node_val| {
const obj = node_val.object;
const role_obj = obj.get("role") orelse continue;
const role_val = role_obj.object.get("value") orelse continue;
if (!std.mem.eql(u8, role_val.string, "searchbox")) continue;
const name_val = obj.get("name").?.object.get("value").?;
if (name_val == .string and std.mem.indexOf(u8, name_val.string, "Search") != null) {
search_named = true;
}
}
try testing.expect(search_named);
// The wrapped input gets its name from its ancestor <label>.
var wrapped_named = false;
for (nodes) |node_val| {
const obj = node_val.object;
const role_obj = obj.get("role") orelse continue;
const role_val = role_obj.object.get("value") orelse continue;
if (!std.mem.eql(u8, role_val.string, "textbox")) continue;
const name_val = obj.get("name").?.object.get("value").?;
if (name_val == .string and std.mem.indexOf(u8, name_val.string, "Wrap") != null) {
wrapped_named = true;
}
}
try testing.expect(wrapped_named);
}

View File

@@ -34,6 +34,7 @@ const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig");
const Page = @import("../browser/Page.zig");
const Mime = @import("../browser/Mime.zig");
const Element = @import("../browser/webapi/Element.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
@@ -527,10 +528,13 @@ pub const BrowserContext = struct {
pub fn axnodeWriter(self: *BrowserContext, root: *const Node, opts: AXNode.Writer.Opts) !AXNode.Writer {
const page = self.session.currentPage() orelse return error.PageNotLoaded;
_ = opts;
const cache = try page.call_arena.create(Element.VisibilityCache);
cache.* = .empty;
return .{
.page = page,
.root = root,
.registry = &self.node_registry,
.visibility_cache = cache,
};
}