Bug 1492497 - [devtools] Add a way to disable (and re-enable) event listener for a given node. r=ochameau,devtools-backward-compat-reviewers,bomsy.

This patch adds a checkbox at the end of each event listeners in the EventTooltip,
which allow the user to disable/re-enable a given event listener.

This is done by managing a Map of nsIEventListenerInfo object in the NodeActor,
which we populate from `getEventListenerInfo`. Each `nsIEventListenerInfo` is
assigned a generated id, which can then be used to call the new NodeActor
methods, `(enable|disable)EventListener`.

We don't support disabling jquery/React event listeners at the moment, so we
display the checkbox for them as well, but disabled.

Differential Revision: https://phabricator.services.mozilla.com/D135133
This commit is contained in:
Nicolas Chevobbe 2022-01-12 12:42:48 +00:00
Родитель 0c74c88e49
Коммит a232d2448c
9 изменённых файлов: 462 добавлений и 27 удалений

Просмотреть файл

@ -27,6 +27,7 @@ support-files =
doc_markup_events_react_production_16.2.0.html
doc_markup_events_react_production_16.2.0_jsx.html
doc_markup_events-source_map.html
doc_markup_events_toggle.html
doc_markup_flashing.html
doc_markup_html_mixed_case.html
doc_markup_image_and_canvas.html
@ -146,6 +147,7 @@ skip-if = true # Bug 1177550
[browser_markup_events_react_production_16.2.0.js]
[browser_markup_events_react_production_16.2.0_jsx.js]
[browser_markup_events_source_map.js]
[browser_markup_events_toggle.js]
[browser_markup_events-windowed-host.js]
[browser_markup_flex_display_badge.js]
[browser_markup_flex_display_badge_telemetry.js]

Просмотреть файл

