Files
tubestation/accessible/tests/browser/shared-head.js
James Teh 7e98770397 Bug 1954448: Add a contentSetup argument to addAccessibleTask to facilitate content setup before a test starts. r=tschuster,eeejay
After bug 1953866, it will no longer be possible to have inline `<script>` elements in any document which runs in a chrome context.
Several accessibility tests currently rely on this to set things up which can't be done in pure HTML/CSS, but which need to be done before the content is initially added to the accessibility tree so that we can explicitly test the initial state (vs the state after it is mutated).
To work around this, a content task can be specified using the contentSetup argument to addAccessibleTask.
This causes the following to happen:

1. aria-hidden="true" is set on the body, preventing the initial accessibility tree update from including any content.
2. contentTask is executed in the context of the document.
3. aria-hidden is removed from the body, so that all of the content (including any changes made by contentSetup) is processed by accessibility.
4. A reorder event is awaited on the body, so that we can guarantee that (3) is complete.

Subsequent patches will adjust existing tests as needed.

Differential Revision: https://phabricator.services.mozilla.com/D242489
2025-03-24 23:28:33 +00:00

1089 lines
34 KiB
JavaScript

/* 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";
/* import-globals-from ../mochitest/common.js */
/* import-globals-from ../mochitest/layout.js */
/* import-globals-from ../mochitest/promisified-events.js */
/* exported Logger, MOCHITESTS_DIR, invokeSetAttribute, invokeFocus,
invokeSetStyle, getAccessibleDOMNodeID, getAccessibleTagName,
addAccessibleTask, findAccessibleChildByID, isDefunct,
CURRENT_CONTENT_DIR, loadScripts, loadContentScripts, snippetToURL,
Cc, Cu, arrayFromChildren, forceGC, contentSpawnMutation,
DEFAULT_IFRAME_ID, DEFAULT_IFRAME_DOC_BODY_ID, invokeContentTask,
matchContentDoc, currentContentDoc, getContentDPR,
waitForImageMap, getContentBoundsForDOMElm, untilCacheIs,
untilCacheOk, testBoundsWithContent, waitForContentPaint,
runPython */
const CURRENT_FILE_DIR = "/browser/accessible/tests/browser/";
/**
* Current browser test directory path used to load subscripts.
*/
const CURRENT_DIR = `chrome://mochitests/content${CURRENT_FILE_DIR}`;
/**
* A11y mochitest directory where we find common files used in both browser and
* plain tests.
*/
const MOCHITESTS_DIR =
"chrome://mochitests/content/a11y/accessible/tests/mochitest/";
/**
* A base URL for test files used in content.
*/
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
const CURRENT_CONTENT_DIR = `http://example.com${CURRENT_FILE_DIR}`;
const LOADED_CONTENT_SCRIPTS = new Map();
const DEFAULT_CONTENT_DOC_BODY_ID = "body";
const DEFAULT_IFRAME_ID = "default-iframe-id";
const DEFAULT_IFRAME_DOC_BODY_ID = "default-iframe-body-id";
const HTML_MIME_TYPE = "text/html";
const XHTML_MIME_TYPE = "application/xhtml+xml";
function loadHTMLFromFile(path) {
// Load the HTML to return in the response from file.
// Since it's relative to the cwd of the test runner, we start there and
// append to get to the actual path of the file.
const testHTMLFile = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
const dirs = path.split("/");
for (let i = 0; i < dirs.length; i++) {
testHTMLFile.append(dirs[i]);
}
const testHTMLFileStream = Cc[
"@mozilla.org/network/file-input-stream;1"
].createInstance(Ci.nsIFileInputStream);
testHTMLFileStream.init(testHTMLFile, -1, 0, 0);
const testHTML = NetUtil.readInputStreamToString(
testHTMLFileStream,
testHTMLFileStream.available()
);
return testHTML;
}
let gIsIframe = false;
let gIsRemoteIframe = false;
function currentContentDoc() {
return gIsIframe ? DEFAULT_IFRAME_DOC_BODY_ID : DEFAULT_CONTENT_DOC_BODY_ID;
}
/**
* Accessible event match criteria based on the id of the current document
* accessible in test.
*
* @param {nsIAccessibleEvent} event
* Accessible event to be tested for a match.
*
* @return {Boolean}
* True if accessible event's accessible object ID matches current
* document accessible ID.
*/
function matchContentDoc(event) {
return getAccessibleDOMNodeID(event.accessible) === currentContentDoc();
}
/**
* Used to dump debug information.
*/
let Logger = {
/**
* Set up this variable to dump log messages into console.
*/
dumpToConsole: false,
/**
* Set up this variable to dump log messages into error console.
*/
dumpToAppConsole: false,
/**
* Return true if dump is enabled.
*/
get enabled() {
return this.dumpToConsole || this.dumpToAppConsole;
},
/**
* Dump information into console if applicable.
*/
log(msg) {
if (this.enabled) {
this.logToConsole(msg);
this.logToAppConsole(msg);
}
},
/**
* Log message to console.
*/
logToConsole(msg) {
if (this.dumpToConsole) {
dump(`\n${msg}\n`);
}
},
/**
* Log message to error console.
*/
logToAppConsole(msg) {
if (this.dumpToAppConsole) {
Services.console.logStringMessage(`${msg}`);
}
},
};
/**
* Asynchronously set or remove content element's attribute (in content process
* if e10s is enabled).
* @param {Object} browser current "tabbrowser" element
* @param {String} id content element id
* @param {String} attr attribute name
* @param {String?} value optional attribute value, if not present, remove
* attribute
* @return {Promise} promise indicating that attribute is set/removed
*/
function invokeSetAttribute(browser, id, attr, value) {
if (value) {
Logger.log(`Setting ${attr} attribute to ${value} for node with id: ${id}`);
} else {
Logger.log(`Removing ${attr} attribute from node with id: ${id}`);
}
return invokeContentTask(
browser,
[id, attr, value],
(contentId, contentAttr, contentValue) => {
let elm = content.document.getElementById(contentId);
if (contentValue) {
elm.setAttribute(contentAttr, contentValue);
} else {
elm.removeAttribute(contentAttr);
}
}
);
}
/**
* Asynchronously set or remove content element's style (in content process if
* e10s is enabled, or in fission process if fission is enabled and a fission
* frame is present).
* @param {Object} browser current "tabbrowser" element
* @param {String} id content element id
* @param {String} aStyle style property name
* @param {String?} aValue optional style property value, if not present,
* remove style
* @return {Promise} promise indicating that style is set/removed
*/
function invokeSetStyle(browser, id, style, value) {
if (value) {
Logger.log(`Setting ${style} style to ${value} for node with id: ${id}`);
} else {
Logger.log(`Removing ${style} style from node with id: ${id}`);
}
return invokeContentTask(
browser,
[id, style, value],
(contentId, contentStyle, contentValue) => {
const elm = content.document.getElementById(contentId);
if (contentValue) {
elm.style[contentStyle] = contentValue;
} else {
delete elm.style[contentStyle];
}
}
);
}
/**
* Asynchronously set focus on a content element (in content process if e10s is
* enabled, or in fission process if fission is enabled and a fission frame is
* present).
* @param {Object} browser current "tabbrowser" element
* @param {String} id content element id
* @return {Promise} promise indicating that focus is set
*/
function invokeFocus(browser, id) {
Logger.log(`Setting focus on a node with id: ${id}`);
return invokeContentTask(browser, [id], contentId => {
const elm = content.document.getElementById(contentId);
if (elm.editor) {
elm.selectionStart = elm.selectionEnd = elm.value.length;
}
elm.focus();
});
}
/**
* Get DPR for a specific content window.
* @param browser
* Browser for which we want its content window's DPR reported.
*
* @return {Promise}
* Promise with the value that resolves to the devicePixelRatio of the
* content window of a given browser.
*
*/
function getContentDPR(browser) {
return invokeContentTask(browser, [], () => content.window.devicePixelRatio);
}
/**
* Asynchronously perform a task in content (in content process if e10s is
* enabled, or in fission process if fission is enabled and a fission frame is
* present).
* @param {Object} browser current "tabbrowser" element
* @param {Array} args arguments for the content task
* @param {Function} task content task function
*
* @return {Promise} promise indicating that content task is complete
*/
function invokeContentTask(browser, args, task) {
return SpecialPowers.spawn(
browser,
[DEFAULT_IFRAME_ID, task.toString(), ...args],
(iframeId, contentTask, ...contentArgs) => {
// eslint-disable-next-line no-eval
const runnableTask = eval(`
(() => {
return (${contentTask});
})();`);
const frame = content.document.getElementById(iframeId);
return frame
? SpecialPowers.spawn(frame, contentArgs, runnableTask)
: runnableTask.call(this, ...contentArgs);
}
);
}
/**
* Compare process ID's between the top level content process and possible
* remote/local iframe proccess.
* @param {Object} browser
* Top level browser object for a tab.
* @param {Boolean} isRemote
* Indicates if we expect the iframe content process to be remote or not.
*/
async function comparePIDs(browser, isRemote) {
function getProcessID() {
return Services.appinfo.processID;
}
const contentPID = await SpecialPowers.spawn(browser, [], getProcessID);
const iframePID = await invokeContentTask(browser, [], getProcessID);
is(
isRemote,
contentPID !== iframePID,
isRemote
? "Remote IFRAME is in a different process."
: "IFRAME is in the same process."
);
}
/**
* Load a list of scripts into the test
* @param {Array} scripts a list of scripts to load
*/
function loadScripts(...scripts) {
for (let script of scripts) {
let path =
typeof script === "string"
? `${CURRENT_DIR}${script}`
: `${script.dir}${script.name}`;
Services.scriptloader.loadSubScript(path, this);
}
}
/**
* Load a list of scripts into target's content.
* @param {Object} target
* target for loading scripts into
* @param {Array} scripts
* a list of scripts to load into content
*/
async function loadContentScripts(target, ...scripts) {
for (let { script, symbol } of scripts) {
let contentScript = `${CURRENT_DIR}${script}`;
let loadedScriptSet = LOADED_CONTENT_SCRIPTS.get(contentScript);
if (!loadedScriptSet) {
loadedScriptSet = new WeakSet();
LOADED_CONTENT_SCRIPTS.set(contentScript, loadedScriptSet);
} else if (loadedScriptSet.has(target)) {
continue;
}
await SpecialPowers.spawn(
target,
[contentScript, symbol],
async (_contentScript, importSymbol) => {
let module = ChromeUtils.importESModule(_contentScript);
content.window[importSymbol] = module[importSymbol];
}
);
loadedScriptSet.add(target);
}
}
function attrsToString(attrs) {
return Object.entries(attrs)
.map(([attr, value]) => `${attr}=${JSON.stringify(value)}`)
.join(" ");
}
function wrapWithIFrame(doc, options = {}) {
let src;
let { iframeAttrs = {}, iframeDocBodyAttrs = {} } = options;
iframeDocBodyAttrs = {
id: DEFAULT_IFRAME_DOC_BODY_ID,
...iframeDocBodyAttrs,
};
if (options.contentSetup) {
// Hide the body initially so we can ensure that any changes made by
// contentSetup are included when the body's content is initially added to
// the accessibility tree.
iframeDocBodyAttrs["aria-hidden"] = "true";
}
if (options.remoteIframe) {
// eslint-disable-next-line @microsoft/sdl/no-insecure-url
const srcURL = new URL(`http://example.net/document-builder.sjs`);
if (doc.endsWith("html")) {
srcURL.searchParams.append("file", `${CURRENT_FILE_DIR}${doc}`);
} else {
// document-builder.sjs can't handle non-ASCII characters. Convert them
// to HTML character entities; e.g. &#8226;.
doc = doc.replace(/[\u00A0-\u2666]/g, c => `&#${c.charCodeAt(0)}`);
srcURL.searchParams.append(
"html",
`<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Accessibility Fission Test</title>
</head>
<body ${attrsToString(iframeDocBodyAttrs)}>${doc}</body>
</html>`
);
}
src = srcURL.href;
} else {
const mimeType = doc.endsWith("xhtml") ? XHTML_MIME_TYPE : HTML_MIME_TYPE;
if (doc.endsWith("html")) {
doc = loadHTMLFromFile(`${CURRENT_FILE_DIR}${doc}`);
doc = doc.replace(
/<body[.\s\S]*?>/,
`<body ${attrsToString(iframeDocBodyAttrs)}>`
);
} else {
doc = `<!doctype html>
<body ${attrsToString(iframeDocBodyAttrs)}>${doc}</body>`;
}
src = `data:${mimeType};charset=utf-8,${encodeURIComponent(doc)}`;
}
if (options.urlSuffix) {
src += options.urlSuffix;
}
iframeAttrs = {
id: DEFAULT_IFRAME_ID,
src,
...iframeAttrs,
};
return `<iframe ${attrsToString(iframeAttrs)}/>`;
}
/**
* Takes an HTML snippet or HTML doc url and returns an encoded URI for a full
* document with the snippet or the URL as a source for the IFRAME.
* @param {String} doc
* a markup snippet or url.
* @param {Object} options (see options in addAccessibleTask).
*
* @return {String}
* a base64 encoded data url of the document container the snippet.
**/
function snippetToURL(doc, options = {}) {
const { contentDocBodyAttrs = {} } = options;
const attrs = {
id: DEFAULT_CONTENT_DOC_BODY_ID,
...contentDocBodyAttrs,
};
if (gIsIframe) {
doc = wrapWithIFrame(doc, options);
} else if (options.contentSetup) {
// Hide the body initially so we can ensure that any changes made by
// contentSetup are included when the body's content is initially added to
// the accessibility tree.
attrs["aria-hidden"] = "true";
}
const encodedDoc = encodeURIComponent(
`<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Accessibility Test</title>
</head>
<body ${attrsToString(attrs)}>${doc}</body>
</html>`
);
let url = `data:text/html;charset=utf-8,${encodedDoc}`;
if (!gIsIframe && options.urlSuffix) {
url += options.urlSuffix;
}
return url;
}
const CacheDomain = {
None: 0,
NameAndDescription: 0x1 << 0,
Value: 0x1 << 1,
Bounds: 0x1 << 2,
Resolution: 0x1 << 3,
Text: 0x1 << 4,
DOMNodeIDAndClass: 0x1 << 5,
State: 0x1 << 6,
GroupInfo: 0x1 << 7,
Actions: 0x1 << 8,
Style: 0x1 << 9,
TransformMatrix: 0x1 << 10,
ScrollPosition: 0x1 << 11,
Table: 0x1 << 12,
TextOffsetAttributes: 0x1 << 13,
Viewport: 0x1 << 14,
ARIA: 0x1 << 15,
Relations: 0x1 << 16,
InnerHTML: 0x1 << 17,
TextBounds: 0x1 << 18,
All: ~0x0,
};
function accessibleTask(doc, task, options = {}) {
const wrapped = async function () {
let cacheDomains;
if (!("cacheDomains" in options)) {
cacheDomains = CacheDomain.All;
} else {
// The DOMNodeIDAndClass domain is required for the tests to initialize.
cacheDomains = options.cacheDomains | CacheDomain.DOMNodeIDAndClass;
}
// Set the required cache domains for the test. Note that this also
// instantiates the accessibility service if it hasn't been already, since
// gAccService is defined lazily.
gAccService.setCacheDomains(cacheDomains);
gIsRemoteIframe = options.remoteIframe;
gIsIframe = options.iframe || gIsRemoteIframe;
const urlSuffix = options.urlSuffix || "";
let url;
if (options.chrome && doc.endsWith("html")) {
// Load with a chrome:// URL so this loads as a chrome document in the
// parent process.
url = `${CURRENT_DIR}${doc}${urlSuffix}`;
} else if (doc.endsWith("html") && !gIsIframe) {
url = `${CURRENT_CONTENT_DIR}${doc}${urlSuffix}`;
} else {
url = snippetToURL(doc, options);
}
registerCleanupFunction(() => {
// XXX Bug 1906779: This will run once for each call to addAccessibleTask,
// but only after the entire test file has completed. This doesn't make
// sense and almost certainly wasn't the intent.
for (let observer of Services.obs.enumerateObservers(
"accessible-event"
)) {
Services.obs.removeObserver(observer, "accessible-event");
}
});
let onContentDocLoad;
if (!options.chrome) {
onContentDocLoad = waitForEvent(
EVENT_DOCUMENT_LOAD_COMPLETE,
DEFAULT_CONTENT_DOC_BODY_ID
);
}
let onIframeDocLoad;
if (options.remoteIframe && !options.skipFissionDocLoad) {
onIframeDocLoad = waitForEvent(
EVENT_DOCUMENT_LOAD_COMPLETE,
DEFAULT_IFRAME_DOC_BODY_ID
);
}
await BrowserTestUtils.withNewTab(
{
gBrowser,
// For chrome, we need a non-remote browser.
opening: !options.chrome
? url
: () => {
// Passing forceNotRemote: true still sets maychangeremoteness,
// which will cause data: URIs to load remotely. There's no way to
// avoid this with gBrowser or BrowserTestUtils. Therefore, we
// load a blank document initially and replace it below.
gBrowser.selectedTab = BrowserTestUtils.addTab(
gBrowser,
"about:blank",
{
allowInheritPrincipal: true,
forceNotRemote: true,
}
);
},
},
async function (browser) {
registerCleanupFunction(() => {
if (browser) {
let tab = gBrowser.getTabForBrowser(browser);
if (tab && !tab.closing && tab.linkedBrowser) {
gBrowser.removeTab(tab);
}
}
});
if (options.chrome) {
await SpecialPowers.pushPrefEnv({
set: [["security.allow_unsafe_parent_loads", true]],
});
// Ensure this never becomes a remote browser.
browser.removeAttribute("maychangeremoteness");
// Now we can load our page without it becoming remote.
browser.setAttribute("src", url);
}
await SimpleTest.promiseFocus(browser);
if (options.chrome) {
ok(!browser.isRemoteBrowser, "Not remote browser");
} else if (Services.appinfo.browserTabsRemoteAutostart) {
ok(browser.isRemoteBrowser, "Actually remote browser");
}
let docAccessible;
if (options.chrome) {
// Chrome documents don't fire DOCUMENT_LOAD_COMPLETE. Instead, wait
// until we can get the DocAccessible and it doesn't have the busy
// state.
await BrowserTestUtils.waitForCondition(() => {
docAccessible = getAccessible(browser.contentWindow.document);
if (!docAccessible) {
return false;
}
const state = {};
docAccessible.getState(state, {});
return !(state.value & STATE_BUSY);
});
} else {
({ accessible: docAccessible } = await onContentDocLoad);
}
let iframeDocAccessible;
if (gIsIframe) {
if (!options.skipFissionDocLoad) {
await comparePIDs(browser, options.remoteIframe);
iframeDocAccessible = onIframeDocLoad
? (await onIframeDocLoad).accessible
: findAccessibleChildByID(docAccessible, DEFAULT_IFRAME_ID)
.firstChild;
}
}
if (options.contentSetup) {
info("Executing contentSetup");
const ready = waitForEvent(EVENT_REORDER, currentContentDoc());
await invokeContentTask(browser, [], options.contentSetup);
// snippetToURL set aria-hidden on the body. We now Remove aria-hidden
// and wait for a reorder on the body. This guarantees that any
// changes made by contentSetup are included when the body's content
// is initially added to the accessibility tree and that the
// accessibility tree is up to date.
await invokeContentTask(browser, [], () => {
content.document.body.removeAttribute("aria-hidden");
});
await ready;
info("contentSetup done");
}
await loadContentScripts(browser, {
script: "Common.sys.mjs",
symbol: "CommonUtils",
});
await task(
browser,
iframeDocAccessible || docAccessible,
iframeDocAccessible && docAccessible
);
}
);
if (gPythonSocket) {
// Remove any globals set by Python code run in this test. We do this here
// rather than using registerCleanupFunction because
// registerCleanupFunction runs after all tests in the file, whereas we
// need this to run after each task.
await runPython(`__reset__`);
}
};
// Propagate the name of the task function to our wrapper function so it shows
// up in test run output. For example:
// 0:39.16 INFO Entering test bound testProtected
// Even if the name is empty, we still propagate it here to override the
// implicit "wrapped" name derived from the assignment at the top of this
// function.
// The "name" property of functions is not writable, but we can override that
// using Object.defineProperty.
Object.defineProperty(wrapped, "name", { value: task.name });
return wrapped;
}
/**
* A wrapper around browser test add_task that triggers an accessible test task
* as a new browser test task with given document, data URL or markup snippet.
* @param {String} doc
* URL (relative to current directory) or data URL or markup snippet
* that is used to test content with
* @param {Function|AsyncFunction} task
* a generator or a function with tests to run
* @param {null|Object} options
* Options for running accessibility test tasks:
* - {Boolean} topLevel
* Flag to run the test with content in the top level content process.
* Default is true.
* - {Boolean} chrome
* Flag to run the test with content as a chrome document in the
* parent process. Default is false. Although url can be a markup
* snippet, a snippet cannot be used for XUL content. To load XUL,
* specify a relative URL to a XUL document. In that case, toplevel
* should usually be set to false, since XUL documents don't work in
* content processes.
* - {Boolean} iframe
* Flag to run the test with content wrapped in an iframe. Default is
* false.
* - {Boolean} remoteIframe
* Flag to run the test with content wrapped in a remote iframe.
* Default is false.
* - {Object} iframeAttrs
* A map of attribute/value pairs to be applied to IFRAME element.
* - {Boolean} skipFissionDocLoad
* If true, the test will not wait for iframe document document
* loaded event (useful for when IFRAME is initially hidden).
* - {Object} contentDocBodyAttrs
* a set of attributes to be applied to a top level content document
* body
* - {Object} iframeDocBodyAttrs
* a set of attributes to be applied to a iframe content document body
* - {String} urlSuffix
* String to append to the document URL. For example, this could be
* "#test" to scroll to the "test" id in the document.
* - {CacheDomain} cacheDomains
* The set of cache domains that should be present at the start of the
* test. If not set, all cache domains will be present.
* - {Function|AsyncFunction} contentSetup
* An optional task to run to set up the content document before the
* test starts. If this test is to be run as a chrome document in the
* parent process (chrome: true), This should be used instead of an
* inline <script> element in the test snippet, since inline script is
* not allowed in such documents. This task is ultimately executed
* using SpecialPowers.spawn. Any updates to the content within the
* body will be included when the content is initially added to the
* accessibility tree. The accessibility tree is guaranteed to be up
* to date when the test starts. This will not work correctly for
* changes to the html or body elements themselves. Note that you will
* need to define this exactly as follows:
* contentSetup: async function contentSetup() { ... }
* async contentSetup() will fail when the task is serialized.
* contentSetup: async function() will be changed to
* async contentSetup() by the linter and likewise fail.
*/
function addAccessibleTask(doc, task, options = {}) {
const {
topLevel = true,
chrome = false,
iframe = false,
remoteIframe = false,
} = options;
if (topLevel) {
add_task(
accessibleTask(doc, task, {
...options,
chrome: false,
iframe: false,
remoteIframe: false,
})
);
}
if (chrome) {
add_task(
accessibleTask(doc, task, {
...options,
topLevel: false,
iframe: false,
remoteIframe: false,
})
);
}
if (iframe) {
add_task(
accessibleTask(doc, task, {
...options,
topLevel: false,
chrome: false,
remoteIframe: false,
})
);
}
if (gFissionBrowser && remoteIframe) {
add_task(
accessibleTask(doc, task, {
...options,
topLevel: false,
chrome: false,
iframe: false,
})
);
}
}
/**
* Check if an accessible object has a defunct test.
* @param {nsIAccessible} accessible object to test defunct state for
* @return {Boolean} flag indicating defunct state
*/
function isDefunct(accessible) {
let defunct = false;
try {
let extState = {};
accessible.getState({}, extState);
defunct = extState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT;
} catch (x) {
defunct = true;
} finally {
if (defunct) {
Logger.log(`Defunct accessible: ${prettyName(accessible)}`);
}
}
return defunct;
}
/**
* Get the DOM tag name for a given accessible.
* @param {nsIAccessible} accessible accessible
* @return {String?} tag name of associated DOM node, or null.
*/
function getAccessibleTagName(acc) {
try {
return acc.attributes.getStringProperty("tag");
} catch (e) {
return null;
}
}
/**
* Traverses the accessible tree starting from a given accessible as a root and
* looks for an accessible that matches based on its DOMNode id.
* @param {nsIAccessible} accessible root accessible
* @param {String} id id to look up accessible for
* @param {Array?} interfaces the interface or an array interfaces
* to query it/them from obtained accessible
* @return {nsIAccessible?} found accessible if any
*/
function findAccessibleChildByID(accessible, id, interfaces) {
if (getAccessibleDOMNodeID(accessible) === id) {
return queryInterfaces(accessible, interfaces);
}
for (let i = 0; i < accessible.children.length; ++i) {
let found = findAccessibleChildByID(accessible.getChildAt(i), id);
if (found) {
return queryInterfaces(found, interfaces);
}
}
return null;
}
function queryInterfaces(accessible, interfaces) {
if (!interfaces) {
return accessible;
}
for (let iface of interfaces.filter(i => !(accessible instanceof i))) {
try {
accessible.QueryInterface(iface);
} catch (e) {
ok(false, "Can't query " + iface);
}
}
return accessible;
}
function arrayFromChildren(accessible) {
return Array.from({ length: accessible.childCount }, (c, i) =>
accessible.getChildAt(i)
);
}
/**
* Force garbage collection.
*/
function forceGC() {
SpecialPowers.gc();
SpecialPowers.forceShrinkingGC();
SpecialPowers.forceCC();
SpecialPowers.gc();
SpecialPowers.forceShrinkingGC();
SpecialPowers.forceCC();
}
/*
* This function spawns a content task and awaits expected mutation events from
* various content changes. It's good at catching events we did *not* expect. We
* do this advancing the layout refresh to flush the relocations/insertions
* queue.
*/
async function contentSpawnMutation(browser, waitFor, func, args = []) {
let onReorders = waitForEvents({ expected: waitFor.expected || [] });
let unexpectedListener = new UnexpectedEvents(waitFor.unexpected || []);
function tick() {
// 100ms is an arbitrary positive number to advance the clock.
// We don't need to advance the clock for a11y mutations, but other
// tick listeners may depend on an advancing clock with each refresh.
content.windowUtils.advanceTimeAndRefresh(100);
}
// This stops the refreh driver from doing its regular ticks, and leaves
// us in control.
await invokeContentTask(browser, [], tick);
// Perform the tree mutation.
await invokeContentTask(browser, args, func);
// Do one tick to flush our queue (insertions, relocations, etc.)
await invokeContentTask(browser, [], tick);
let events = await onReorders;
unexpectedListener.stop();
// Go back to normal refresh driver ticks.
await invokeContentTask(browser, [], function () {
content.windowUtils.restoreNormalRefresh();
});
return events;
}
async function waitForImageMap(browser, accDoc, id = "imgmap") {
let acc = findAccessibleChildByID(accDoc, id);
if (!acc) {
const onShow = waitForEvent(EVENT_SHOW, id);
acc = (await onShow).accessible;
}
if (acc.firstChild) {
return;
}
const onReorder = waitForEvent(EVENT_REORDER, id);
// Wave over image map
await invokeContentTask(browser, [id], contentId => {
const { ContentTaskUtils } = ChromeUtils.importESModule(
"resource://testing-common/ContentTaskUtils.sys.mjs"
);
const EventUtils = ContentTaskUtils.getEventUtils(content);
EventUtils.synthesizeMouse(
content.document.getElementById(contentId),
10,
10,
{ type: "mousemove" },
content
);
});
await onReorder;
}
async function getContentBoundsForDOMElm(browser, id) {
return invokeContentTask(browser, [id], contentId => {
const { Layout: LayoutUtils } = ChromeUtils.importESModule(
"chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs"
);
return LayoutUtils.getBoundsForDOMElm(contentId, content.document);
});
}
const CACHE_WAIT_TIMEOUT_MS = 5000;
/**
* Wait for a predicate to be true after cache ticks.
* This function takes two callbacks, the condition is evaluated
* by calling the first callback with the arguments returned by the second.
* This allows us to asynchronously return the arguments as a result if the condition
* of the first callback is met, or if it times out. The returned arguments can then
* be used to record a pass or fail in the test.
*/
function untilCacheCondition(conditionFunc, argsFunc) {
return new Promise(resolve => {
let args = argsFunc();
if (conditionFunc(...args)) {
resolve(args);
return;
}
let cacheObserver = {
observe() {
args = argsFunc();
if (conditionFunc(...args)) {
clearTimeout(this.timer);
Services.obs.removeObserver(this, "accessible-cache");
resolve(args);
}
},
timeout() {
ok(false, "Timeout while waiting for cache update");
Services.obs.removeObserver(this, "accessible-cache");
args = argsFunc();
resolve(args);
},
};
cacheObserver.timer = setTimeout(
cacheObserver.timeout.bind(cacheObserver),
CACHE_WAIT_TIMEOUT_MS
);
Services.obs.addObserver(cacheObserver, "accessible-cache");
});
}
function untilCacheOk(conditionFunc, message) {
return untilCacheCondition(
(v, _unusedMessage) => v,
() => [conditionFunc(), message]
).then(([v, msg]) => ok(v, msg));
}
function untilCacheIs(retrievalFunc, expected, message) {
return untilCacheCondition(
(a, b, _unusedMessage) => Object.is(a, b),
() => [retrievalFunc(), expected, message]
).then(([got, exp, msg]) => is(got, exp, msg));
}
async function waitForContentPaint(browser) {
await SpecialPowers.spawn(browser, [], () => {
return new Promise(function (r) {
content.requestAnimationFrame(() => content.setTimeout(r));
});
});
}
// Returns true if both number arrays match within `FUZZ`.
function areBoundsFuzzyEqual(actual, expected) {
const FUZZ = 1;
return actual
.map((val, i) => Math.abs(val - expected[i]) <= FUZZ)
.reduce((a, b) => a && b, true);
}
function assertBoundsFuzzyEqual(actual, expected) {
ok(
areBoundsFuzzyEqual(actual, expected),
`${actual} fuzzily matches expected ${expected}`
);
}
async function testBoundsWithContent(iframeDocAcc, id, browser) {
// Retrieve layout bounds from content
let expectedBounds = await invokeContentTask(browser, [id], _id => {
const { Layout: LayoutUtils } = ChromeUtils.importESModule(
"chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs"
);
return LayoutUtils.getBoundsForDOMElm(_id, content.document);
});
function isWithinExpected(bounds) {
return areBoundsFuzzyEqual(bounds, expectedBounds);
}
const acc = findAccessibleChildByID(iframeDocAcc, id);
let [accBounds] = await untilCacheCondition(isWithinExpected, () => [
getBounds(acc),
]);
assertBoundsFuzzyEqual(accBounds, expectedBounds);
return accBounds;
}
let gPythonSocket = null;
/**
* Run some Python code. This is useful for testing OS APIs.
* This function returns a Promise which is resolved or rejected when the Python
* code completes. The Python code can return a result with the return
* statement, as long as the result can be serialized to JSON. For convenience,
* if the code is a single line which does not begin with return, it will be
* treated as an expression and its result will be returned. The JS Promise will
* 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) {
// Keep the socket open across calls to avoid repeated setup overhead.
gPythonSocket = new WebSocket(
"ws://mochi.test:8888/browser/accessible/tests/browser/python_runner"
);
if (gPythonSocket.readyState != WebSocket.OPEN) {
gPythonSocket.onopen = () => {
gPythonSocket.send(code);
gPythonSocket.onopen = null;
};
}
}
return new Promise((resolve, reject) => {
gPythonSocket.onmessage = evt => {
const message = JSON.parse(evt.data);
if (message[0] == "return") {
gPythonSocket.onmessage = null;
resolve(message[1]);
} else if (message[0] == "exception") {
gPythonSocket.onmessage = null;
reject(new Error(message[1]));
} else if (message[0] == "info") {
info(message[1]);
}
};
// If gPythonSocket isn't open yet, we'll send the message when .onopen is
// called. If it's open, we can send it immediately.
if (gPythonSocket.readyState == WebSocket.OPEN) {
gPythonSocket.send(code);
}
});
}