diff --git a/CHANGELOG.md b/CHANGELOG.md index eb83b329..87c5ef08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased](https://github.com/rubycdp/ferrum/compare/v0.17.2...main) ## ### Added +- `page.accessibility` API and `Node#axnode` for reading the CDP accessibility tree ### Changed diff --git a/lib/ferrum/accessibility.rb b/lib/ferrum/accessibility.rb new file mode 100644 index 00000000..a2e0a3e5 --- /dev/null +++ b/lib/ferrum/accessibility.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "ferrum/accessibility/ax_node" + +module Ferrum + # + # Wraps the CDP [Accessibility](https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/) + # domain. The query commands work without `enable`; `enable`/`disable` are + # provided for completeness (live AX events). + # + class Accessibility + def initialize(page) + @page = page + end + + # + # The single non-ignored AXNode for a DOM node, or `nil`. + # + # @param [Ferrum::Node] node + # @return [AXNode, nil] + # + def node_for(node) + partial_tree(node: node).find { |ax_node| !ax_node.ignored? } + end + + # + # The partial AX tree for a DOM node. + # + # @param [Ferrum::Node] node + # @param [Boolean] fetch_relatives + # @return [Array] + # + def partial_tree(node:, fetch_relatives: false) + nodes = node.page.command("Accessibility.getPartialAXTree", + nodeId: node.node_id, + fetchRelatives: fetch_relatives)["nodes"] + build(nodes) + end + + # + # The full AX tree for the page. + # + # @param [Integer, nil] depth + # @param [String, nil] frame_id + # @return [Array] + # + def snapshot(depth: nil, frame_id: nil) + params = { depth: depth, frameId: frame_id }.compact + build(@page.command("Accessibility.getFullAXTree", **params)["nodes"]) + end + + # + # Query the AX tree by accessible name and/or role. + # + # @param [String, nil] name + # @param [String, nil] role + # @param [Ferrum::Node, nil] node Scope the query to this node's subtree. + # @return [Array] + # + def query(name: nil, role: nil, node: nil) + page = node ? node.page : @page + params = { accessibleName: name, role: role }.compact + params[:nodeId] = node ? node.node_id : @page.document_node_id + build(page.command("Accessibility.queryAXTree", **params)["nodes"]) + end + + # + # The root AXNode of the (optionally framed) document. + # + # @param [String, nil] frame_id + # @return [AXNode, nil] + # + def root(frame_id: nil) + params = { depth: 1, frameId: frame_id }.compact + build(@page.command("Accessibility.getFullAXTree", **params)["nodes"]).first + end + + # @return [self] + def enable + @page.command("Accessibility.enable") + self + end + + # @return [self] + def disable + @page.command("Accessibility.disable") + self + end + + private + + def build(nodes) + Array(nodes).map { |node| AXNode.new(node) } + end + end +end diff --git a/lib/ferrum/accessibility/ax_node.rb b/lib/ferrum/accessibility/ax_node.rb new file mode 100644 index 00000000..2ff336e3 --- /dev/null +++ b/lib/ferrum/accessibility/ax_node.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Ferrum + class Accessibility + # + # Represents an [AXNode](https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/#type-AXNode) + # from the CDP Accessibility domain. + # + class AXNode + # + # @param [Hash{String => Object}] params + # The parsed CDP AXNode attributes. + # + def initialize(params) + @params = deep_freeze(params) + end + + # @return [String, nil] + def role + @params.dig("role", "value") + end + + # @return [String, nil] + def name + @params.dig("name", "value") + end + + # @return [String, nil] + def description + @params.dig("description", "value") + end + + # @return [String, Numeric, Boolean, nil] raw CDP AXValue.value; type varies by control + def value + @params.dig("value", "value") + end + + # @return [Hash{String => Object}] + # ARIA/computed properties flattened to `name => value`. + def properties + Array(@params["properties"]).to_h do |property| + [property["name"], property.dig("value", "value")] + end + end + + # @return [Boolean] + def ignored? + @params["ignored"] == true + end + + # @return [Array, nil] + def ignored_reasons + @params["ignoredReasons"] + end + + # @return [String, nil] + def node_id + @params["nodeId"] + end + + # @return [Integer, nil] + def backend_dom_node_id + @params["backendDOMNodeId"] + end + + # @return [Array, nil] + def child_ids + @params["childIds"] + end + + # @return [Hash] + # The raw CDP AXNode hash. + def to_h + @params + end + + private + + def deep_freeze(object) + case object + when Hash then object.each_value { |value| deep_freeze(value) } + when Array then object.each { |value| deep_freeze(value) } + end + object.freeze + end + end + end +end diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index c7f429cb..eddaa940 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -21,7 +21,7 @@ class Browser delegate %i[go_to goto go back forward refresh reload stop wait_for_reload at_css at_xpath css xpath current_url current_title url title body doctype content= - headers cookies network downloads + headers cookies network accessibility downloads mouse keyboard screenshot pdf mhtml viewport_size device_pixel_ratio start_screencast stop_screencast diff --git a/lib/ferrum/node.rb b/lib/ferrum/node.rb index 3209aa5f..192b780b 100644 --- a/lib/ferrum/node.rb +++ b/lib/ferrum/node.rb @@ -214,6 +214,14 @@ def computed_style .each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) } end + # Returns the computed accessibility node for the element, or nil if the + # element is ignored by the accessibility tree. + # + # @return [Accessibility::AXNode, nil] + def axnode + page.accessibility.node_for(self) + end + def remove page.command("DOM.removeNode", nodeId: node_id) end diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index 4e641135..8870072e 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -8,6 +8,7 @@ require "ferrum/cookies" require "ferrum/dialog" require "ferrum/network" +require "ferrum/accessibility" require "ferrum/downloads" require "ferrum/page/frames" require "ferrum/page/screencast" @@ -57,6 +58,11 @@ class Page # @return [Network] attr_reader :network + # Accessibility object. + # + # @return [Accessibility] + attr_reader :accessibility + # Headers object. # # @return [Headers] @@ -88,6 +94,7 @@ def initialize(client, context_id:, target_id:, proxy: nil) @headers = Headers.new(self) @cookies = Cookies.new(self) @network = Network.new(self) + @accessibility = Accessibility.new(self) @tracing = Tracing.new(self) @downloads = Downloads.new(self) diff --git a/sig/ferrum/accessibility.rbs b/sig/ferrum/accessibility.rbs new file mode 100644 index 00000000..49bb8fbf --- /dev/null +++ b/sig/ferrum/accessibility.rbs @@ -0,0 +1,25 @@ +module Ferrum + class Accessibility + @page: Page + + def initialize: (Page page) -> void + + def node_for: (Node node) -> AXNode? + + def partial_tree: (node: Node, ?fetch_relatives: bool) -> Array[AXNode] + + def snapshot: (?depth: Integer?, ?frame_id: String?) -> Array[AXNode] + + def query: (?name: String?, ?role: String?, ?node: Node?) -> Array[AXNode] + + def root: (?frame_id: String?) -> AXNode? + + def enable: () -> self + + def disable: () -> self + + private + + def build: (Array[untyped]? nodes) -> Array[AXNode] + end +end diff --git a/sig/ferrum/accessibility/ax_node.rbs b/sig/ferrum/accessibility/ax_node.rbs new file mode 100644 index 00000000..73aadb90 --- /dev/null +++ b/sig/ferrum/accessibility/ax_node.rbs @@ -0,0 +1,31 @@ +module Ferrum + class Accessibility + class AXNode + @params: Hash[String, untyped] + + def initialize: (Hash[String, untyped] params) -> void + + def role: () -> String? + + def name: () -> String? + + def description: () -> String? + + def value: () -> untyped + + def properties: () -> Hash[String, untyped] + + def ignored?: () -> bool + + def ignored_reasons: () -> Array[untyped]? + + def node_id: () -> String? + + def backend_dom_node_id: () -> Integer? + + def child_ids: () -> Array[untyped]? + + def to_h: () -> Hash[String, untyped] + end + end +end diff --git a/sig/ferrum/node.rbs b/sig/ferrum/node.rbs index 63872cb5..12bc3241 100644 --- a/sig/ferrum/node.rbs +++ b/sig/ferrum/node.rbs @@ -74,6 +74,8 @@ module Ferrum def computed_style: () -> untyped + def axnode: () -> Accessibility::AXNode? + private def bounding_rect_coordinates: () -> untyped diff --git a/sig/ferrum/page.rbs b/sig/ferrum/page.rbs index 4fd80ebd..fd842ecc 100644 --- a/sig/ferrum/page.rbs +++ b/sig/ferrum/page.rbs @@ -18,6 +18,7 @@ module Ferrum attr_reader mouse: Mouse attr_reader keyboard: Keyboard attr_reader network: Network + attr_reader accessibility: Accessibility attr_reader headers: Headers attr_reader cookies: Cookies attr_reader downloads: Downloads diff --git a/spec/accessibility_spec.rb b/spec/accessibility_spec.rb new file mode 100644 index 00000000..6ce96549 --- /dev/null +++ b/spec/accessibility_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +describe Ferrum::Accessibility do + describe "#node_for" do + before { browser.go_to("/accessibility") } + + it "returns the AXNode for a DOM node" do + ax = browser.page.accessibility.node_for(browser.at_css("#submit")) + + expect(ax).to be_a(Ferrum::Accessibility::AXNode) + expect(ax.role).to eq("button") + expect(ax.name).to eq("Send form") + expect(ax.description).to eq("Sends the form to the server") + expect(ax.properties).to include("focusable" => true, "describedby" => "hint") + end + + it "returns nil for an ignored element" do + expect(browser.page.accessibility.node_for(browser.at_css("#hidden"))).to be_nil + end + end + + describe "#partial_tree" do + before { browser.go_to("/accessibility") } + + it "returns an array of AXNodes" do + nodes = browser.page.accessibility.partial_tree(node: browser.at_css("#submit")) + + expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) + expect(nodes.map(&:role)).to include("button") + end + end + + describe "#snapshot" do + before { browser.go_to("/accessibility") } + + it "returns the full tree as AXNodes including a button" do + nodes = browser.page.accessibility.snapshot + + expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) + expect(nodes.map(&:role)).to include("button") + end + end + + describe "#query" do + before { browser.go_to("/accessibility") } + + it "finds nodes by accessible name" do + nodes = browser.page.accessibility.query(name: "Send form") + + expect(nodes.map(&:name)).to include("Send form") + end + + it "scopes the query to a node's subtree — inside node is found" do + scope = browser.at_css("#scope") + nodes = browser.page.accessibility.query(name: "Inside only", node: scope) + + expect(nodes.map(&:name)).to include("Inside only") + end + + it "scopes the query to a node's subtree — outside sibling is absent" do + scope = browser.at_css("#scope") + nodes = browser.page.accessibility.query(name: "Outside only", node: scope) + + expect(nodes).to be_empty + end + end + + describe "across an iframe" do + before { browser.go_to("/accessibility_iframe") } + + let(:frame_node) do + frame = browser.page.at_css("iframe").frame + frame.at_css("#child_btn") + end + + it "node_for resolves the AXNode across the frame boundary" do + ax = frame_node.axnode + + expect(ax).to be_a(Ferrum::Accessibility::AXNode) + expect(ax.role).to eq("button") + expect(ax.name).to eq("Frame button") + end + + it "partial_tree returns AXNodes for a frame-resident node" do + nodes = browser.page.accessibility.partial_tree(node: frame_node) + + expect(nodes).to all(be_a(Ferrum::Accessibility::AXNode)) + expect(nodes).not_to be_empty + expect(nodes.map(&:role)).to include("button") + expect(nodes.map(&:name)).to include("Frame button") + end + + it "query scoped to a frame-resident node returns the button" do + nodes = browser.page.accessibility.query(name: "Frame button", node: frame_node) + + expect(nodes).not_to be_empty + expect(nodes.map(&:name)).to include("Frame button") + end + end + + describe "#root" do + before { browser.go_to("/accessibility") } + + it "returns a single root AXNode" do + expect(browser.page.accessibility.root).to be_a(Ferrum::Accessibility::AXNode) + end + end + + describe "#value" do + before { browser.go_to("/accessibility") } + + it "returns the raw CDP value for a range input as a Numeric (Chrome does not stringify it)" do + ax = browser.page.accessibility.node_for(browser.at_css("#volume")) + value = ax.value + + # Chrome returns the range value as Integer 7, not the string "7" + expect(value).to eq(7) + expect(value).to be_a(Numeric) + end + end + + it "is reachable from the browser" do + expect(browser.accessibility).to be_a(described_class) + end +end diff --git a/spec/node_spec.rb b/spec/node_spec.rb index 061edf49..3574babc 100644 --- a/spec/node_spec.rb +++ b/spec/node_spec.rb @@ -359,6 +359,22 @@ end end + describe "#axnode" do + before { browser.go_to("/accessibility") } + + it "returns the AXNode for the element" do + ax = browser.at_css("#submit").axnode + + expect(ax).to be_a(Ferrum::Accessibility::AXNode) + expect(ax.role).to eq("button") + expect(ax.name).to eq("Send form") + end + + it "returns nil for an ignored element" do + expect(browser.at_css("#hidden").axnode).to be_nil + end + end + describe "#remove" do it "removes node" do browser.go_to("/simple") diff --git a/spec/support/views/accessibility.erb b/spec/support/views/accessibility.erb new file mode 100644 index 00000000..df2b3543 --- /dev/null +++ b/spec/support/views/accessibility.erb @@ -0,0 +1,16 @@ + + + + + + + +

Sends the form to the server

+ + +
+ +
+ + + diff --git a/spec/support/views/accessibility_iframe.erb b/spec/support/views/accessibility_iframe.erb new file mode 100644 index 00000000..9f271fdd --- /dev/null +++ b/spec/support/views/accessibility_iframe.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/spec/support/views/accessibility_iframe_child.erb b/spec/support/views/accessibility_iframe_child.erb new file mode 100644 index 00000000..9516effa --- /dev/null +++ b/spec/support/views/accessibility_iframe_child.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/spec/unit/accessibility/ax_node_spec.rb b/spec/unit/accessibility/ax_node_spec.rb new file mode 100644 index 00000000..2ce222a8 --- /dev/null +++ b/spec/unit/accessibility/ax_node_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "ferrum/accessibility/ax_node" + +describe Ferrum::Accessibility::AXNode do + let(:params) do + { + "nodeId" => "7", + "ignored" => false, + "role" => { "type" => "role", "value" => "button" }, + "name" => { "type" => "computedString", "value" => "Send form" }, + "description" => { "type" => "computedString", "value" => "Sends the form" }, + "value" => { "type" => "computedString", "value" => "Go" }, + "properties" => [ + { "name" => "focusable", "value" => { "type" => "booleanOrUndefined", "value" => true } }, + { "name" => "required", "value" => { "type" => "booleanOrUndefined", "value" => true } } + ], + "childIds" => %w[8 9], + "backendDOMNodeId" => 42 + } + end + + subject(:ax_node) { described_class.new(params) } + + it "exposes role, name, description and value" do + expect(ax_node.role).to eq("button") + expect(ax_node.name).to eq("Send form") + expect(ax_node.description).to eq("Sends the form") + expect(ax_node.value).to eq("Go") + end + + it "flattens properties into a name => value hash" do + expect(ax_node.properties).to eq("focusable" => true, "required" => true) + end + + it "exposes identity and tree fields" do + expect(ax_node.node_id).to eq("7") + expect(ax_node.backend_dom_node_id).to eq(42) + expect(ax_node.child_ids).to eq(%w[8 9]) + expect(ax_node.ignored?).to be(false) + expect(ax_node.to_h).to eq(params) + end + + it "tolerates missing keys" do + node = described_class.new({ "nodeId" => "1", "ignored" => true }) + expect(node.role).to be_nil + expect(node.name).to be_nil + expect(node.description).to be_nil + expect(node.value).to be_nil + expect(node.properties).to eq({}) + expect(node.ignored?).to be(true) + expect(node.ignored_reasons).to be_nil + end + + describe "immutability (deep freeze)" do + let(:mutable_params) do + { + "nodeId" => "10", + "ignored" => false, + "role" => { "type" => "role", "value" => "slider" }, + "childIds" => %w[11 12], + "properties" => [ + { "name" => "focusable", "value" => { "type" => "booleanOrUndefined", "value" => true } } + ] + } + end + + subject(:frozen_node) { described_class.new(mutable_params) } + + it "raises FrozenError when mutating child_ids" do + expect { frozen_node.child_ids << "x" }.to raise_error(FrozenError) + end + + it "raises FrozenError when mutating the properties array from to_h" do + expect { frozen_node.to_h["properties"] << {} }.to raise_error(FrozenError) + end + end +end