diff --git a/accessible/moz.build b/accessible/moz.build index 6d7abf93b5c9..67373fd3a8ca 100644 --- a/accessible/moz.build +++ b/accessible/moz.build @@ -48,6 +48,7 @@ BROWSER_CHROME_MANIFESTS += [ "tests/browser/telemetry/browser.toml", "tests/browser/text/browser.toml", "tests/browser/tree/browser.toml", + "tests/browser/windows/ia2/browser.toml", ] with Files("**"): diff --git a/accessible/tests/browser/browser.toml b/accessible/tests/browser/browser.toml index 45c2d3059f95..e2ae7407f9a7 100644 --- a/accessible/tests/browser/browser.toml +++ b/accessible/tests/browser/browser.toml @@ -7,6 +7,8 @@ support-files = [ "head.js", "python_runner_wsh.py", "shared-head.js", + "windows/a11y_setup.py", + "windows/a11y_setup_requirements.txt", ] prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"] diff --git a/accessible/tests/browser/python_runner_wsh.py b/accessible/tests/browser/python_runner_wsh.py index 6581174325ee..d10bc4afe292 100644 --- a/accessible/tests/browser/python_runner_wsh.py +++ b/accessible/tests/browser/python_runner_wsh.py @@ -9,6 +9,8 @@ It is intended to be called from JS browser tests. """ import json +import os +import sys import traceback 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.""" 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 = {} + 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): """Log an info message.""" @@ -43,6 +63,11 @@ def web_socket_transfer_data(request): namespace = cleanNamespace.copy() 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 # return a result by simply using the return statement. if "\n" not in code and not code.lstrip().startswith("return "): diff --git a/accessible/tests/browser/shared-head.js b/accessible/tests/browser/shared-head.js index 6f65093f2c6a..fe87a777650f 100644 --- a/accessible/tests/browser/shared-head.js +++ b/accessible/tests/browser/shared-head.js @@ -935,6 +935,8 @@ let gPythonSocket = null; * be resolved with the deserialized result. If the Python code raises an * exception, the JS Promise will be rejected with the Python traceback. * 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) { if (!gPythonSocket) { diff --git a/accessible/tests/browser/windows/a11y_setup.py b/accessible/tests/browser/windows/a11y_setup.py new file mode 100644 index 000000000000..e7bdeda35ba5 --- /dev/null +++ b/accessible/tests/browser/windows/a11y_setup.py @@ -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 diff --git a/accessible/tests/browser/windows/a11y_setup_requirements.txt b/accessible/tests/browser/windows/a11y_setup_requirements.txt new file mode 100644 index 000000000000..ea131a6f92f2 --- /dev/null +++ b/accessible/tests/browser/windows/a11y_setup_requirements.txt @@ -0,0 +1 @@ +comtypes==1.2.0 diff --git a/accessible/tests/browser/windows/ia2/browser.toml b/accessible/tests/browser/windows/ia2/browser.toml new file mode 100644 index 000000000000..d72b5f8a2db2 --- /dev/null +++ b/accessible/tests/browser/windows/ia2/browser.toml @@ -0,0 +1,9 @@ +[DEFAULT] +subsuite = "a11y" +skip-if = [ + "os != 'win'", + "headless", +] +support-files = ["head.js"] + +["browser_role.js"] diff --git a/accessible/tests/browser/windows/ia2/browser_role.js b/accessible/tests/browser/windows/ia2/browser_role.js new file mode 100644 index 000000000000..08e44c280f5c --- /dev/null +++ b/accessible/tests/browser/windows/ia2/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
+ `, + 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 } +); diff --git a/accessible/tests/browser/windows/ia2/head.js b/accessible/tests/browser/windows/ia2/head.js new file mode 100644 index 000000000000..afc50984bda3 --- /dev/null +++ b/accessible/tests/browser/windows/ia2/head.js @@ -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 } +); diff --git a/python/mozbuild/mozbuild/action/test_archive.py b/python/mozbuild/mozbuild/action/test_archive.py index 36ebf7aced67..af761ea662d1 100644 --- a/python/mozbuild/mozbuild/action/test_archive.py +++ b/python/mozbuild/mozbuild/action/test_archive.py @@ -287,6 +287,13 @@ ARCHIVE_FILES = { "pattern": "specialpowers/**", "dest": "mochitest/extensions", }, + # Needed by Windows a11y browser tests. + { + "source": buildconfig.topobjdir, + "base": "accessible/interfaces/ia2", + "pattern": "IA2Typelib.tlb", + "dest": "mochitest", + }, ], "mozharness": [ { diff --git a/testing/mochitest/mach_commands.py b/testing/mochitest/mach_commands.py index 4a3427d19692..f719da42061f 100644 --- a/testing/mochitest/mach_commands.py +++ b/testing/mochitest/mach_commands.py @@ -403,6 +403,20 @@ def run_mochitest_general( # reason it doesn't get set when calling `activate_this.py` in the virtualenv. 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 # filtered tests to the mochitest harness. Mochitest harness will read # the master manifest in that case. diff --git a/testing/mozharness/scripts/desktop_unittest.py b/testing/mozharness/scripts/desktop_unittest.py index 05709d40e498..1aaa0803d862 100755 --- a/testing/mozharness/scripts/desktop_unittest.py +++ b/testing/mozharness/scripts/desktop_unittest.py @@ -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: self.register_virtualenv_module( requirements=[requirements_file], two_pass=True