tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

a11y_setup.py (16655B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      4 
      5 """Python environment for Windows a11y browser tests."""
      6 
      7 import ctypes
      8 import os
      9 import threading
     10 from ctypes import POINTER, byref
     11 from ctypes.wintypes import BOOL, HWND, LPARAM, POINT  # noqa: F401
     12 from dataclasses import dataclass
     13 
     14 import comtypes.automation
     15 import comtypes.client
     16 import psutil
     17 from comtypes import GUID, COMError, IServiceProvider
     18 
     19 CHILDID_SELF = 0
     20 COWAIT_DEFAULT = 0
     21 EVENT_OBJECT_FOCUS = 0x8005
     22 EVENT_SYSTEM_SCROLLINGSTART = 0x12
     23 GA_ROOT = 2
     24 NAVRELATION_EMBEDS = 0x1009
     25 OBJID_CARET = -8
     26 OBJID_CLIENT = -4
     27 RPC_S_CALLPENDING = -2147417835
     28 WINEVENT_OUTOFCONTEXT = 0
     29 WM_CLOSE = 0x0010
     30 
     31 
     32 def registerIa2Proxy():
     33    """Register the IAccessible2 proxy.
     34    This is only used on CI because we don't want to mess with the registry on
     35    local developer machines. Developers should register the proxy themselves
     36    using regsvr32.
     37    This registers in HKEY_CURRENT_USER rather than HKEY_LOCAL_MACHINE so that
     38    this can be done without administrator privileges. regsvr32 registers in
     39    HKEY_LOCAL_MACHINE, so we can't use that here.
     40    """
     41    import winreg
     42 
     43    dll = os.path.join(os.getcwd(), "IA2Marshal.dll")
     44    if not os.path.isfile(dll):
     45        raise RuntimeError(f"Couldn't find IAccessible2 proxy dll: {dll}")
     46    # This must be kept in sync with accessible/interfaces/ia2/moz.build.
     47    clsid = "{F9A6CC32-B0EF-490B-B102-179DDEEB08ED}"
     48    with winreg.CreateKey(
     49        winreg.HKEY_CURRENT_USER, rf"SOFTWARE\Classes\CLSID\{clsid}\InProcServer32"
     50    ) as key:
     51        winreg.SetValue(key, None, winreg.REG_SZ, dll)
     52    for interface in (
     53        # IA2 interfaces that aren't included in the proxy dll bundled with
     54        # Windows.
     55        # IAccessibleTextSelectionContainer
     56        "{2118B599-733F-43D0-A569-0B31D125ED9A}",
     57    ):
     58        with winreg.CreateKey(
     59            winreg.HKEY_CURRENT_USER,
     60            rf"SOFTWARE\Classes\Interface\{interface}\ProxyStubClsid32",
     61        ) as key:
     62            winreg.SetValue(key, None, winreg.REG_SZ, clsid)
     63 
     64 
     65 user32 = ctypes.windll.user32
     66 oleacc = ctypes.oledll.oleacc
     67 oleaccMod = comtypes.client.GetModule("oleacc.dll")
     68 IAccessible = oleaccMod.IAccessible
     69 del oleaccMod
     70 # This is the path if running locally.
     71 ia2Tlb = os.path.join(
     72    os.getcwd(),
     73    "..",
     74    "..",
     75    "..",
     76    "accessible",
     77    "interfaces",
     78    "ia2",
     79    "IA2Typelib.tlb",
     80 )
     81 if not os.path.isfile(ia2Tlb):
     82    # This is the path if running in CI.
     83    ia2Tlb = os.path.join(os.getcwd(), "ia2Typelib.tlb")
     84    registerIa2Proxy()
     85 ia2Mod = comtypes.client.GetModule(ia2Tlb)
     86 del ia2Tlb
     87 # Shove all the IAccessible* interfaces and IA2_* constants directly
     88 # into our namespace for convenience.
     89 globals().update((k, getattr(ia2Mod, k)) for k in ia2Mod.__all__)
     90 # We use this below. The linter doesn't understand our globals() update hack.
     91 IAccessible2 = ia2Mod.IAccessible2
     92 del ia2Mod
     93 
     94 uiaMod = comtypes.client.GetModule("UIAutomationCore.dll")
     95 globals().update((k, getattr(uiaMod, k)) for k in uiaMod.__all__)
     96 uiaClient = comtypes.CoCreateInstance(
     97    uiaMod.CUIAutomation._reg_clsid_,
     98    interface=uiaMod.IUIAutomation,
     99    clsctx=comtypes.CLSCTX_INPROC_SERVER,
    100 )
    101 
    102 # Register UIA custom properties.
    103 # IUIAutomationRegistrar is in a different type library.
    104 uiaCoreMod = comtypes.client.GetModule(("{930299ce-9965-4dec-b0f4-a54848d4b667}",))
    105 uiaReg = comtypes.CoCreateInstance(
    106    uiaCoreMod.CUIAutomationRegistrar._reg_clsid_,
    107    interface=uiaCoreMod.IUIAutomationRegistrar,
    108 )
    109 uiaAccessibleActionsPropertyId = uiaReg.RegisterProperty(
    110    byref(
    111        uiaCoreMod.UIAutomationPropertyInfo(
    112            GUID("{8C787AC3-0405-4C94-AC09-7A56A173F7EF}"),
    113            "AccessibleActions",
    114            uiaCoreMod.UIAutomationType_ElementArray,
    115        )
    116    )
    117 )
    118 del uiaReg, uiaCoreMod
    119 
    120 _threadLocal = threading.local()
    121 
    122 
    123 def setup():
    124    if getattr(_threadLocal, "isSetup", False):
    125        return
    126    # We can do most setup at module level. However, because modules are cached
    127    # and pywebsocket3 can serve requests on any thread, we need to do setup for
    128    # each new thread here.
    129    comtypes.CoInitialize()
    130    _threadLocal.isSetup = True
    131 
    132 
    133 def AccessibleObjectFromWindow(hwnd, objectID=OBJID_CLIENT):
    134    p = POINTER(IAccessible)()
    135    oleacc.AccessibleObjectFromWindow(
    136        hwnd, objectID, byref(IAccessible._iid_), byref(p)
    137    )
    138    return p
    139 
    140 
    141 def getWindowClass(hwnd):
    142    MAX_CHARS = 257
    143    buffer = ctypes.create_unicode_buffer(MAX_CHARS)
    144    user32.GetClassNameW(hwnd, buffer, MAX_CHARS)
    145    return buffer.value
    146 
    147 
    148 def getFirefoxHwnd():
    149    """Search all top level windows for the Firefox instance being
    150    tested.
    151    We search by window class name and window title prefix.
    152    """
    153    # We can compare the grandparent process ids to find the Firefox started by
    154    # the test harness.
    155    commonPid = psutil.Process().parent().ppid()
    156    # We need something mutable to store the result from the callback.
    157    found = []
    158 
    159    @ctypes.WINFUNCTYPE(BOOL, HWND, LPARAM)
    160    def callback(hwnd, lParam):
    161        if getWindowClass(hwnd) != "MozillaWindowClass":
    162            return True
    163        pid = ctypes.wintypes.DWORD()
    164        user32.GetWindowThreadProcessId(hwnd, byref(pid))
    165        if psutil.Process(pid.value).parent().ppid() != commonPid:
    166            return True  # Not the Firefox being tested.
    167        found.append(hwnd)
    168        return False
    169 
    170    user32.EnumWindows(callback, LPARAM(0))
    171    if not found:
    172        raise LookupError("Couldn't find Firefox HWND")
    173    return found[0]
    174 
    175 
    176 def toIa2(obj):
    177    serv = obj.QueryInterface(IServiceProvider)
    178    return serv.QueryService(IAccessible2._iid_, IAccessible2)
    179 
    180 
    181 def getDocIa2():
    182    """Get the IAccessible2 for the document being tested."""
    183    hwnd = getFirefoxHwnd()
    184    root = AccessibleObjectFromWindow(hwnd)
    185    doc = root.accNavigate(NAVRELATION_EMBEDS, 0)
    186    try:
    187        child = toIa2(doc.accChild(1))
    188        if "id:default-iframe-id;" in child.attributes:
    189            # This is an iframe or remoteIframe test.
    190            doc = child.accChild(1)
    191    except COMError:
    192        pass  # No child.
    193    return toIa2(doc)
    194 
    195 
    196 def findIa2ByDomId(root, id):
    197    search = f"id:{id};"
    198    # Child ids begin at 1.
    199    for i in range(1, root.accChildCount + 1):
    200        child = toIa2(root.accChild(i))
    201        if search in child.attributes:
    202            return child
    203        descendant = findIa2ByDomId(child, id)
    204        if descendant:
    205            return descendant
    206 
    207 
    208 @dataclass
    209 class WinEvent:
    210    event: int
    211    hwnd: int
    212    objectId: int
    213    childId: int
    214 
    215    def getIa2(self):
    216        acc = ctypes.POINTER(IAccessible)()
    217        child = comtypes.automation.VARIANT()
    218        ctypes.oledll.oleacc.AccessibleObjectFromEvent(
    219            self.hwnd,
    220            self.objectId,
    221            self.childId,
    222            ctypes.byref(acc),
    223            ctypes.byref(child),
    224        )
    225        if child.value != CHILDID_SELF:
    226            # This isn't an IAccessible2 object.
    227            return None
    228        return toIa2(acc)
    229 
    230 
    231 class WaitForWinEvent:
    232    """Wait for a win event, usually for IAccessible2.
    233    This should be used as follows:
    234    1. Create an instance to wait for the desired event.
    235    2. Perform the action that should fire the event.
    236    3. Call wait() on the instance you created in 1) to wait for the event.
    237    """
    238 
    239    def __init__(self, eventId, match):
    240        """eventId is the event id to wait for.
    241        match is either None to match any object, an str containing the DOM id
    242        of the desired object, or a function taking a WinEvent which should
    243        return True if this is the requested event.
    244        """
    245        self._matched = None
    246        # A kernel event used to signal when we get the desired event.
    247        self._signal = ctypes.windll.kernel32.CreateEventW(None, True, False, None)
    248 
    249        # We define this as a nested function because it has to be a static
    250        # function, but we need a reference to self.
    251        @ctypes.WINFUNCTYPE(
    252            None,
    253            ctypes.wintypes.HANDLE,
    254            ctypes.wintypes.DWORD,
    255            ctypes.wintypes.HWND,
    256            ctypes.wintypes.LONG,
    257            ctypes.wintypes.LONG,
    258            ctypes.wintypes.DWORD,
    259            ctypes.wintypes.DWORD,
    260        )
    261        def winEventProc(hook, eventId, hwnd, objectId, childId, thread, time):
    262            event = WinEvent(eventId, hwnd, objectId, childId)
    263            if isinstance(match, str):
    264                try:
    265                    ia2 = event.getIa2()
    266                    if f"id:{match};" in ia2.attributes:
    267                        self._matched = event
    268                except (comtypes.COMError, TypeError):
    269                    pass
    270            elif callable(match):
    271                try:
    272                    if match(event):
    273                        self._matched = event
    274                except Exception as e:
    275                    self._matched = e
    276            if self._matched:
    277                ctypes.windll.kernel32.SetEvent(self._signal)
    278 
    279        self._hook = user32.SetWinEventHook(
    280            eventId, eventId, None, winEventProc, 0, 0, WINEVENT_OUTOFCONTEXT
    281        )
    282        # Hold a reference to winEventProc so it doesn't get destroyed.
    283        self._proc = winEventProc
    284 
    285    def wait(self):
    286        """Wait for and return the desired WinEvent."""
    287        # Pump Windows messages until we get the desired event, which will be
    288        # signalled using a kernel event.
    289        handles = (ctypes.c_void_p * 1)(self._signal)
    290        index = ctypes.wintypes.DWORD()
    291        TIMEOUT = 10000
    292        try:
    293            ctypes.oledll.ole32.CoWaitForMultipleHandles(
    294                COWAIT_DEFAULT, TIMEOUT, 1, handles, ctypes.byref(index)
    295            )
    296        except OSError as e:
    297            if e.winerror == RPC_S_CALLPENDING:
    298                raise TimeoutError("Timeout before desired event received")
    299            raise
    300        finally:
    301            user32.UnhookWinEvent(self._hook)
    302            ctypes.windll.kernel32.CloseHandle(self._signal)
    303            self._proc = None
    304        if isinstance(self._matched, Exception):
    305            raise self._matched from self._matched
    306        return self._matched
    307 
    308 
    309 def getDocUia():
    310    """Get the IUIAutomationElement for the document being tested."""
    311    # There's no efficient way to find the document we want with UIA. We can't
    312    # get the IA2 and then get UIA from that because that will always use the
    313    # IA2 -> UIA proxy, but we don't want that if we're trying to test our
    314    # native implementation. For now, we just search the tree. In future, we
    315    # could perhaps implement a custom property.
    316    hwnd = getFirefoxHwnd()
    317    root = uiaClient.ElementFromHandle(hwnd)
    318    doc = findUiaByDomId(root, "body")
    319    if not doc:
    320        # Sometimes, when UIA is disabled, we can't find the document for some
    321        # unknown reason. Since this only happens when UIA is disabled, we want
    322        # the IA2 -> UIA proxy anyway, so we can start with IA2 in this case.
    323        info("getUiaDoc: Falling back to IA2")  # noqa: F821
    324        ia2 = getDocIa2()
    325        return uiaClient.ElementFromIAccessible(ia2, CHILDID_SELF)
    326    child = uiaClient.RawViewWalker.GetFirstChildElement(doc)
    327    if child and child.CurrentAutomationId == "default-iframe-id":
    328        # This is an iframe or remoteIframe test.
    329        doc = uiaClient.RawViewWalker.GetFirstChildElement(child)
    330    return doc
    331 
    332 
    333 def findUiaByDomId(root, id):
    334    cond = uiaClient.CreatePropertyCondition(uiaMod.UIA_AutomationIdPropertyId, id)
    335    # FindFirst ignores elements in the raw tree, so we have to use
    336    # FindFirstBuildCache to override that, even though we don't want to cache
    337    # anything.
    338    request = uiaClient.CreateCacheRequest()
    339    request.TreeFilter = uiaClient.RawViewCondition
    340    el = root.FindFirstBuildCache(uiaMod.TreeScope_Descendants, cond, request)
    341    if not el:
    342        return None
    343    # We need to test things that were introduced after UIA was initially
    344    # introduced in Windows 7.
    345    return el.QueryInterface(uiaMod.IUIAutomationElement9)
    346 
    347 
    348 class WaitForUiaEvent(comtypes.COMObject):
    349    """Wait for a UIA event.
    350    This should be used as follows:
    351    1. Create an instance to wait for the desired event.
    352    2. Perform the action that should fire the event.
    353    3. Call wait() on the instance you created in 1) to wait for the event.
    354    """
    355 
    356    # This tells comtypes which COM interfaces we implement. It will then call
    357    # either `ISomeInterface_SomeMethod` or just `SomeMethod` on this instance
    358    # when that method is called using COM. We use the shorter convention, since
    359    # we don't anticipate method name conflicts with UIA interfaces.
    360    _com_interfaces_ = [
    361        uiaMod.IUIAutomationFocusChangedEventHandler,
    362        uiaMod.IUIAutomationPropertyChangedEventHandler,
    363        uiaMod.IUIAutomationEventHandler,
    364    ]
    365 
    366    def __init__(self, *, eventId=None, property=None, match=None):
    367        """eventId is the event id to wait for. Alternatively, you can pass
    368        property to wait for a particular property to change.
    369        match is either None to match any object, an str containing the DOM id
    370        of the desired object, or a function taking a IUIAutomationElement which
    371        should return True if this is the requested event.
    372        """
    373        self._match = match
    374        self._matched = None
    375        # A kernel event used to signal when we get the desired event.
    376        self._signal = ctypes.windll.kernel32.CreateEventW(None, True, False, None)
    377        if eventId == uiaMod.UIA_AutomationFocusChangedEventId:
    378            uiaClient.AddFocusChangedEventHandler(None, self)
    379        elif eventId:
    380            # Generic automation event.
    381            uiaClient.AddAutomationEventHandler(
    382                eventId,
    383                uiaClient.GetRootElement(),
    384                uiaMod.TreeScope_Subtree,
    385                None,
    386                self,
    387            )
    388        elif property:
    389            uiaClient.AddPropertyChangedEventHandler(
    390                uiaClient.GetRootElement(),
    391                uiaMod.TreeScope_Subtree,
    392                None,
    393                self,
    394                [property],
    395            )
    396        else:
    397            raise ValueError("No supported event specified")
    398 
    399    def _checkMatch(self, sender):
    400        if isinstance(self._match, str):
    401            try:
    402                if sender.CurrentAutomationId == self._match:
    403                    self._matched = sender
    404            except comtypes.COMError:
    405                pass
    406        elif callable(self._match):
    407            try:
    408                if self._match(sender):
    409                    self._matched = sender
    410            except Exception as e:
    411                self._matched = e
    412        else:
    413            self._matched = sender
    414        if self._matched:
    415            ctypes.windll.kernel32.SetEvent(self._signal)
    416 
    417    def HandleFocusChangedEvent(self, sender):
    418        self._checkMatch(sender)
    419 
    420    def HandlePropertyChangedEvent(self, sender, propertyId, newValue):
    421        self._checkMatch(sender)
    422 
    423    def HandleAutomationEvent(self, sender, eventId):
    424        self._checkMatch(sender)
    425 
    426    def wait(self):
    427        """Wait for and return the IUIAutomationElement which sent the desired
    428        event."""
    429        # Pump Windows messages until we get the desired event, which will be
    430        # signalled using a kernel event.
    431        handles = (ctypes.c_void_p * 1)(self._signal)
    432        index = ctypes.wintypes.DWORD()
    433        TIMEOUT = 10000
    434        try:
    435            ctypes.oledll.ole32.CoWaitForMultipleHandles(
    436                COWAIT_DEFAULT, TIMEOUT, 1, handles, ctypes.byref(index)
    437            )
    438        except OSError as e:
    439            if e.winerror == RPC_S_CALLPENDING:
    440                raise TimeoutError("Timeout before desired event received")
    441            raise
    442        finally:
    443            uiaClient.RemoveAllEventHandlers()
    444            ctypes.windll.kernel32.CloseHandle(self._signal)
    445        if isinstance(self._matched, Exception):
    446            raise self._matched from self._matched
    447        return self._matched
    448 
    449 
    450 def getUiaPattern(element, patternName):
    451    """Get a control pattern interface from an IUIAutomationElement."""
    452    patternId = getattr(uiaMod, f"UIA_{patternName}PatternId")
    453    unknown = element.GetCurrentPattern(patternId)
    454    if not unknown:
    455        return None
    456    # GetCurrentPattern returns an IUnknown. We have to QI to the real
    457    # interface.
    458    # Get the comtypes interface object.
    459    interface = getattr(uiaMod, f"IUIAutomation{patternName}Pattern")
    460    return unknown.QueryInterface(interface)