/* 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", `