Source code
Revision control
Copy as Markdown
Other Tools
/* 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
/**
* Checks that a browser-chrome test only clicks inside popups that are open.
*
* Anything inside a popup that is not open is invisible to the accessibility
* APIs and not focusable, so clicking it does not do what the test intends.
* This needs no accessibility service, so unlike the accessibility checks it
* runs on every configuration, not only those that enable a11y_checks.
*/
"use strict";
this.ClickChecks = (function () {
let SimpleTest = null;
let handler = null;
let shouldHandleClicks = true;
// The popup the last mousedown happened in and the state it was in then, or
// null if that mousedown was not inside a popup.
let mouseDownState = null;
/**
* Find the closest popup ancestor of a node, crossing shadow boundaries.
*
* @param {Node} node
* @returns {?Element} the popup, or null if the node is not inside one.
*/
function popupAncestor(node) {
// A click can target something that is not an element, and sometimes not
// even a node: a document, a text node, or a window.
for (let current = node; current; current = current.getRootNode?.().host) {
const popup = current.closest?.("panel, menupopup");
if (popup) {
return popup;
}
}
return null;
}
/**
* @param {Element} popup
* @param {string} [state] the state to report, defaulting to the current one.
* @returns {string} eg. `panel#notification-popup (state="showing")`
*/
function describeElement(element) {
return `${element.localName}${element.id ? `#${element.id}` : ""}`;
}
function describePopup(popup, state = popup.state) {
return `${describeElement(popup)} (state="${state}")`;
}
/**
* Whether a node has no box, ie. it is not rendered. That means the popup was
* never opened at all, rather than clicked before it finished opening.
*
* Copied from AccessibilityUtils, which needs the same thing, rather than
* reaching into it: this check has to work when the accessibility checks are
* disabled.
*
* @param {Node} node
* @returns {boolean}
*/
function hasEmptyBounds(node) {
const bounds =
node.documentGlobal?.windowUtils?.getBoundsWithoutFlushing(node);
return !!bounds && (bounds.width == 0 || bounds.height == 0);
}
/**
* Report a click that happened inside a popup that was not open.
*
* @param {Node} node the click target.
* @param {?object} recorded the popup state recorded at mousedown.
*/
function assertClickedPopupWasOpen(node, recorded) {
const popup = popupAncestor(node);
if (!popup) {
return;
}
// Activating a menuitem hides the menu on mouseup, so a legitimately
// clicked popup is already hiding by the time the click is dispatched.
const state = recorded?.popup == popup ? recorded.state : popup.state;
if (state == "open") {
return;
}
const advice =
popup.localName == "menupopup"
? "activate it with BrowserTestUtils.activateMenuItem(item)"
: `wait for it with BrowserTestUtils.waitForPopupEvent(popup, "shown")`;
const message =
`Clicked ${describeElement(node)} inside ${describePopup(popup, state)}, ` +
`which was not open. Its contents are only interactive while it is ` +
`open; ${advice}.`;
if (hasEmptyBounds(node)) {
// The popup was never opened, which is the same mistake but too
SimpleTest.todo(false, message);
} else {
SimpleTest.ok(false, message);
}
}
return {
popupAncestor,
describePopup,
init(simpleTest) {
SimpleTest = simpleTest;
shouldHandleClicks = true;
mouseDownState = null;
// A top level xul window's DocShell doesn't have a chromeEventHandler
// attribute. In that case, the chrome event handler is just the global
// window object.
handler = window.docShell.chromeEventHandler ?? window.docShell.domWindow;
handler.addEventListener("mousedown", this, true, true);
handler.addEventListener("click", this, true, true);
},
uninit() {
handler.removeEventListener("mousedown", this, true);
handler.removeEventListener("click", this, true);
handler = null;
SimpleTest = null;
mouseDownState = null;
},
/**
* Suppress (or disable suppression of) handling of captured click events.
* This should only be called by EventUtils, etc. when a click event will
* be generated but we know it is not actually a click intended to activate
* a control; e.g. drag/drop.
*/
suppressClickHandling(shouldSuppress) {
shouldHandleClicks = !shouldSuppress;
},
reset() {
shouldHandleClicks = true;
mouseDownState = null;
},
/**
* Forget the popup recorded at the last mousedown. Called when a test ends,
* as the recorded popup is only useful for the click that follows the
* mousedown: keeping it until the next test starts would make the test that
* ended on a mousedown look like it leaked the popup's window.
*/
forgetMouseDownState() {
mouseDownState = null;
},
handleEvent(event) {
if (event.type == "mousedown") {
const popup = popupAncestor(event.composedTarget);
mouseDownState = popup ? { popup, state: popup.state } : null;
return;
}
// Consume the recorded state even when suppressed, so that the next
// click is not matched against the state recorded for this one.
const recorded = mouseDownState;
mouseDownState = null;
if (!shouldHandleClicks) {
return;
}
let node = event.composedTarget;
if (node.localName == "slot") {
// Events don't target text nodes, so a click on the text inside a slot
// is retargeted to the slot, which isn't itself rendered.
node = node.flattenedTreeParentNode;
}
assertClickedPopupWasOpen(node, recorded);
},
};
})();