diff options
Diffstat (limited to 'devtools/client/accessibility/test/browser/head.js')
-rw-r--r-- | devtools/client/accessibility/test/browser/head.js | 823 |
1 files changed, 823 insertions, 0 deletions
diff --git a/devtools/client/accessibility/test/browser/head.js b/devtools/client/accessibility/test/browser/head.js new file mode 100644 index 0000000000..1a94c723e0 --- /dev/null +++ b/devtools/client/accessibility/test/browser/head.js @@ -0,0 +1,823 @@ +/* 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; + + const enableButton = doc.getElementById("accessibility-enable-button"); + // If enable button is not found, asume the tool is already enabled. + if (enableButton) { + EventUtils.sendMouseEvent({ type: "click" }, enableButton, win); + } + + 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 test URL + * @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(buildURL(uri, options)); + await task(env); + await closeTabToolboxAccessibility(env.tab); + }); +} |