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 all 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
32 changes: 32 additions & 0 deletions core-aam/acacia/blockquote.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<meta charset=utf-8>
<title>core-aam: blockquote role</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=blockquote>quote</div>

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

if (node.API == 'atspi') {
assert_equals(node.role, 'block quote', 'Atspi role');
}
else if (node.API == 'axapi') {
assert_equals(node.role, 'AXGroup', 'AX API role');
}
else if (node.API == 'windows') {
assert_equals(node.msaa_role, 'ROLE_SYSTEM_GROUPING', 'MSAA Role');
assert_equals(node.ia2_role, 'IA2_ROLE_BLOCK_QUOTE', 'IA2 Role');
}
else {
assert_unreached(`Unknown API: ${node.API}`)
}
}, 'role blockquote');
</script>
</body>
31 changes: 31 additions & 0 deletions core-aam/acacia/button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!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_platform_accessibility_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 if (node.API == 'windows') {
assert_equals(node.msaa_role, 'ROLE_SYSTEM_PUSHBUTTON', 'MSAA 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_platform_accessibility_node: async function(dom_id) {
return window.test_driver_internal.get_platform_accessibility_node(dom_id, location.href)
.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_platform_accessibility_node(dom_id, url) {
throw new Error("get_platform_accessibility_node() is not available.");
}
};
})();
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
16 changes: 15 additions & 1 deletion tools/wptrunner/wptrunner/executors/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,19 @@ def __init__(self, logger, protocol):
def __call__(self, payload):
return self.protocol.device_posture.clear_device_posture()

class GetAccessibilityAPINodeAction:
name = "get_platform_accessibility_node"

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

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


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

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

import sys


def poll_for_tab(root, product, url):
tab = find_tab(root, product, url)
while not tab:
time.sleep(0.01)
tab = find_tab(root, product, url)

return tab


def find_tab(root, product, url):
stack = [root]
while stack:
node = stack.pop()
if Atspi.Accessible.get_role_name(node) == "frame":
relationset = Atspi.Accessible.get_relation_set(node)
for relation in relationset:
if relation.get_relation_type() == Atspi.RelationType.EMBEDS:
tab = relation.get_target(0)
if is_ready(tab, product, url):
return tab
else:
return None
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 is_ready(tab, product, url):
# Firefox uses the "BUSY" state to indicate the page is not ready.
if product == "firefox":
state_set = Atspi.Accessible.get_state_set(tab)
return not Atspi.StateSet.contains(state_set, Atspi.StateType.BUSY)

# Chromium family browsers do not use "BUSY", but you can
# tell if the document can be queried by URL attribute. If the 'URL'
# attribute is not here, we need to query for a new accessible object.
document = Atspi.Accessible.get_document_iface(tab)
document_attributes = Atspi.Document.get_document_attributes(document)
if "URI" in document_attributes and document_attributes["URI"] == url:
return True
return False


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 setup(self, product_name, logger):
self.logger = logger
self.product_name = product_name
self.full_app_name = ""
self.root = None
self.document = None
self.test_url = None

(self.root, self.full_app_name) = find_browser(self.product_name)
if not self.root:
self.logger.error(
f"Couldn't find browser {self.product_name} in accessibility API ATSPI. Accessibility API queries will not succeeded."
)


def get_platform_accessibility_node(self, dom_id, url):
if not self.root:
raise Exception(
f"Couldn't find browser {self.product_name} in accessibility API ATSPI. Did you turn on accessibility?"
)

if self.test_url != url or not self.document:
self.test_url = url
self.document = poll_for_tab(self.root, self.product_name, url)

node = find_node(self.document, dom_id)
if not node:
raise Exception(
f"Couldn't find node with id={dom_id} in accessibility API ATSPI."
)

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

from Cocoa import (
NSApplicationActivationPolicyRegular,
NSPredicate,
NSWorkspace,
)

import json
import time

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
browser = AXUIElementCreateApplication(pid)
activate_accessibility(browser)
return browser

def activate_accessibility(browser):
AXUIElementSetAttributeValue(browser, "AXEnhancedUserInterface", 1)

def poll_for_tab(root, url):
tab = find_tab(root, url)
loops = 0
while not tab:
loops += 1
time.sleep(0.01)
tab = find_tab(root, url)

return tab

def find_tab(root, url):
stack = [root]
tabs = []
while stack:
node = stack.pop()

(err, role) = AXUIElementCopyAttributeValue(node, "AXRole", None)
if err:
continue
if role == "AXWebArea":
(err, tab_url) = AXUIElementCopyAttributeValue(node, "AXURL", None)
# tab_url is a NSURL object and must be converted to string.
if not err and str(tab_url) == url:
return node
else:
continue

(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 = None
self.document = None
self.test_url = None


def get_platform_accessibility_node(self, dom_id, url):
if not self.root:
self.root = find_browser(self.product_name)
if not self.root:
raise Exception(
f"Couldn't find browser {self.product_name} in accessibility API: AX API. Did you turn on accessibility?"
)

if self.test_url != url or not self.document:
self.test_url = url
self.document = poll_for_tab(self.root, url)

node = find_node(self.document, "AXDOMIdentifier", dom_id)
if not node:
raise Exception(f"Couldn't find node with ID {dom_id}.")
return json.dumps(serialize_node(node))
Loading