Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiments with extending testdriver.js for platform accessibility API testing #2

Draft
wants to merge 32 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5fbd9d7
Extend testdriver to add accessibility API testing
spectranaut May 1, 2024
21d2d49
Switch from acacia to gi.repository Atspi
spectranaut May 15, 2024
e95bc28
Wait for document:load-complete
spectranaut May 16, 2024
d9dc813
Very minimal first pass
alice May 17, 2024
c794ae7
Add executoratspi and executoraxapi
alice May 19, 2024
1e9eee4
Update class name
spectranaut May 21, 2024
0dd7518
Fixes for when browser is not found
spectranaut May 21, 2024
8a14630
Clean up example
spectranaut May 29, 2024
c80d378
python black formatter
spectranaut May 29, 2024
de8626e
Merge pull request #5 from Igalia/cleanup-testdriver
alice May 30, 2024
4bfc87c
Implement get_accessibility_api_node for AXAPI
alice May 30, 2024
52b469c
Merge pull request #7 from Igalia/cleanup-testdriver
bkardell Jun 3, 2024
9790540
Minor fixes
spectranaut Jun 12, 2024
1422467
Remove the name acacia
spectranaut Jun 12, 2024
b061a95
just poll for the active tab
alice Jul 10, 2024
e6ad95b
Remove unnecessary function
spectranaut Jul 10, 2024
f31b0fb
Remove file
spectranaut Jul 10, 2024
90e5d94
Fix polling to work for chrome
spectranaut Jul 17, 2024
b5c17b6
Remove extra logs
spectranaut Jul 17, 2024
b73654b
Update tools/wptrunner/wptrunner/executors/executoratspi.py
alice Jul 18, 2024
41ad1e2
Merge pull request #9 from Igalia/poll-active-tab
alice Jul 19, 2024
eb3958c
Add IA2 testing to testdriver.js
spectranaut Jun 19, 2024
e177942
Update tools/wptrunner/wptrunner/executors/executorwindowsaccessibili…
spectranaut Jul 17, 2024
b43ba7d
Review from Alice"
spectranaut Jul 19, 2024
5edd923
Use URL instead of title
spectranaut Jul 19, 2024
78a91b2
Linux: Add support to run multiple tests
spectranaut Jul 19, 2024
c031240
Fix test
spectranaut Jul 19, 2024
8b315ec
Update API name to include 'platform'
spectranaut Jul 25, 2024
d1cec03
Merge pull request #12 from Igalia/api-name
alice Jul 29, 2024
6417c83
Updates to AXAPI implementation: Do not throw error in init, and poll…
spectranaut Jul 30, 2024
217aeb9
remove accidental file
spectranaut Sep 2, 2024
17470fe
Activate accessibility at the point where you get the AXUIElement for…
alice Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions core-aam/acacia/all_apis_example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<meta charset=utf-8>
<title>core-aam: role button</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="/resources/testdriver-actions.js"></script>

<body>
<div id=test role=button>click me</div>

<script>
promise_test(async t => {
const node = await test_driver.get_accessibility_api_node('test');

if (node.API == 'atspi') {
assert_equals(node.role, 'push button', 'Atspi role');
}
else if (node.API == 'axapi') {
assert_equals(node.role, 'AXButton', 'AX API role');
}
else {
assert_unreached(`Unknown API: ${node.API}`)
}
}, 'role button');
</script>
</body>
18 changes: 18 additions & 0 deletions resources/testdriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,20 @@
*/
clear_device_posture: function(context=null) {
return window.test_driver_internal.clear_device_posture(context);
},

/**
* Get a serialized object representing the accessibility API's accessibility node.
*
* @param {id} id of element
* @returns {Promise} Fullfilled with object representing accessibilty node,
* rejected in the cases of failures.
*/
get_accessibility_api_node: async function(dom_id) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe there's an open question (still @cookiecrook?) with the WebDriver accessibility APIs about whether to have a way to get this kind of bag of properties, or to instead behave more like the rest of the WebDriver APIs and only support fetching single properties.

We should probably consider for ourselves which will work better for us as well - getting the bag of properties might be slow if any of the properties are slow to compute or cause exceptions, etc.; however, accessing individual properties instead would probably require a kind of "stringly typed" (i.e. passing a string representing the property to query) API rather than having every single property we might want to test on each operating system as its own method.

Still, it might be worth prototyping what it might look like to access single properties as well as this bag of properties API?

return window.test_driver_internal.get_accessibility_api_node(dom_id)
.then((jsonresult) => {
return JSON.parse(jsonresult);
});
}
};

Expand Down Expand Up @@ -1254,6 +1268,10 @@

async clear_device_posture(context=null) {
throw new Error("clear_device_posture() is not implemented by testdriver-vendor.js");
},

async get_accessibility_api_node(dom_id) {
throw new Error("get_accessibility_api_node() is not available.");
}
};
})();
4 changes: 4 additions & 0 deletions tools/webdriver/webdriver/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,10 @@ def get_computed_label(self):
def get_computed_role(self):
return self.send_element_command("GET", "computedrole")