@ -0,0 +1,262 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/* import-globals-from helper_events_test_runner.js */
"use strict";
// Test that event listeners can be disabled and re-enabled from the markup view event bubble.
const TEST_URL = URL_ROOT_SSL + "doc_markup_events_toggle.html";
loadHelperScript("helper_events_test_runner.js");
add_task(async function() {
const { inspector, toolbox } = await openInspectorForURL(TEST_URL);
const { resourceCommand } = toolbox.commands;
await inspector.markup.expandAll();
await selectNode("#target", inspector);
info(
"Click on the target element to make sure the event listeners are properly set"
);
// There's a "mouseup" event listener that is `console.info` (so we can check "native" events).
// In order to know if it was called, we listen for the next console.info resource.
let {
onResource: onConsoleInfoMessage,
} = await resourceCommand.waitForNextResource(
resourceCommand.TYPES.CONSOLE_MESSAGE,
{
ignoreExistingResources: true,
predicate(resource) {
return resource.message.level == "info";
},
}
);
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
let data = await getTargetElementHandledEventData();
is(data.click, 1, `target handled one "click" event`);
is(data.mousedown, 1, `target handled one "mousedown" event`);
await onConsoleInfoMessage;
ok(true, `the "mouseup" event listener (console.info) was called`);
info("Check that the event tooltip has the expected content");
const container = await getContainerForSelector("#target", inspector);
const eventTooltipBadge = container.elt.querySelector(
".inspector-badge.interactive[data-event]"
);
ok(eventTooltipBadge, "The event tooltip badge is displayed");
const tooltip = inspector.markup.eventDetailsTooltip;
let onTooltipShown = tooltip.once("shown");
eventTooltipBadge.click();
await onTooltipShown;
ok(true, "The tooltip is shown");
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click [x]", "mousedown [x]", "mouseup [x]"],
"The expected events are displayed, all enabled"
);
const [
clickHeader,
mousedownHeader,
mouseupHeader,
] = getHeadersInEventTooltip(tooltip);
info("Uncheck the mousedown event checkbox");
await toggleEventListenerCheckbox(tooltip, mousedownHeader);
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click [x]", "mousedown []", "mouseup [x]"],
"mousedown checkbox was unchecked"
);
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
data = await getTargetElementHandledEventData();
is(data.click, 2, `target handled another "click" event…`);
is(data.mousedown, 1, `… but not a mousedown one`);
info("Uncheck the click event checkbox");
await toggleEventListenerCheckbox(tooltip, clickHeader);
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click []", "mousedown []", "mouseup [x]"],
"click checkbox was unchecked"
);
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
data = await getTargetElementHandledEventData();
is(data.click, 2, `click event listener was disabled`);
is(data.mousedown, 1, `and mousedown still is disabled as well`);
info("Uncheck the mouseup event checkbox");
await toggleEventListenerCheckbox(tooltip, mouseupHeader);
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click []", "mousedown []", "mouseup []"],
"mouseup checkbox was unchecked"
);
({
onResource: onConsoleInfoMessage,
} = await resourceCommand.waitForNextResource(
resourceCommand.TYPES.CONSOLE_MESSAGE,
{
ignoreExistingResources: true,
predicate(resource) {
return resource.message.level == "info";
},
}
));
const onTimeout = wait(500).then(() => "TIMEOUT");
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
const raceResult = await Promise.race([onConsoleInfoMessage, onTimeout]);
is(
raceResult,
"TIMEOUT",
"The mouseup event didn't trigger a console.info call, meaning the event listener was disabled"
);
info("Re-enable the mousedown event");
await toggleEventListenerCheckbox(tooltip, mousedownHeader);
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click []", "mousedown [x]", "mouseup []"],
"mousedown checkbox is checked again"
);
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
data = await getTargetElementHandledEventData();
is(data.click, 2, `no additional "click" event were handled`);
is(
data.mousedown,
2,
`but we did get a new "mousedown", the event listener was re-enabled`
);
info("Hide the tooltip and show it again");
const tooltipHidden = tooltip.once("hidden");
tooltip.hide();
await tooltipHidden;
onTooltipShown = tooltip.once("shown");
eventTooltipBadge.click();
await onTooltipShown;
ok(true, "The tooltip is shown again");
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click []", "mousedown [x]", "mouseup []"],
"Only mousedown checkbox is checked"
);
info("Re-enable mouseup events");
await toggleEventListenerCheckbox(
tooltip,
getHeadersInEventTooltip(tooltip).at(-1)
);
Assert.deepEqual(
getAsciiHeadersViz(tooltip),
["click []", "mousedown [x]", "mouseup [x]"],
"mouseup is checked again"
);
({
onResource: onConsoleInfoMessage,
} = await resourceCommand.waitForNextResource(
resourceCommand.TYPES.CONSOLE_MESSAGE,
{
ignoreExistingResources: true,
predicate(resource) {
return resource.message.level == "info";
},
}
));
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
await onConsoleInfoMessage;
ok(true, "The mouseup event was re-enabled");
data = await getTargetElementHandledEventData();
is(data.click, 2, `"click" is still disabled`);
is(
data.mousedown,
3,
`we received a new "mousedown" event as part of the click`
);
info("Close DevTools to check that event listeners are re-enabled");
await closeToolbox();
await safeSynthesizeMouseEventAtCenterInContentPage("#target");
data = await getTargetElementHandledEventData();
is(
data.click,
3,
`a new "click" event was handled after the devtools was closed`
);
is(
data.mousedown,
4,
`a new "mousedown" event was handled after the devtools was closed`
);
});
function getHeadersInEventTooltip(tooltip) {
return Array.from(tooltip.panel.querySelectorAll(".event-header"));
}
/**
* Get an array of string representing a header in its state, e.g.
* [
* "click [x]",
* "mousedown []",
* ]
*
* represents an event tooltip with a click and a mousedown event, where the mousedown
* event has been disabled.
*
* @param {EventTooltip} tooltip
* @returns Array<String>
*/
function getAsciiHeadersViz(tooltip) {
return getHeadersInEventTooltip(tooltip).map(
el =>
`${el.querySelector(".event-tooltip-event-type").textContent} [${
getHeaderCheckbox(el).checked ? "x" : ""
}]`
);
}
function getHeaderCheckbox(headerEl) {
return headerEl.querySelector("input[type=checkbox]");
}
async function toggleEventListenerCheckbox(tooltip, headerEl) {
const onEventToggled = tooltip.once("event-tooltip-listener-toggled");
const checkbox = getHeaderCheckbox(headerEl);
const previousValue = checkbox.checked;
EventUtils.synthesizeMouseAtCenter(
getHeaderCheckbox(headerEl),
{},
headerEl.ownerGlobal
);
await onEventToggled;
is(checkbox.checked, !previousValue, "The checkbox was toggled");
is(
headerEl.classList.contains("content-expanded"),
false,
"Clicking on the checkbox did not expand the header"
);
}
/**
* @returns Promise<Object> The object keys are event names (e.g. "click", "mousedown"), and
* the values are number representing the number of time the event was handled.
* Note that "mouseup" isn't handled here.
*/
function getTargetElementHandledEventData() {
return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function() {
// In doc_markup_events_toggle.html , we count the events handled by the target in
// a stringified object in dataset.handledEvents.
return JSON.parse(
content.document.getElementById("target").dataset.handledEvents
);
});
}

