Bug 1569859 - [devtools] Automagically move the toolbox between the original tab we debug and its popups. r=nchevobbe

This is also behind "devtools.popups.debug", to be set to true manually.

This special mode break the old fundamental principal where a given toolbox
is bound to one unique given tab. Now one toolbox starts being shared between
many tabs.

When we select the original tab we debug, or any of its popups opened in distinct tabs,
we will move the toolbox between each of these tabs.
We will have one toolbox instance, one toolbox iframe, which will be moved
around each tab's host. This is somewhat similar to host switching within the same tab.
This is all based on the same trick where we swap the toolbox iframe to another location.

Differential Revision: https://phabricator.services.mozilla.com/D131802
This commit is contained in:
Alexandre Poirot 2022-01-14 12:02:25 +00:00
parent e515c00f2a
commit be162a1fe8
7 changed files with 436 additions and 17 deletions

View File

@ -50,6 +50,7 @@ skip-if = debug # Window leaks: bug 1575332
[browser_dbg-breakpoints-debugger-statement.js]
[browser_dbg-breakpoints-duplicate-functions.js]
[browser_dbg-breakpoints-in-evaled-sources.js]
[browser_dbg-breakpoints-popup.js]
[browser_dbg-browser-content-toolbox.js]
skip-if = !e10s || verify # This test is only valid in e10s
[browser_dbg-continue-to-here.js]

View File

