Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-17 15:23:04 +02:00
14 changed files with 567 additions and 38 deletions

View File

@@ -231,6 +231,31 @@ 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, considering only the `visibility`
/// chain (walks ancestors since `visibility` inherits by default). Ignores
/// display:none: an ancestor with display:none means the element isn't
/// rendered, but its computed `visibility` still reflects inherited visibility.
pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
if (self.isElementHidden(elem, .{ .check_display = false, .check_visibility = true })) {
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)
@@ -246,11 +271,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
var opacity_priority: u64 = 0;
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true; // Early exit for hiding value
if (options.check_display) {
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true; // Early exit for hiding value
}
display_none = false;
display_priority = INLINE_PRIORITY;
}
display_none = false;
} else {
// Pin to INLINE_PRIORITY so rule-matching skips display entirely.
display_priority = INLINE_PRIORITY;
}
@@ -685,8 +715,9 @@ const VisibilityRule = struct {
};
const CheckVisibilityOptions = struct {
check_opacity: bool = false,
check_display: bool = true,
check_visibility: bool = false,
check_opacity: bool = false,
};
// Inline styles always win over stylesheets - use max u64 as sentinel

View File

@@ -318,19 +318,18 @@ fn consumeWhiteSpace(self: *Tokenizer, newline: bool) Token {
} else {
self.advance(1);
}
self.skipWhitespace();
return .{ .white_space = self.sliceFrom(start_position) };
}
fn skipWhitespace(self: *Tokenizer) void {
while (!self.isEof()) {
const b = self.nextByteUnchecked();
switch (b) {
' ', '\t' => {
self.advance(1);
},
'\n', '\x0C', '\r' => {
self.consumeNewline();
},
switch (self.nextByteUnchecked()) {
' ', '\t' => self.advance(1),
'\n', '\r', '\x0C' => self.consumeNewline(),
else => break,
}
}
return .{ .white_space = self.sliceFrom(start_position) };
}
fn consumeComment(self: *Tokenizer) []const u8 {
@@ -642,13 +641,74 @@ fn consumeNumeric(self: *Tokenizer) Token {
} };
}
// Consume a url token per CSS Syntax Level 3 §4.3.6, called after `url(`.
// Returns null if the value is quoted so the caller emits <function-token>
// ("url") and the string is tokenized by the main loop as its own token.
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
// TODO: true url parser
if (self.nextByte()) |it| {
return self.consumeString(it == '\'');
self.skipWhitespace();
if (self.isEof()) return .{ .url = "" };
switch (self.nextByteUnchecked()) {
'"', '\'' => return null,
else => {},
}
return null;
const start_pos = self.position;
while (!self.isEof()) {
switch (self.nextByteUnchecked()) {
')' => {
const value = self.sliceFrom(start_pos);
self.advance(1);
return .{ .url = value };
},
' ', '\t', '\n', '\r', '\x0C' => {
const value_end = self.position;
self.skipWhitespace();
if (self.isEof()) return .{ .url = self.slice(start_pos, value_end) };
if (self.nextByteUnchecked() == ')') {
self.advance(1);
return .{ .url = self.slice(start_pos, value_end) };
}
self.consumeBadUrlRemnants();
return .{ .bad_url = self.sliceFrom(start_pos) };
},
'"', '\'', '(' => {
self.consumeBadUrlRemnants();
return .{ .bad_url = self.sliceFrom(start_pos) };
},
'\\' => {
if (self.hasNewlineAt(1)) {
self.consumeBadUrlRemnants();
return .{ .bad_url = self.sliceFrom(start_pos) };
}
self.advance(1);
self.consumeEscape();
},
else => self.consumeChar(),
}
}
return .{ .url = self.sliceFrom(start_pos) };
}
fn consumeBadUrlRemnants(self: *Tokenizer) void {
while (!self.isEof()) {
switch (self.nextByteUnchecked()) {
')' => {
self.advance(1);
return;
},
'\n', '\r', '\x0C' => self.consumeNewline(),
'\\' => {
if (self.hasNewlineAt(1)) {
self.advance(1);
} else {
self.advance(1);
self.consumeEscape();
}
},
else => self.consumeChar(),
}
}
}
fn consumeIdentLike(self: *Tokenizer) Token {
@@ -823,3 +883,69 @@ test "smoke" {
.close_curly_bracket,
});
}
test "url: unquoted" {
try expectTokensEqual("url(foo.png)", &.{.{ .url = "foo.png" }});
}
test "url: unquoted with surrounding whitespace" {
try expectTokensEqual("url( foo.png )", &.{.{ .url = "foo.png" }});
}
test "url: empty" {
try expectTokensEqual("url()", &.{.{ .url = "" }});
}
test "url: quoted double emits function + string" {
try expectTokensEqual("url(\"foo.png\")", &.{
.{ .function = "url" },
.{ .string = "foo.png" },
.close_parenthesis,
});
}
test "url: quoted single emits function + string" {
try expectTokensEqual("url('foo.png')", &.{
.{ .function = "url" },
.{ .string = "foo.png" },
.close_parenthesis,
});
}
test "url: unquoted token bounds the next rule" {
try expectTokensEqual(".a{background:url(x.png)}.b{color:red}", &.{
.{ .delim = '.' },
.{ .ident = "a" },
.curly_bracket_block,
.{ .ident = "background" },
.colon,
.{ .url = "x.png" },
.close_curly_bracket,
.{ .delim = '.' },
.{ .ident = "b" },
.curly_bracket_block,
.{ .ident = "color" },
.colon,
.{ .ident = "red" },
.close_curly_bracket,
});
}
test "url: bad url with internal whitespace" {
try expectTokensEqual("url(foo bar)", &.{.{ .bad_url = "foo bar)" }});
}
test "url: EOF mid-token emits url" {
try expectTokensEqual("url(foo", &.{.{ .url = "foo" }});
}
test "url: escape inside unquoted value" {
try expectTokensEqual("url(foo\\20 bar)", &.{.{ .url = "foo\\20 bar" }});
}
test "url: bad-url swallows through escape but stops at )" {
try expectTokensEqual("url(foo\"\\)bar)x", &.{
.{ .bad_url = "foo\"\\)bar)" },
.{ .ident = "x" },
});
}

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>AX Tree Fixture</title>
<style>
.display-none { display: none; }
.visibility-hidden { visibility: hidden; }
</style>
</head>
<body>
<main>
<h1>Visible</h1>
<div id="d-none" class="display-none">
<p id="p-under-display-none">under-display-none</p>
</div>
<div id="v-hidden" class="visibility-hidden">
<p id="p-under-visibility">under-visibility-hidden</p>
</div>
<div id="has-hidden-attr" hidden>
<p id="p-under-hidden-attr">under-hidden-attr</p>
</div>
<div id="has-aria-hidden" aria-hidden="true">
<p id="p-under-aria-hidden">under-aria-hidden</p>
</div>
<p id="visible-para">visible-para</p>
<label for="search-input">Search</label>
<input type="search" id="search-input">
<label id="wrap-label">
Wrap
<input type="text" id="wrapped-input">
</label>
</main>
</body>
</html>

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,83 @@ 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;
}
/// Lazy `for`-attribute → `<label>` index. Built in one tree walk on first
/// lookup; subsequent lookups are O(1). Use when the same document is queried
/// multiple times (e.g. one AX tree walk resolves names for every labellable
/// control).
pub const LabelByForIndex = struct {
map: std.StringHashMapUnmanaged(*Element) = .empty,
populated: bool = false,
pub fn lookup(self: *LabelByForIndex, root: *Node, id: []const u8, allocator: std.mem.Allocator) !?*Element {
if (!self.populated) {
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 (for_attr.len == 0) continue;
const gop = try self.map.getOrPut(allocator, for_attr);
if (!gop.found_existing) gop.value_ptr.* = el;
}
self.populated = true;
}
return self.map.get(id);
}
};
/// 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,8 @@ pub const Writer = struct {
root: *const Node,
registry: *Node.Registry,
page: *Page,
visibility_cache: *DOMNode.Element.VisibilityCache,
label_index: *Label.LabelByForIndex,
pub const Opts = struct {};
@@ -51,13 +54,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 +68,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 +85,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 +228,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 +459,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 +472,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);
@@ -473,7 +494,7 @@ pub const Writer = struct {
try w.objectField("type");
try w.write(@tagName(.computedString));
try w.objectField("value");
const source = try axn.writeName(w, self.page);
const source = try axn.writeName(w, self.page, self.label_index);
if (source) |s| {
try self.writeAXSource(s, w);
}
@@ -502,6 +523,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 +539,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);
}
@@ -776,7 +810,7 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons
const w: TextCaptureWriter = .{ .aw = &aw, .writer = &aw.writer };
const source = try self.writeName(w, page);
const source = try self.writeName(w, page, null);
if (source != null) {
// Remove literal quotes inserted by writeString.
var raw_text = std.mem.trim(u8, aw.written(), "\"");
@@ -787,7 +821,7 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons
return null;
}
fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource {
fn writeName(axnode: AXNode, w: anytype, page: *Page, label_index: ?*Label.LabelByForIndex) !?AXSource {
const node = axnode.dom;
return switch (node._type) {
@@ -839,6 +873,12 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource {
return .aria_label;
}
if (isLabellableTag(el.getTag())) {
if (try writeLabelName(node, el, page, label_index, w)) |source| {
return source;
}
}
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try w.write(alt);
return .alt;
@@ -948,7 +988,58 @@ 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,
label_index: ?*Label.LabelByForIndex,
w: anytype,
) !?AXSource {
if (el.getAttributeSafe(comptime .wrap("id"))) |id_value| {
if (id_value.len > 0) {
if (node.ownerDocument(page)) |doc| {
const match: ?*DOMNode.Element = if (label_index) |idx|
try idx.lookup(doc.asNode(), id_value, page.call_arena)
else
Label.findLabelByFor(doc.asNode(), id_value);
if (match) |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 +1054,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 +1105,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 +1147,11 @@ fn isIgnore(self: AXNode, page: *Page) bool {
}
}
if (isHidden(elt)) {
if (in_aria_hidden) {
return true;
}
if (isHidden(elt, page, cache)) {
return true;
}
@@ -1064,11 +1162,11 @@ fn isIgnore(self: AXNode, page: *Page) bool {
const has_aria_labelledby = elt.hasAttributeSafe(.wrap("aria-labelledby"));
if (!has_role and !has_aria_label and !has_aria_labelledby) {
// Check if it has any non-ignored children
// Check if it has any non-ignored children.
var it = node.childrenIterator();
while (it.next()) |child| {
const axn = AXNode.fromNode(child);
if (!axn.isIgnore(page)) {
if (!axn.isIgnore(page, cache, in_aria_hidden)) {
return false;
}
}
@@ -1184,10 +1282,16 @@ 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 caches the same
// allocator and let the page arena clean them up.
var visibility_cache: DOMNode.Element.VisibilityCache = .empty;
var label_index: Label.LabelByForIndex = .{};
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
.root = node,
.registry = &registry,
.page = page,
.visibility_cache = &visibility_cache,
.label_index = &label_index,
}, .{});
defer testing.allocator.free(json);
@@ -1241,3 +1345,86 @@ 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;
var label_index: Label.LabelByForIndex = .{};
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
.root = node,
.registry = &registry,
.page = page,
.visibility_cache = &visibility_cache,
.label_index = &label_index,
}, .{});
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,8 @@ 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 Label = @import("../browser/webapi/element/html/Label.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
@@ -527,10 +529,16 @@ 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;
const label_index = try page.call_arena.create(Label.LabelByForIndex);
label_index.* = .{};
return .{
.page = page,
.root = root,
.registry = &self.node_registry,
.visibility_cache = cache,
.label_index = label_index,
};
}