From ab2495e6d5699959695f38a007087e9cb8789554 Mon Sep 17 00:00:00 2001
From: Theo Hale <5333778+HaTheo@users.noreply.github.com>
Date: Wed, 24 Jun 2026 19:58:12 -0700
Subject: [PATCH 1/4] Add ChromeDriver accessibility protocol support
---
.../wptrunner/executors/executorchrome.py | 91 +++++++++++++++++++
wai-aria/scripts/aria-utils.js | 2 +-
2 files changed, 92 insertions(+), 1 deletion(-)
diff --git a/tools/wptrunner/wptrunner/executors/executorchrome.py b/tools/wptrunner/wptrunner/executors/executorchrome.py
index 1898695fb3ff2e..f9989df26f82a0 100644
--- a/tools/wptrunner/wptrunner/executors/executorchrome.py
+++ b/tools/wptrunner/wptrunner/executors/executorchrome.py
@@ -15,6 +15,7 @@
from .executorwebdriver import (
WebDriverBaseProtocolPart,
WebDriverCrashtestExecutor,
+ WebDriverAccessibilityProtocolPart,
WebDriverFedCMProtocolPart,
WebDriverPrintRefTestExecutor,
WebDriverProtocol,
@@ -191,6 +192,94 @@ def confirm_idp_login(self):
f"{self.parent.vendor_prefix}/fedcm/confirmidplogin")
+class ChromeDriverAccessibilityProtocolPart(WebDriverAccessibilityProtocolPart):
+ def setup(self):
+ super().setup()
+ self._nodes_by_id = {}
+
+ def after_connect(self):
+ super().after_connect()
+ self.parent.cdp.execute_cdp_command("Accessibility.enable")
+
+ def teardown(self):
+ try:
+ self.parent.cdp.execute_cdp_command("Accessibility.disable")
+ except error.WebDriverException:
+ pass
+
+ def get_computed_label(self, element):
+ node = self._get_ax_node_for_element(element)
+ return self._serialize_node(node).get("label", "") if node else ""
+
+ def get_computed_role(self, element):
+ node = self._get_ax_node_for_element(element)
+ return self._serialize_node(node).get("role", "") if node else ""
+
+ def get_accessibility_properties_for_element(self, element):
+ node = self._get_ax_node_for_element(element)
+ return self._serialize_node(node) if node else {}
+
+ def get_accessibility_properties_for_accessibility_node(self, id):
+ node = self._get_ax_node_by_backend_id(id)
+ return self._serialize_node(node) if node else {}
+
+ def _get_ax_node_for_element(self, element):
+ # Parse the ID, then hand it off to the shared helper
+ parsed_ids = self._extract_chromedriver_ids(element.id)
+
+ if parsed_ids and parsed_ids.get("element"):
+ return self._get_ax_node_by_backend_id(parsed_ids["element"])
+
+ return None
+
+ def _get_ax_node_by_backend_id(self, backend_node_id):
+ """Shared CDP call to fetch an accessibility node by its backend ID."""
+ ax_tree = self.parent.cdp.execute_cdp_command(
+ "Accessibility.getPartialAXTree",
+ {
+ "backendNodeId": int(backend_node_id),
+ "fetchRelatives": False,
+ }
+ )
+ nodes = ax_tree.get("nodes", [])
+ return nodes[0] if nodes else None
+
+ @staticmethod
+ def _extract_chromedriver_ids(element_id_string):
+ """
+ Extracts Frame, Document, and Element IDs from a ChromeDriver id.
+ Expected format: f.[hash].d.[hash].e.[id]
+ """
+ pattern = r"^f\.(?P[^.]+)\.d\.(?P[^.]+)\.e\.(?P.+)$"
+ match = re.match(pattern, element_id_string)
+
+ return match.groupdict() if match else None
+
+ @staticmethod
+ def _serialize_node(node):
+ rv = {
+ "accessibilityId": node["nodeId"],
+ "children": node.get("childIds", []),
+ }
+
+ if "parentId" in node:
+ rv["parent"] = node["parentId"]
+
+ # Parse native fields
+ if "role" in node:
+ rv["role"] = node["role"].get("value")
+ if "name" in node:
+ rv["label"] = node["name"].get("value")
+ if "value" in node:
+ rv["value"] = node["value"].get("value")
+ if "description" in node:
+ rv["description"] = node["description"].get("value")
+
+ for prop in node.get("properties", []):
+ rv[prop["name"]] = prop["value"].get("value")
+
+ return rv
+
class ChromeDriverDevToolsProtocolPart(ProtocolPart):
"""A low-level API for sending Chrome DevTools Protocol [0] commands directly to the browser.
@@ -244,6 +333,7 @@ def get_trace(self):
class ChromeDriverProtocol(WebDriverProtocol):
implements = [
+ ChromeDriverAccessibilityProtocolPart,
ChromeDriverBaseProtocolPart,
ChromeDriverDevToolsProtocolPart,
ChromeDriverFedCMProtocolPart,
@@ -269,6 +359,7 @@ def __init__(self, executor, browser, capabilities, **kwargs):
class ChromeDriverBidiProtocol(WebDriverBidiProtocol):
implements = [
+ ChromeDriverAccessibilityProtocolPart,
ChromeDriverBaseProtocolPart,
ChromeDriverDevToolsProtocolPart,
ChromeDriverFedCMProtocolPart,
diff --git a/wai-aria/scripts/aria-utils.js b/wai-aria/scripts/aria-utils.js
index 29942f27cfe25a..37d88c31427343 100644
--- a/wai-aria/scripts/aria-utils.js
+++ b/wai-aria/scripts/aria-utils.js
@@ -225,7 +225,7 @@ const AriaUtils = {
promise_test(async t => {
const actual = await test_driver.get_accessibility_properties_for_element(el);
for (const key in expected) {
- assert_equals(actual[key], expected[key], `${key}: ${el.outerHTML}`);
+ assert_equals(String(actual[key]), expected[key], `${key}: ${el.outerHTML}`);
}
}, testName);
}
From cdcfa1f3e34acd4d7090aa701918e5ecccd14456 Mon Sep 17 00:00:00 2001
From: Theo Hale <5333778+HaTheo@users.noreply.github.com>
Date: Mon, 20 Jul 2026 22:14:30 -0400
Subject: [PATCH 2/4] Refactor ChromeDriverAccessibilityProtocolPart given
feedback and investigation.
---
.../wptrunner/executors/executorchrome.py | 69 +++++++++++--------
1 file changed, 42 insertions(+), 27 deletions(-)
diff --git a/tools/wptrunner/wptrunner/executors/executorchrome.py b/tools/wptrunner/wptrunner/executors/executorchrome.py
index f9989df26f82a0..4cab8192e45bc1 100644
--- a/tools/wptrunner/wptrunner/executors/executorchrome.py
+++ b/tools/wptrunner/wptrunner/executors/executorchrome.py
@@ -196,10 +196,7 @@ class ChromeDriverAccessibilityProtocolPart(WebDriverAccessibilityProtocolPart):
def setup(self):
super().setup()
self._nodes_by_id = {}
-
- def after_connect(self):
- super().after_connect()
- self.parent.cdp.execute_cdp_command("Accessibility.enable")
+ self.full_ax_tree = {}
def teardown(self):
try:
@@ -207,32 +204,48 @@ def teardown(self):
except error.WebDriverException:
pass
- def get_computed_label(self, element):
- node = self._get_ax_node_for_element(element)
- return self._serialize_node(node).get("label", "") if node else ""
-
- def get_computed_role(self, element):
- node = self._get_ax_node_for_element(element)
- return self._serialize_node(node).get("role", "") if node else ""
-
def get_accessibility_properties_for_element(self, element):
+ # Wipe cached tree on new call
+ self.full_ax_tree = {}
+
node = self._get_ax_node_for_element(element)
return self._serialize_node(node) if node else {}
def get_accessibility_properties_for_accessibility_node(self, id):
- node = self._get_ax_node_by_backend_id(id)
+ # Wipe cached tree on new call to avoid stale properties
+ self.full_ax_tree = {}
+
+ node = self._find_ax_node_by_ax_node_id(id)
return self._serialize_node(node) if node else {}
+ def _set_full_ax_tree(self):
+ self.parent.cdp.execute_cdp_command("Accessibility.enable")
+ node_array = self.parent.cdp.execute_cdp_command(
+ "Accessibility.getFullAXTree",
+ {}
+ ).get("nodes", [])
+
+ self.full_ax_tree = {node["nodeId"]: node for node in node_array}
+
+ def _find_ax_node_by_ax_node_id(self, ax_node_id: str ) -> any:
+ node = self.full_ax_tree.get(ax_node_id, None)
+
+ if not node:
+ self._set_full_ax_tree()
+ node = self.full_ax_tree.get(ax_node_id, None)
+
+ return node
+
def _get_ax_node_for_element(self, element):
# Parse the ID, then hand it off to the shared helper
parsed_ids = self._extract_chromedriver_ids(element.id)
if parsed_ids and parsed_ids.get("element"):
- return self._get_ax_node_by_backend_id(parsed_ids["element"])
+ return self._get_ax_node_by_backend_node_id(parsed_ids["element"])
return None
- def _get_ax_node_by_backend_id(self, backend_node_id):
+ def _get_ax_node_by_backend_node_id(self, backend_node_id):
"""Shared CDP call to fetch an accessibility node by its backend ID."""
ax_tree = self.parent.cdp.execute_cdp_command(
"Accessibility.getPartialAXTree",
@@ -244,19 +257,10 @@ def _get_ax_node_by_backend_id(self, backend_node_id):
nodes = ax_tree.get("nodes", [])
return nodes[0] if nodes else None
- @staticmethod
- def _extract_chromedriver_ids(element_id_string):
- """
- Extracts Frame, Document, and Element IDs from a ChromeDriver id.
- Expected format: f.[hash].d.[hash].e.[id]
- """
- pattern = r"^f\.(?P[^.]+)\.d\.(?P[^.]+)\.e\.(?P.+)$"
- match = re.match(pattern, element_id_string)
-
- return match.groupdict() if match else None
+ def _serialize_node(self, node):
+ if node.get("ignored", False) and len(node.get("childIds", [])) == 1:
+ node = self._find_ax_node_by_ax_node_id(node.get("childIds", [])[0])
- @staticmethod
- def _serialize_node(node):
rv = {
"accessibilityId": node["nodeId"],
"children": node.get("childIds", []),
@@ -280,6 +284,17 @@ def _serialize_node(node):
return rv
+ @staticmethod
+ def _extract_chromedriver_ids(element_id_string):
+ """
+ Extracts Frame, Document, and Element IDs from a ChromeDriver id.
+ Expected format: f.[hash].d.[hash].e.[id]
+ """
+ pattern = r"^f\.(?P[^.]+)\.d\.(?P[^.]+)\.e\.(?P.+)$"
+ match = re.match(pattern, element_id_string)
+
+ return match.groupdict() if match else None
+
class ChromeDriverDevToolsProtocolPart(ProtocolPart):
"""A low-level API for sending Chrome DevTools Protocol [0] commands directly to the browser.
From 0cb57227f2c582b15f4da9fcce93fe4f8b50fcc6 Mon Sep 17 00:00:00 2001
From: Theo Hale <5333778+HaTheo@users.noreply.github.com>
Date: Thu, 23 Jul 2026 20:46:07 -0400
Subject: [PATCH 3/4] Added type hints and cleaned up, and removed patch for
ignored AXNodes and replaced with todo.
---
.../wptrunner/executors/executorchrome.py | 28 ++++++++++---------
1 file changed, 15 insertions(+), 13 deletions(-)
diff --git a/tools/wptrunner/wptrunner/executors/executorchrome.py b/tools/wptrunner/wptrunner/executors/executorchrome.py
index 4cab8192e45bc1..ce16412eca2891 100644
--- a/tools/wptrunner/wptrunner/executors/executorchrome.py
+++ b/tools/wptrunner/wptrunner/executors/executorchrome.py
@@ -29,6 +29,8 @@
here = os.path.dirname(__file__)
+AXNode = Mapping[str, Any]
+
def _update_capabilities_if_extension_test(
browser: Any, capabilities: Optional[MutableMapping[str, Any]]
) -> Optional[MutableMapping[str, Any]]:
@@ -196,7 +198,7 @@ class ChromeDriverAccessibilityProtocolPart(WebDriverAccessibilityProtocolPart):
def setup(self):
super().setup()
self._nodes_by_id = {}
- self.full_ax_tree = {}
+ self.full_ax_tree: MutableMapping[str, AXNode] = {}
def teardown(self):
try:
@@ -218,17 +220,17 @@ def get_accessibility_properties_for_accessibility_node(self, id):
node = self._find_ax_node_by_ax_node_id(id)
return self._serialize_node(node) if node else {}
- def _set_full_ax_tree(self):
+ def _set_full_ax_tree(self) -> None:
self.parent.cdp.execute_cdp_command("Accessibility.enable")
- node_array = self.parent.cdp.execute_cdp_command(
+ node_array = self.parent.cdp.execute_cdp_command(
"Accessibility.getFullAXTree",
{}
).get("nodes", [])
self.full_ax_tree = {node["nodeId"]: node for node in node_array}
- def _find_ax_node_by_ax_node_id(self, ax_node_id: str ) -> any:
- node = self.full_ax_tree.get(ax_node_id, None)
+ def _find_ax_node_by_ax_node_id(self, ax_node_id: str) -> Optional[AXNode]:
+ node: Optional[AXNode] = self.full_ax_tree.get(ax_node_id, None)
if not node:
self._set_full_ax_tree()
@@ -236,7 +238,7 @@ def _find_ax_node_by_ax_node_id(self, ax_node_id: str ) -> any:
return node
- def _get_ax_node_for_element(self, element):
+ def _get_ax_node_for_element(self, element: Any) -> Optional[AXNode]:
# Parse the ID, then hand it off to the shared helper
parsed_ids = self._extract_chromedriver_ids(element.id)
@@ -245,7 +247,7 @@ def _get_ax_node_for_element(self, element):
return None
- def _get_ax_node_by_backend_node_id(self, backend_node_id):
+ def _get_ax_node_by_backend_node_id(self, backend_node_id: str) -> Optional[AXNode]:
"""Shared CDP call to fetch an accessibility node by its backend ID."""
ax_tree = self.parent.cdp.execute_cdp_command(
"Accessibility.getPartialAXTree",
@@ -254,14 +256,14 @@ def _get_ax_node_by_backend_node_id(self, backend_node_id):
"fetchRelatives": False,
}
)
- nodes = ax_tree.get("nodes", [])
+ nodes: list[AXNode] = ax_tree.get("nodes", [])
return nodes[0] if nodes else None
- def _serialize_node(self, node):
- if node.get("ignored", False) and len(node.get("childIds", [])) == 1:
- node = self._find_ax_node_by_ax_node_id(node.get("childIds", [])[0])
+ def _serialize_node(self, node: AXNode) -> Mapping[str, Any]:
+ # TODO: Define an approach to handle ignored items as this is different
+ # browsers by browser and might make testing the Tree harder.
- rv = {
+ rv: dict[str,Any] = {
"accessibilityId": node["nodeId"],
"children": node.get("childIds", []),
}
@@ -285,7 +287,7 @@ def _serialize_node(self, node):
return rv
@staticmethod
- def _extract_chromedriver_ids(element_id_string):
+ def _extract_chromedriver_ids(element_id_string: str) -> Optional[Mapping[str, str]]:
"""
Extracts Frame, Document, and Element IDs from a ChromeDriver id.
Expected format: f.[hash].d.[hash].e.[id]
From dec4481320ab98d46ef026cfb395289841cca486 Mon Sep 17 00:00:00 2001
From: Theo Hale <5333778+HaTheo@users.noreply.github.com>
Date: Wed, 5 Aug 2026 17:07:39 -0400
Subject: [PATCH 4/4] Migrated code to clear tree to
_find_ax_node_by_ax_node_id, and reduced properties to a smaller subset for
now.
---
.../wptrunner/executors/executorchrome.py | 28 ++++++++-----------
1 file changed, 12 insertions(+), 16 deletions(-)
diff --git a/tools/wptrunner/wptrunner/executors/executorchrome.py b/tools/wptrunner/wptrunner/executors/executorchrome.py
index ce16412eca2891..58422bc92f83d7 100644
--- a/tools/wptrunner/wptrunner/executors/executorchrome.py
+++ b/tools/wptrunner/wptrunner/executors/executorchrome.py
@@ -198,7 +198,6 @@ class ChromeDriverAccessibilityProtocolPart(WebDriverAccessibilityProtocolPart):
def setup(self):
super().setup()
self._nodes_by_id = {}
- self.full_ax_tree: MutableMapping[str, AXNode] = {}
def teardown(self):
try:
@@ -207,34 +206,25 @@ def teardown(self):
pass
def get_accessibility_properties_for_element(self, element):
- # Wipe cached tree on new call
- self.full_ax_tree = {}
-
node = self._get_ax_node_for_element(element)
return self._serialize_node(node) if node else {}
def get_accessibility_properties_for_accessibility_node(self, id):
- # Wipe cached tree on new call to avoid stale properties
- self.full_ax_tree = {}
-
node = self._find_ax_node_by_ax_node_id(id)
return self._serialize_node(node) if node else {}
- def _set_full_ax_tree(self) -> None:
+ def _get_full_ax_tree(self) -> Mapping[str, AXNode]:
self.parent.cdp.execute_cdp_command("Accessibility.enable")
node_array = self.parent.cdp.execute_cdp_command(
"Accessibility.getFullAXTree",
{}
).get("nodes", [])
- self.full_ax_tree = {node["nodeId"]: node for node in node_array}
+ return {node["nodeId"]: node for node in node_array}
def _find_ax_node_by_ax_node_id(self, ax_node_id: str) -> Optional[AXNode]:
- node: Optional[AXNode] = self.full_ax_tree.get(ax_node_id, None)
-
- if not node:
- self._set_full_ax_tree()
- node = self.full_ax_tree.get(ax_node_id, None)
+ full_ax_tree = self._get_full_ax_tree()
+ node = full_ax_tree.get(ax_node_id, None)
return node
@@ -261,7 +251,7 @@ def _get_ax_node_by_backend_node_id(self, backend_node_id: str) -> Optional[AXNo
def _serialize_node(self, node: AXNode) -> Mapping[str, Any]:
# TODO: Define an approach to handle ignored items as this is different
- # browsers by browser and might make testing the Tree harder.
+ # browsers by browser and might make testing the subtree harder.
rv: dict[str,Any] = {
"accessibilityId": node["nodeId"],
@@ -281,8 +271,14 @@ def _serialize_node(self, node: AXNode) -> Mapping[str, Any]:
if "description" in node:
rv["description"] = node["description"].get("value")
+ # We only support a subset of properties for now outside of the native fields.
+ allowed_properties = {'checked', 'pressed', 'level',
+ 'multiline', 'orientation', 'required',
+ 'roledescription', 'selected'}
+
for prop in node.get("properties", []):
- rv[prop["name"]] = prop["value"].get("value")
+ if prop["name"] in allowed_properties:
+ rv[prop["name"]] = prop["value"].get("value")
return rv