@ -0,0 +1,198 @@
/* 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/>. */
// Verify that we hit breakpoints on popups
const TEST_URI = "https://example.org/document-builder.sjs?html=main page";
const POPUP_URL = "https://example.com/document-builder.sjs?html=" + escape(`popup for breakpoints
<script>
var paused = true;
console.log('popup');
paused = false;
</script>
`);
const POPUP_DEBUGGER_STATEMENT_URL = "https://example.com/document-builder.sjs?html="+ escape(`popup with debugger;
<script>
var paused = true;
debugger;
paused = false;
</script>
`);
function isPopupPaused(popupBrowsingContext) {
return SpecialPowers.spawn(popupBrowsingContext, [], function(url) {
return content.wrappedJSObject.paused;
});
}
async function openPopup(popupUrl, browser = gBrowser.selectedBrowser) {
const onPopupTabSelected = BrowserTestUtils.waitForEvent(
gBrowser.tabContainer,
"TabSelect"
);
const popupBrowsingContext = await SpecialPowers.spawn(browser, [popupUrl], function(url) {
const popup = content.open(url);
return popup.browsingContext;
});
await onPopupTabSelected;
is(gBrowser.selectedBrowser.browsingContext, popupBrowsingContext, "The popup is the selected tab");
return popupBrowsingContext;
}
async function closePopup(browsingContext) {
const onPreviousTabSelected = BrowserTestUtils.waitForEvent(
gBrowser.tabContainer,
"TabSelect"
);
await SpecialPowers.spawn(browsingContext, [], function() {
content.close();
});
await onPreviousTabSelected;
}
add_task(async function testPausedByBreakpoint() {
await pushPref("devtools.popups.debug", true);
info("Test breakpoints set in popup scripts");
const dbg = await initDebuggerWithAbsoluteURL(
TEST_URI
);
info("Open the popup in order to be able to set a breakpoint");
const firstPopupBrowsingContext = await openPopup(POPUP_URL);
const source = await waitForSource(dbg, POPUP_URL);
await selectSource(dbg, source.url);
await addBreakpoint(dbg, source.url, 4);
info("Now close and reopen the popup");
await closePopup(firstPopupBrowsingContext);
info("Re-open the popup");
const popupBrowsingContext = await openPopup(POPUP_URL);
await waitForPaused(dbg);
is(await isPopupPaused(popupBrowsingContext), true, "The popup is really paused");
// As we still spawn distinct reducer sources when the sources come from distinct
// thread/target (i.e. behavior of `makeSourceId`).
// Wait for the second source to be created. We can't use `waitForSource/findSource`
// as that isn't designed to handle more than one source per url :/
const sources = await waitFor(() => {
const list = dbg.selectors
.getSourceList()
.filter(s => s.url.includes(POPUP_URL));
return list.length == 2 ? list : null;
});
is(sources[0], source, "The first source is the previous one, related to the closed popup");
const newSource = sources[1];
isnot(source, newSource, "The second one is related to the new popup and is different");
isnot(source.id, newSource.id, "The source IDs are different");
assertPausedAtSourceAndLine(dbg, newSource.id, 4);
await resume(dbg);
is(await isPopupPaused(popupBrowsingContext), false, "The popup resumed its execution");
});
add_task(async function testPausedByDebuggerStatement() {
info("Test debugger statements in popup scripts");
const dbg = await initDebuggerWithAbsoluteURL(
TEST_URI
);
info("Open a popup with a debugger statement");
const popupBrowsingContext = await openPopup(POPUP_DEBUGGER_STATEMENT_URL);
await waitForPaused(dbg);
is(await isPopupPaused(popupBrowsingContext), true, "The popup is really paused");
const source = findSource(dbg, POPUP_DEBUGGER_STATEMENT_URL);
assertPausedAtSourceAndLine(dbg, source.id, 4);
await resume(dbg);
is(await isPopupPaused(popupBrowsingContext), false, "The popup resumed its execution");
});
add_task(async function testPausedInTwoPopups() {
info("Test being paused in two popup at the same time");
const dbg = await initDebuggerWithAbsoluteURL(
TEST_URI
);
info("Open the popup in order to be able to set a breakpoint");
let browser = gBrowser.selectedBrowser;
const popupBrowsingContext = await openPopup(POPUP_URL);
const source = await waitForSource(dbg, POPUP_URL);
await selectSource(dbg, source.url);
await addBreakpoint(dbg, source.url, 4);
info("Now close and reopen the popup");
await closePopup(popupBrowsingContext);
info("Open a first popup which will hit the breakpoint");
const firstPopupBrowsingContext = await openPopup(POPUP_URL);
await waitForPaused(dbg);
const { targetCommand } = dbg.commands;
const firstTarget = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]).find(targetFront => targetFront.url == POPUP_URL);
is(firstTarget.browsingContextID, firstPopupBrowsingContext.id, "The popup target matches the popup BrowsingContext");
const firstThread = (await firstTarget.getFront("thread")).actorID;
is(dbg.selectors.getCurrentThread(), firstThread, "The popup thread is automatically selected on pause");
is(await isPopupPaused(firstPopupBrowsingContext), true, "The first popup is really paused");
info("Open a second popup which will also hit the breakpoint");
let onAvailable;
const onNewTarget = new Promise(resolve => {
onAvailable = ({ targetFront }) => {
if (targetFront.url == POPUP_URL && targetFront.browsingContextID != firstPopupBrowsingContext.id) {
targetCommand.unwatchTargets({
types: [targetCommand.TYPES.FRAME],
onAvailable,
});
resolve(targetFront);
}
};
});
await targetCommand.watchTargets({
types: [targetCommand.TYPES.FRAME],
onAvailable,
});
const secondPopupBrowsingContext = await openPopup(POPUP_URL, browser);
info("Wait for second popup's target");
const popupTarget = await onNewTarget;
is(popupTarget.browsingContextID, secondPopupBrowsingContext.id, "The new target matches the popup WindowGlobal");
const secondThread = (await popupTarget.getFront("thread")).actorID;
await waitForPausedThread(dbg, secondThread);
is(dbg.selectors.getCurrentThread(), secondThread, "The second popup thread is automatically selected on pause");
is(await isPopupPaused(secondPopupBrowsingContext), true, "The second popup is really paused");
info("Resume the execution of the second popup");
await resume(dbg);
is(await isPopupPaused(secondPopupBrowsingContext), false, "The second popup resumed its execution");
is(await isPopupPaused(firstPopupBrowsingContext), true, "The first popup is still paused");
info("Resume the execution of the first popup");
await dbg.actions.selectThread(getContext(dbg), firstThread);
await resume(dbg);
is(await isPopupPaused(firstPopupBrowsingContext), false, "The first popup resumed its execution");
});
add_task(async function testClosingOriginalTab() {
info("Test closing the toolbox on the original tab while the popup is kept open");
const dbg = await initDebuggerWithAbsoluteURL(
TEST_URI
);
await dbg.toolbox.selectTool("webconsole");
info("Open a popup");
const originalTab = gBrowser.selectedTab;
const popupBrowsingContext = await openPopup("about:blank");
await wait(1000);
const popupTab = gBrowser.selectedTab;
gBrowser.selectedTab = originalTab;
info("Close the toolbox from the original tab");
await dbg.toolbox.closeToolbox();
await wait(1000);
info("Re-select the popup");
gBrowser.selectedTab = popupTab;
await wait(1000);
});

