1083 lines
29 KiB
JavaScript
1083 lines
29 KiB
JavaScript
/* 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: _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) {
|
|
const listeners = [];
|
|
const listenersTargets = [];
|
|
|
|
if (
|
|
typeof node.nodeName !== "undefined" &&
|
|
node.nodeName.toLowerCase() === "html"
|
|
) {
|
|
listenersTargets.push(node.ownerGlobal, node, node.parentNode);
|
|
} else {
|
|
listenersTargets.push(node);
|
|
}
|
|
|
|
for (const el of listenersTargets) {
|
|
const elListeners = Services.els.getListenerInfoFor(el);
|
|
if (!elListeners) {
|
|
continue;
|
|
}
|
|
for (const listener of elListeners) {
|
|
const obj = this.unwrap(listener.listenerObject);
|
|
if (!obj || !obj[EXCLUDED_LISTENER]) {
|
|
listeners.push(listener);
|
|
}
|
|
}
|
|
}
|
|
|
|
return listeners;
|
|
}
|
|
|
|
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 in eventsObj) {
|
|
let events = eventsObj[type];
|
|
// We can get arrays or objects. When we get the latter,
|
|
// the events are the object values.
|
|
if (!Array.isArray(events)) {
|
|
events = Object.values(events);
|
|
}
|
|
for (const event of 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 jqueryData = jQuery._data || jQuery.data;
|
|
|
|
if (jqueryData) {
|
|
// 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 = jqueryData(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 && node.ownerDocument && node.matches) {
|
|
for (const eventName in events) {
|
|
const eventHolder = events[eventName];
|
|
for (const idx in eventHolder) {
|
|
if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
|
|
continue;
|
|
}
|
|
|
|
const event = eventHolder[idx];
|
|
let { selector, data } = event;
|
|
|
|
if (!selector && data) {
|
|
selector = data.selector || data;
|
|
}
|
|
|
|
if (!selector) {
|
|
continue;
|
|
}
|
|
|
|
let matches;
|
|
try {
|
|
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 && 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;
|