Просмотреть файл

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>Toggle Event Listeners</h1>
<button id="target" onclick="handleEvent(event)">Target</button>
<script>
"use strict";
function handleEvent(e) {
const data = JSON.parse(e.target.dataset.handledEvents || "{}");
data[e.type] = (data[e.type] || 0) + 1;
e.target.dataset.handledEvents = JSON.stringify(data);
}
const domEventsElement = document.getElementById("target");
// adding regular event listener
domEventsElement.addEventListener("mousedown", handleEvent);
// and a "native" event listener
domEventsElement.addEventListener("mouseup", console.info)
</script>
</body>
</html>

Просмотреть файл

@ -169,6 +169,19 @@ async function checkEventsForNode(test, inspector) {
ok
);
const checkbox = header.querySelector("input[type=checkbox]");
ok(checkbox, "The event toggling checkbox is displayed");
const disabled = checkbox.hasAttribute("disabled");
// We can't disable React/jQuery events at the moment, so ensure that for those,
// the checkbox is disabled.
const shouldBeDisabled =
expected[i].attributes?.includes("React") ||
expected[i].attributes?.includes("jQuery");
ok(
disabled === shouldBeDisabled,
`The checkbox is ${shouldBeDisabled ? "disabled" : "enabled"}\n`
);
info(`${label} END`);
}

Просмотреть файл