View File

@ -583,6 +583,22 @@ DevTools.prototype = {
tab,
{ toolId, hostType, startTime, raise, reason, hostOptions } = {}
) {
// Popups are debugged via the toolbox of their opener document/tab.
// So avoid opening dedicated toolbox for them.
if (tab.linkedBrowser.browsingContext.opener) {
const openerTab = tab.ownerGlobal.gBrowser.getTabForBrowser(
tab.linkedBrowser.browsingContext.opener.embedderElement
);
const openerDescriptor = await TabDescriptorFactory.getDescriptorForTab(
openerTab
);
if (this.getToolboxForDescriptor(openerDescriptor)) {
console.log(
"Can't open a toolbox for this document as this is debugged from its opener tab"
);
return;
}
}
const descriptor = await TabDescriptorFactory.createDescriptorForTab(tab);
return this.showToolbox(descriptor, {
toolId,

View File

@ -59,6 +59,16 @@ let ID_COUNTER = 1;
function ToolboxHostManager(descriptor, hostType, hostOptions) {
this.descriptor = descriptor;
// When debugging a local tab, we keep a reference of the current tab into which the toolbox is displayed.
// This will only change from the descriptor's localTab when we start debugging popups (i.e. window.open).
this.currentTab = this.descriptor.localTab;
// Keep the previously instantiated Host for all tabs where we displayed the Toolbox.
// This will only be useful when we start debugging popups (i.e. window.open).
// This is used to re-use the previous host instance when we re-select the original tab
// we were debugging before the popup opened.
this.hostPerTab = new Map();
this.frameId = ID_COUNTER++;
if (!hostType) {
@ -69,6 +79,7 @@ function ToolboxHostManager(descriptor, hostType, hostOptions) {
hostType = Services.prefs.getCharPref(LAST_HOST);
}
}
this.eventController = new AbortController();
this.host = this.createHost(hostType, hostOptions);
this.hostType = hostType;
this.telemetry = new Telemetry();
@ -81,11 +92,15 @@ function ToolboxHostManager(descriptor, hostType, hostOptions) {
ToolboxHostManager.prototype = {
async create(toolId) {
await this.host.create();
if (this.currentTab) {
this.hostPerTab.set(this.currentTab, this.host);
}
this.host.frame.setAttribute("aria-label", L10N.getStr("toolbox.label"));
this.host.frame.ownerDocument.defaultView.addEventListener(
"message",
this._onMessage
this._onMessage,
{ signal: this.eventController.signal }
);
const msSinceProcessStart = parseInt(
@ -162,6 +177,9 @@ ToolboxHostManager.prototype = {
case "switch-host":
this.switchHost(msg.hostType);
break;
case "switch-host-to-tab":
this.switchHostToTab(msg.tabBrowsingContextID);
break;
case "raise-host":
this.host.raise();
break;
@ -178,7 +196,15 @@ ToolboxHostManager.prototype = {
destroy() {
Services.prefs.removeObserver(ZOOM_VALUE_PREF, this.setMinWidthWithZoom);
this.eventController.abort();
this.eventController = null;
this.destroyHost();
// When we are debugging popup, we created host for each popup opened
// in some other tabs. Ensure destroying them here.
for (const host of this.hostPerTab.values()) {
host.destroy();
}
this.hostPerTab.clear();
this.host = null;
this.hostType = null;
this.descriptor = null;
@ -201,11 +227,27 @@ ToolboxHostManager.prototype = {
if (!Hosts[hostType]) {
throw new Error("Unknown hostType: " + hostType);
}
const newHost = new Hosts[hostType](this.descriptor.localTab, options);
const newHost = new Hosts[hostType](this.currentTab, options);
return newHost;
},
async switchHost(hostType) {
/**
* Migrate the toolbox to a new host, while keeping it fully functional.
* The toolbox's iframe will be moved as-is to the new host.
*
* @param {String} hostType
* The new type of host to spawn
* @param {Boolean} destroyPreviousHost
* Defaults to true. If false is passed, we will avoid destroying
* the previous host. This is helpful for popup debugging,
* where we migrate the toolbox between two tabs. In this scenario
* we are reusing previously instantiated hosts. This is especially
* useful when we close the current tab and have to have an
* already instantiated host to migrate to. If we don't have one,
* the toolbox iframe will already be destroyed before we have a chance
* to migrate it.
*/
async switchHost(hostType, destroyPreviousHost = true) {
if (hostType == "previous") {
// Switch to the last used host for the toolbox UI.
// This is determined by the devtools.toolbox.previousHost pref.
@ -234,8 +276,9 @@ ToolboxHostManager.prototype = {
// change toolbox document's parent to the new host
newIframe.swapFrameLoaders(iframe);
this.destroyHost();
if (destroyPreviousHost) {
this.destroyHost();
}
if (
this.hostType !== Toolbox.HostType.BROWSERTOOLBOX &&
@ -245,11 +288,15 @@ ToolboxHostManager.prototype = {
}
this.host = newHost;
if (this.currentTab) {
this.hostPerTab.set(this.currentTab, newHost);
}
this.hostType = hostType;
this.host.setTitle(this.host.frame.contentWindow.document.title);
this.host.frame.ownerDocument.defaultView.addEventListener(
"message",
this._onMessage
this._onMessage,
{ signal: this.eventController.signal }
);
this.setMinWidthWithZoom();
@ -268,21 +315,52 @@ ToolboxHostManager.prototype = {
});
},
/**
* When we are debugging popup, we are moving around the toolbox between original tab
* and popup tabs. This method will only move the host to a new tab, while
* keeping the same host type.
*
* @param {String} tabBrowsingContextID
* The ID of the browsing context of the tab we want to move to.
*/
async switchHostToTab(tabBrowsingContextID) {
const { gBrowser } = this.host.frame.ownerDocument.defaultView;
const previousTab = this.currentTab;
const newTab = gBrowser.tabs.find(
tab => tab.linkedBrowser.browsingContext.id == tabBrowsingContextID
);
// Note that newTab will be undefined when the popup opens in a new top level window.
if (newTab && newTab != previousTab) {
this.currentTab = newTab;
const newHost = this.hostPerTab.get(this.currentTab);
if (newHost) {
newHost.frame.swapFrameLoaders(this.host.frame);
this.host = newHost;
} else {
await this.switchHost(this.hostType, false);
}
previousTab.addEventListener(
"TabSelect",
event => {
this.switchHostToTab(event.target.linkedBrowser.browsingContext.id);
},
{ once: true, signal: this.eventController.signal }
);
}
this.postMessage({
name: "switched-host-to-tab",
browsingContextID: tabBrowsingContextID,
});
},
/**
* Destroy the current host, and remove event listeners from its frame.
*
* @return {promise} to be resolved when the host is destroyed.
*/
destroyHost() {
// When Firefox toplevel is closed, the frame may already be detached and
// the top level document gone
if (this.host.frame.ownerDocument.defaultView) {
this.host.frame.ownerDocument.defaultView.removeEventListener(
"message",
this._onMessage
);
}
return this.host.destroy();
},
};

View File

@ -744,6 +744,18 @@ Toolbox.prototype = {
],
});
}
// If a new popup is debugged, automagically switch the toolbox to become
// an independant window so that we can easily keep debugging the new tab.
// Only do that if that's not the current top level, otherwise it means
// we opened a toolbox dedicated to the popup.
if (
targetFront.targetForm.isPopup &&
!targetFront.isTopLevel &&
this.descriptorFront.isLocalTab
) {
await this.switchHostToTab(targetFront.targetForm.browsingContextID);
}
},
async _onTargetSelected({ targetFront }) {
@ -1751,9 +1763,12 @@ Toolbox.prototype = {
// Called whenever the chrome send a message
_onBrowserMessage: function(event) {
if (event.data && event.data.name === "switched-host") {
if (event.data?.name === "switched-host") {
this._onSwitchedHost(event.data);
}
if (event.data?.name === "switched-host-to-tab") {
this._onSwitchedHostToTab(event.data.browsingContextID);
}
},
_saveSplitConsoleHeight: function() {
@ -3573,6 +3588,25 @@ Toolbox.prototype = {
return this.once("host-changed");
},
/**
* Request to Firefox UI to move the toolbox to another tab.
* This is used when we move a toolbox to a new popup opened by the tab we were currently debugging.
* We also move the toolbox back to the original tab we were debugging if we select it via Firefox tabs.
*
* @param {String} tabBrowsingContextID
* The BrowsingContext ID of the tab we want to move to.
* @returns {Promise<undefined>}
* This will resolve only once we moved to the new tab.
*/
switchHostToTab(tabBrowsingContextID) {
this.postMessage({
name: "switch-host-to-tab",
tabBrowsingContextID,
});
return this.once("switched-host-to-tab");
},
_onSwitchedHost: function({ hostType }) {
this._hostType = hostType;
@ -3593,6 +3627,25 @@ Toolbox.prototype = {
this.component.setCurrentHostType(hostType);
},
/**
* Event handler fired when the toolbox was moved to another tab.
* This fires when the toolbox itself requests to be moved to another tab,
* but also when we select the original tab where the toolbox originally was.
*
* @param {String} browsingContextID
* The BrowsingContext ID of the tab the toolbox has been moved to.
*/
_onSwitchedHostToTab(browsingContextID) {
const targets = this.commands.targetCommand.getAllTargets([
this.commands.targetCommand.TYPES.FRAME,
]);
const target = targets.find(
target => target.browsingContextID == browsingContextID
);
this.commands.targetCommand.selectTarget(target);
},
/**
* Test the availability of a tool (both globally registered tools and
* additional tools registered to this toolbox) by tool id.

View File

@ -154,7 +154,10 @@ class EvaluationContextSelector extends Component {
}
}
const items = [mainTarget];
// Note that while debugging popups, we might have a small period
// of time where we don't have any top level target when we reload
// the original tab
const items = mainTarget ? [mainTarget] : [];
for (const [targetType, menuItems] of Object.entries(dict)) {
if (menuItems.length > 0) {

View File

@ -12,6 +12,7 @@ const IFRAME_PATH = `${FILE_FOLDER}/test-console-evaluation-context-selector-chi
// the context is set to the top one if the destroyed target was selected).
add_task(async function() {
await pushPref("devtools.popups.debug", true);
await pushPref("devtools.webconsole.input.context", true);
const hud = await openNewTabWithIframesAndConsole(TEST_URI, [
@ -165,4 +166,73 @@ add_task(async function() {
await waitForEagerEvaluationResult(hud, `"example.com"`);
ok(true, "Instant evaluation is done against the top frame context");
info("Open a popup");
const originalTab = gBrowser.selectedTab;
await ContentTask.spawn(gBrowser.selectedBrowser, [IFRAME_PATH], function(
path
) {
content.open(`https://test2.example.org/${path}?id=popup`);
});
// Wait until the popup is rendered in the context selector
// and that it is automatically switched to (aria-checked==true).
await waitFor(() => {
try {
const items = getContextSelectorItems(hud);
return (
items.length === 3 &&
items.some(
el =>
el
.querySelector(".label")
?.textContent.includes("popup|test2.example.org") &&
el.getAttribute("aria-checked") === "true"
)
);
} catch (e) {
// The context list may be wiped while updating and getContextSelectorItems will throw
}
return false;
});
const expectedPopupItem = {
label: `popup|test2.example.org`,
tooltip: `https://test2.example.org/${IFRAME_PATH}?id=popup`,
};
await checkContextSelectorMenu(hud, [
{
...expectedTopItem,
checked: false,
},
expectedSeparatorItem,
{
...expectedPopupItem,
checked: true,
},
]);
await waitForEagerEvaluationResult(hud, `"test2.example.org"`);
ok(true, "The context was set to the popup document");
info("Open a second popup and reload the original tab");
await ContentTask.spawn(originalTab.linkedBrowser, [IFRAME_PATH], function(
path
) {
content.open(`https://test2.example.org/${path}?id=popup2`);
});
// Reloading the tab while having two popups opened used to
// generate exception in the context selector component
const onBrowserLoaded = BrowserTestUtils.browserLoaded(
originalTab.linkedBrowser
);
gBrowser.reloadTab(originalTab);
await onBrowserLoaded;
ok(
!hud.ui.document.querySelector(".app-error-panel"),
"The web console did not crash"
);
});