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