shared-head.js (36052B)
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 "use strict"; 6 7 /* import-globals-from ../mochitest/common.js */ 8 /* import-globals-from ../mochitest/layout.js */ 9 /* import-globals-from ../mochitest/promisified-events.js */ 10 11 /* exported Logger, MOCHITESTS_DIR, invokeSetAttribute, invokeFocus, 12 invokeSetStyle, getAccessibleDOMNodeID, getAccessibleTagName, 13 addAccessibleTask, findAccessibleChildByID, isDefunct, 14 CURRENT_CONTENT_DIR, loadScripts, loadContentScripts, snippetToURL, 15 Cc, Cu, arrayFromChildren, forceGC, contentSpawnMutation, 16 DEFAULT_IFRAME_ID, DEFAULT_IFRAME_DOC_BODY_ID, invokeContentTask, 17 matchContentDoc, currentContentDoc, getContentDPR, 18 waitForImageMap, getContentBoundsForDOMElm, untilCacheIs, 19 untilCacheOk, testBoundsWithContent, waitForContentPaint, 20 runPython */ 21 22 const CURRENT_FILE_DIR = "/browser/accessible/tests/browser/"; 23 24 /** 25 * Current browser test directory path used to load subscripts. 26 */ 27 const CURRENT_DIR = `chrome://mochitests/content${CURRENT_FILE_DIR}`; 28 /** 29 * A11y mochitest directory where we find common files used in both browser and 30 * plain tests. 31 */ 32 const MOCHITESTS_DIR = 33 "chrome://mochitests/content/a11y/accessible/tests/mochitest/"; 34 /** 35 * A base URL for test files used in content. 36 */ 37 // eslint-disable-next-line @microsoft/sdl/no-insecure-url 38 const CURRENT_CONTENT_DIR = `http://example.com${CURRENT_FILE_DIR}`; 39 40 const LOADED_CONTENT_SCRIPTS = new Map(); 41 42 const DEFAULT_CONTENT_DOC_BODY_ID = "body"; 43 const DEFAULT_IFRAME_ID = "default-iframe-id"; 44 const DEFAULT_IFRAME_DOC_BODY_ID = "default-iframe-body-id"; 45 46 const HTML_MIME_TYPE = "text/html"; 47 const XHTML_MIME_TYPE = "application/xhtml+xml"; 48 49 function loadHTMLFromFile(path) { 50 // Load the HTML to return in the response from file. 51 // Since it's relative to the cwd of the test runner, we start there and 52 // append to get to the actual path of the file. 53 const testHTMLFile = Services.dirsvc.get("CurWorkD", Ci.nsIFile); 54 const dirs = path.split("/"); 55 for (let i = 0; i < dirs.length; i++) { 56 testHTMLFile.append(dirs[i]); 57 } 58 59 const testHTMLFileStream = Cc[ 60 "@mozilla.org/network/file-input-stream;1" 61 ].createInstance(Ci.nsIFileInputStream); 62 testHTMLFileStream.init(testHTMLFile, -1, 0, 0); 63 const testHTML = NetUtil.readInputStreamToString( 64 testHTMLFileStream, 65 testHTMLFileStream.available() 66 ); 67 68 return testHTML; 69 } 70 71 let gIsIframe = false; 72 let gIsRemoteIframe = false; 73 74 function currentContentDoc() { 75 return gIsIframe ? DEFAULT_IFRAME_DOC_BODY_ID : DEFAULT_CONTENT_DOC_BODY_ID; 76 } 77 78 /** 79 * Accessible event match criteria based on the id of the current document 80 * accessible in test. 81 * 82 * @param {nsIAccessibleEvent} event 83 * Accessible event to be tested for a match. 84 * 85 * @return {boolean} 86 * True if accessible event's accessible object ID matches current 87 * document accessible ID. 88 */ 89 function matchContentDoc(event) { 90 return getAccessibleDOMNodeID(event.accessible) === currentContentDoc(); 91 } 92 93 /** 94 * Used to dump debug information. 95 */ 96 let Logger = { 97 /** 98 * Set up this variable to dump log messages into console. 99 */ 100 dumpToConsole: false, 101 102 /** 103 * Set up this variable to dump log messages into error console. 104 */ 105 dumpToAppConsole: false, 106 107 /** 108 * Return true if dump is enabled. 109 */ 110 get enabled() { 111 return this.dumpToConsole || this.dumpToAppConsole; 112 }, 113 114 /** 115 * Dump information into console if applicable. 116 */ 117 log(msg) { 118 if (this.enabled) { 119 this.logToConsole(msg); 120 this.logToAppConsole(msg); 121 } 122 }, 123 124 /** 125 * Log message to console. 126 */ 127 logToConsole(msg) { 128 if (this.dumpToConsole) { 129 dump(`\n${msg}\n`); 130 } 131 }, 132 133 /** 134 * Log message to error console. 135 */ 136 logToAppConsole(msg) { 137 if (this.dumpToAppConsole) { 138 Services.console.logStringMessage(`${msg}`); 139 } 140 }, 141 }; 142 143 /** 144 * Asynchronously set or remove content element's attribute (in content process 145 * if e10s is enabled). 146 * 147 * @param {object} browser current "tabbrowser" element 148 * @param {string} id content element id 149 * @param {string} attr attribute name 150 * @param {string?} value optional attribute value, if not present, remove 151 * attribute 152 * @return {Promise} promise indicating that attribute is set/removed 153 */ 154 function invokeSetAttribute(browser, id, attr, value = null) { 155 if (value !== null) { 156 Logger.log(`Setting ${attr} attribute to ${value} for node with id: ${id}`); 157 } else { 158 Logger.log(`Removing ${attr} attribute from node with id: ${id}`); 159 } 160 161 return invokeContentTask( 162 browser, 163 [id, attr, value], 164 (contentId, contentAttr, contentValue) => { 165 let elm = content.document.getElementById(contentId); 166 if (contentValue !== null) { 167 elm.setAttribute(contentAttr, contentValue); 168 } else { 169 elm.removeAttribute(contentAttr); 170 } 171 } 172 ); 173 } 174 175 /** 176 * Asynchronously set or remove content element's style (in content process if 177 * e10s is enabled, or in fission process if fission is enabled and a fission 178 * frame is present). 179 * 180 * @param {object} browser current "tabbrowser" element 181 * @param {string} id content element id 182 * @param {string} aStyle style property name 183 * @param {string?} aValue optional style property value, if not present, 184 * remove style 185 * @return {Promise} promise indicating that style is set/removed 186 */ 187 function invokeSetStyle(browser, id, style, value) { 188 if (value) { 189 Logger.log(`Setting ${style} style to ${value} for node with id: ${id}`); 190 } else { 191 Logger.log(`Removing ${style} style from node with id: ${id}`); 192 } 193 194 return invokeContentTask( 195 browser, 196 [id, style, value], 197 (contentId, contentStyle, contentValue) => { 198 const elm = content.document.getElementById(contentId); 199 if (contentValue) { 200 elm.style[contentStyle] = contentValue; 201 } else { 202 delete elm.style[contentStyle]; 203 } 204 } 205 ); 206 } 207 208 /** 209 * Asynchronously set focus on a content element (in content process if e10s is 210 * enabled, or in fission process if fission is enabled and a fission frame is 211 * present). 212 * 213 * @param {object} browser current "tabbrowser" element 214 * @param {string} id content element id 215 * @return {Promise} promise indicating that focus is set 216 */ 217 function invokeFocus(browser, id) { 218 Logger.log(`Setting focus on a node with id: ${id}`); 219 220 return invokeContentTask(browser, [id], contentId => { 221 const elm = content.document.getElementById(contentId); 222 if (elm.editor) { 223 elm.selectionStart = elm.selectionEnd = elm.value.length; 224 } 225 226 elm.focus(); 227 }); 228 } 229 230 /** 231 * Get DPR for a specific content window. 232 * 233 * @param browser 234 * Browser for which we want its content window's DPR reported. 235 * 236 * @return {Promise} 237 * Promise with the value that resolves to the devicePixelRatio of the 238 * content window of a given browser. 239 */ 240 function getContentDPR(browser) { 241 return invokeContentTask(browser, [], () => content.window.devicePixelRatio); 242 } 243 244 /** 245 * Asynchronously perform a task in content (in content process if e10s is 246 * enabled, or in fission process if fission is enabled and a fission frame is 247 * present). 248 * 249 * @param {object} browser current "tabbrowser" element 250 * @param {Array} args arguments for the content task 251 * @param {Function} task content task function 252 * 253 * @return {Promise} promise indicating that content task is complete 254 */ 255 function invokeContentTask(browser, args, task) { 256 return SpecialPowers.spawn( 257 browser, 258 [DEFAULT_IFRAME_ID, task.toString(), ...args], 259 (iframeId, contentTask, ...contentArgs) => { 260 // eslint-disable-next-line no-eval 261 const runnableTask = eval(` 262 (() => { 263 return (${contentTask}); 264 })();`); 265 const frame = content.document.getElementById(iframeId); 266 267 return frame 268 ? SpecialPowers.spawn(frame, contentArgs, runnableTask) 269 : runnableTask.call(this, ...contentArgs); 270 } 271 ); 272 } 273 274 /** 275 * Compare process ID's between the top level content process and possible 276 * remote/local iframe proccess. 277 * 278 * @param {object} browser 279 * Top level browser object for a tab. 280 * @param {boolean} isRemote 281 * Indicates if we expect the iframe content process to be remote or not. 282 */ 283 async function comparePIDs(browser, isRemote) { 284 function getProcessID() { 285 return Services.appinfo.processID; 286 } 287 288 const contentPID = await SpecialPowers.spawn(browser, [], getProcessID); 289 const iframePID = await invokeContentTask(browser, [], getProcessID); 290 is( 291 isRemote, 292 contentPID !== iframePID, 293 isRemote 294 ? "Remote IFRAME is in a different process." 295 : "IFRAME is in the same process." 296 ); 297 } 298 299 /** 300 * Load a list of scripts into the test 301 * 302 * @param {Array} scripts a list of scripts to load 303 */ 304 function loadScripts(...scripts) { 305 for (let script of scripts) { 306 let path = 307 typeof script === "string" 308 ? `${CURRENT_DIR}${script}` 309 : `${script.dir}${script.name}`; 310 Services.scriptloader.loadSubScript(path, this); 311 } 312 } 313 314 /** 315 * Load a list of scripts into target's content. 316 * 317 * @param {object} target 318 * target for loading scripts into 319 * @param {Array} scripts 320 * a list of scripts to load into content 321 */ 322 async function loadContentScripts(target, ...scripts) { 323 for (let { script, symbol } of scripts) { 324 let contentScript = `${CURRENT_DIR}${script}`; 325 let loadedScriptSet = LOADED_CONTENT_SCRIPTS.get(contentScript); 326 if (!loadedScriptSet) { 327 loadedScriptSet = new WeakSet(); 328 LOADED_CONTENT_SCRIPTS.set(contentScript, loadedScriptSet); 329 } else if (loadedScriptSet.has(target)) { 330 continue; 331 } 332 333 await SpecialPowers.spawn( 334 target, 335 [contentScript, symbol], 336 async (_contentScript, importSymbol) => { 337 let module = ChromeUtils.importESModule(_contentScript); 338 content.window[importSymbol] = module[importSymbol]; 339 } 340 ); 341 loadedScriptSet.add(target); 342 } 343 } 344 345 function attrsToString(attrs) { 346 return Object.entries(attrs) 347 .map(([attr, value]) => `${attr}=${JSON.stringify(value)}`) 348 .join(" "); 349 } 350 351 function wrapWithIFrame(doc, options = {}) { 352 let src; 353 let { iframeAttrs = {}, iframeDocBodyAttrs = {} } = options; 354 iframeDocBodyAttrs = { 355 id: DEFAULT_IFRAME_DOC_BODY_ID, 356 ...iframeDocBodyAttrs, 357 }; 358 if (options.contentSetup) { 359 // Hide the body initially so we can ensure that any changes made by 360 // contentSetup are included when the body's content is initially added to 361 // the accessibility tree. Use `hidden` instead of `aria-hidden` because the 362 // latter is ignored when applied to top level docs/<body> elements and we 363 // want to remain consistent with our handling for non-iframe docs. 364 iframeDocBodyAttrs.hidden = true; 365 } 366 if (options.remoteIframe) { 367 // eslint-disable-next-line @microsoft/sdl/no-insecure-url 368 const srcURL = new URL(`http://example.net/document-builder.sjs`); 369 if (doc.endsWith("html")) { 370 srcURL.searchParams.append("file", `${CURRENT_FILE_DIR}${doc}`); 371 } else { 372 // document-builder.sjs can't handle non-ASCII characters. Convert them 373 // to HTML character entities; e.g. •. 374 doc = doc.replace(/[\u00A0-\u2666]/g, c => `&#${c.charCodeAt(0)}`); 375 srcURL.searchParams.append( 376 "html", 377 `<!doctype html> 378 <html> 379 <head> 380 <meta charset="utf-8"/> 381 <title>Accessibility Fission Test</title> 382 </head> 383 <body ${attrsToString(iframeDocBodyAttrs)}>${doc}</body> 384 </html>` 385 ); 386 } 387 src = srcURL.href; 388 } else { 389 const mimeType = doc.endsWith("xhtml") ? XHTML_MIME_TYPE : HTML_MIME_TYPE; 390 if (doc.endsWith("html")) { 391 doc = loadHTMLFromFile(`${CURRENT_FILE_DIR}${doc}`); 392 doc = doc.replace( 393 /<body[.\s\S]*?>/, 394 `<body ${attrsToString(iframeDocBodyAttrs)}>` 395 ); 396 } else { 397 doc = `<!doctype html> 398 <body ${attrsToString(iframeDocBodyAttrs)}>${doc}</body>`; 399 } 400 401 src = `data:${mimeType};charset=utf-8,${encodeURIComponent(doc)}`; 402 } 403 404 if (options.urlSuffix) { 405 src += options.urlSuffix; 406 } 407 408 iframeAttrs = { 409 id: DEFAULT_IFRAME_ID, 410 src, 411 ...iframeAttrs, 412 }; 413 414 return `<iframe ${attrsToString(iframeAttrs)}/>`; 415 } 416 417 /** 418 * Takes an HTML snippet or HTML doc url and returns an encoded URI for a full 419 * document with the snippet or the URL as a source for the IFRAME. 420 * 421 * @param {string} doc 422 * a markup snippet or url. 423 * @param {object} options (see options in addAccessibleTask). 424 * 425 * @return {string} 426 * a base64 encoded data url of the document container the snippet. 427 */ 428 function snippetToURL(doc, options = {}) { 429 const { contentDocBodyAttrs = {} } = options; 430 const attrs = { 431 id: DEFAULT_CONTENT_DOC_BODY_ID, 432 ...contentDocBodyAttrs, 433 }; 434 435 if (gIsIframe) { 436 doc = wrapWithIFrame(doc, options); 437 } else if (options.contentSetup) { 438 // Hide the body initially so we can ensure that any changes made by 439 // contentSetup are included when the body's content is initially added to 440 // the accessibility tree. Use `hidden` instead of `aria-hidden` because the 441 // latter is ignored when applied to top level docs/<body> elements. 442 attrs.hidden = true; 443 } 444 445 const encodedDoc = encodeURIComponent( 446 `<!doctype html> 447 <html> 448 <head> 449 <meta charset="utf-8"/> 450 <title>Accessibility Test</title> 451 </head> 452 <body ${attrsToString(attrs)}>${doc}</body> 453 </html>` 454 ); 455 456 let url = `data:text/html;charset=utf-8,${encodedDoc}`; 457 if (!gIsIframe && options.urlSuffix) { 458 url += options.urlSuffix; 459 } 460 return url; 461 } 462 463 const CacheDomain = { 464 None: 0, 465 NameAndDescription: 0x1 << 0, 466 Value: 0x1 << 1, 467 Bounds: 0x1 << 2, 468 Resolution: 0x1 << 3, 469 Text: 0x1 << 4, 470 DOMNodeIDAndClass: 0x1 << 5, 471 State: 0x1 << 6, 472 GroupInfo: 0x1 << 7, 473 Actions: 0x1 << 8, 474 Style: 0x1 << 9, 475 TransformMatrix: 0x1 << 10, 476 ScrollPosition: 0x1 << 11, 477 Table: 0x1 << 12, 478 TextOffsetAttributes: 0x1 << 13, 479 Viewport: 0x1 << 14, 480 ARIA: 0x1 << 15, 481 Relations: 0x1 << 16, 482 InnerHTML: 0x1 << 17, 483 TextBounds: 0x1 << 18, 484 All: ~0x0, 485 }; 486 487 function accessibleTask(doc, task, options = {}) { 488 const wrapped = async function () { 489 let cacheDomains; 490 if (!("cacheDomains" in options)) { 491 cacheDomains = CacheDomain.All; 492 } else { 493 // The DOMNodeIDAndClass domain is required for the tests to initialize. 494 cacheDomains = options.cacheDomains | CacheDomain.DOMNodeIDAndClass; 495 } 496 497 // Set the required cache domains for the test. Note that this also 498 // instantiates the accessibility service if it hasn't been already, since 499 // gAccService is defined lazily. 500 gAccService.setCacheDomains(cacheDomains); 501 502 gIsRemoteIframe = options.remoteIframe; 503 gIsIframe = options.iframe || gIsRemoteIframe; 504 const urlSuffix = options.urlSuffix || ""; 505 let url; 506 if (options.chrome && doc.endsWith("html")) { 507 // Load with a chrome:// URL so this loads as a chrome document in the 508 // parent process. 509 url = `${CURRENT_DIR}${doc}${urlSuffix}`; 510 } else if (doc.endsWith("html") && !gIsIframe) { 511 url = `${CURRENT_CONTENT_DIR}${doc}${urlSuffix}`; 512 } else { 513 url = snippetToURL(doc, options); 514 } 515 516 registerCleanupFunction(() => { 517 // XXX Bug 1906779: This will run once for each call to addAccessibleTask, 518 // but only after the entire test file has completed. This doesn't make 519 // sense and almost certainly wasn't the intent. 520 for (let observer of Services.obs.enumerateObservers( 521 "accessible-event" 522 )) { 523 Services.obs.removeObserver(observer, "accessible-event"); 524 } 525 }); 526 527 let onContentDocLoad; 528 if (!options.chrome) { 529 onContentDocLoad = waitForEvent( 530 EVENT_DOCUMENT_LOAD_COMPLETE, 531 DEFAULT_CONTENT_DOC_BODY_ID 532 ); 533 } 534 535 let onIframeDocLoad; 536 if (options.remoteIframe && !options.skipFissionDocLoad) { 537 onIframeDocLoad = waitForEvent( 538 EVENT_DOCUMENT_LOAD_COMPLETE, 539 DEFAULT_IFRAME_DOC_BODY_ID 540 ); 541 } 542 543 await BrowserTestUtils.withNewTab( 544 { 545 gBrowser, 546 // For chrome, we need a non-remote browser. 547 opening: !options.chrome 548 ? url 549 : () => { 550 // Passing forceNotRemote: true still sets maychangeremoteness, 551 // which will cause data: URIs to load remotely. There's no way to 552 // avoid this with gBrowser or BrowserTestUtils. Therefore, we 553 // load a blank document initially and replace it below. 554 gBrowser.selectedTab = BrowserTestUtils.addTab( 555 gBrowser, 556 "about:blank", 557 { 558 allowInheritPrincipal: true, 559 forceNotRemote: true, 560 } 561 ); 562 }, 563 }, 564 async function (browser) { 565 registerCleanupFunction(() => { 566 if (browser) { 567 let tab = gBrowser.getTabForBrowser(browser); 568 if (tab && !tab.closing && tab.linkedBrowser) { 569 gBrowser.removeTab(tab); 570 } 571 } 572 }); 573 574 if (options.chrome) { 575 await SpecialPowers.pushPrefEnv({ 576 set: [["security.allow_unsafe_parent_loads", true]], 577 }); 578 // Ensure this never becomes a remote browser. 579 browser.removeAttribute("maychangeremoteness"); 580 // Now we can load our page without it becoming remote. 581 browser.setAttribute("src", url); 582 } 583 584 await SimpleTest.promiseFocus(browser); 585 586 if (options.chrome) { 587 ok(!browser.isRemoteBrowser, "Not remote browser"); 588 } else if (Services.appinfo.browserTabsRemoteAutostart) { 589 ok(browser.isRemoteBrowser, "Actually remote browser"); 590 } 591 592 let docAccessible; 593 if (options.chrome) { 594 // Chrome documents don't fire DOCUMENT_LOAD_COMPLETE. Instead, wait 595 // until we can get the DocAccessible and it doesn't have the busy 596 // state. 597 await BrowserTestUtils.waitForCondition(() => { 598 docAccessible = getAccessible(browser.contentWindow.document); 599 if (!docAccessible) { 600 return false; 601 } 602 const state = {}; 603 docAccessible.getState(state, {}); 604 return !(state.value & STATE_BUSY); 605 }); 606 } else { 607 ({ accessible: docAccessible } = await onContentDocLoad); 608 } 609 // The test may want to access document methods/attributes such as URL 610 // and browsingContext. 611 docAccessible.QueryInterface(nsIAccessibleDocument); 612 let iframeDocAccessible; 613 if (gIsIframe) { 614 if (!options.skipFissionDocLoad) { 615 await comparePIDs(browser, options.remoteIframe); 616 iframeDocAccessible = onIframeDocLoad 617 ? (await onIframeDocLoad).accessible 618 : findAccessibleChildByID(docAccessible, DEFAULT_IFRAME_ID) 619 .firstChild; 620 iframeDocAccessible.QueryInterface(nsIAccessibleDocument); 621 } 622 } 623 624 if (options.contentSetup) { 625 info("Executing contentSetup"); 626 const ready = waitForEvent(EVENT_REORDER, currentContentDoc()); 627 await invokeContentTask(browser, [], options.contentSetup); 628 // snippetToURL set hidden on the body. We now Remove hidden 629 // and wait for a reorder on the body. This guarantees that any 630 // changes made by contentSetup are included when the body's content 631 // is initially added to the accessibility tree and that the 632 // accessibility tree is up to date. 633 await invokeContentTask(browser, [], () => { 634 content.document.body.removeAttribute("hidden"); 635 }); 636 await ready; 637 info("contentSetup done"); 638 } 639 await loadContentScripts(browser, { 640 script: "Common.sys.mjs", 641 symbol: "CommonUtils", 642 }); 643 644 await task( 645 browser, 646 iframeDocAccessible || docAccessible, 647 iframeDocAccessible && docAccessible 648 ); 649 } 650 ); 651 652 if (gPythonSocket) { 653 // Remove any globals set by Python code run in this test. We do this here 654 // rather than using registerCleanupFunction because 655 // registerCleanupFunction runs after all tests in the file, whereas we 656 // need this to run after each task. 657 await runPython(`__reset__`); 658 } 659 }; 660 // Propagate the name of the task function to our wrapper function so it shows 661 // up in test run output. Suffix with the test type. For example: 662 // 0:39.16 INFO Entering test bound testProtected_remoteIframe 663 // Even if the name is empty, we still propagate it here to override the 664 // implicit "wrapped" name derived from the assignment at the top of this 665 // function. 666 let name = task.name; 667 if (name) { 668 if (options.chrome) { 669 name += "_chrome"; 670 } else if (options.iframe) { 671 name += "_iframe"; 672 } else if (options.remoteIframe) { 673 name += "_remoteIframe"; 674 } else { 675 name += "_topLevel"; 676 } 677 } 678 // The "name" property of functions is not writable, but we can override that 679 // using Object.defineProperty. 680 Object.defineProperty(wrapped, "name", { value: name }); 681 return wrapped; 682 } 683 684 /** 685 * A wrapper around browser test add_task that triggers an accessible test task 686 * as a new browser test task with given document, data URL or markup snippet. 687 * 688 * @param {string} doc 689 * URL (relative to current directory) or data URL or markup snippet 690 * that is used to test content with 691 * @param {Function|AsyncFunction} task 692 * a generator or a function with tests to run 693 * @param {null | object} options 694 * Options for running accessibility test tasks: 695 * - {Boolean} topLevel 696 * Flag to run the test with content in the top level content process. 697 * Default is true. 698 * - {Boolean} chrome 699 * Flag to run the test with content as a chrome document in the 700 * parent process. Default is false. Although url can be a markup 701 * snippet, a snippet cannot be used for XUL content. To load XUL, 702 * specify a relative URL to a XUL document. In that case, toplevel 703 * should usually be set to false, since XUL documents don't work in 704 * content processes. 705 * - {Boolean} iframe 706 * Flag to run the test with content wrapped in an iframe. Default is 707 * false. 708 * - {Boolean} remoteIframe 709 * Flag to run the test with content wrapped in a remote iframe. 710 * Default is false. 711 * - {Object} iframeAttrs 712 * A map of attribute/value pairs to be applied to IFRAME element. 713 * - {Boolean} skipFissionDocLoad 714 * If true, the test will not wait for iframe document document 715 * loaded event (useful for when IFRAME is initially hidden). 716 * - {Object} contentDocBodyAttrs 717 * a set of attributes to be applied to a top level content document 718 * body 719 * - {Object} iframeDocBodyAttrs 720 * a set of attributes to be applied to a iframe content document body 721 * - {String} urlSuffix 722 * String to append to the document URL. For example, this could be 723 * "#test" to scroll to the "test" id in the document. 724 * - {CacheDomain} cacheDomains 725 * The set of cache domains that should be present at the start of the 726 * test. If not set, all cache domains will be present. 727 * - {Function|AsyncFunction} contentSetup 728 * An optional task to run to set up the content document before the 729 * test starts. If this test is to be run as a chrome document in the 730 * parent process (chrome: true), This should be used instead of an 731 * inline <script> element in the test snippet, since inline script is 732 * not allowed in such documents. This task is ultimately executed 733 * using SpecialPowers.spawn. Any updates to the content within the 734 * body will be included when the content is initially added to the 735 * accessibility tree. The accessibility tree is guaranteed to be up 736 * to date when the test starts. This will not work correctly for 737 * changes to the html or body elements themselves. Note that you will 738 * need to define this exactly as follows: 739 * contentSetup: async function contentSetup() { ... } 740 * async contentSetup() will fail when the task is serialized. 741 * contentSetup: async function() will be changed to 742 * async contentSetup() by the linter and likewise fail. 743 */ 744 function addAccessibleTask(doc, task, options = {}) { 745 const { 746 topLevel = true, 747 chrome = false, 748 iframe = false, 749 remoteIframe = false, 750 } = options; 751 if (topLevel) { 752 add_task( 753 accessibleTask(doc, task, { 754 ...options, 755 chrome: false, 756 iframe: false, 757 remoteIframe: false, 758 }) 759 ); 760 } 761 762 if (chrome) { 763 add_task( 764 accessibleTask(doc, task, { 765 ...options, 766 topLevel: false, 767 iframe: false, 768 remoteIframe: false, 769 }) 770 ); 771 } 772 773 if (iframe) { 774 add_task( 775 accessibleTask(doc, task, { 776 ...options, 777 topLevel: false, 778 chrome: false, 779 remoteIframe: false, 780 }) 781 ); 782 } 783 784 if (gFissionBrowser && remoteIframe) { 785 add_task( 786 accessibleTask(doc, task, { 787 ...options, 788 topLevel: false, 789 chrome: false, 790 iframe: false, 791 }) 792 ); 793 } 794 } 795 796 /** 797 * Check if an accessible object has a defunct test. 798 * 799 * @param {nsIAccessible} accessible object to test defunct state for 800 * @return {boolean} flag indicating defunct state 801 */ 802 function isDefunct(accessible) { 803 let defunct = false; 804 try { 805 let extState = {}; 806 accessible.getState({}, extState); 807 defunct = extState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT; 808 } catch (x) { 809 defunct = true; 810 } finally { 811 if (defunct) { 812 Logger.log(`Defunct accessible: ${prettyName(accessible)}`); 813 } 814 } 815 return defunct; 816 } 817 818 /** 819 * Get the DOM tag name for a given accessible. 820 * 821 * @param {nsIAccessible} accessible accessible 822 * @return {string?} tag name of associated DOM node, or null. 823 */ 824 function getAccessibleTagName(acc) { 825 try { 826 return acc.attributes.getStringProperty("tag"); 827 } catch (e) { 828 return null; 829 } 830 } 831 832 /** 833 * Traverses the accessible tree starting from a given accessible as a root and 834 * looks for an accessible that matches based on its DOMNode id. 835 * 836 * @param {nsIAccessible} accessible root accessible 837 * @param {string} id id to look up accessible for 838 * @param {Array?} interfaces the interface or an array interfaces 839 * to query it/them from obtained accessible 840 * @return {nsIAccessible?} found accessible if any 841 */ 842 function findAccessibleChildByID(accessible, id, interfaces) { 843 if (getAccessibleDOMNodeID(accessible) === id) { 844 return queryInterfaces(accessible, interfaces); 845 } 846 for (let i = 0; i < accessible.children.length; ++i) { 847 let found = findAccessibleChildByID(accessible.getChildAt(i), id); 848 if (found) { 849 return queryInterfaces(found, interfaces); 850 } 851 } 852 return null; 853 } 854 855 function queryInterfaces(accessible, interfaces) { 856 if (!interfaces) { 857 return accessible; 858 } 859 860 for (let iface of interfaces.filter(i => !(accessible instanceof i))) { 861 try { 862 accessible.QueryInterface(iface); 863 } catch (e) { 864 ok(false, "Can't query " + iface); 865 } 866 } 867 868 return accessible; 869 } 870 871 function arrayFromChildren(accessible) { 872 return Array.from({ length: accessible.childCount }, (c, i) => 873 accessible.getChildAt(i) 874 ); 875 } 876 877 /** 878 * Force garbage collection. 879 */ 880 function forceGC() { 881 SpecialPowers.gc(); 882 SpecialPowers.forceShrinkingGC(); 883 SpecialPowers.forceCC(); 884 SpecialPowers.gc(); 885 SpecialPowers.forceShrinkingGC(); 886 SpecialPowers.forceCC(); 887 } 888 889 /* 890 * This function spawns a content task and awaits expected mutation events from 891 * various content changes. It's good at catching events we did *not* expect. We 892 * do this advancing the layout refresh to flush the relocations/insertions 893 * queue. 894 */ 895 async function contentSpawnMutation(browser, waitFor, func, args = []) { 896 let onReorders = waitForEvents({ expected: waitFor.expected || [] }); 897 let unexpectedListener = new UnexpectedEvents(waitFor.unexpected || []); 898 899 function tick() { 900 // 100ms is an arbitrary positive number to advance the clock. 901 // We don't need to advance the clock for a11y mutations, but other 902 // tick listeners may depend on an advancing clock with each refresh. 903 content.windowUtils.advanceTimeAndRefresh(100); 904 } 905 906 // This stops the refreh driver from doing its regular ticks, and leaves 907 // us in control. 908 await invokeContentTask(browser, [], tick); 909 910 // Perform the tree mutation. 911 await invokeContentTask(browser, args, func); 912 913 // Do one tick to flush our queue (insertions, relocations, etc.) 914 await invokeContentTask(browser, [], tick); 915 916 let events = await onReorders; 917 918 unexpectedListener.stop(); 919 920 // Go back to normal refresh driver ticks. 921 await invokeContentTask(browser, [], function () { 922 content.windowUtils.restoreNormalRefresh(); 923 }); 924 925 return events; 926 } 927 928 async function waitForImageMap(browser, accDoc, id = "imgmap") { 929 let acc = findAccessibleChildByID(accDoc, id); 930 931 if (!acc) { 932 const onShow = waitForEvent(EVENT_SHOW, id); 933 acc = (await onShow).accessible; 934 } 935 936 if (acc.firstChild) { 937 return; 938 } 939 940 const onReorder = waitForEvent(EVENT_REORDER, id); 941 // Wave over image map 942 await invokeContentTask(browser, [id], contentId => { 943 const { ContentTaskUtils } = ChromeUtils.importESModule( 944 "resource://testing-common/ContentTaskUtils.sys.mjs" 945 ); 946 const EventUtils = ContentTaskUtils.getEventUtils(content); 947 EventUtils.synthesizeMouse( 948 content.document.getElementById(contentId), 949 10, 950 10, 951 { type: "mousemove" }, 952 content 953 ); 954 }); 955 await onReorder; 956 } 957 958 async function getContentBoundsForDOMElm(browser, id) { 959 return invokeContentTask(browser, [id], contentId => { 960 const { Layout: LayoutUtils } = ChromeUtils.importESModule( 961 "chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs" 962 ); 963 964 return LayoutUtils.getBoundsForDOMElm(contentId, content.document); 965 }); 966 } 967 968 const CACHE_WAIT_TIMEOUT_MS = 5000; 969 970 /** 971 * Wait for a predicate to be true after cache ticks. 972 * This function takes two callbacks, the condition is evaluated 973 * by calling the first callback with the arguments returned by the second. 974 * This allows us to asynchronously return the arguments as a result if the condition 975 * of the first callback is met, or if it times out. The returned arguments can then 976 * be used to record a pass or fail in the test. 977 */ 978 function untilCacheCondition(conditionFunc, argsFunc) { 979 return new Promise(resolve => { 980 let args = argsFunc(); 981 if (conditionFunc(...args)) { 982 resolve(args); 983 return; 984 } 985 986 let cacheObserver = { 987 observe() { 988 args = argsFunc(); 989 if (conditionFunc(...args)) { 990 clearTimeout(this.timer); 991 Services.obs.removeObserver(this, "accessible-cache"); 992 resolve(args); 993 } 994 }, 995 996 timeout() { 997 ok(false, "Timeout while waiting for cache update"); 998 Services.obs.removeObserver(this, "accessible-cache"); 999 args = argsFunc(); 1000 resolve(args); 1001 }, 1002 }; 1003 1004 cacheObserver.timer = setTimeout( 1005 cacheObserver.timeout.bind(cacheObserver), 1006 CACHE_WAIT_TIMEOUT_MS 1007 ); 1008 Services.obs.addObserver(cacheObserver, "accessible-cache"); 1009 }); 1010 } 1011 1012 function untilCacheOk(conditionFunc, message) { 1013 return untilCacheCondition( 1014 (v, _unusedMessage) => v, 1015 () => [conditionFunc(), message] 1016 ).then(([v, msg]) => ok(v, msg)); 1017 } 1018 1019 function untilCacheIs(retrievalFunc, expected, message) { 1020 return untilCacheCondition( 1021 (a, b, _unusedMessage) => Object.is(a, b), 1022 () => [retrievalFunc(), expected, message] 1023 ).then(([got, exp, msg]) => is(got, exp, msg)); 1024 } 1025 1026 async function waitForContentPaint(browser) { 1027 await SpecialPowers.spawn(browser, [], () => { 1028 return new Promise(function (r) { 1029 content.requestAnimationFrame(() => content.setTimeout(r)); 1030 }); 1031 }); 1032 } 1033 1034 // Returns true if both number arrays match within `FUZZ`. 1035 function areBoundsFuzzyEqual(actual, expected) { 1036 const FUZZ = 1; 1037 return actual 1038 .map((val, i) => Math.abs(val - expected[i]) <= FUZZ) 1039 .reduce((a, b) => a && b, true); 1040 } 1041 1042 function assertBoundsFuzzyEqual(actual, expected) { 1043 ok( 1044 areBoundsFuzzyEqual(actual, expected), 1045 `${actual} fuzzily matches expected ${expected}` 1046 ); 1047 } 1048 1049 async function testBoundsWithContent(iframeDocAcc, id, browser) { 1050 // Retrieve layout bounds from content 1051 let expectedBounds = await invokeContentTask(browser, [id], _id => { 1052 const { Layout: LayoutUtils } = ChromeUtils.importESModule( 1053 "chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs" 1054 ); 1055 return LayoutUtils.getBoundsForDOMElm(_id, content.document); 1056 }); 1057 1058 function isWithinExpected(bounds) { 1059 return areBoundsFuzzyEqual(bounds, expectedBounds); 1060 } 1061 1062 const acc = findAccessibleChildByID(iframeDocAcc, id); 1063 let [accBounds] = await untilCacheCondition(isWithinExpected, () => [ 1064 getBounds(acc), 1065 ]); 1066 1067 assertBoundsFuzzyEqual(accBounds, expectedBounds); 1068 1069 return accBounds; 1070 } 1071 1072 let gPythonSocket = null; 1073 1074 /** 1075 * Run some Python code. This is useful for testing OS APIs. 1076 * This function returns a Promise which is resolved or rejected when the Python 1077 * code completes. The Python code can return a result with the return 1078 * statement, as long as the result can be serialized to JSON. For convenience, 1079 * if the code is a single line which does not begin with return, it will be 1080 * treated as an expression and its result will be returned. The JS Promise will 1081 * be resolved with the deserialized result. If the Python code raises an 1082 * exception, the JS Promise will be rejected with the Python traceback. 1083 * An info() function is provided in Python to log an info message. 1084 * See windows/a11y_setup.py for other things available in the Python 1085 * environment. 1086 */ 1087 function runPython(code) { 1088 if (!gPythonSocket) { 1089 // Keep the socket open across calls to avoid repeated setup overhead. 1090 gPythonSocket = new WebSocket( 1091 "ws://mochi.test:8888/browser/accessible/tests/browser/python_runner" 1092 ); 1093 if (gPythonSocket.readyState != WebSocket.OPEN) { 1094 gPythonSocket.onopen = () => { 1095 gPythonSocket.send(code); 1096 gPythonSocket.onopen = null; 1097 }; 1098 } 1099 } 1100 return new Promise((resolve, reject) => { 1101 gPythonSocket.onmessage = evt => { 1102 const message = JSON.parse(evt.data); 1103 if (message[0] == "return") { 1104 gPythonSocket.onmessage = null; 1105 resolve(message[1]); 1106 } else if (message[0] == "exception") { 1107 gPythonSocket.onmessage = null; 1108 reject(new Error(message[1])); 1109 } else if (message[0] == "info") { 1110 info(message[1]); 1111 } 1112 }; 1113 // If gPythonSocket isn't open yet, we'll send the message when .onopen is 1114 // called. If it's open, we can send it immediately. 1115 if (gPythonSocket.readyState == WebSocket.OPEN) { 1116 gPythonSocket.send(code); 1117 } 1118 }); 1119 }