зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1852190 part 3: Add Windows MSAA/IA2 interfaces, constants and utility functions to the Python environment. r=eeejay,jmaher
This also adds a simple role test which shows all of this working. Differential Revision: https://phabricator.services.mozilla.com/D187746
This commit is contained in:
Родитель
bd293df36c
Коммит
232e8c986c
|
@ -48,6 +48,7 @@ BROWSER_CHROME_MANIFESTS += [
|
||||||
"tests/browser/telemetry/browser.toml",
|
"tests/browser/telemetry/browser.toml",
|
||||||
"tests/browser/text/browser.toml",
|
"tests/browser/text/browser.toml",
|
||||||
"tests/browser/tree/browser.toml",
|
"tests/browser/tree/browser.toml",
|
||||||
|
"tests/browser/windows/ia2/browser.toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
with Files("**"):
|
with Files("**"):
|
||||||
|
|
|
@ -7,6 +7,8 @@ support-files = [
|
||||||
"head.js",
|
"head.js",
|
||||||
"python_runner_wsh.py",
|
"python_runner_wsh.py",
|
||||||
"shared-head.js",
|
"shared-head.js",
|
||||||
|
"windows/a11y_setup.py",
|
||||||
|
"windows/a11y_setup_requirements.txt",
|
||||||
]
|
]
|
||||||
prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"]
|
prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"]
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,8 @@ It is intended to be called from JS browser tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from mod_pywebsocket import msgutil
|
from mod_pywebsocket import msgutil
|
||||||
|
@ -23,9 +25,27 @@ def web_socket_transfer_data(request):
|
||||||
"""Send a response to the client as a JSON array."""
|
"""Send a response to the client as a JSON array."""
|
||||||
msgutil.send_message(request, json.dumps(args))
|
msgutil.send_message(request, json.dumps(args))
|
||||||
|
|
||||||
# XXX Anything that should be accessible to code run
|
|
||||||
# via the WebSocket should be added to cleanNamespace.
|
|
||||||
cleanNamespace = {}
|
cleanNamespace = {}
|
||||||
|
testDir = None
|
||||||
|
if sys.platform == "win32":
|
||||||
|
testDir = "windows"
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
# XXX ATK code goes here.
|
||||||
|
pass
|
||||||
|
if testDir:
|
||||||
|
sys.path.append(
|
||||||
|
os.path.join(
|
||||||
|
os.getcwd(), "browser", "accessible", "tests", "browser", testDir
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
import a11y_setup
|
||||||
|
|
||||||
|
cleanNamespace = a11y_setup.__dict__
|
||||||
|
setupExc = None
|
||||||
|
except Exception:
|
||||||
|
setupExc = traceback.format_exc()
|
||||||
|
sys.path.pop()
|
||||||
|
|
||||||
def info(message):
|
def info(message):
|
||||||
"""Log an info message."""
|
"""Log an info message."""
|
||||||
|
@ -43,6 +63,11 @@ def web_socket_transfer_data(request):
|
||||||
namespace = cleanNamespace.copy()
|
namespace = cleanNamespace.copy()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if setupExc:
|
||||||
|
# a11y_setup failed. Report an exception immediately.
|
||||||
|
send("exception", setupExc)
|
||||||
|
continue
|
||||||
|
|
||||||
# Wrap the code in a function called run(). This allows the code to
|
# Wrap the code in a function called run(). This allows the code to
|
||||||
# return a result by simply using the return statement.
|
# return a result by simply using the return statement.
|
||||||
if "\n" not in code and not code.lstrip().startswith("return "):
|
if "\n" not in code and not code.lstrip().startswith("return "):
|
||||||
|
|
|
@ -935,6 +935,8 @@ let gPythonSocket = null;
|
||||||
* be resolved with the deserialized result. If the Python code raises an
|
* be resolved with the deserialized result. If the Python code raises an
|
||||||
* exception, the JS Promise will be rejected with the Python traceback.
|
* exception, the JS Promise will be rejected with the Python traceback.
|
||||||
* An info() function is provided in Python to log an info message.
|
* An info() function is provided in Python to log an info message.
|
||||||
|
* See windows/a11y_setup.py for other things available in the Python
|
||||||
|
* environment.
|
||||||
*/
|
*/
|
||||||
function runPython(code) {
|
function runPython(code) {
|
||||||
if (!gPythonSocket) {
|
if (!gPythonSocket) {
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
"""Python environment for Windows a11y browser tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
from ctypes import POINTER, byref
|
||||||
|
from ctypes.wintypes import BOOL, HWND, LPARAM
|
||||||
|
|
||||||
|
import comtypes.client
|
||||||
|
import psutil
|
||||||
|
from comtypes import COMError, IServiceProvider
|
||||||
|
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
oleacc = ctypes.oledll.oleacc
|
||||||
|
oleaccMod = comtypes.client.GetModule("oleacc.dll")
|
||||||
|
IAccessible = oleaccMod.IAccessible
|
||||||
|
del oleaccMod
|
||||||
|
OBJID_CLIENT = -4
|
||||||
|
CHILDID_SELF = 0
|
||||||
|
NAVRELATION_EMBEDS = 0x1009
|
||||||
|
# This is the path if running locally.
|
||||||
|
ia2Tlb = os.path.join(
|
||||||
|
os.getcwd(),
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"accessible",
|
||||||
|
"interfaces",
|
||||||
|
"ia2",
|
||||||
|
"IA2Typelib.tlb",
|
||||||
|
)
|
||||||
|
if not os.path.isfile(ia2Tlb):
|
||||||
|
# This is the path if running in CI.
|
||||||
|
ia2Tlb = os.path.join(os.getcwd(), "ia2Typelib.tlb")
|
||||||
|
ia2Mod = comtypes.client.GetModule(ia2Tlb)
|
||||||
|
del ia2Tlb
|
||||||
|
# Shove all the IAccessible* interfaces and IA2_* constants directly
|
||||||
|
# into our namespace for convenience.
|
||||||
|
globals().update((k, getattr(ia2Mod, k)) for k in ia2Mod.__all__)
|
||||||
|
# We use this below. The linter doesn't understand our globals() update hack.
|
||||||
|
IAccessible2 = ia2Mod.IAccessible2
|
||||||
|
del ia2Mod
|
||||||
|
|
||||||
|
|
||||||
|
def AccessibleObjectFromWindow(hwnd, objectID=OBJID_CLIENT):
|
||||||
|
p = POINTER(IAccessible)()
|
||||||
|
oleacc.AccessibleObjectFromWindow(
|
||||||
|
hwnd, objectID, byref(IAccessible._iid_), byref(p)
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def getFirefoxHwnd():
|
||||||
|
"""Search all top level windows for the Firefox instance being
|
||||||
|
tested.
|
||||||
|
We search by window class name and window title prefix.
|
||||||
|
"""
|
||||||
|
# We can compare the grandparent process ids to find the Firefox started by
|
||||||
|
# the test harness.
|
||||||
|
commonPid = psutil.Process().parent().ppid()
|
||||||
|
# We need something mutable to store the result from the callback.
|
||||||
|
found = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
|
||||||
|
def callback(hwnd, lParam):
|
||||||
|
name = ctypes.create_unicode_buffer(100)
|
||||||
|
user32.GetClassNameW(hwnd, name, 100)
|
||||||
|
if name.value != "MozillaWindowClass":
|
||||||
|
return True
|
||||||
|
pid = ctypes.wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, byref(pid))
|
||||||
|
if psutil.Process(pid.value).parent().ppid() != commonPid:
|
||||||
|
return True # Not the Firefox being tested.
|
||||||
|
found.append(hwnd)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user32.EnumWindows(callback, LPARAM(0))
|
||||||
|
if not found:
|
||||||
|
raise LookupError("Couldn't find Firefox HWND")
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
|
def toIa2(obj):
|
||||||
|
serv = obj.QueryInterface(IServiceProvider)
|
||||||
|
return serv.QueryService(IAccessible2._iid_, IAccessible2)
|
||||||
|
|
||||||
|
|
||||||
|
def getDocIa2():
|
||||||
|
"""Get the IAccessible2 for the document being tested."""
|
||||||
|
hwnd = getFirefoxHwnd()
|
||||||
|
root = AccessibleObjectFromWindow(hwnd)
|
||||||
|
doc = root.accNavigate(NAVRELATION_EMBEDS, 0)
|
||||||
|
try:
|
||||||
|
child = toIa2(doc.accChild(1))
|
||||||
|
if "id:default-iframe-id;" in child.attributes:
|
||||||
|
# This is an iframe or remoteIframe test.
|
||||||
|
doc = child.accChild(1)
|
||||||
|
except COMError:
|
||||||
|
pass # No child.
|
||||||
|
return toIa2(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def findIa2ByDomId(root, id):
|
||||||
|
search = f"id:{id};"
|
||||||
|
# Child ids begin at 1.
|
||||||
|
for i in range(1, root.accChildCount + 1):
|
||||||
|
child = toIa2(root.accChild(i))
|
||||||
|
if search in child.attributes:
|
||||||
|
return child
|
||||||
|
descendant = findIa2ByDomId(child, id)
|
||||||
|
if descendant:
|
||||||
|
return descendant
|
|
@ -0,0 +1 @@
|
||||||
|
comtypes==1.2.0
|
|
@ -0,0 +1,9 @@
|
||||||
|
[DEFAULT]
|
||||||
|
subsuite = "a11y"
|
||||||
|
skip-if = [
|
||||||
|
"os != 'win'",
|
||||||
|
"headless",
|
||||||
|
]
|
||||||
|
support-files = ["head.js"]
|
||||||
|
|
||||||
|
["browser_role.js"]
|
|
@ -0,0 +1,39 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const ROLE_SYSTEM_DOCUMENT = 15;
|
||||||
|
const ROLE_SYSTEM_GROUPING = 20;
|
||||||
|
const IA2_ROLE_PARAGRAPH = 1054;
|
||||||
|
|
||||||
|
addAccessibleTask(
|
||||||
|
`
|
||||||
|
<p id="p">p</p>
|
||||||
|
`,
|
||||||
|
async function (browser, docAcc) {
|
||||||
|
let role = await runPython(`
|
||||||
|
global doc
|
||||||
|
doc = getDocIa2()
|
||||||
|
return doc.accRole(CHILDID_SELF)
|
||||||
|
`);
|
||||||
|
is(role, ROLE_SYSTEM_DOCUMENT, "doc has correct MSAA role");
|
||||||
|
role = await runPython(`doc.role()`);
|
||||||
|
is(role, ROLE_SYSTEM_DOCUMENT, "doc has correct IA2 role");
|
||||||
|
ok(
|
||||||
|
await runPython(`
|
||||||
|
global p
|
||||||
|
p = findIa2ByDomId(doc, "p")
|
||||||
|
firstChild = toIa2(doc.accChild(1))
|
||||||
|
return p == firstChild
|
||||||
|
`),
|
||||||
|
"doc's first child is p"
|
||||||
|
);
|
||||||
|
role = await runPython(`p.accRole(CHILDID_SELF)`);
|
||||||
|
is(role, ROLE_SYSTEM_GROUPING, "p has correct MSAA role");
|
||||||
|
role = await runPython(`p.role()`);
|
||||||
|
is(role, IA2_ROLE_PARAGRAPH, "p has correct IA2 role");
|
||||||
|
},
|
||||||
|
{ chrome: true, topLevel: true, iframe: true, remoteIframe: true }
|
||||||
|
);
|
|
@ -0,0 +1,18 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Load the shared-head file first.
|
||||||
|
Services.scriptloader.loadSubScript(
|
||||||
|
"chrome://mochitests/content/browser/accessible/tests/browser/shared-head.js",
|
||||||
|
this
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading and common.js from accessible/tests/mochitest/ for all tests, as
|
||||||
|
// well as promisified-events.js.
|
||||||
|
loadScripts(
|
||||||
|
{ name: "common.js", dir: MOCHITESTS_DIR },
|
||||||
|
{ name: "promisified-events.js", dir: MOCHITESTS_DIR }
|
||||||
|
);
|
|
@ -287,6 +287,13 @@ ARCHIVE_FILES = {
|
||||||
"pattern": "specialpowers/**",
|
"pattern": "specialpowers/**",
|
||||||
"dest": "mochitest/extensions",
|
"dest": "mochitest/extensions",
|
||||||
},
|
},
|
||||||
|
# Needed by Windows a11y browser tests.
|
||||||
|
{
|
||||||
|
"source": buildconfig.topobjdir,
|
||||||
|
"base": "accessible/interfaces/ia2",
|
||||||
|
"pattern": "IA2Typelib.tlb",
|
||||||
|
"dest": "mochitest",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"mozharness": [
|
"mozharness": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -403,6 +403,20 @@ def run_mochitest_general(
|
||||||
# reason it doesn't get set when calling `activate_this.py` in the virtualenv.
|
# reason it doesn't get set when calling `activate_this.py` in the virtualenv.
|
||||||
sys.executable = command_context.virtualenv_manager.python_path
|
sys.executable = command_context.virtualenv_manager.python_path
|
||||||
|
|
||||||
|
if ("browser-chrome", "a11y") in suites and sys.platform == "win32":
|
||||||
|
# Only Windows a11y browser tests need this.
|
||||||
|
req = os.path.join(
|
||||||
|
"accessible",
|
||||||
|
"tests",
|
||||||
|
"browser",
|
||||||
|
"windows",
|
||||||
|
"a11y_setup_requirements.txt",
|
||||||
|
)
|
||||||
|
command_context.virtualenv_manager.activate()
|
||||||
|
command_context.virtualenv_manager.install_pip_requirements(
|
||||||
|
req, require_hashes=False
|
||||||
|
)
|
||||||
|
|
||||||
# This is a hack to introduce an option in mach to not send
|
# This is a hack to introduce an option in mach to not send
|
||||||
# filtered tests to the mochitest harness. Mochitest harness will read
|
# filtered tests to the mochitest harness. Mochitest harness will read
|
||||||
# the master manifest in that case.
|
# the master manifest in that case.
|
||||||
|
|
|
@ -527,6 +527,24 @@ class DesktopUnittest(TestingMixin, MercurialScript, MozbaseMixin, CodeCoverageM
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._query_specified_suites("mochitest", "mochitest-browser-a11y")
|
||||||
|
is not None
|
||||||
|
and sys.platform == "win32"
|
||||||
|
):
|
||||||
|
# Only Windows a11y browser tests need this.
|
||||||
|
requirements_files.append(
|
||||||
|
os.path.join(
|
||||||
|
dirs["abs_mochitest_dir"],
|
||||||
|
"browser",
|
||||||
|
"accessible",
|
||||||
|
"tests",
|
||||||
|
"browser",
|
||||||
|
"windows",
|
||||||
|
"a11y_setup_requirements.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for requirements_file in requirements_files:
|
for requirements_file in requirements_files:
|
||||||
self.register_virtualenv_module(
|
self.register_virtualenv_module(
|
||||||
requirements=[requirements_file], two_pass=True
|
requirements=[requirements_file], two_pass=True
|
||||||
|
|
Загрузка…
Ссылка в новой задаче