@command
def get_accessibility_api_node(self):
spectranaut marked this conversation as resolved.
Show resolved Hide resolved
return self.send_element_command("GET", "accessibilityapinode")

# This MUST come last because otherwise @property decorators above
# will be overridden by this.
@command
Expand Down
3 changes: 2 additions & 1 deletion tools/wpt/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,8 @@ def setup_kwargs(self, kwargs):
# We are on Taskcluster, where our Docker container does not have
# enough capabilities to run Chrome with sandboxing. (gh-20133)
kwargs["binary_args"].append("--no-sandbox")

if kwargs["force_renderer_accessibility"]:
kwargs["binary_args"].append("--force-renderer-accessibility")

class ContentShell(BrowserSetup):
name = "content_shell"
Expand Down
15 changes: 14 additions & 1 deletion tools/wptrunner/wptrunner/executors/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,18 @@ def __init__(self, logger, protocol):
def __call__(self, payload):
return self.protocol.device_posture.clear_device_posture()

class GetAccessibilityAPINodeAction:
name = "get_accessibility_api_node"

def __init__(self, logger, protocol):
self.logger = logger
self.protocol = protocol

def __call__(self, payload):
dom_id = payload["dom_id"]
return self.protocol.platform_accessibility.get_accessibility_api_node(dom_id)


actions = [ClickAction,
DeleteAllCookiesAction,
GetAllCookiesAction,
Expand Down Expand Up @@ -499,4 +511,5 @@ def __call__(self, payload):
RemoveVirtualSensorAction,
GetVirtualSensorInformationAction,
SetDevicePostureAction,
ClearDevicePostureAction]
ClearDevicePostureAction,
GetAccessibilityAPINodeAction]
116 changes: 116 additions & 0 deletions tools/wptrunner/wptrunner/executors/executoratspi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import gi

gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
import json
import threading

def find_active_tab(root):
stack = [root]
while stack:
node = stack.pop()

if Atspi.Accessible.get_role_name(node) == "frame":
## Helper: list of string relations, get targets for relation?
relationset = Atspi.Accessible.get_relation_set(node)
for relation in relationset:
if relation.get_relation_type() == Atspi.RelationType.EMBEDS:
return relation.get_target(0)
continue

for i in range(Atspi.Accessible.get_child_count(node)):
child = Atspi.Accessible.get_child_at_index(node, i)
stack.append(child)

return None


def serialize_node(node):
node_dictionary = {}
node_dictionary["API"] = "atspi"
node_dictionary["role"] = Atspi.Accessible.get_role_name(node)
node_dictionary["name"] = Atspi.Accessible.get_name(node)
node_dictionary["description"] = Atspi.Accessible.get_description(node)

return node_dictionary


def find_node(root, dom_id):
stack = [root]
while stack:
node = stack.pop()

attributes = Atspi.Accessible.get_attributes(node)
if "id" in attributes and attributes["id"] == dom_id:
return node

for i in range(Atspi.Accessible.get_child_count(node)):
child = Atspi.Accessible.get_child_at_index(node, i)
stack.append(child)

return None


def find_browser(name):
desktop = Atspi.get_desktop(0)
child_count = Atspi.Accessible.get_child_count(desktop)
for i in range(child_count):
app = Atspi.Accessible.get_child_at_index(desktop, i)
full_app_name = Atspi.Accessible.get_name(app)
if name in full_app_name.lower():
return (app, full_app_name)
return (None, None)


class AtspiExecutorImpl:
def start_atspi_listener(self):
self._event_listener = Atspi.EventListener.new(self.handle_event)
self._event_listener.register("document:load-complete")
Atspi.event_main()

def handle_event(self, e):
app = Atspi.Accessible.get_application(e.source)
app_name = Atspi.Accessible.get_name(app)
if self.full_app_name == app_name and e.any_data:
self.load_complete = True
self._event_listener.deregister("document:load-complete")
Atspi.event_quit()

def setup(self, product_name):
self.product_name = product_name
self.full_app_name = ""
self.root = None
self.found_browser = False
self.load_complete = False

self.atspi_listener_thread = threading.Thread(target=self.start_atspi_listener)

(self.root, self.full_app_name) = find_browser(self.product_name)
if self.root:
self.found_browser = True
self.atspi_listener_thread.start()
else:
print(
f"Cannot find root accessibility node for {self.product_name} - did you turn on accessibility?"
)

def get_accessibility_api_node(self, dom_id):
if not self.found_browser:
raise Exception(
f"Couldn't find browser {self.product_name}. Did you turn on accessibility?"
)

if not self.load_complete:
self.atspi_listener_thread.join()

active_tab = find_active_tab(self.root)
if not active_tab:
raise Exception(
f"Could not find the test page within the browser. Did you turn on accessiblity?"
)

