summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/inspector/event-collector.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/inspector/event-collector.js')
-rw-r--r--devtools/server/actors/inspector/event-collector.js1069
1 files changed, 1069 insertions, 0 deletions
diff --git a/devtools/server/actors/inspector/event-collector.js b/devtools/server/actors/inspector/event-collector.js
new file mode 100644
index 0000000000..4ee8dc388f
--- /dev/null
+++ b/devtools/server/actors/inspector/event-collector.js
@@ -0,0 +1,1069 @@
+/* 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/. */
+
+// This file contains event collectors that are then used by developer tools in
+// order to find information about events affecting an HTML element.
+
+"use strict";
+
+const {
+ isAfterPseudoElement,
+ isBeforePseudoElement,
+ isMarkerPseudoElement,
+ isNativeAnonymous,
+} = require("resource://devtools/shared/layout/utils.js");
+const Debugger = require("Debugger");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+// eslint-disable-next-line
+const JQUERY_LIVE_REGEX =
+ /return typeof \w+.*.event\.triggered[\s\S]*\.event\.(dispatch|handle).*arguments/;
+
+const REACT_EVENT_NAMES = [
+ "onAbort",
+ "onAnimationEnd",
+ "onAnimationIteration",
+ "onAnimationStart",
+ "onAuxClick",
+ "onBeforeInput",
+ "onBlur",
+ "onCanPlay",
+ "onCanPlayThrough",
+ "onCancel",
+ "onChange",
+ "onClick",
+ "onClose",
+ "onCompositionEnd",
+ "onCompositionStart",
+ "onCompositionUpdate",
+ "onContextMenu",
+ "onCopy",
+ "onCut",
+ "onDoubleClick",
+ "onDrag",
+ "onDragEnd",
+ "onDragEnter",
+ "onDragExit",
+ "onDragLeave",
+ "onDragOver",
+ "onDragStart",
+ "onDrop",
+ "onDurationChange",
+ "onEmptied",
+ "onEncrypted",
+ "onEnded",
+ "onError",
+ "onFocus",
+ "onGotPointerCapture",
+ "onInput",
+ "onInvalid",
+ "onKeyDown",
+ "onKeyPress",
+ "onKeyUp",
+ "onLoad",
+ "onLoadStart",
+ "onLoadedData",
+ "onLoadedMetadata",
+ "onLostPointerCapture",
+ "onMouseDown",
+ "onMouseEnter",
+ "onMouseLeave",
+ "onMouseMove",
+ "onMouseOut",
+ "onMouseOver",
+ "onMouseUp",
+ "onPaste",
+ "onPause",
+ "onPlay",
+ "onPlaying",
+ "onPointerCancel",
+ "onPointerDown",
+ "onPointerEnter",
+ "onPointerLeave",
+ "onPointerMove",
+ "onPointerOut",
+ "onPointerOver",
+ "onPointerUp",
+ "onProgress",
+ "onRateChange",
+ "onReset",
+ "onScroll",
+ "onSeeked",
+ "onSeeking",
+ "onSelect",
+ "onStalled",
+ "onSubmit",
+ "onSuspend",
+ "onTimeUpdate",
+ "onToggle",
+ "onTouchCancel",
+ "onTouchEnd",
+ "onTouchMove",
+ "onTouchStart",
+ "onTransitionEnd",
+ "onVolumeChange",
+ "onWaiting",
+ "onWheel",
+ "onAbortCapture",
+ "onAnimationEndCapture",
+ "onAnimationIterationCapture",
+ "onAnimationStartCapture",
+ "onAuxClickCapture",
+ "onBeforeInputCapture",
+ "onBlurCapture",
+ "onCanPlayCapture",
+ "onCanPlayThroughCapture",
+ "onCancelCapture",
+ "onChangeCapture",
+ "onClickCapture",
+ "onCloseCapture",
+ "onCompositionEndCapture",
+ "onCompositionStartCapture",
+ "onCompositionUpdateCapture",
+ "onContextMenuCapture",
+ "onCopyCapture",
+ "onCutCapture",
+ "onDoubleClickCapture",
+ "onDragCapture",
+ "onDragEndCapture",
+ "onDragEnterCapture",
+ "onDragExitCapture",
+ "onDragLeaveCapture",
+ "onDragOverCapture",
+ "onDragStartCapture",
+ "onDropCapture",
+ "onDurationChangeCapture",
+ "onEmptiedCapture",
+ "onEncryptedCapture",
+ "onEndedCapture",
+ "onErrorCapture",
+ "onFocusCapture",
+ "onGotPointerCaptureCapture",
+ "onInputCapture",
+ "onInvalidCapture",
+ "onKeyDownCapture",
+ "onKeyPressCapture",
+ "onKeyUpCapture",
+ "onLoadCapture",
+ "onLoadStartCapture",
+ "onLoadedDataCapture",
+ "onLoadedMetadataCapture",
+ "onLostPointerCaptureCapture",
+ "onMouseDownCapture",
+ "onMouseEnterCapture",
+ "onMouseLeaveCapture",
+ "onMouseMoveCapture",
+ "onMouseOutCapture",
+ "onMouseOverCapture",
+ "onMouseUpCapture",
+ "onPasteCapture",
+ "onPauseCapture",
+ "onPlayCapture",
+ "onPlayingCapture",
+ "onPointerCancelCapture",
+ "onPointerDownCapture",
+ "onPointerEnterCapture",
+ "onPointerLeaveCapture",
+ "onPointerMoveCapture",
+ "onPointerOutCapture",
+ "onPointerOverCapture",
+ "onPointerUpCapture",
+ "onProgressCapture",
+ "onRateChangeCapture",
+ "onResetCapture",
+ "onScrollCapture",
+ "onSeekedCapture",
+ "onSeekingCapture",
+ "onSelectCapture",
+ "onStalledCapture",
+ "onSubmitCapture",
+ "onSuspendCapture",
+ "onTimeUpdateCapture",
+ "onToggleCapture",
+ "onTouchCancelCapture",
+ "onTouchEndCapture",
+ "onTouchMoveCapture",
+ "onTouchStartCapture",
+ "onTransitionEndCapture",
+ "onVolumeChangeCapture",
+ "onWaitingCapture",
+ "onWheelCapture",
+];
+
+/**
+ * The base class that all the enent collectors should be based upon.
+ */
+class MainEventCollector {
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ * Check if a node has any event listeners attached. Please do not override
+ * this method... your getListeners() implementation needs to have the
+ * following signature:
+ * `getListeners(node, {checkOnly} = {})`
+ *
+ * @param {DOMNode} node
+ * The not for which we want to check for event listeners.
+ * @return {Boolean}
+ * true if the node has event listeners, false otherwise.
+ */
+ hasListeners(node) {
+ return this.getListeners(node, {
+ checkOnly: true,
+ });
+ }
+
+ /**
+ * Get all listeners for a node. This method must be overridden.
+ *
+ * @param {DOMNode} node
+ * The not for which we want to get event listeners.
+ * @param {Object} options
+ * An object for passing in options.
+ * @param {Boolean} [options.checkOnly = false]
+ * Don't get any listeners but return true when the first event is
+ * found.
+ * @return {Array}
+ * An array of event handlers.
+ */
+ getListeners(node, { checkOnly }) {
+ throw new Error("You have to implement the method getListeners()!");
+ }
+
+ /**
+ * Get unfiltered DOM Event listeners for a node.
+ * NOTE: These listeners may contain invalid events and events based
+ * on C++ rather than JavaScript.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to get unfiltered event listeners.
+ * @return {Array}
+ * An array of unfiltered event listeners or an empty array
+ */
+ getDOMListeners(node) {
+ let listeners;
+ if (
+ typeof node.nodeName !== "undefined" &&
+ node.nodeName.toLowerCase() === "html"
+ ) {
+ const winListeners =
+ Services.els.getListenerInfoFor(node.ownerGlobal) || [];
+ const docElementListeners = Services.els.getListenerInfoFor(node) || [];
+ const docListeners =
+ Services.els.getListenerInfoFor(node.parentNode) || [];
+
+ listeners = [...winListeners, ...docElementListeners, ...docListeners];
+ } else {
+ listeners = Services.els.getListenerInfoFor(node) || [];
+ }
+
+ return listeners.filter(listener => {
+ const obj = this.unwrap(listener.listenerObject);
+ return !obj || !obj[EXCLUDED_LISTENER];
+ });
+ }
+
+ getJQuery(node) {
+ if (Cu.isDeadWrapper(node)) {
+ return null;
+ }
+
+ const global = this.unwrap(node.ownerGlobal);
+ if (!global) {
+ return null;
+ }
+
+ const hasJQuery = global.jQuery?.fn?.jquery;
+
+ if (hasJQuery) {
+ return global.jQuery;
+ }
+ return null;
+ }
+
+ unwrap(obj) {
+ return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj;
+ }
+
+ isChromeHandler(handler) {
+ try {
+ const handlerPrincipal = Cu.getObjectPrincipal(handler);
+
+ // Chrome codebase may register listeners on the page from a frame script or
+ // JSM <video> tags may also report internal listeners, but they won't be
+ // coming from the system principal. Instead, they will be using an expanded
+ // principal.
+ return (
+ handlerPrincipal.isSystemPrincipal ||
+ handlerPrincipal.isExpandedPrincipal
+ );
+ } catch (e) {
+ // Anything from a dead object to a CSP error can leave us here so let's
+ // return false so that we can fail gracefully.
+ return false;
+ }
+ }
+}
+
+/**
+ * Get or detect DOM events. These may include DOM events created by libraries
+ * that enable their custom events to work. At this point we are unable to
+ * effectively filter them as they may be proxied or wrapped. Although we know
+ * there is an event, we may not know the true contents until it goes
+ * through `processHandlerForEvent()`.
+ */
+class DOMEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const listeners = this.getDOMListeners(node);
+
+ for (const listener of listeners) {
+ // Ignore listeners without a type, e.g.
+ // node.addEventListener("", function() {})
+ if (!listener.type) {
+ continue;
+ }
+
+ // Get the listener object, either a Function or an Object.
+ const obj = listener.listenerObject;
+
+ // Ignore listeners without any listener, e.g.
+ // node.addEventListener("mouseover", null);
+ if (!obj) {
+ continue;
+ }
+
+ let handler = null;
+
+ // An object without a valid handleEvent is not a valid listener.
+ if (typeof obj === "object") {
+ const unwrapped = this.unwrap(obj);
+ if (typeof unwrapped.handleEvent === "function") {
+ handler = Cu.unwaiveXrays(unwrapped.handleEvent);
+ }
+ } else if (typeof obj === "function") {
+ // Ignore DOM events used to trigger jQuery events as they are only
+ // useful to the developers of the jQuery library.
+ if (JQUERY_LIVE_REGEX.test(obj.toString())) {
+ continue;
+ }
+ // Otherwise, the other valid listener type is function.
+ handler = obj;
+ }
+
+ // Ignore listeners that have no handler.
+ if (!handler) {
+ continue;
+ }
+
+ // If we shouldn't be showing chrome events due to context and this is a
+ // chrome handler we can ignore it.
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ // If this is checking if a node has any listeners then we have found one
+ // so return now.
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ nsIEventListenerInfo: listener,
+ capturing: listener.capturing,
+ type: listener.type,
+ handler,
+ enabled: listener.enabled,
+ };
+
+ handlers.push(eventInfo);
+ }
+
+ // If this is checking if a node has any listeners then none were found so
+ // return false.
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery events.
+ */
+class JQueryEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ // If jQuery is not on the page, if this is an anonymous node or a pseudo
+ // element we need to return early.
+ if (
+ !jQuery ||
+ isNativeAnonymous(node) ||
+ isMarkerPseudoElement(node) ||
+ isBeforePseudoElement(node) ||
+ isAfterPseudoElement(node)
+ ) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ let eventsObj = null;
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // jQuery 1.2+
+ try {
+ eventsObj = data(node, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+ } else {
+ // JQuery 1.0 & 1.1
+ let entry;
+ try {
+ entry = entry = jQuery(node)[0];
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (!entry || !entry.events) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ eventsObj = entry.events;
+ }
+
+ if (eventsObj) {
+ for (const [type, events] of Object.entries(eventsObj)) {
+ for (const [, event] of Object.entries(events)) {
+ // Skip events that are part of jQueries internals.
+ if (node.nodeType == node.DOCUMENT_NODE && event.selector) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ type,
+ handler,
+ tags: "jQuery",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery live events.
+ */
+class JQueryLiveEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ if (!jQuery) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // Live events are added to the document and bubble up to all elements.
+ // Any element matching the specified selector will trigger the live
+ // event.
+ const win = this.unwrap(node.ownerGlobal);
+ let events = null;
+
+ try {
+ events = data(win.document, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (events) {
+ for (const [, eventHolder] of Object.entries(events)) {
+ for (const [idx, event] of Object.entries(eventHolder)) {
+ if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
+ continue;
+ }
+
+ let selector = event.selector;
+
+ if (!selector && event.data) {
+ selector = event.data.selector || event.data || event.selector;
+ }
+
+ if (!selector || !node.ownerDocument) {
+ continue;
+ }
+
+ let matches;
+ try {
+ matches = node.matches && node.matches(selector);
+ } catch (e) {
+ // Invalid selector, do nothing.
+ }
+
+ if (!matches) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+ const eventInfo = {
+ type: event.origType || event.type.substr(selector.length + 1),
+ handler,
+ tags: "jQuery,Live",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ if (!eventInfo.type && event.data?.live) {
+ eventInfo.type = event.data.live;
+ }
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ normalizeListener(handlerDO) {
+ function isFunctionInProxy(funcDO) {
+ // If the anonymous function is inside the |proxy| function and the
+ // function only has guessed atom, the guessed atom should starts with
+ // "proxy/".
+ const displayName = funcDO.displayName;
+ if (displayName && displayName.startsWith("proxy/")) {
+ return true;
+ }
+
+ // If the anonymous function is inside the |proxy| function and the
+ // function gets name at compile time by SetFunctionName, its guessed
+ // atom doesn't contain "proxy/". In that case, check if the caller is
+ // "proxy" function, as a fallback.
+ const calleeDS = funcDO.environment?.calleeScript;
+ if (!calleeDS) {
+ return false;
+ }
+ const calleeName = calleeDS.displayName;
+ return calleeName == "proxy";
+ }
+
+ function getFirstFunctionVariable(funcDO) {
+ // The handler function inside the |proxy| function should point the
+ // unwrapped function via environment variable.
+ const names = funcDO.environment ? funcDO.environment.names() : [];
+ for (const varName of names) {
+ const varDO = handlerDO.environment
+ ? handlerDO.environment.getVariable(varName)
+ : null;
+ if (!varDO) {
+ continue;
+ }
+ if (varDO.class == "Function") {
+ return varDO;
+ }
+ }
+ return null;
+ }
+
+ if (!isFunctionInProxy(handlerDO)) {
+ return handlerDO;
+ }
+
+ const MAX_NESTED_HANDLER_COUNT = 2;
+ for (let i = 0; i < MAX_NESTED_HANDLER_COUNT; i++) {
+ const funcDO = getFirstFunctionVariable(handlerDO);
+ if (!funcDO) {
+ return handlerDO;
+ }
+
+ handlerDO = funcDO;
+ if (isFunctionInProxy(handlerDO)) {
+ continue;
+ }
+ break;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * Get or detect React events.
+ */
+class ReactEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const props = this.getProps(node);
+
+ if (props) {
+ for (const [name, prop] of Object.entries(props)) {
+ if (REACT_EVENT_NAMES.includes(name)) {
+ const listener = prop?.__reactBoundMethod || prop;
+
+ if (typeof listener !== "function") {
+ continue;
+ }
+
+ if (!this.chromeEnabled && this.isChromeHandler(listener)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const handler = {
+ type: name,
+ handler: listener,
+ tags: "React",
+ override: {
+ capturing: name.endsWith("Capture"),
+ },
+ };
+
+ handlers.push(handler);
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+
+ getProps(node) {
+ node = this.unwrap(node);
+
+ for (const key of Object.keys(node)) {
+ if (key.startsWith("__reactInternalInstance$")) {
+ const value = node[key];
+ if (value.memoizedProps) {
+ return value.memoizedProps; // React 16
+ }
+ return value?._currentElement?.props; // React 15
+ }
+ }
+ return null;
+ }
+
+ normalizeListener(handlerDO, listener) {
+ let functionText = "";
+
+ if (handlerDO.boundTargetFunction) {
+ handlerDO = handlerDO.boundTargetFunction;
+ }
+
+ const script = handlerDO.script;
+ // Script might be undefined (eg for methods bound several times, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1589658)
+ const introScript = script?.source.introductionScript;
+
+ // If this is a Babel transpiled function we have no access to the
+ // source location so we need to hide the filename and debugger
+ // icon.
+ if (introScript && introScript.displayName.endsWith("/transform.run")) {
+ listener.hide.debugger = true;
+ listener.hide.filename = true;
+
+ if (!handlerDO.isArrowFunction) {
+ functionText += "function (";
+ } else {
+ functionText += "(";
+ }
+
+ functionText += handlerDO.parameterNames.join(", ");
+
+ functionText += ") {\n";
+
+ const scriptSource = script.source.text;
+ functionText += scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ listener.override.handler = functionText;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * The exposed class responsible for gathering events.
+ */
+class EventCollector {
+ constructor(targetActor) {
+ this.targetActor = targetActor;
+
+ // The event collector array. Please preserve the order otherwise there will
+ // be multiple failing tests.
+ this.eventCollectors = [
+ new ReactEventCollector(),
+ new JQueryLiveEventCollector(),
+ new JQueryEventCollector(),
+ new DOMEventCollector(),
+ ];
+ }
+
+ /**
+ * Destructor (must be called manually).
+ */
+ destroy() {
+ this.eventCollectors = null;
+ }
+
+ /**
+ * Iterate through all event collectors returning on the first found event.
+ *
+ * @param {DOMNode} node
+ * The node to be checked for events.
+ * @return {Boolean}
+ * True if the node has event listeners, false otherwise.
+ */
+ hasEventListeners(node) {
+ for (const collector of this.eventCollectors) {
+ if (collector.hasListeners(node)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ *
+ * @param {DOMNode} node
+ * The node for which events are to be gathered.
+ * @return {Array<Object>}
+ * An array containing objects in the following format:
+ * {
+ * {String} type: The event type, e.g. "click"
+ * {Function} handler: The function called when event is triggered.
+ * {Boolean} enabled: Whether the listener is enabled or not (event listeners can
+ * be disabled via the inspector)
+ * {String} tags: Comma separated list of tags displayed inside event bubble (e.g. "JQuery")
+ * {Object} hide: Flags for hiding certain properties.
+ * {Boolean} capturing
+ * }
+ * {Boolean} native
+ * {String|undefined} sourceActor: The sourceActor id of the event listener
+ * {nsIEventListenerInfo|undefined} nsIEventListenerInfo
+ * }
+ */
+ getEventListeners(node) {
+ const listenerArray = [];
+ let dbg;
+ if (!this.chromeEnabled) {
+ dbg = new Debugger();
+ } else {
+ // When the chrome pref is turned on, we may try to debug system compartments.
+ // But since bug 1517210, the server is also loaded using the system principal
+ // and so here, we have to ensure using a special Debugger instance, loaded
+ // in a compartment flagged with invisibleToDebugger=true. This helps the Debugger
+ // know about the precise boundary between debuggee and debugger code.
+ const ChromeDebugger = require("ChromeDebugger");
+ dbg = new ChromeDebugger();
+ }
+
+ for (const collector of this.eventCollectors) {
+ const listeners = collector.getListeners(node);
+
+ if (!listeners) {
+ continue;
+ }
+
+ for (const listener of listeners) {
+ const eventObj = this.processHandlerForEvent(
+ listener,
+ dbg,
+ collector.normalizeListener
+ );
+ if (eventObj) {
+ listenerArray.push(eventObj);
+ }
+ }
+ }
+
+ listenerArray.sort((a, b) => {
+ return a.type.localeCompare(b.type);
+ });
+
+ return listenerArray;
+ }
+
+ /**
+ * Process an event listener.
+ *
+ * @param {EventListener} listener
+ * The event listener to process.
+ * @param {Debugger} dbg
+ * Debugger instance.
+ * @param {Function|null} normalizeListener
+ * An optional function that will be called to retrieve data about the listener.
+ * It should be a *Collector method.
+ *
+ * @return {Array}
+ * An array of objects where a typical object looks like this:
+ * {
+ * type: "click",
+ * handler: function() { doSomething() },
+ * origin: "http://www.mozilla.com",
+ * tags: tags,
+ * capturing: true,
+ * hide: {
+ * capturing: true
+ * },
+ * native: false,
+ * enabled: true
+ * sourceActor: "sourceActor.1234",
+ * nsIEventListenerInfo: nsIEventListenerInfo {…},
+ * }
+ */
+ // eslint-disable-next-line complexity
+ processHandlerForEvent(listener, dbg, normalizeListener) {
+ let globalDO;
+ let eventObj;
+
+ try {
+ const { capturing, handler } = listener;
+
+ const global = Cu.getGlobalForObject(handler);
+
+ // It is important that we recreate the globalDO for each handler because
+ // their global object can vary e.g. resource:// URLs on a video control. If
+ // we don't do this then all chrome listeners simply display "native code."
+ globalDO = dbg.addDebuggee(global);
+ let listenerDO = globalDO.makeDebuggeeValue(handler);
+
+ if (normalizeListener) {
+ listenerDO = normalizeListener(listenerDO, listener);
+ }
+
+ const hide = listener.hide || {};
+ const override = listener.override || {};
+ const tags = listener.tags || "";
+ const type = listener.type || "";
+ const enabled = !!listener.enabled;
+ let functionSource = handler.toString();
+ let line = 0;
+ let column = null;
+ let native = false;
+ let url = "";
+ let sourceActor = "";
+
+ // If the listener is an object with a 'handleEvent' method, use that.
+ if (
+ listenerDO.class === "Object" ||
+ /^XUL\w*Element$/.test(listenerDO.class)
+ ) {
+ let desc;
+
+ while (!desc && listenerDO) {
+ desc = listenerDO.getOwnPropertyDescriptor("handleEvent");
+ listenerDO = listenerDO.proto;
+ }
+
+ if (desc?.value) {
+ listenerDO = desc.value;
+ }
+ }
+
+ // If the listener is bound to a different context then we need to switch
+ // to the bound function.
+ if (listenerDO.isBoundFunction) {
+ listenerDO = listenerDO.boundTargetFunction;
+ }
+
+ const { isArrowFunction, name, script, parameterNames } = listenerDO;
+
+ if (script) {
+ const scriptSource = script.source.text;
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ line = script.startLine;
+ column = script.startColumn - columnBase;
+ url = script.url;
+ const actor = this.targetActor.sourcesManager.getOrCreateSourceActor(
+ script.source
+ );
+ sourceActor = actor ? actor.actorID : null;
+
+ // Checking for the string "[native code]" is the only way at this point
+ // to check for native code. Even if this provides a false positive then
+ // grabbing the source code a second time is harmless.
+ if (
+ functionSource === "[object Object]" ||
+ functionSource === "[object XULElement]" ||
+ functionSource.includes("[native code]")
+ ) {
+ functionSource = scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ // At this point the script looks like this:
+ // () { ... }
+ // We prefix this with "function" if it is not a fat arrow function.
+ if (!isArrowFunction) {
+ functionSource = "function " + functionSource;
+ }
+ }
+ } else {
+ // If the listener is a native one (provided by C++ code) then we have no
+ // access to the script. We use the native flag to prevent showing the
+ // debugger button because the script is not available.
+ native = true;
+ }
+
+ // Arrow function text always contains the parameters. Function
+ // parameters are often missing e.g. if Array.sort is used as a handler.
+ // If they are missing we provide the parameters ourselves.
+ if (parameterNames && parameterNames.length) {
+ const prefix = "function " + name + "()";
+ const paramString = parameterNames.join(", ");
+
+ if (functionSource.startsWith(prefix)) {
+ functionSource = functionSource.substr(prefix.length);
+
+ functionSource = `function ${name} (${paramString})${functionSource}`;
+ }
+ }
+
+ // If the listener is native code we display the filename "[native code]."
+ // This is the official string and should *not* be translated.
+ let origin;
+ if (native) {
+ origin = "[native code]";
+ } else {
+ origin =
+ url +
+ (line ? ":" + line + (column === null ? "" : ":" + column) : "");
+ }
+
+ eventObj = {
+ type: override.type || type,
+ handler: override.handler || functionSource.trim(),
+ origin: override.origin || origin,
+ tags: override.tags || tags,
+ capturing:
+ typeof override.capturing !== "undefined"
+ ? override.capturing
+ : capturing,
+ hide: typeof override.hide !== "undefined" ? override.hide : hide,
+ native,
+ sourceActor,
+ nsIEventListenerInfo: listener.nsIEventListenerInfo,
+ enabled,
+ };
+
+ // Hide the debugger icon for DOM0 and native listeners. DOM0 listeners are
+ // generated dynamically from e.g. an onclick="" attribute so the script
+ // doesn't actually exist.
+ if (!sourceActor) {
+ eventObj.hide.debugger = true;
+ }
+ } finally {
+ // Ensure that we always remove the debuggee.
+ if (globalDO) {
+ dbg.removeDebuggee(globalDO);
+ }
+ }
+
+ return eventObj;
+ }
+}
+
+exports.EventCollector = EventCollector;