Skip to content

Commit 9e987aa

Browse files
committed
feat: add page.accessibility CDP domain API
Expose a first-class `page.accessibility` domain object (mirroring Ferrum::Network) wrapping the CDP Accessibility domain, replacing ad-hoc `Accessibility.getPartialAXTree` command calls downstream. - Ferrum::Accessibility (page.accessibility, delegated as browser.accessibility): node_for, partial_tree, snapshot, query, root, enable/disable. Node-scoped commands run on the node's own page so they resolve correctly across same-process iframes. - Ferrum::Accessibility::AXNode value object: role, name, description, value, properties, ignored?, ignored_reasons, node_id, backend_dom_node_id, child_ids, to_h. Params are deep-frozen for immutability; value is untyped since CDP AXValue.value may be a string, number, or boolean. - Ferrum::Node#axnode convenience, mirroring #computed_style. Adds RBS signatures, specs (unit and browser-driven, including subtree scoping and iframe coverage), fixtures, and a CHANGELOG entry.
1 parent 0a492ef commit 9e987aa

16 files changed

Lines changed: 515 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- `Ferrum::Frame#loader_id` provides a loader id when frame navigates [#583]
55
- `Ferrum::Frame#lifecycle_events` provides a list of frame's events like init, networkIdle, firstPaint, etc. [#583]
66
- `Ferrum::Frame#idle?` whether frame was loaded [#583]
7+
- `page.accessibility` API and `Node#axnode` for reading the CDP accessibility tree
78

89
### Changed
910
- `Ferrum::PendingConnectionsError` and `Ferrum::TimeoutError` were swallowed even though happening when traffic iterator results in empty array. [#583]

‎lib/ferrum/accessibility.rb‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
3+
require "ferrum/accessibility/ax_node"
4+
5+
module Ferrum
6+
#
7+
# Wraps the CDP [Accessibility](https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/)
8+
# domain. The query commands work without `enable`; `enable`/`disable` are
9+
# provided for completeness (live AX events).
10+
#
11+
# @note The node-scoped methods (`node_for`, `partial_tree`, `query` with a
12+
# `node:`) issue the command against the node's owning page session. They
13+
# support same-process (same-target) iframes; nodes living in an
14+
# out-of-process iframe (OOPIF, separate CDP target) are not resolvable and
15+
# will error or return an empty result.
16+
#
17+
class Accessibility
18+
def initialize(page)
19+
@page = page
20+
end
21+
22+
#
23+
# The single non-ignored AXNode for a DOM node, or `nil`.
24+
#
25+
# @param [Ferrum::Node] node
26+
# @return [AXNode, nil]
27+
#
28+
def node_for(node)
29+
partial_tree(node: node).find { |ax_node| !ax_node.ignored? }
30+
end
31+
32+
#
33+
# The partial AX tree for a DOM node.
34+
#
35+
# @param [Ferrum::Node] node
36+
# @param [Boolean] fetch_relatives
37+
# @return [Array<AXNode>]
38+
#
39+
def partial_tree(node:, fetch_relatives: false)
40+
nodes = node.page.command("Accessibility.getPartialAXTree",
41+
nodeId: node.node_id,
42+
fetchRelatives: fetch_relatives)["nodes"]
43+
build(nodes)
44+
end
45+
46+
#
47+
# The full AX tree for the page.
48+
#
49+
# @param [Integer, nil] depth
50+
# @param [String, nil] frame_id
51+
# @return [Array<AXNode>]
52+
#
53+
def snapshot(depth: nil, frame_id: nil)
54+
params = { depth: depth, frameId: frame_id }.compact
55+
build(@page.command("Accessibility.getFullAXTree", **params)["nodes"])
56+
end
57+
58+
#
59+
# Query the AX tree by accessible name and/or role.
60+
#
61+
# @param [String, nil] name
62+
# @param [String, nil] role
63+
# @param [Ferrum::Node, nil] node Scope the query to this node's subtree.
64+
# @return [Array<AXNode>]
65+
#
66+
def query(name: nil, role: nil, node: nil)
67+
page = node ? node.page : @page
68+
params = { accessibleName: name, role: role }.compact
69+
params[:nodeId] = node ? node.node_id : page.document_node_id
70+
build(page.command("Accessibility.queryAXTree", **params)["nodes"])
71+
end
72+
73+
#
74+
# The root AXNode of the (optionally framed) document.
75+
#
76+
# @param [String, nil] frame_id
77+
# @return [AXNode, nil]
78+
#
79+
def root(frame_id: nil)
80+
params = { depth: 1, frameId: frame_id }.compact
81+
build(@page.command("Accessibility.getFullAXTree", **params)["nodes"]).first
82+
end
83+
84+
# @return [self]
85+
def enable
86+
@page.command("Accessibility.enable")
87+
self
88+
end
89+
90+
# @return [self]
91+
def disable
92+
@page.command("Accessibility.disable")
93+
self
94+
end
95+
96+
private
97+
98+
def build(nodes)
99+
Array(nodes).map { |node| AXNode.new(node) }
100+
end
101+
end
102+
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
module Ferrum
4+
class Accessibility
5+
#
6+
# Represents an [AXNode](https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/#type-AXNode)
7+
# from the CDP Accessibility domain.
8+
#
9+
class AXNode
10+
#
11+
# @param [Hash{String => Object}] params
12+
# The parsed CDP AXNode attributes.
13+
#
14+
def initialize(params)
15+
@params = deep_freeze(params)
16+
end
17+
18+
# @return [String, nil]
19+
def role
20+
@params.dig("role", "value")
21+
end
22+
23+
# @return [String, nil]
24+
def name
25+
@params.dig("name", "value")
26+
end
27+
28+
# @return [String, nil]
29+
def description
30+
@params.dig("description", "value")
31+
end
32+
33+
# @return [String, Numeric, Boolean, nil] raw CDP AXValue.value; type varies by control
34+
def value
35+
@params.dig("value", "value")
36+
end
37+
38+
# @return [Hash{String => Object}]
39+
# ARIA/computed properties flattened to `name => value`.
40+
def properties
41+
Array(@params["properties"]).to_h do |property|
42+
[property["name"], property.dig("value", "value")]
43+
end
44+
end
45+
46+
# @return [Boolean]
47+
def ignored?
48+
@params["ignored"] == true
49+
end
50+
51+
# @return [Array, nil]
52+
def ignored_reasons
53+
@params["ignoredReasons"]
54+
end
55+
56+
# @return [String, nil]
57+
def node_id
58+
@params["nodeId"]
59+
end
60+
61+
# @return [Integer, nil]
62+
def backend_dom_node_id
63+
@params["backendDOMNodeId"]
64+
end
65+
66+
# @return [Array, nil]
67+
def child_ids
68+
@params["childIds"]
69+
end
70+
71+
# @return [Hash]
72+
# The raw CDP AXNode hash.
73+
def to_h
74+
@params
75+
end
76+
77+
private
78+
79+
def deep_freeze(object)
80+
case object
81+
when Hash then object.each_value { |value| deep_freeze(value) }
82+
when Array then object.each { |value| deep_freeze(value) }
83+
end
84+
object.freeze
85+
end
86+
end
87+
end
88+
end

‎lib/ferrum/browser.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class Browser
2121
delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
2222
at_css at_xpath css xpath current_url current_title url title
2323
body doctype content=
24-
headers cookies network downloads
24+
headers cookies network accessibility downloads
2525
mouse keyboard
2626
screenshot pdf mhtml viewport_size device_pixel_ratio
2727
start_screencast stop_screencast

‎lib/ferrum/node.rb‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,14 @@ def computed_style
229229
.each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) }
230230
end
231231

232+
# Returns the computed accessibility node for the element, or nil if the
233+
# element is ignored by the accessibility tree.
234+
#
235+
# @return [Accessibility::AXNode, nil]
236+
def axnode
237+
page.accessibility.node_for(self)
238+
end
239+
232240
def remove
233241
page.command("DOM.removeNode", nodeId: node_id)
234242
end

‎lib/ferrum/page.rb‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "ferrum/cookies"
99
require "ferrum/dialog"
1010
require "ferrum/network"
11+
require "ferrum/accessibility"
1112
require "ferrum/downloads"
1213
require "ferrum/page/frames"
1314
require "ferrum/page/screencast"
@@ -57,6 +58,11 @@ class Page
5758
# @return [Network]
5859
attr_reader :network
5960

61+
# Accessibility object.
62+
#
63+
# @return [Accessibility]
64+
attr_reader :accessibility
65+
6066
# Headers object.
6167
#
6268
# @return [Headers]
@@ -88,6 +94,7 @@ def initialize(client, context_id:, target_id:, proxy: nil)
8894
@headers = Headers.new(self)
8995
@cookies = Cookies.new(self)
9096
@network = Network.new(self)
97+
@accessibility = Accessibility.new(self)
9198
@tracing = Tracing.new(self)
9299
@downloads = Downloads.new(self)
93100

‎sig/ferrum/accessibility.rbs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module Ferrum
2+
class Accessibility
3+
@page: Page
4+
5+
def initialize: (Page page) -> void
6+
7+
def node_for: (Node node) -> AXNode?
8+
9+
def partial_tree: (node: Node, ?fetch_relatives: bool) -> Array[AXNode]
10+
11+
def snapshot: (?depth: Integer?, ?frame_id: String?) -> Array[AXNode]
12+
13+
def query: (?name: String?, ?role: String?, ?node: Node?) -> Array[AXNode]
14+
15+
def root: (?frame_id: String?) -> AXNode?
16+
17+
def enable: () -> self
18+
19+
def disable: () -> self
20+
21+
private
22+
23+
def build: (Array[untyped]? nodes) -> Array[AXNode]
24+
end
25+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
module Ferrum
2+
class Accessibility
3+
class AXNode
4+
@params: Hash[String, untyped]
5+
6+
def initialize: (Hash[String, untyped] params) -> void
7+
8+
def role: () -> String?
9+
10+
def name: () -> String?
11+
12+
def description: () -> String?
13+
14+
def value: () -> untyped
15+
16+
def properties: () -> Hash[String, untyped]
17+
18+
def ignored?: () -> bool
19+
20+
def ignored_reasons: () -> Array[untyped]?
21+
22+
def node_id: () -> String?
23+
24+
def backend_dom_node_id: () -> Integer?
25+
26+
def child_ids: () -> Array[untyped]?
27+
28+
def to_h: () -> Hash[String, untyped]
29+
end
30+
end
31+
end

‎sig/ferrum/node.rbs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ module Ferrum
7777

7878
def computed_style: () -> untyped
7979

80+
def axnode: () -> Accessibility::AXNode?
81+
8082
private
8183

8284
def bounding_rect_coordinates: () -> untyped

‎sig/ferrum/page.rbs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module Ferrum
1818
attr_reader mouse: Mouse
1919
attr_reader keyboard: Keyboard
2020
attr_reader network: Network
21+
attr_reader accessibility: Accessibility
2122
attr_reader headers: Headers
2223
attr_reader cookies: Cookies
2324
attr_reader downloads: Downloads

0 commit comments

Comments
 (0)