/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* global waitUntilState, gBrowser */
/* exported addTestTab, checkTreeState, checkSidebarState, checkAuditState, selectRow,
toggleRow, toggleMenuItem, addA11yPanelTestsTask, navigate,
openSimulationMenu, toggleSimulationOption, TREE_FILTERS_MENU_ID,
PREFS_MENU_ID */
"use strict";
// Import framework's shared head.
Services.scriptloader.loadSubScript(
"chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
this
);
// Import inspector's shared head.
Services.scriptloader.loadSubScript(
"chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
this
);
const {
ORDERED_PROPS,
PREF_KEYS,
} = require(
"resource://devtools/client/accessibility/constants.js");
// Enable the Accessibility panel
Services.prefs.setBoolPref(
"devtools.accessibility.enabled",
true);
const SIMULATION_MENU_BUTTON_ID =
"#simulation-menu-button";
const TREE_FILTERS_MENU_ID =
"accessibility-tree-filters-menu";
const PREFS_MENU_ID =
"accessibility-tree-filters-prefs-menu";
const MENU_INDEXES = {
[TREE_FILTERS_MENU_ID]: 0,
[PREFS_MENU_ID]: 1,
};
/**
* Wait for accessibility service to shut down. We consider it shut down when
* an "a11y-init-or-shutdown" event is received with a value of "0".
*/
function waitForAccessibilityShutdown() {
return new Promise(resolve => {
if (!Services.appinfo.accessibilityEnabled) {
resolve();
return;
}
const observe = (subject, topic, data) => {
if (data ===
"0") {
Services.obs.removeObserver(observe,
"a11y-init-or-shutdown");
// Sanity check
ok(
!Services.appinfo.accessibilityEnabled,
"Accessibility disabled in this process"
);
resolve();
}
};
// This event is coming from Gecko accessibility module when the
// accessibility service is shutdown or initialzied. We attempt to shutdown
// accessibility service naturally if there are no more XPCOM references to
// a11y related objects (after GC/CC).
Services.obs.addObserver(observe,
"a11y-init-or-shutdown");
// Force garbage collection.
SpecialPowers.gc();
SpecialPowers.forceShrinkingGC();
SpecialPowers.forceCC();
});
}
/**
* Ensure that accessibility is completely shutdown.
*/
async
function shutdownAccessibility(browser) {
await waitForAccessibilityShutdown();
await SpecialPowers.spawn(browser, [], waitForAccessibilityShutdown);
}
registerCleanupFunction(async () => {
info(
"Cleaning up...");
Services.prefs.clearUserPref(
"devtools.accessibility.enabled");
});
const EXPANDABLE_PROPS = [
"actions",
"states",
"attributes"];
/**
* Add a new test tab in the browser and load the given url.
* @param {String} url
* The url to be loaded in the new tab
* @return a promise that resolves to the tab object when
* the url is loaded
*/
async
function addTestTab(url) {
info(
"Adding a new test tab with URL: '" + url +
"'");
const tab = await addTab(url);
const panel = await initAccessibilityPanel(tab);
const win = panel.panelWin;
const doc = win.document;
const store = win.view.store;
win.focus();
await waitUntilState(
store,
state =>
state.accessibles.size === 1 &&
state.details.accessible &&
state.details.accessible.role ===
"document"
);
return {
tab,
browser: tab.linkedBrowser,
panel,
win,
toolbox: panel._toolbox,
doc,
store,
};
}
/**
* Open the Accessibility panel for the given tab.
*
* @param {Element} tab
* Optional tab element for which you want open the Accessibility panel.
* The default tab is taken from the global variable |tab|.
* @return a promise that is resolved once the panel is open.
*/
async
function initAccessibilityPanel(tab = gBrowser.selectedTab) {
const toolbox = await gDevTools.showToolboxForTab(tab, {
toolId:
"accessibility",
});
return toolbox.getCurrentPanel();
}
/**
* Compare text within the list of potential badges rendered for accessibility
* tree row when its accessible object has accessibility failures.
* @param {DOMNode} badges
* Container element that contains badge elements.
* @param {Array|null} expected
* List of expected badge labels for failing accessibility checks.
*/
function compareBadges(badges, expected = []) {
const badgeEls = badges ? [...badges.querySelectorAll(
".badge")] : [];
return (
badgeEls.length === expected.length &&
badgeEls.every((badge, i) => badge.textContent === expected[i])
);
}
/**
* Find an ancestor that is scrolled for a given DOMNode.
*
* @param {DOMNode} node
* DOMNode that to find an ancestor for that is scrolled.
*/
function closestScrolledParent(node) {
if (node ==
null) {
return null;
}
if (node.scrollHeight > node.clientHeight) {
return node;
}
return closestScrolledParent(node.parentNode);
}
/**
* Check if a given element is visible to the user and is not scrolled off
* because of the overflow.
*
* @param {Element} element
* Element to be checked whether it is visible and is not scrolled off.
*
* @returns {Boolean}
* True if the element is visible.
*/
function isVisible(element) {
const { top, bottom } = element.getBoundingClientRect();
const scrolledParent = closestScrolledParent(element.parentNode);
const scrolledParentRect = scrolledParent
? scrolledParent.getBoundingClientRect()
:
null;
return (
!scrolledParent ||
(top >= scrolledParentRect.top && bottom <= scrolledParentRect.bottom)
);
}
/**
* Check selected styling and visibility for a given row in the accessibility
* tree.
* @param {DOMNode} row
* DOMNode for a given accessibility row.
* @param {Boolean} expected
* Expected selected state.
*
* @returns {Boolean}
* True if visibility and styling matches expected selected state.
*/
function checkSelected(row, expected) {
if (!expected) {
return true;
}
if (row.classList.contains(
"selected") !== expected) {
return false;
}
return isVisible(row);
}
/**
* Check level for a given row in the accessibility tree.
* @param {DOMNode} row
* DOMNode for a given accessibility row.
* @param {Boolean} expected
* Expected row level (aria-level).
*
* @returns {Boolean}
* True if the aria-level for the row is as expected.
*/
function checkLevel(row, expected) {
if (!expected) {
return true;
}
return parseInt(row.getAttribute(
"aria-level"), 10) === expected;
}
/**
* Check the state of the accessibility tree.
* @param {document} doc panel documnent.
* @param {Array} expected an array that represents an expected row list.
*/
async
function checkTreeState(doc, expected) {
info(
"Checking tree state.");
const hasExpectedStructure = await BrowserTestUtils.waitForCondition(() => {
const rows = [...doc.querySelectorAll(
".treeRow")];
if (rows.length !== expected.length) {
return false;
}
return rows.every((row, i) => {
const { role, name, badges, selected, level } = expected[i];
return (
row.querySelector(
".treeLabelCell").textContent === role &&
row.querySelector(
".treeValueCell").textContent === name &&
compareBadges(row.querySelector(
".badges"), badges) &&
checkSelected(row, selected) &&
checkLevel(row, level)
);
});
},
"Wait for the right tree update.");
ok(hasExpectedStructure,
"Tree structure is correct.");
}
/**
* Check if relations object matches what is expected. Note: targets are matched by their
* name and role.
* @param {Object} relations Relations to test.
* @param {Object} expected Expected relations.
* @return {Boolean} True if relation types and their targers match what is
* expected.
*/
function relationsMatch(relations, expected) {
for (
const relationType in expected) {
let expTargets = expected[relationType];
expTargets = Array.isArray(expTargets) ? expTargets : [expTargets];
let targets = relations ? relations[relationType] : [];
targets = Array.isArray(targets) ? targets : [targets];
for (
const index in expTargets) {
if (!targets[index]) {
return false;
}
if (
expTargets[index].name !== targets[index].name ||
expTargets[index].role !== targets[index].role
) {
return false;
}
}
}
return true;
}
/**
* When comparing numerical values (for example contrast), we only care about the 2
* decimal points.
* @param {String} _
* Key of the property that is parsed.
* @param {Any} value
* Value of the property that is parsed.
* @return {Any}
* Newly formatted value in case of the numeric value.
*/
function parseNumReplacer(_, value) {
if (
typeof value ===
"number") {
return value.toFixed(2);
}
return value;
}
/**
* Check the state of the accessibility sidebar audit(checks).
* @param {Object} store React store for the panel (includes store for
* the audit).
* @param {Object} expectedState Expected state of the sidebar audit(checks).
*/
async
function checkAuditState(store, expectedState) {
info(
"Checking audit state.");
await waitUntilState(store, ({ details }) => {
const { audit } = details;
for (
const key in expectedState) {
const expected = expectedState[key];
if (expected &&
typeof expected ===
"object") {
if (
JSON.stringify(audit[key], parseNumReplacer) !==
JSON.stringify(expected, parseNumReplacer)
) {
return false;
}
}
else if (audit && audit[key] !== expected) {
return false;
}
}
ok(
true,
"Audit state is correct.");
return true;
});
}
/**
* Check the state of the accessibility sidebar.
* @param {Object} store React store for the panel (includes store for
* the sidebar).
* @param {Object} expectedState Expected state of the sidebar.
*/
async
function checkSidebarState(store, expectedState) {
info(
"Checking sidebar state.");
await waitUntilState(store, ({ details }) => {
for (
const key of ORDERED_PROPS) {
const expected = expectedState[key];
if (expected === undefined) {
continue;
}
if (key ===
"relations") {
if (!relationsMatch(details.relations, expected)) {
return false;
}
}
else if (EXPANDABLE_PROPS.includes(key)) {
if (
JSON.stringify(details.accessible[key]) !== JSON.stringify(expected)
) {
return false;
}
}
else if (details.accessible && details.accessible[key] !== expected) {
return false;
}
}
ok(
true,
"Sidebar state is correct.");
return true;
});
}
/**
* Check the state of the accessibility related prefs.
* @param {Document} doc
* accessibility inspector panel document.
* @param {Object} toolbarPrefValues
* Expected state of the panel prefs as well as the redux state that
* keeps track of it. Includes:
* - SCROLL_INTO_VIEW (devtools.accessibility.scroll-into-view)
* @param {Object} store
* React store for the panel (includes store for the sidebar).
*/
async
function checkToolbarPrefsState(doc, toolbarPrefValues, store) {
info(
"Checking toolbar prefs state.");
const [hasExpectedStructure] = await Promise.all([
// Check that appropriate preferences are set as expected.
BrowserTestUtils.waitForCondition(() => {
return Object.keys(toolbarPrefValues).every(
name =>
Services.prefs.getBoolPref(PREF_KEYS[name],
false) ===
toolbarPrefValues[name]
);
},
"Wait for the right prefs state."),
// Check that ui state is set as expected.
waitUntilState(store, ({ ui }) => {
for (
const name in toolbarPrefValues) {
if (ui[name] !== toolbarPrefValues[name]) {
return false;
}
}
ok(
true,
"UI pref state is correct.");
return true;
}),
]);
ok(hasExpectedStructure,
"Prefs state is correct.");
}
/**
* Check the state of the accessibility checks toolbar.
* @param {Object} store
* React store for the panel (includes store for the sidebar).
* @param {Object} activeToolbarFilters
* Expected active state of the filters in the toolbar.
*/
async
function checkToolbarState(doc, activeToolbarFilters) {
info(
"Checking toolbar state.");
const hasExpectedStructure = await BrowserTestUtils.waitForCondition(
() =>
[
...doc.querySelectorAll(
"#accessibility-tree-filters-menu .command"),
].every(
(filter, i) =>
(activeToolbarFilters[i] ?
"true" :
null) ===
filter.getAttribute(
"aria-checked")
),
"Wait for the right toolbar state."
);
ok(hasExpectedStructure,
"Toolbar state is correct.");
}
/**
* Check the state of the simulation button and menu components.
* @param {Object} doc Panel document.
* @param {Object} expected Expected states of the simulation components:
* menuVisible, buttonActive, checkedOptionIndices (Optional)
*/
async
function checkSimulationState(doc, expected) {
const { buttonActive, checkedOptionIndices } = expected;
const simulationMenuOptions = doc
.querySelector(SIMULATION_MENU_BUTTON_ID +
"-menu")
.querySelectorAll(
".menuitem");
// Check simulation menu button state
is(
doc.querySelector(SIMULATION_MENU_BUTTON_ID).className,
`devtools-button toolbar-menu-button simulation${
buttonActive ?
" active" :
""
}`,
`Simulation menu button contains ${buttonActive ?
"active" :
"base"}
class.`
);
// Check simulation menu options states, if specified
if (checkedOptionIndices) {
simulationMenuOptions.forEach((menuListItem, index) => {
const isChecked = checkedOptionIndices.includes(index);
const button = menuListItem.firstChild;
is(
button.getAttribute(
"aria-checked"),
isChecked ?
"true" :
null,
`Simulation option ${index} is ${isChecked ?
"" :
"not "}selected.`
);
});
}
}
/**
* Focus accessibility properties tree in the a11y inspector sidebar. If focused for the
* first time, the tree will select first rendered node as defult selection for keyboard
* purposes.
*
* @param {Document} doc accessibility inspector panel document.
*/
async
function focusAccessibleProperties(doc) {
const tree = doc.querySelector(
".tree");
if (doc.activeElement !== tree) {
tree.focus();
await BrowserTestUtils.waitForCondition(
() => tree.querySelector(
".node.focused"),
"Tree selected."
);
}
}
/**
* Select accessibility property in the sidebar.
* @param {Document} doc accessibility inspector panel document.
* @param {String} id id of the property to be selected.
* @return {DOMNode} Node that corresponds to the selected accessibility property.
*/
async
function selectProperty(doc, id) {
const win = doc.defaultView;
let selected =
false;
let node;
await focusAccessibleProperties(doc);
await BrowserTestUtils.waitForCondition(() => {
node = doc.getElementById(`${id}`);
if (node) {
if (selected) {
return node.firstChild.classList.contains(
"focused");
}
AccessibilityUtils.setEnv({
// Keyboard navigation is handled on the container level using arrow
// keys.
nonNegativeTabIndexRule:
false,
});
EventUtils.sendMouseEvent({ type:
"click" }, node, win);
AccessibilityUtils.resetEnv();
selected =
true;
}
else {
const tree = doc.querySelector(
".tree");
tree.scrollTop = parseFloat(win.getComputedStyle(tree).height);
}
return false;
});
return node;
}
/**
* Select tree row.
* @param {document} doc panel documnent.
* @param {Number} rowNumber number of the row/tree node to be selected.
*/
function selectRow(doc, rowNumber) {
info(`Selecting row ${rowNumber}.`);
AccessibilityUtils.setEnv({
// Keyboard navigation is handled on the container level using arrow keys.
nonNegativeTabIndexRule:
false,
});
EventUtils.sendMouseEvent(
{ type:
"click" },
doc.querySelectorAll(
".treeRow")[rowNumber],
doc.defaultView
);
AccessibilityUtils.resetEnv();
}
/**
* Toggle an expandable tree row.
* @param {document} doc panel documnent.
* @param {Number} rowNumber number of the row/tree node to be toggled.
*/
async
function toggleRow(doc, rowNumber) {
const win = doc.defaultView;
const row = doc.querySelectorAll(
".treeRow")[rowNumber];
const twisty = row.querySelector(
".theme-twisty");
const expected = !twisty.classList.contains(
"open");
info(`${expected ?
"Expanding" :
"Collapsing"} row ${rowNumber}.`);
AccessibilityUtils.setEnv({
// We intentionally remove the twisty from the accessibility tree in the
// TreeView component and handle keyboard navigation using the arrow keys.
mustHaveAccessibleRule:
false,
});
EventUtils.sendMouseEvent({ type:
"click" }, twisty, win);
AccessibilityUtils.resetEnv();
await BrowserTestUtils.waitForCondition(
() =>
!twisty.classList.contains(
"devtools-throbber") &&
expected === twisty.classList.contains(
"open"),
"Twisty updated."
);
}
/**
* Toggle a specific menu item based on its index in the menu.
* @param {document} toolboxDoc
* toolbox document.
* @param {document} doc
* panel document.
* @param {String} menuId
* The id of the menu (menuId passed to the MenuButton component)
* @param {Number} menuItemIndex
* index of the menu item to be toggled.
*/
async
function toggleMenuItem(doc, toolboxDoc, menuId, menuItemIndex) {
const toolboxWin = toolboxDoc.defaultView;
const panelWin = doc.defaultView;
const menuButton = doc.querySelectorAll(
".toolbar-menu-button")[
MENU_INDEXES[menuId]
];
ok(menuButton,
"Expected menu button");
const menuEl = toolboxDoc.getElementById(menuId);
const menuItem = menuEl.querySelectorAll(
".command")[menuItemIndex];
ok(menuItem,
"Expected menu item");
const expected =
menuItem.getAttribute(
"aria-checked") ===
"true" ?
null :
"true";
// Make the menu visible first.
const onPopupShown =
new Promise(r =>
toolboxDoc.addEventListener(
"popupshown", r, { once:
true })
);
EventUtils.synthesizeMouseAtCenter(menuButton, {}, panelWin);
await onPopupShown;
const boundingRect = menuItem.getBoundingClientRect();
ok(
boundingRect.width > 0 && boundingRect.height > 0,
"Menu item is visible."
);
EventUtils.synthesizeMouseAtCenter(menuItem, {}, toolboxWin);
await BrowserTestUtils.waitForCondition(
() => expected === menuItem.getAttribute(
"aria-checked"),
"Menu item updated."
);
}
async
function openSimulationMenu(doc) {
doc.querySelector(SIMULATION_MENU_BUTTON_ID).click();
await BrowserTestUtils.waitForCondition(() =>
doc
.querySelector(SIMULATION_MENU_BUTTON_ID +
"-menu")
.classList.contains(
"tooltip-visible")
);
}
async
function toggleSimulationOption(doc, optionIndex) {
const simulationMenu = doc.querySelector(SIMULATION_MENU_BUTTON_ID +
"-menu");
simulationMenu.querySelectorAll(
".menuitem")[optionIndex].firstChild.click();
await BrowserTestUtils.waitForCondition(
() => !simulationMenu.classList.contains(
"tooltip-visible")
);
}
async
function findAccessibleFor(
{
toolbox: { target },
panel: {
accessibilityProxy: {
accessibilityFront: { accessibleWalkerFront },
},
},
},
selector
) {
const domWalker = (await target.getFront(
"inspector")).walker;
const node = await domWalker.querySelector(domWalker.rootNode, selector);
return accessibleWalkerFront.getAccessibleFor(node);
}
async
function selectAccessibleForNode(env, selector) {
const { panel, win } = env;
const front = await findAccessibleFor(env, selector);
const { EVENTS } = win;
const onSelected = win.once(EVENTS.NEW_ACCESSIBLE_FRONT_SELECTED);
panel.selectAccessible(front);
await onSelected;
}
/**
* Iterate over setups/tests structure and test the state of the
* accessibility panel.
* @param {JSON} tests
* test data that has the format of:
* {
* desc {String} description for better logging
* setup {Function} An optional setup that needs to be
* performed before the state of the
* tree and the sidebar can be checked
* expected {JSON} An expected states for parts of
* accessibility panel:
* - tree: state of the accessibility tree widget
* - sidebar: state of the accessibility panel sidebar
* - audit: state of the audit redux state of the
* panel
* - toolbarPrefValues: state of the accessibility panel
* toolbar prefs and corresponding user
* preferences.
* - activeToolbarFilters: state of the accessibility panel
* toolbar filters.
* }
* @param {Object} env
* contains all relevant environment objects (same structure as the
* return value of 'addTestTab' funciton)
*/
async
function runA11yPanelTests(tests, env) {
for (
const { desc, setup, expected } of tests) {
info(desc);
if (setup) {
await setup(env);
}
const {
tree,
sidebar,
audit,
toolbarPrefValues,
activeToolbarFilters,
simulation,
} = expected;
if (tree) {
await checkTreeState(env.doc, tree);
}
if (sidebar) {
await checkSidebarState(env.store, sidebar);
}
if (activeToolbarFilters) {
await checkToolbarState(env.doc, activeToolbarFilters);
}
if (toolbarPrefValues) {
await checkToolbarPrefsState(env.doc, toolbarPrefValues, env.store);
}
if (
typeof audit !==
"undefined") {
await checkAuditState(env.store, audit);
}
if (simulation) {
await checkSimulationState(env.doc, simulation);
}
}
}
/**
* Build a valid URL from an HTML snippet.
* @param {String} uri HTML snippet
* @param {Object} options options for the test
* @return {String} built URL
*/
function buildURL(uri, options = {}) {
if (options.remoteIframe) {
const srcURL =
new URL(`http:
//example.net/document-builder.sjs`);
srcURL.searchParams.append(
"html",
`<html>
<head>
<meta charset=
"utf-8"/>
<title>Accessibility Panel Test (OOP)</title>
</head>
<body>${uri}</body>
</html>`
);
uri = `<iframe title=
"Accessibility Panel Test (OOP)" src=
"${srcURL.href}"/>`;
}
return `data:text/html;charset=UTF-8,${encodeURIComponent(uri)}`;
}
/**
* Add a test task based on the test structure and a test URL.
* @param {JSON} tests test data that has the format of:
* {
* desc {String} description for better logging
* setup {Function} An optional setup that needs to be
* performed before the state of the
* tree and the sidebar can be checked
* expected {JSON} An expected states for the tree and
* the sidebar
* }
* @param {String} uri test URL
* @param {String} msg a message that is printed for the test
* @param {Object} options options for the test
*/
function addA11yPanelTestsTask(tests, uri, msg, options) {
addA11YPanelTask(msg, uri, env => runA11yPanelTests(tests, env), options);
}
/**
* Borrowed from framework's shared head. Close toolbox, completely disable
* accessibility and remove the tab.
* @param {Tab}
* tab The tab to close.
* @return {Promise}
* Resolves when the toolbox and tab have been destroyed and closed.
*/
async
function closeTabToolboxAccessibility(tab = gBrowser.selectedTab) {
if (gDevTools.hasToolboxForTab(tab)) {
await gDevTools.closeToolboxForTab(tab);
}
await shutdownAccessibility(gBrowser.getBrowserForTab(tab));
await removeTab(tab);
await
new Promise(resolve => setTimeout(resolve, 0));
}
/**
* A wrapper function around add_task that sets up the test environment, runs
* the test and then disables accessibility tools.
* @param {String} msg a message that is printed for the test
* @param {String} uri absolute test URL or HTML snippet
* @param {Function} task task function containing the tests.
* @param {Object} options options for the test
*/
function addA11YPanelTask(msg, uri, task, options = {}) {
add_task(async
function a11YPanelTask() {
info(msg);
const env = await addTestTab(
uri.startsWith(
"http") ? uri : buildURL(uri, options)
);
await task(env);
await closeTabToolboxAccessibility(env.tab);
});
}