node = find_node(active_tab, dom_id)
if not node:
raise Exception(f"Couldn't find node with id {dom_id}.")

return json.dumps(serialize_node(node))
99 changes: 99 additions & 0 deletions tools/wptrunner/wptrunner/executors/executoraxapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from ApplicationServices import (
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
AXUIElementCreateApplication,
)

from Cocoa import (
NSApplicationActivationPolicyRegular,
NSPredicate,
NSWorkspace,
)

import json

def find_browser(name):
ws = NSWorkspace.sharedWorkspace()
regular_predicate = NSPredicate.predicateWithFormat_(f"activationPolicy == {NSApplicationActivationPolicyRegular}")
running_apps = ws.runningApplications().filteredArrayUsingPredicate_(regular_predicate)
name_predicate = NSPredicate.predicateWithFormat_(f"localizedName contains[c] '{name}'")
filtered_apps = running_apps.filteredArrayUsingPredicate_(name_predicate)
if filtered_apps.count() == 0:
return None
app = filtered_apps[0]
pid = app.processIdentifier()
if pid == -1:
return None
return AXUIElementCreateApplication(pid)


def find_active_tab(browser):
stack = [browser]
tabs = []
while stack:
node = stack.pop()

(err, role) = AXUIElementCopyAttributeValue(node, "AXRole", None)
if err:
continue
if role == "AXWebArea":
return node

(err, children) = AXUIElementCopyAttributeValue(node, "AXChildren", None)
if err:
continue
stack.extend(children)

return None


def find_node(root, attribute, expected_value):
stack = [root]
while stack:
node = stack.pop()

(err, attributes) = AXUIElementCopyAttributeNames(node, None)
if err:
continue
if attribute in attributes:
(err, value) = AXUIElementCopyAttributeValue(node, attribute, None)
if err:
continue
if value == expected_value:
return node

(err, children) = AXUIElementCopyAttributeValue(node, "AXChildren", None)
if err:
continue
stack.extend(children)
return None


def serialize_node(node):
props = {}
props["API"] = "axapi"
(err, role) = AXUIElementCopyAttributeValue(node, "AXRole", None)
props["role"] = role
(err, name) = AXUIElementCopyAttributeValue(node, "AXTitle", None)
props["name"] = name
(err, description) = AXUIElementCopyAttributeValue(node, "AXDescription", None)
props["description"] = description

return props


class AXAPIExecutorImpl:
def setup(self, product_name):
self.product_name = product_name
self.root = find_browser(self.product_name)

if not self.root:
raise Exception(f"Couldn't find application: {product_name}")


def get_accessibility_api_node(self, dom_id):
tab = find_active_tab(self.root)
node = find_node(tab, "AXDOMIdentifier", dom_id)
if not node:
raise Exception(f"Couldn't find node with ID {dom_id}. Try passing --force-renderer-accessibility.")
return json.dumps(serialize_node(node))
6 changes: 5 additions & 1 deletion tools/wptrunner/wptrunner/executors/executormarionette.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
WdspecExecutor,
get_pages,
strip_server)

from .protocol import (AccessibilityProtocolPart,
ActionSequenceProtocolPart,
AssertsProtocolPart,
Expand All @@ -48,6 +49,7 @@
DevicePostureProtocolPart,
merge_dicts)

from .executorplatformaccessibility import (PlatformAccessibilityProtocolPart)

def do_delayed_imports():
global errors, marionette, Addons, WebAuthn
Expand Down Expand Up @@ -782,12 +784,14 @@ class MarionetteProtocol(Protocol):
MarionetteDebugProtocolPart,
MarionetteAccessibilityProtocolPart,
MarionetteVirtualSensorProtocolPart,
MarionetteDevicePostureProtocolPart]
MarionetteDevicePostureProtocolPart,
PlatformAccessibilityProtocolPart]

def __init__(self, executor, browser, capabilities=None, timeout_multiplier=1, e10s=True, ccov=False):
do_delayed_imports()

super().__init__(executor, browser)
self.product_name = browser.product_name
self.marionette = None
self.marionette_port = browser.marionette_port
self.capabilities = capabilities
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from .protocol import ProtocolPart

from abc import ABCMeta
from sys import platform

linux = False
mac = False
if platform == "linux":
linux = True
from .executoratspi import *
if platform == "darwin":
mac = True
from .executoraxapi import *


class PlatformAccessibilityProtocolPart(ProtocolPart):
"""Protocol part for platform accessibility introspection"""
__metaclass__ = ABCMeta

name = "platform_accessibility"

def setup(self):
self.product_name = self.parent.product_name
self.impl = None
if linux:
self.impl = AtspiExecutorImpl()
self.impl.setup(self.product_name)
if mac:
self.impl = AXAPIExecutorImpl()
self.impl.setup(self.product_name)

def get_accessibility_api_node(self, dom_id):
return self.impl.get_accessibility_api_node(dom_id)
Loading