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)