@ -75,7 +75,12 @@ MarkupElementContainer.prototype = extend(MarkupContainer.prototype, {
const toolbox = this.markup.toolbox;
// Create the EventTooltip which will populate the tooltip content.
const eventTooltip = new EventTooltip(tooltip, listenerInfo, toolbox);
const eventTooltip = new EventTooltip(
tooltip,
listenerInfo,
toolbox,
this.node
);
// Disable the image preview tooltip while we display the event details
this.markup._disableImagePreviewTooltip();

Просмотреть файл

@ -27,17 +27,24 @@ class EventTooltip {
* A list of event listeners
* @param {Toolbox} toolbox
* Toolbox used to select debugger panel
* @param {NodeFront} nodeFront
* The nodeFront we're displaying event listeners for.
*/
constructor(tooltip, eventListenerInfos, toolbox) {
constructor(tooltip, eventListenerInfos, toolbox, nodeFront) {
this._tooltip = tooltip;
this._toolbox = toolbox;
this._eventEditors = new WeakMap();
this._nodeFront = nodeFront;
this._eventListenersAbortController = new AbortController();
// Used in tests: add a reference to the EventTooltip instance on the HTMLTooltip.
this._tooltip.eventTooltip = this;
this._headerClicked = this._headerClicked.bind(this);
this._debugClicked = this._debugClicked.bind(this);
this._eventToggleCheckboxChanged = this._eventToggleCheckboxChanged.bind(
this
);
this._subscriptions = [];
const config = {
@ -109,7 +116,7 @@ class EventTooltip {
filename.setAttribute("title", newURI);
// This is emitted for testing.
this._tooltip.emit("event-tooltip-source-map-ready");
this._tooltip.emitForTests("event-tooltip-source-map-ready");
}
)
);
@ -160,6 +167,27 @@ class EventTooltip {
attributesBox.appendChild(capturing);
}
const toggleListenerCheckbox = doc.createElementNS(XHTML_NS, "input");
toggleListenerCheckbox.type = "checkbox";
toggleListenerCheckbox.className =
"event-tooltip-listener-toggle-checkbox";
if (listener.eventListenerInfoId) {
toggleListenerCheckbox.checked = listener.enabled;
toggleListenerCheckbox.setAttribute(
"data-event-listener-info-id",
listener.eventListenerInfoId
);
toggleListenerCheckbox.addEventListener(
"change",
this._eventToggleCheckboxChanged,
{ signal: this._eventListenersAbortController.signal }
);
} else {
toggleListenerCheckbox.checked = true;
toggleListenerCheckbox.setAttribute("disabled", true);
}
header.appendChild(toggleListenerCheckbox);
// Content
const editor = new Editor(config);
this._eventEditors.set(content, {
@ -182,10 +210,21 @@ class EventTooltip {
}
_addContentListeners(header) {
header.addEventListener("click", this._headerClicked);
header.addEventListener("click", this._headerClicked, {
signal: this._eventListenersAbortController.signal,
});
}
_headerClicked(event) {
// Clicking on the checkbox shouldn't impact the header (checkbox state change is
// handled in _eventToggleCheckboxChanged).
if (
event.target.classList.contains("event-tooltip-listener-toggle-checkbox")
) {
event.stopPropagation();
return;
}
if (event.target.classList.contains("event-tooltip-debugger-icon")) {
this._debugClicked(event);
event.stopPropagation();
@ -241,7 +280,7 @@ class EventTooltip {
content.scrollIntoView(false);
}
this._tooltip.emit("event-tooltip-ready");
this._tooltip.emitForTests("event-tooltip-ready");
});
}
}
@ -266,6 +305,17 @@ class EventTooltip {
}
}
async _eventToggleCheckboxChanged(event) {
const checkbox = event.currentTarget;
const id = checkbox.getAttribute("data-event-listener-info-id");
if (checkbox.checked) {
await this._nodeFront.enableEventListener(id);
} else {
await this._nodeFront.disableEventListener(id);
}
this._tooltip.emitForTests("event-tooltip-listener-toggled");
}
/**
* Parse URI and return {url, line, column}; or return null if it can't be parsed.
*/
@ -308,24 +358,16 @@ class EventTooltip {
this._tooltip.eventTooltip = null;
}
const headerNodes = this.container.querySelectorAll(".event-header");
for (const node of headerNodes) {
node.removeEventListener("click", this._headerClicked);
}
const sourceNodes = this.container.querySelectorAll(
".event-tooltip-debugger-icon"
);
for (const node of sourceNodes) {
node.removeEventListener("click", this._debugClicked);
if (this._eventListenersAbortController) {
this._eventListenersAbortController.abort();
this._eventListenersAbortController = null;
}
for (const unsubscribe of this._subscriptions) {
unsubscribe();
}
this._toolbox = this._tooltip = null;
this._toolbox = this._tooltip = this._nodeFront = null;
}
}

Просмотреть файл

@ -385,9 +385,11 @@ class DOMEventCollector extends MainEventCollector {
}
const eventInfo = {
nsIEventListenerInfo: listener,
capturing: listener.capturing,
type: listener.type,
handler: handler,
enabled: listener.enabled,
};
handlers.push(eventInfo);
@ -820,16 +822,20 @@ class EventCollector {
*
* @param {DOMNode} node
* The node for which events are to be gathered.
* @return {Array}
* @return {Array<Object>}
* An array containing objects in the following format:
* {
* type: type, // e.g. "click"
* handler: handler, // The function called when event is triggered.
* tags: "jQuery", // Comma separated list of tags displayed
* // inside event bubble.
* hide: { // Flags for hiding certain properties.
* capturing: true,
* {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) {
@ -895,7 +901,10 @@ class EventCollector {
* hide: {
* capturing: true
* },
* native: false
* native: false,
* enabled: true
* sourceActor: "sourceActor.1234",
* nsIEventListenerInfo: nsIEventListenerInfo {},
* }
*/
// eslint-disable-next-line complexity
@ -923,6 +932,7 @@ class EventCollector {
const tags = listener.tags || "";
const type = listener.type || "";
let isScriptBoundToNonScriptElement = false;
const enabled = !!listener.enabled;
let functionSource = handler.toString();
let line = 0;
let column = null;
@ -1039,6 +1049,8 @@ class EventCollector {
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

Просмотреть файл

@ -95,6 +95,9 @@ const NodeActor = protocol.ActorClassWithSpec(nodeSpec, {
this.walker = walker;
this.rawNode = node;
this._eventCollector = new EventCollector(this.walker.targetActor);
// Map<id -> nsIEventListenerInfo> that we maintain to be able to disable/re-enable event listeners
// The id is generated from getEventListenerInfo
this._nsIEventListenersInfo = new Map();
// Store the original display type and scrollable state and whether or not the node is
// displayed to track changes when reflows occur.
@ -159,6 +162,16 @@ const NodeActor = protocol.ActorClassWithSpec(nodeSpec, {
this._waitForFrameLoadIntervalId = null;
}
if (this._nsIEventListenersInfo) {
// Re-enable all event listeners that we might have disabled
for (const nsIEventListenerInfo of this._nsIEventListenersInfo.values()) {
if (!nsIEventListenerInfo.enabled) {
nsIEventListenerInfo.enabled = true;
}
}
this._nsIEventListenersInfo = null;
}
this._eventCollector.destroy();
this._eventCollector = null;
this.rawNode = null;
@ -560,7 +573,56 @@ const NodeActor = protocol.ActorClassWithSpec(nodeSpec, {
* Get all event listeners that are listening on this node.
*/
getEventListenerInfo: function() {
return this._eventCollector.getEventListeners(this.rawNode);
this._nsIEventListenersInfo.clear();
const eventListenersData = this._eventCollector.getEventListeners(
this.rawNode
);
let counter = 0;
for (const eventListenerData of eventListenersData) {
if (eventListenerData.nsIEventListenerInfo) {
const id = `event-listener-info-${++counter}`;
this._nsIEventListenersInfo.set(
id,
eventListenerData.nsIEventListenerInfo
);
eventListenerData.eventListenerInfoId = id;
// remove the nsIEventListenerInfo since we don't want to send it to the client.
delete eventListenerData.nsIEventListenerInfo;
}
}
return eventListenersData;
},
/**
* Disable a specific event listener given its associated id
*
* @param {String} eventListenerInfoId
*/
disableEventListener: function(eventListenerInfoId) {
const nsEventListenerInfo = this._nsIEventListenersInfo.get(
eventListenerInfoId
);
if (!nsEventListenerInfo) {
throw new Error("Unkown nsEventListenerInfo");
}
nsEventListenerInfo.enabled = false;
},
/**
* (Re-)enable a specific event listener given its associated id
*
* @param {String} eventListenerInfoId
*/
enableEventListener: function(eventListenerInfoId) {
const nsEventListenerInfo = this._nsIEventListenersInfo.get(
eventListenerInfoId
);
if (!nsEventListenerInfo) {
throw new Error("Unkown nsEventListenerInfo");
}
nsEventListenerInfo.enabled = true;
},
/**

Просмотреть файл

@ -114,6 +114,18 @@ const nodeSpec = generateActorSpec({
events: RetVal("json"),
},
},
enableEventListener: {
request: {
eventListenerInfoId: Arg(0),
},
response: {},
},
disableEventListener: {
request: {
eventListenerInfoId: Arg(0),
},
response: {},
},
modifyAttributes: {
request: {
modifications: Arg(0, "array:json"),